diff --git a/.github/scripts/arch-lint.ts b/.github/scripts/arch-lint.ts index 902bae0f..9c11bb39 100644 --- a/.github/scripts/arch-lint.ts +++ b/.github/scripts/arch-lint.ts @@ -2,22 +2,22 @@ * The architecture lint (bun run lint:arch): src/ imports between layers must be exactly the edges architecture.yml * declares; an undeclared edge and a stale allowance both fail. dependency-cruiser was the intended tool, but it * needs the TypeScript compiler API, which the pinned typescript 7 no longer ships, so it resolves nothing here. - * runtime loads (what the changed-sections scanner reads) -> edges - * type-only imports, re-exports -> edges too + * runtime loads: imports, re-exports, literal `import()` and `require()` -> edges + * type-only imports and re-exports -> edges too * `import("./x.js").T`, `import X = require("./x.js")` in type positions -> edges too + * a computed `import(x)` or `require(x)` -> fails: the graph cannot follow it * * The same walk carries the never-throw rule the `throws` block of architecture.yml states beside its lists. */ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, normalize, relative } from "node:path"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, normalize, relative, resolve } from "node:path"; import { inspect } from "node:util"; import { err, ok, Result } from "neverthrow"; import { type Node, parseSync } from "oxc-parser"; import { parseDocument } from "yaml"; import { z } from "zod"; import { countNoun } from "../../src/text.js"; -import { resolveImport, scanImports } from "./changed-sections.js"; export const ARCHITECTURE_PATH = "architecture.yml"; @@ -170,26 +170,106 @@ function* nodesOf( const stateless = (): undefined => undefined; -export function importSpecifiers(text: string, file: string): string[] { - const { program, module } = parseSync(file, text); - const typeLevel = [...nodesOf(program, undefined, stateless)].flatMap(({ node }) => { - const source = - node.type === "TSImportType" - ? node.source - : node.type === "TSExternalModuleReference" - ? node.expression +/** The expression under the wrappers parentheses and TypeScript add: `(require)(x)`, `require!(x)`, + * `(require as any)(x)`, `require(x)`. */ +function unwrapped(expression: Node): Node { + switch (expression.type) { + case "ParenthesizedExpression": + case "TSNonNullExpression": + case "TSAsExpression": + case "TSSatisfiesExpression": + case "TSTypeAssertion": + case "TSInstantiationExpression": + return unwrapped(expression.expression); + default: + return expression; + } +} + +/** Whether the node loads a module: a static import or re-export, `import()`, `require()`, `import("./x.js").T`, + * or `import X = require("./x.js")`. */ +function isModuleLoad(node: Node): boolean { + switch (node.type) { + case "ImportDeclaration": + case "ExportAllDeclaration": + case "ImportExpression": + case "TSImportType": + case "TSExternalModuleReference": + return true; + case "ExportNamedDeclaration": + return node.source !== null; + case "CallExpression": { + const callee = unwrapped(node.callee); + return callee.type === "Identifier" && callee.name === "require"; + } + default: + return false; + } +} + +/** The module a load names, or undefined when the specifier is computed (`import(x)`, `require(x)`, a template + * with expressions), which the graph cannot follow. */ +function literalModuleRequest(node: Node): string | undefined { + const raw = + node.type === "TSExternalModuleReference" + ? node.expression + : node.type === "CallExpression" + ? node.arguments[0] + : "source" in node + ? node.source : undefined; - return source?.type === "Literal" && typeof source.value === "string" ? [source.value] : []; - }); - const all = new Set([ - ...scanImports(text, file), - ...module.staticImports.map((entry) => entry.moduleRequest.value), - ...module.staticExports.flatMap((entry) => - entry.entries.flatMap((item) => (item.moduleRequest ? [item.moduleRequest.value] : [])), - ), - ...typeLevel, - ]); - return [...all].filter((specifier) => /^\.\.?\//.test(specifier)); + const request = raw === undefined || raw === null ? undefined : unwrapped(raw); + if (request?.type === "Literal" && typeof request.value === "string") { + return request.value; + } + if (request?.type === "TemplateLiteral" && request.expressions.length === 0) { + return request.quasis[0]?.value.cooked ?? undefined; + } + return undefined; +} + +/** Every relative specifier `text` loads, each once. A file that does not parse or loads a module through a + * computed specifier throws naming the line: a dropped edge would let a forbidden import pass. */ +export function importSpecifiers(text: string, file: string): string[] { + const { program, errors } = parseSync(file, text); + const lineOf = (offset: number): number => text.slice(0, offset).split("\n").length; + const [error] = errors; + if (error) { + throw new Error( + `${file}:${lineOf(error.labels[0]?.start ?? 0)} does not parse: ${error.message}`, + ); + } + const specifiers = new Set(); + for (const { node } of nodesOf(program, undefined, stateless)) { + if (!isModuleLoad(node)) { + continue; + } + const specifier = literalModuleRequest(node); + if (specifier === undefined) { + throw new Error( + `${file}:${lineOf(node.start)} loads a module through a computed specifier, which the import graph cannot follow - use a string literal`, + ); + } + specifiers.add(specifier); + } + return [...specifiers].filter((specifier) => /^\.\.?\//.test(specifier)); +} + +/** Source spells the emitted `.js`, hence the `.ts` and `/index.ts` candidates. Nothing found throws: a dangling + * specifier would silently drop an edge. */ +function resolveImport(importer: string, specifier: string): string { + const target = resolve(dirname(importer), specifier); + const candidates = specifier.endsWith(".json") + ? [target] + : [`${target.replace(/\.[jt]s$/, "")}.ts`, join(target.replace(/\.[jt]s$/, ""), "index.ts")]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + throw new Error( + `${importer} imports "${specifier}", which resolves to no file (tried ${candidates.join(" and ")})`, + ); } /** The src/ files under the lint, root-relative. */ diff --git a/.github/scripts/changed-sections.ts b/.github/scripts/changed-sections.ts deleted file mode 100644 index 96ed70b0..00000000 --- a/.github/scripts/changed-sections.ts +++ /dev/null @@ -1,473 +0,0 @@ -/** - * The diff-aware section selector for the PR e2e smoke job: a PR touching one section runs that section's scenarios - * and fuzz rather than the whole corpus, and a PR touching nothing settings-related skips the smoke steps. A path - * under src/sections/ or docs/sections/, or in a directory under test/sections/, that no rule recognizes throws, so a - * new file cannot silently skip them. - * - * src/sections//... -> , whatever the file - * test/sections//... -> (the section's tests, mock, generators, scenarios) - * test/sections/, test/sections/snapshot-rows/ -> none (the cross-section suites and their fixtures; a flat - * file named after a section is misplaced and throws) - * docs/sections/.docs.yml -> ; shared.docs.yml and docs/schema.docs.yml select none - * src/sections/shared/.ts -> the sections that transitively import it (deriveSharedFanOut) - * contract/, registry.ts, the engine, the schema, the e2e harness -> every section - * - * CLI: `bun .github/scripts/changed-sections.ts [base-ref]` (default origin/main) prints a comma-separated section - * list, `all`, or `none`; the smoke steps run unless the output is `none`. - */ - -import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { type Node, parseSync } from "oxc-parser"; -import { SECTION_KEYS, type SectionKey } from "../../src/schema.js"; - -/** The sentinel the CLI prints (and the job branches on) when every section is in play. */ -export const ALL = "all"; -/** The sentinel printed when nothing settings-related changed. */ -export const NONE = "none"; - -const REPO_ROOT = join(import.meta.dir, "..", ".."); - -/** registry.ts wires every handler, so it selects EVERY section. docs-registry.ts never reaches the bundle and its - * drift is gated by build:check, so it selects NONE (the same reasoning keeps lib/ out of ALL_SELECTING_PREFIXES). */ -const ALL_SELECTING_SECTION_FILES = new Set(["registry.ts"]); -const NONE_SELECTING_SECTION_FILES = new Set(["docs-registry.ts"]); - -const SECTION_KEY_SET: ReadonlySet = new Set(SECTION_KEYS); - -/** `lib/` is deliberately NOT here: its only committed file is the generated settings.schema.json, which mirrors a - * `src/schema.ts` change and is gated by the schema-check job on its own. test/scripts/changed-sections.test.ts - * checks that every top-level `src/` entry other than `sections/` is listed, so a new module cannot be silently skipped. */ -export const ALL_SELECTING_PREFIXES = [ - // Every section is written against the contract's layered modules, so a change there selects everything. - "src/sections/contract/", - "src/engine/", - "src/flows/", - "src/github/", - "src/action/", - // The CLI runs the same flows the action does, so its change is smoked like the action's. - "src/cli/", - "src/cli.ts", - "src/discovery/", - "src/report/", - // Cross-cutting: gap files define supplemental route typing across sections. - "src/upstream-gaps/", - "src/index.ts", - "src/internal.ts", - "src/io.ts", - "src/main.ts", - "src/plain-data.ts", - "src/private-open.ts", - "src/private.ts", - "src/problem.ts", - "src/schema.ts", - "src/text.ts", - "src/types.ts", - "test/e2e/", - // The selection machinery itself: a PR touching only these must not select "none" and skip the very job they configure. - ".github/scripts/", - ".github/workflows/checks.yml", - ".github/actions/", -]; - -export type Selection = - | { kind: "all" } - | { kind: "some"; sections: SectionKey[] } - | { kind: "none" }; - -/** A deleted file has no code left to smoke. */ -export interface ChangedFile { - path: string; - deleted: boolean; -} - -/** bun's own TypeScript parser, so comments, strings, and templates never read as imports. */ -const TRANSPILER = new Bun.Transpiler({ loader: "ts" }); - -function isLiteralSpecifier(node: Node | undefined): boolean { - return ( - (node?.type === "Literal" && typeof node.value === "string") || - (node?.type === "TemplateLiteral" && node.expressions.length === 0) - ); -} - -function isComputedModuleLoad(node: Node): boolean { - if (node.type === "ImportExpression") { - return !isLiteralSpecifier(node.source); - } - return ( - node.type === "CallExpression" && - node.callee.type === "Identifier" && - node.callee.name === "require" && - !isLiteralSpecifier(node.arguments[0]) - ); -} - -function* nodesOf(value: unknown): Generator { - if (Array.isArray(value)) { - for (const item of value) { - yield* nodesOf(item); - } - return; - } - if (typeof value !== "object" || value === null) { - return; - } - if ("type" in value && typeof value.type === "string") { - yield value as Node; - } - for (const child of Object.values(value)) { - yield* nodesOf(child); - } -} - -/** A computed specifier is an edge the graph cannot read, and a missing edge can under-select, the one failure this - * selector exists to prevent: the file is rewritten, not skipped. */ -function assertNoComputedImports(text: string, file: string): void { - // Bun's import list silently omits a computed import()/require(), so oxc walks the AST only to find those. - const { program, errors } = parseSync(file, text); - const lineOf = (offset: number): number => text.slice(0, offset).split("\n").length; - const [error] = errors; - if (error) { - throw new Error( - `${file}:${lineOf(error.labels[0]?.start ?? 0)} does not parse: ${error.message}`, - ); - } - for (const node of nodesOf(program)) { - if (isComputedModuleLoad(node)) { - throw new Error( - `${file}:${lineOf(node.start)} loads a module through a computed specifier, which the import graph cannot follow - use a string literal`, - ); - } - } -} - -/** Type-only imports are erased from the bundle, so they are not edges here (arch-lint.ts adds them itself). The - * errors name the file, not this tool, because arch-lint.ts shares this and resolveImport. */ -export function scanImports(text: string, file: string): string[] { - assertNoComputedImports(text, file); - // Bun.Transpiler rejects a shebang line (the bin entry keeps one); oxc above accepts it. - return TRANSPILER.scanImports(text.replace(/^#!.*\n/, "")) - .map((entry) => entry.path) - .filter((specifier) => specifier.startsWith("./") || specifier.startsWith("../")); -} - -/** Source spells the emitted `.js`, hence the `.ts` and `/index.ts` candidates. Nothing found throws: a dangling - * specifier would silently drop an edge. */ -export function resolveImport(importer: string, specifier: string): string { - const target = resolve(dirname(importer), specifier); - const candidates = specifier.endsWith(".json") - ? [target] - : [`${target.replace(/\.[jt]s$/, "")}.ts`, join(target.replace(/\.[jt]s$/, ""), "index.ts")]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - throw new Error( - `${importer} imports "${specifier}", which resolves to no file (tried ${candidates.join(" and ")})`, - ); -} - -/** Every .ts file under `root`; src/ holds code only, so nothing here is a test. */ -export function sourceFilesUnder(root: string): string[] { - return readdirSync(root, { recursive: true, encoding: "utf8" }) - .filter((entry) => entry.endsWith(".ts")) - .map((entry) => join(root, entry)); -} - -export function reverseImportGraph(files: readonly string[]): Map> { - const importedBy = new Map>(); - for (const file of files) { - for (const specifier of scanImports(readFileSync(file, "utf8"), file)) { - const target = resolveImport(file, specifier); - const importers = importedBy.get(target) ?? new Set(); - importers.add(file); - importedBy.set(target, importers); - } - } - return importedBy; -} - -export function transitiveDependents( - importedBy: ReadonlyMap>, - file: string, -): Set { - const seen = new Set(); - const pending = [file]; - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - for (const importer of importedBy.get(next) ?? []) { - if (!seen.has(importer)) { - seen.add(importer); - pending.push(importer); - } - } - } - return seen; -} - -/** A dependent outside a section directory adds no key: an all-selecting path selects everything on its own, and a - * shared/ dependent has a fan-out of its own. A shared file with no section dependent is dead code or a scan gap, and - * neither may quietly select nothing. */ -export function deriveSharedFanOut(repoRoot: string): Record { - const sectionsDir = join(repoRoot, "src", "sections"); - const sharedDir = join(sectionsDir, "shared"); - const importedBy = reverseImportGraph(sourceFilesUnder(join(repoRoot, "src"))); - const fanOut: Record = {}; - for (const shared of sourceFilesUnder(sharedDir)) { - const keys = new Set(); - for (const dependent of transitiveDependents(importedBy, shared)) { - const [dir] = relative(sectionsDir, dependent).split("/"); - if (dir !== undefined && SECTION_KEY_SET.has(dir)) { - keys.add(dir); - } - } - const sharedPath = relative(sharedDir, shared); - if (keys.size === 0) { - throw new Error( - `changed-sections: no section imports src/sections/shared/${sharedPath}, so no smoke selection covers it - delete the dead file, or fix the import scan if a section does import it`, - ); - } - fanOut[sharedPath] = SECTION_KEYS.filter((key) => keys.has(key)); - } - return fanOut; -} - -export type SharedFanOut = Record; - -let realFanOut: SharedFanOut | undefined; - -function realSharedFanOut(): SharedFanOut { - realFanOut ??= deriveSharedFanOut(REPO_ROOT); - return realFanOut; -} - -/** `foo.ts` and `foo/index.ts` are interchangeable to every importer of `./foo.js`. */ -function siblingResolution(sharedPath: string): string { - return sharedPath.endsWith("/index.ts") - ? `${sharedPath.slice(0, -"/index.ts".length)}.ts` - : `${sharedPath.slice(0, -".ts".length)}/index.ts`; -} - -/** Below the ALL_SELECTING_PREFIXES check, so src/sections/contract/ never reaches here. Anything unrecognized - * throws: a silently ignored section path would let a PR skip the very scenarios its change needs. */ -function sectionsForSectionsPath( - { path, deleted }: ChangedFile, - sharedFanOut: () => SharedFanOut, -): SectionKey[] | "all" { - const rest = path.slice("src/sections/".length); - const slash = rest.indexOf("/"); - if (slash < 0) { - if (ALL_SELECTING_SECTION_FILES.has(rest)) { - return "all"; - } - if (NONE_SELECTING_SECTION_FILES.has(rest)) { - return []; - } - throw new Error( - `changed-sections: ${path} matches no selector rule; src/sections/ holds only the flat ` + - `files in ALL_SELECTING_SECTION_FILES and NONE_SELECTING_SECTION_FILES, the per-section ` + - `/ directories, contract/, and shared/ - move the file under its section directory`, - ); - } - const dir = rest.slice(0, slash); - if (SECTION_KEY_SET.has(dir)) { - return [dir as SectionKey]; - } - if (dir === "shared") { - const sharedPath = rest.slice(slash + 1); - if (deleted) { - // A deleted shared file adds nothing itself: its importers either changed in the same diff (typecheck fails - // otherwise) and select their sections, or, for a .ts, now resolve to the sibling spelling, whose fan-out is - // then theirs. A deleted file of any other kind has nothing left to smoke. - return sharedPath.endsWith(".ts") - ? (sharedFanOut()[siblingResolution(sharedPath)] ?? []) - : []; - } - const keys = sharedFanOut()[sharedPath]; - if (keys) { - return keys; - } - throw new Error( - `changed-sections: ${path} matches no selector rule; under src/sections/shared/ only .ts files (fanning out through the import graph) are recognized`, - ); - } - throw new Error( - `changed-sections: ${path} matches no selector rule; a section directory must spell its SectionKey verbatim (or add the directory to ALL_SELECTING_PREFIXES if it is cross-cutting)`, - ); -} - -const TEST_SECTIONS_PREFIX = "test/sections/"; -/** The fixture directories beside the section mirrors under test/sections/: cross-section, like the flat suites. */ -const CROSS_SECTION_TEST_DIRS: ReadonlySet = new Set(["snapshot-rows"]); -const SECTION_DOCS_PREFIX = "docs/sections/"; -/** The document root's schema prose, gated by build:check like the docs registry. */ -const ROOT_DOCS_FILE = "docs/schema.docs.yml"; -/** The shared factories' schema prose: it belongs to no one section, and build:check gates it too. */ -const SHARED_DOCS_FILE = `${SECTION_DOCS_PREFIX}shared.docs.yml`; - -/** labels.test.ts and labels-schema.test.ts, but not list-section.test.ts: the stem is a key or its dashed slug, - * alone or before a dash or underscore. */ -function isSectionNamedFile(name: string): boolean { - const stem = name.split(".")[0] ?? ""; - return SECTION_KEYS.some((key) => { - const slug = key.replaceAll("_", "-"); - return ( - stem === key || - stem === slug || - stem.startsWith(`${key}-`) || - stem.startsWith(`${key}_`) || - stem.startsWith(`${slug}-`) || - stem.startsWith(`${slug}_`) - ); - }); -} - -/** A section's tests, mock, generators, and scenarios mirror it under test/sections//; a deleted scenario can - * leave a route cold, so the section still runs. The flat files beside those directories are the cross-section suites - * and select none, as does a fixture directory in CROSS_SECTION_TEST_DIRS. A flat file named after a section belongs - * in its directory, so it throws unless deleted (the fold of a flat suite into its directory is such a deletion, and - * the added file selects the section). */ -function sectionsForTestSectionsPath({ path, deleted }: ChangedFile): SectionKey[] { - const rest = path.slice(TEST_SECTIONS_PREFIX.length); - const slash = rest.indexOf("/"); - if (slash < 0) { - if (!deleted && isSectionNamedFile(rest)) { - throw new Error( - `changed-sections: ${path} matches no selector rule; a file named after a section lives in ${TEST_SECTIONS_PREFIX}/, not beside the cross-section suites`, - ); - } - return []; - } - const dir = rest.slice(0, slash); - if (SECTION_KEY_SET.has(dir)) { - return [dir as SectionKey]; - } - if (CROSS_SECTION_TEST_DIRS.has(dir)) { - return []; - } - throw new Error( - `changed-sections: ${path} matches no selector rule; a directory under ${TEST_SECTIONS_PREFIX} spells its SectionKey verbatim (or is named in CROSS_SECTION_TEST_DIRS if it holds cross-section fixtures)`, - ); -} - -/** docs/sections/.docs.yml is the section's authored prose and selects it; the shared file selects none. */ -function sectionsForSectionDocsPath(path: string): SectionKey[] { - if (path === SHARED_DOCS_FILE) { - return []; - } - const rest = path.slice(SECTION_DOCS_PREFIX.length); - const key = rest.endsWith(".docs.yml") ? rest.slice(0, -".docs.yml".length) : ""; - if (!key.includes("/") && SECTION_KEY_SET.has(key)) { - return [key as SectionKey]; - } - throw new Error( - `changed-sections: ${path} matches no selector rule; ${SECTION_DOCS_PREFIX} holds only .docs.yml (the SectionKey verbatim) and shared.docs.yml`, - ); -} - -/** The section keys a non-cross-cutting path selects, or undefined for a path that is no selector input at all. */ -function sectionsForPath( - file: ChangedFile, - sharedFanOut: () => SharedFanOut, -): SectionKey[] | "all" | undefined { - const { path } = file; - if (path.startsWith("src/sections/")) { - return sectionsForSectionsPath(file, sharedFanOut); - } - if (path.startsWith(TEST_SECTIONS_PREFIX)) { - return sectionsForTestSectionsPath(file); - } - if (path.startsWith(SECTION_DOCS_PREFIX)) { - return sectionsForSectionDocsPath(path); - } - if (path === ROOT_DOCS_FILE) { - return []; - } - return undefined; -} - -/** Every section-shaped path is resolved even when a cross-cutting path already forces "all", so a stale flat path - * cannot ride along unnoticed. */ -export function sectionsForFiles( - files: readonly ChangedFile[], - sharedFanOut: () => SharedFanOut = realSharedFanOut, -): Selection { - const selected = new Set(); - let all = false; - for (const file of files) { - if (ALL_SELECTING_PREFIXES.some((prefix) => file.path.startsWith(prefix))) { - all = true; - continue; - } - const keys = sectionsForPath(file, sharedFanOut); - if (keys === "all") { - all = true; - } else if (keys !== undefined) { - for (const key of keys) { - selected.add(key); - } - } - } - if (all) { - return { kind: "all" }; - } - if (selected.size === 0) { - return { kind: "none" }; - } - // SECTION_KEYS order, so the printed list is stable. - return { kind: "some", sections: SECTION_KEYS.filter((key) => selected.has(key)) }; -} - -export function renderSelection(selection: Selection): string { - if (selection.kind === "all") { - return ALL; - } - if (selection.kind === "none") { - return NONE; - } - return selection.sections.join(","); -} - -/** Exactly the statuses `git diff --name-status --no-renames` can emit; a scored R/C or a letter git does not use is - * a shape this parser refuses. */ -const GIT_STATUS = /^[ADMTUXB]$/; - -/** `-z` keeps paths raw (git would otherwise C-quote unicode, tabs, and newlines, hiding a src/sections/ prefix), and - * `--no-renames` keeps every record single-path (a rename is a D plus an A). Any other shape throws, never skipped. */ -export function parseNameStatus(out: string): ChangedFile[] { - if (out !== "" && !out.endsWith("\0")) { - throw new Error( - `changed-sections: git name-status output is not NUL-terminated: ${JSON.stringify(out.slice(-40))}`, - ); - } - const fields = out.split("\0"); - fields.pop(); - const files: ChangedFile[] = []; - for (let i = 0; i < fields.length; i += 2) { - const status = fields[i] ?? ""; - const path = fields[i + 1]; - if (!GIT_STATUS.test(status) || path === undefined || path === "") { - throw new Error( - `changed-sections: unparseable git name-status record ${JSON.stringify(fields.slice(i, i + 2))}`, - ); - } - files.push({ path, deleted: status === "D" }); - } - return files; -} - -export function changedFiles(baseRef: string): ChangedFile[] { - return parseNameStatus( - execFileSync("git", ["diff", "--name-status", "--no-renames", "-z", `${baseRef}...HEAD`], { - encoding: "utf8", - }), - ); -} - -if (import.meta.main) { - const baseRef = process.argv[2] ?? "origin/main"; - const selection = sectionsForFiles(changedFiles(baseRef)); - console.log(renderSelection(selection)); -} diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 72656720..95519a08 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -121,71 +121,32 @@ jobs: *) echo "::error::action crashed (no result output)"; exit 1 ;; esac - # The action bundle runs under node24, so node is installed beside bun. A non-PR event (push, dispatch) has no base - # to diff against, so it runs the full corpus. + # The action bundle runs under node24, so node is installed beside bun. The whole corpus runs on every event: + # scenarios, then fuzz. e2e-smoke: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Select sections from the diff - id: select - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - if [ -n "$BASE_SHA" ]; then - SECTIONS="$(bun .github/scripts/changed-sections.ts "$BASE_SHA")" - else - SECTIONS="all" - fi - echo "sections=$SECTIONS" >> "$GITHUB_OUTPUT" - echo "selected sections: $SECTIONS" - name: Run scenarios and fuzz - if: steps.select.outputs.sections != 'none' - env: - SECTIONS: ${{ steps.select.outputs.sections }} run: | - if [ "$SECTIONS" = "all" ]; then - bun test/e2e/run.ts - bun test/e2e/fuzz.ts --iterations 25 - else - bun test/e2e/run.ts --sections "$SECTIONS" - bun test/e2e/fuzz.ts --iterations 25 --sections "$SECTIONS" - fi + bun test/e2e/run.ts + bun test/e2e/fuzz.ts --iterations 25 # A PR that adds an endpoint without a scenario reaching it must fail on that PR, not the next night. Coldness is a - # whole-corpus property (deleting one scenario can chill any route), so the full corpus runs (~10 minutes) unless - # the selector finds nothing settings-related in the diff. + # whole-corpus property (deleting one scenario can chill any route), so the full corpus runs. endpoint-coverage: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - # Only the selector's "none" answer matters here. - - name: Check whether the diff can affect route coverage - id: select - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - if [ -n "$BASE_SHA" ]; then - SECTIONS="$(bun .github/scripts/changed-sections.ts "$BASE_SHA")" - else - SECTIONS="all" - fi - echo "sections=$SECTIONS" >> "$GITHUB_OUTPUT" - echo "selected sections: $SECTIONS" - name: Endpoint-coverage tripwire - if: steps.select.outputs.sections != 'none' run: bun .github/scripts/check-endpoint-coverage.ts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 47660015..bf315205 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -113,7 +113,7 @@ jobs: - name: Run the gate on the floated resolution run: bun run check - # The whole curated corpus (PR CI runs only the sections a PR changed) plus the endpoint-coverage tripwire. + # The whole curated corpus plus the endpoint-coverage tripwire. # The bundle runs under node24, so node is installed beside bun. e2e: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18a3ef49..6e568380 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ The end-to-end tests build the bundle to a temp path and run it as a subprocess - Every section ships the standard scenario set under `test/sections//scenarios/`, named after the section's dashed key: `-apply-converges`, `-check-drift` (a section with a planning read), `-snapshot-roundtrip` (a section with snapshot()), and for a section under the undeclared policy `-undeclared-delete` and `-undeclared-keep-note`; `test/sections/scenario-set.test.ts` derives the set from the registry. - `bun run fuzz` runs seeded property fuzzing: random scenarios, each checked against an oracle that predicts the outcome class from the token mask, policy, and mode. - The mock serves the section endpoints plus the core routes the action calls outside the sections. A request that matches no registered route fails loudly; the mock never invents a response. -- PR CI runs the sections a pull request changed. The nightly workflow's `e2e` job runs the full corpus and files a red night under the `nightly-failure` issue; the fuzz nightly runs the full fuzz and files under `fuzz-nightly` with a replay command. +- PR CI runs the full scenario corpus, the endpoint-coverage tripwire, and 25 fuzz iterations on every pull request. The nightly workflow's `e2e` job runs the full corpus and files a red night under the `nightly-failure` issue; the fuzz nightly runs the full fuzz and files under `fuzz-nightly` with a replay command. The fuzzer is deterministic. It prints a master seed and a per-iteration seed: diff --git a/src/sections/shared/repo-secrets.ts b/src/sections/shared/repo-secrets.ts index b2dbaaba..43303c6d 100644 --- a/src/sections/shared/repo-secrets.ts +++ b/src/sections/shared/repo-secrets.ts @@ -3,8 +3,7 @@ * same four endpoints under a different path segment and differ only in PAT resource, noun, and (Codespaces) * the grade GitHub gates the reads at, so each section module is ONE repoSecretsSection() call. * - * environments section -> plans its nested secrets through ./secrets-engine.ts too, one scope per environment - * .github/scripts/changed-sections.ts -> derives this file's smoke fan-out from the import graph + * environments section -> plans its nested secrets through ./secrets-engine.ts too, one scope per environment */ import { ok, type Result } from "neverthrow"; diff --git a/src/sections/shared/repo-variables.ts b/src/sections/shared/repo-variables.ts index 4c82bf46..aa1c2763 100644 --- a/src/sections/shared/repo-variables.ts +++ b/src/sections/shared/repo-variables.ts @@ -3,8 +3,7 @@ * a different path segment and differ only in PAT resource and noun, so each section module is ONE * repoVariablesSection() call. * - * environments section -> plans its nested variables through ./variables-engine.ts too, one scope per environment - * .github/scripts/changed-sections.ts -> derives this file's smoke fan-out from the import graph + * environments section -> plans its nested variables through ./variables-engine.ts too, one scope per environment */ import { ok, type Result } from "neverthrow"; diff --git a/src/sections/shared/schema-helpers.ts b/src/sections/shared/schema-helpers.ts index 25f1be70..17c38a14 100644 --- a/src/sections/shared/schema-helpers.ts +++ b/src/sections/shared/schema-helpers.ts @@ -1,7 +1,6 @@ /** * Imports only zod, renamed-key.ts, and the text leaf: a section schema importing src/schema.ts back would be a cycle * whose top-level consts TDZ-crash at import time, so everything both sides need lives here. - * The smoke selector (.github/scripts/changed-sections.ts) derives this file's section fan-out from the import graph. */ import { z } from "zod"; diff --git a/test/architecture/architecture.test.ts b/test/architecture/architecture.test.ts index 2b96fa37..4648d17b 100644 --- a/test/architecture/architecture.test.ts +++ b/test/architecture/architecture.test.ts @@ -71,6 +71,30 @@ describe("importSpecifiers", () => { ["a star re-export", 'export * from "./a.js";', ["./a.js"]], ["a require call", 'const a = require("./a.js");', ["./a.js"]], ["a literal dynamic import", 'const a = await import("./a.js");', ["./a.js"]], + [ + "a template dynamic import with no expression", + "const a = await import(`./a.js`);", + ["./a.js"], + ], + [ + "a file opening with a shebang, as the bin entry does", + '#!/usr/bin/env node\nimport { a } from "./a.js";', + ["./a.js"], + ], + ["an inline type-only specifier", 'import { type A } from "./a.js";', ["./a.js"]], + ["a runtime import-equals", 'import A = require("./a.js");', ["./a.js"]], + [ + "a require or its argument behind parentheses, a non-null assertion, a cast, or a type argument", + [ + 'const a = (require)("./a.js");', + 'const b = require!("./b.js");', + 'const c = (require as any)("./c.js");', + 'const d = ((require as (id: string) => T))("./d.js");', + 'const e = require(("./e.js"));', + 'const f = require("./f.js" as string);', + ].join("\n"), + ["./a.js", "./b.js", "./c.js", "./d.js", "./e.js", "./f.js"], + ], ["an import in a type position", 'export type A = import("./a.js").A;', ["./a.js"]], ["a type-only import-equals", 'import type A = require("./a.js");', ["./a.js"]], ["a package import, which is not an edge", 'import { z } from "zod";', []], @@ -84,12 +108,24 @@ describe("importSpecifiers", () => { }); test("a computed dynamic import throws rather than dropping the edge", () => { - // The whole message: the scanner is shared with changed-sections, so under lint:arch it must not blame that tool. - expect(() => importSpecifiers('const m = "./a.js"; await import(m);', "x.ts")).toThrow( - new Error( - "x.ts:1 loads a module through a computed specifier, which the import graph cannot follow - use a string literal", - ), - ); + // The whole message, with the file and line: the fix is in that file, not in the lint. + for (const [text, form] of [ + ['const m = "./a.js";\nawait import(m);', "import(m)"], + ['const m = "./a.js";\nconst a = require(m);', "require(m)"], + [`const m = "a";\nawait import(\`./\${m}.js\`);`, "a template with an expression"], + ] as const) { + expect(() => importSpecifiers(text, "x.ts"), form).toThrow( + new Error( + "x.ts:2 loads a module through a computed specifier, which the import graph cannot follow - use a string literal", + ), + ); + } + }); + + test("a file that does not parse throws naming the line rather than reading a partial graph", () => { + expect(() => + importSpecifiers('import { a } from "./a.js";\nexport function broken(: never {', "x.ts"), + ).toThrow(/^x\.ts:2 does not parse: /); }); }); diff --git a/test/docs/checks-sections-gate.test.ts b/test/docs/checks-sections-gate.test.ts deleted file mode 100644 index 18da01ea..00000000 --- a/test/docs/checks-sections-gate.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * The checks.yml e2e-smoke job compares the selector's printed token as a raw shell string, so its literals are pinned to the constants the selector - * prints. - */ - -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { ALL, NONE } from "../../.github/scripts/changed-sections.js"; -import { ROOT } from "../root.js"; - -describe("checks.yml e2e-smoke section-selection sentinels", () => { - const workflow = readFileSync(join(ROOT, ".github", "workflows", "checks.yml"), "utf8"); - - test.each<[string, string]>([ - [ - "the smoke job is gated on the selector NOT printing the none token", - `steps.select.outputs.sections != '${NONE}'`, - ], - [ - "the full-corpus branch compares against the all token", - `if [ "$SECTIONS" = "${ALL}" ]; then`, - ], - ["non-PR events fall back to the all token (no base to diff against)", `SECTIONS="${ALL}"`], - ])("%s", (_case, literal) => { - expect(workflow).toContain(literal); - }); -}); diff --git a/test/e2e/schema.ts b/test/e2e/schema.ts index 71b68bff..e80d9d5b 100644 --- a/test/e2e/schema.ts +++ b/test/e2e/schema.ts @@ -586,6 +586,10 @@ export function collectYmlFiles(dir: string): string[] { return out; } +/** The section test mirror: / under it holds the section's tests, mock.ts, generators.ts, and scenarios/. + * The layout census (test/layout.test.ts) reads the root from here, so a move edits this one line. */ +export const SECTIONS_TEST_ROOT = join(ROOT, "test", "sections"); + /** * The curated corpus; run.ts and .github/scripts/check-endpoint-coverage.ts both read it, so they * cannot disagree. The roots are not filtered by existence: an existsSync filter would silently drop @@ -593,7 +597,7 @@ export function collectYmlFiles(dir: string): string[] { * test/e2e/scenarios/ -> multi-section flows; a scenario exercising ONE section lives with that section * //scenarios/ -> every registered section, so a new section's first scenario needs no list edit */ -export function scenarioRoots(sectionsDir: string = join(ROOT, "test", "sections")): string[] { +export function scenarioRoots(sectionsDir: string = SECTIONS_TEST_ROOT): string[] { return [ join(import.meta.dir, "scenarios"), ...SECTION_KEYS.map((key) => join(sectionsDir, key, "scenarios")), diff --git a/test/layout.test.ts b/test/layout.test.ts new file mode 100644 index 00000000..481097d8 --- /dev/null +++ b/test/layout.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync } from "node:fs"; +import { basename, join, relative } from "node:path"; +import { SECTION_KEYS } from "../src/schema.js"; +import { SECTIONS_TEST_ROOT } from "./e2e/schema.js"; +import { ROOT } from "./root.js"; + +/** The mirror root as the messages name it, e.g. test/sections. */ +const MIRROR = relative(ROOT, SECTIONS_TEST_ROOT); + +/** labels.test.ts and labels-schema.test.ts, but not list-section.test.ts: the stem is a key or its dashed slug, + * alone or before a dash or underscore. */ +function isSectionNamedFile(name: string): boolean { + const stem = name.split(".")[0] ?? ""; + return SECTION_KEYS.some((key) => { + const slug = key.replaceAll("_", "-"); + return [key, slug].some( + (spelling) => + stem === spelling || stem.startsWith(`${spelling}-`) || stem.startsWith(`${spelling}_`), + ); + }); +} + +describe("repository layout", () => { + test("src/ holds code only: no test, mock, generator, scenario, or docs prose lives under it", () => { + const strays = readdirSync(join(ROOT, "src"), { recursive: true, encoding: "utf8" }).filter( + (entry) => + entry.endsWith(".test.ts") || + ["mock.ts", "generators.ts"].includes(basename(entry)) || + entry.endsWith(".docs.yml") || + entry.split("/").includes("scenarios"), + ); + expect( + strays.map((entry) => `src/${entry}`), + `move these under ${MIRROR}/`, + ).toEqual([]); + }); + + // readdirSync throws on a missing root, so a mirror that moved without moving the constant fails here by path. + const entries = readdirSync(SECTIONS_TEST_ROOT, { withFileTypes: true }); + + test(`${MIRROR}/ holds at least one section directory`, () => { + const sectionDirs = entries.filter( + (entry) => entry.isDirectory() && SECTION_KEYS.some((key) => key === entry.name), + ); + expect(sectionDirs.map((entry) => entry.name)).not.toEqual([]); + }); + + test(`a file named after a section lives in ${MIRROR}//, never flat beside the directories`, () => { + const strays = entries + .filter((entry) => entry.isFile() && isSectionNamedFile(entry.name)) + .map((entry) => `${MIRROR}/${entry.name}`); + expect(strays, "move each into its section's directory").toEqual([]); + }); + + test.each<[string, boolean]>([ + ["labels.test.ts", true], + ["labels-schema.test.ts", true], + ["labels_schema.test.ts", true], + ["secret-scanning-custom-patterns-schema.test.ts", true], + ["actions_secrets.ts", true], + ["interaction_limits_schema.test.ts", true], + ["list-section.test.ts", false], + ["secret-variable-schema.test.ts", false], + ["labelsx.test.ts", false], + ])("%s is named after a section: %p", (name, named) => { + expect(isSectionNamedFile(name)).toBe(named); + }); +}); diff --git a/test/scripts/changed-sections.test.ts b/test/scripts/changed-sections.test.ts deleted file mode 100644 index 7f770e59..00000000 --- a/test/scripts/changed-sections.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { - ALL_SELECTING_PREFIXES, - type ChangedFile, - deriveSharedFanOut, - parseNameStatus, - renderSelection, - resolveImport, - scanImports, - sectionsForFiles, -} from "../../.github/scripts/changed-sections.js"; -import { SECTION_KEYS, type SectionKey } from "../../src/schema.js"; -import { ROOT } from "../root.js"; -import { withTempDir } from "../temp-dir.js"; - -const SRC_DIR = join(ROOT, "src"); -const SECTIONS_DIR = join(SRC_DIR, "sections"); -const SECTION_KEY_SET: ReadonlySet = new Set(SECTION_KEYS); - -function changed(...paths: string[]): ChangedFile[] { - return paths.map((path) => ({ path, deleted: false })); -} - -function removed(...paths: string[]): ChangedFile[] { - return paths.map((path) => ({ path, deleted: true })); -} - -/** Every path under src/sections on disk, repo-relative with forward slashes. */ -function sectionsPathsOnDisk(dir = SECTIONS_DIR, prefix = "src/sections"): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = `${prefix}/${entry.name}`; - if (entry.isDirectory()) { - out.push(...sectionsPathsOnDisk(join(dir, entry.name), path)); - } else if (entry.isFile()) { - out.push(path); - } - } - return out; -} - -/** The given keys in SECTION_KEYS order, which is the order the fan-out emits. */ -function inKeyOrder(...keys: SectionKey[]): SectionKey[] { - const wanted = new Set(keys); - return SECTION_KEYS.filter((key) => wanted.has(key)); -} - -/** `root` filled as a repo holding `files` (repo-relative path -> text). */ -function syntheticRepo(root: string, files: Record): string { - mkdirSync(join(root, "src", "sections", "shared"), { recursive: true }); - for (const [path, text] of Object.entries(files)) { - mkdirSync(dirname(join(root, path)), { recursive: true }); - writeFileSync(join(root, path), text); - } - return root; -} - -const GRAPH_FIXTURE: Record = { - "src/sections/shared/engine.ts": "export const engine = 1;\n", - "src/sections/shared/factory.ts": - 'import { engine } from "./engine.js";\nexport const factory = engine;\n', - "src/sections/shared/util/index.ts": "export const util = 1;\n", - "src/sections/labels/index.ts": - 'import {\n factory,\n} from "../shared/factory.js";\nexport default factory;\n', - "src/sections/teams/index.ts": 'export { engine } from "../shared/engine";\n', - "src/sections/milestones/index.ts": 'export const util = await import("../shared/util");\n', - "src/sections/pages/index.ts": "export const util = await import(`../shared/util`);\n", - "src/sections/pages/schema.ts": - 'import type { engine } from "../shared/engine.js";\nexport type Engine = typeof engine;\n', - "src/schema.ts": - 'import { engine } from "./sections/shared/engine.js";\nexport default engine;\n', - "src/sections/registry.ts": 'import "./labels/index.js";\nimport "./teams/index.js";\n', -}; - -describe("changed-sections derived fan-out", () => { - test("every section that spells a shared import is in that shared file's derived fan-out", () => { - // The scan against a plain-text reading of the tree: a form the scan misses, or a section file it skips, would - // shrink a fan-out and let a PR under-select its smoke run. - const fanOut = deriveSharedFanOut(ROOT); - const spelled = new Map>(); - for (const path of sectionsPathsOnDisk()) { - const dir = path.split("/")[2] ?? ""; - if (!SECTION_KEY_SET.has(dir) || !path.endsWith(".ts")) { - continue; - } - const text = readFileSync(join(ROOT, path), "utf8"); - for (const [, spec = ""] of text.matchAll(/from "\.\.\/shared\/([^"]+)"/g)) { - const shared = `${spec.replace(/\.js$/, "")}.ts`; - spelled.set(shared, new Set([...(spelled.get(shared) ?? []), dir])); - } - } - expect(spelled.size).toBeGreaterThan(5); - for (const [shared, dirs] of spelled) { - expect(fanOut[shared], shared).toEqual(expect.arrayContaining([...dirs])); - } - }); - - test("scanImports finds every runtime import form and nothing that only looks like one", () => { - const text = [ - 'import { a } from "./a.js";', - "import {", - " b,", - " c,", - '} from "../b/c.js";', - 'export { e } from "./e";', - 'export * from "../f/index.js";', - 'const g = await import("./g.js");', - 'import "./side-effect.js";', - 'const r = require("./require.js");', - 'import tsr = require("./ts-require.js");', - // Bare specifiers are not edges in the src/ graph. - 'import { z } from "zod";', - 'import { readFileSync } from "node:fs";', - // Type-only imports are erased from the bundle. - 'import type { D } from "./type-only.js";', - 'import { type Only } from "./inline-type-only.js";', - 'export type { T } from "./type-reexport.js";', - // Lookalikes a regex would take for imports. - '// import { nope } from "./line-comment.js";', - '/* export { nope } from "./block-comment.js"; */', - 'const s = "import(\\"./string.js\\")";', - 'const tpl = `import x from "./template.js"`;', - ].join("\n"); - expect(scanImports(text, "probe.ts")).toEqual([ - "./a.js", - "../b/c.js", - "./e", - "../f/index.js", - "./g.js", - "./side-effect.js", - "./require.js", - "./ts-require.js", - ]); - }); - - test("scanImports throws on a computed specifier instead of dropping the edge", () => { - // Bun's import list silently omits these three; the error must name the file and line. - for (const [line, form] of [ - ["const d = await import(name);", "import(name)"], - ["const r = require(name);", "require(name)"], - [`const t = await import(\`./\${name}.js\`);`, "template import"], - ] as const) { - expect( - () => scanImports(`const name = "./dyn.js";\n${line}\n`, "src/sections/labels/index.ts"), - form, - ).toThrow(/src\/sections\/labels\/index\.ts:2 loads a module through a computed specifier/); - } - expect(scanImports("const t = await import(`./lit.js`);\n", "probe.ts")).toEqual(["./lit.js"]); - }); - - test("scanImports reads a file that opens with a shebang, as the bin entry does", () => { - expect( - scanImports( - '#!/usr/bin/env node\nimport { main } from "./cli/program.js";\nmain();\n', - "cli.ts", - ), - ).toEqual(["./cli/program.js"]); - }); - - test("resolveImport maps .js to .ts, a directory to its index, and .json to itself, and throws on a dangling one", () => - withTempDir("changed-sections-", (dir) => { - const root = syntheticRepo(dir, { - "src/sections/shared/engine.ts": "", - "src/sections/shared/util/index.ts": "", - "src/sections/labels/index.ts": "", - "lib/settings.schema.json": "{}", - }); - const importer = join(root, "src/sections/labels/index.ts"); - expect(resolveImport(importer, "../shared/engine.js")).toBe( - join(root, "src/sections/shared/engine.ts"), - ); - expect(resolveImport(importer, "../shared/engine")).toBe( - join(root, "src/sections/shared/engine.ts"), - ); - expect(resolveImport(importer, "../shared/util")).toBe( - join(root, "src/sections/shared/util/index.ts"), - ); - expect(resolveImport(importer, "../../../lib/settings.schema.json")).toBe( - join(root, "lib/settings.schema.json"), - ); - expect(() => resolveImport(importer, "../shared/missing.js")).toThrow( - /imports "\.\.\/shared\/missing\.js", which resolves to no file/, - ); - expect(() => resolveImport(importer, "../../../lib/missing.json")).toThrow( - /imports "\.\.\/\.\.\/\.\.\/lib\/missing\.json", which resolves to no file/, - ); - })); - - test("the fan-out follows the graph through intermediates and ignores non-section importers", () => - withTempDir("changed-sections-", (dir) => { - const fanOut = deriveSharedFanOut(syntheticRepo(dir, GRAPH_FIXTURE)); - expect(fanOut).toEqual({ - // src/schema.ts imports engine directly and registry.ts reaches it through teams; neither adds a key, and pages' type-only import is no edge. - "engine.ts": inKeyOrder("labels", "teams"), - "factory.ts": inKeyOrder("labels"), - "util/index.ts": inKeyOrder("pages", "milestones"), - }); - })); - - test.each<[label: string, files: Record, error: RegExp]>([ - [ - "a shared file no section imports", - { - "src/sections/shared/live.ts": "export const live = 1;\n", - "src/sections/shared/dead.ts": "export const dead = 1;\n", - "src/sections/labels/index.ts": - 'import { live } from "../shared/live.js";\nexport default live;\n', - }, - /no section imports src\/sections\/shared\/dead\.ts/, - ], - [ - "a dangling relative import anywhere under src", - { - "src/sections/shared/engine.ts": "export const engine = 1;\n", - "src/sections/labels/index.ts": - 'import { gone } from "../shared/gone.js";\nexport default gone;\n', - }, - /resolves to no file/, - ], - [ - // The graph must read every file through the computed-specifier check, not the transpiler alone, which - // silently drops such an edge and under-selects. - "a computed import anywhere under src, naming the file", - { - ...GRAPH_FIXTURE, - "src/sections/webhooks/index.ts": - 'const which = "../shared/engine.js";\nexport const engine = await import(which);\n', - }, - /src\/sections\/webhooks\/index\.ts:2 loads a module through a computed specifier/, - ], - ])("%s fails the whole derivation", (_label, files, error) => - withTempDir("changed-sections-", (dir) => { - expect(() => deriveSharedFanOut(syntheticRepo(dir, files))).toThrow(error); - }), - ); -}); - -describe("changed-sections file map", () => { - test("every path on disk under src/sections, test/sections, and docs/sections resolves through some selector rule", () => { - const paths = [ - ...sectionsPathsOnDisk(), - ...sectionsPathsOnDisk(join(ROOT, "test", "sections"), "test/sections"), - ...sectionsPathsOnDisk(join(ROOT, "docs", "sections"), "docs/sections"), - "docs/schema.docs.yml", - ]; - expect(paths.filter((path) => path.startsWith("test/")).length).toBeGreaterThan(300); - expect(paths.filter((path) => path.startsWith("docs/")).length).toBeGreaterThan(20); - for (const path of paths) { - expect(() => sectionsForFiles(changed(path)), `${path} does not resolve`).not.toThrow(); - } - }); - - test("src/ holds code only: no test, mock, generator, scenario, or docs prose lives under it", () => { - const strays = readdirSync(SRC_DIR, { recursive: true, encoding: "utf8" }).filter( - (entry) => - entry.endsWith(".test.ts") || - entry.endsWith("/mock.ts") || - entry.endsWith("/generators.ts") || - entry.endsWith(".docs.yml") || - entry.split("/").includes("scenarios"), - ); - expect(strays).toEqual([]); - }); - - test("every top-level src entry is either sections/ or all-selecting", () => { - // A new top-level src module the selector does not know would let PRs touching only it skip the smoke job. Stray artifacts like .DS_Store are not - // selector inputs. - for (const entry of readdirSync(SRC_DIR, { withFileTypes: true })) { - if (!entry.isDirectory() && !entry.name.endsWith(".ts")) { - continue; - } - const path = entry.isDirectory() ? `src/${entry.name}/` : `src/${entry.name}`; - if (path === "src/sections/") { - continue; - } - expect( - ALL_SELECTING_PREFIXES.includes(path), - `${path} is not in ALL_SELECTING_PREFIXES, so the selector would ignore changes to it`, - ).toBe(true); - } - }); -}); - -describe("changed-sections selection", () => { - // Every rule that reads a path list to a rendered selection: "none", "all", or the keys in SECTION_KEYS order. - test.each<[label: string, rendered: string, files: string[]]>([ - ["a docs-only change", "none", ["README.md", "COVERAGE.md", ".github/workflows/ci.yml"]], - ["a section's entry", "labels", ["src/sections/labels/index.ts"]], - ["a section's mock", "labels", ["test/sections/labels/mock.ts"]], - ["a section's generators", "labels", ["test/sections/labels/generators.ts"]], - ["a section's unit test", "labels", ["test/sections/labels/labels.test.ts"]], - [ - "a section's scenario", - "environments", - ["test/sections/environments/scenarios/environments-apply.yml"], - ], - ["a section's docs prose", "labels", ["docs/sections/labels.docs.yml"]], - [ - "a section whose key carries underscores", - "secret_scanning_custom_patterns", - ["src/sections/secret_scanning_custom_patterns/schema.ts"], - ], - [ - "a mirrored key carrying underscores", - "secret_scanning_custom_patterns", - ["test/sections/secret_scanning_custom_patterns/mock.ts"], - ], - [ - "multiple section directories, which union in SECTION_KEYS order", - "labels,milestones", - ["src/sections/milestones/index.ts", "test/sections/labels/labels.test.ts"], - ], - // The suites and fixtures beside the mirrors are cross-section: they select none, as they did before the mirrors joined them. - [ - "a cross-section suite, helper, and fixture beside the mirrors", - "none", - [ - "test/sections/contract.test.ts", - "test/sections/section-run.ts", - "test/sections/snapshot-rows/labels.ts", - ], - ], - ["registry.ts", "all", ["src/sections/registry.ts"]], - ["the shared docs prose, like the docs registry", "none", ["docs/sections/shared.docs.yml"]], - ["the document root's docs prose", "none", ["docs/schema.docs.yml"]], - [ - "the shared docs prose beside a section", - "labels", - ["docs/sections/shared.docs.yml", "docs/schema.docs.yml", "src/sections/labels/index.ts"], - ], - // The docs-only aggregator is never in the bundle and build:check gates docs drift, so it behaves like lib/. - ["docs-registry.ts", "none", ["src/sections/docs-registry.ts"]], - [ - "docs-registry.ts beside a section, which it never masks", - "labels", - ["src/sections/docs-registry.ts", "src/sections/labels/index.ts"], - ], - [ - "docs-registry.ts beside a core path", - "all", - ["src/sections/docs-registry.ts", "src/schema.ts"], - ], - // The src/ entries are held to ALL_SELECTING_PREFIXES by the file-map test; these cross-cutting prefixes outside - // src's top level have no such reading. - ["the request layer", "all", ["src/sections/contract/requests.ts"]], - ["the e2e runner", "all", ["test/e2e/runner.ts"]], - ["the selector itself", "all", [".github/scripts/changed-sections.ts"]], - ["a workflow", "all", [".github/workflows/checks.yml"]], - ["a composite action", "all", [".github/actions/setup/action.yml"]], - // lib/settings.schema.json regenerates alongside schema-affecting src changes; forcing "all" would kill diff-awareness. - [ - "a section change plus a regenerated schema, which scopes to the section", - "labels", - ["src/sections/labels/index.ts", "lib/settings.schema.json"], - ], - [ - "a core-path change beside a section change, which wins", - "all", - ["src/sections/labels/index.ts", "src/engine/diff.ts"], - ], - ])("%s selects %s", (_label, rendered, files) => { - expect(renderSelection(sectionsForFiles(changed(...files)))).toBe(rendered); - }); - - test("a shared file selects its derived fan-out", () => { - const fanOut = deriveSharedFanOut(ROOT)["roles.ts"] ?? []; - expect(fanOut.length).toBeGreaterThan(1); - expect(renderSelection(sectionsForFiles(changed("src/sections/shared/roles.ts")))).toBe( - fanOut.join(","), - ); - }); - - test("a deleted shared file adds nothing itself; every other path rule still applies", () => { - // Its importers had to change in the same diff and select their sections. - expect(sectionsForFiles(removed("src/sections/shared/roles.ts")).kind).toBe("none"); - expect( - renderSelection( - sectionsForFiles([ - ...removed("src/sections/shared/roles.ts"), - ...changed("src/sections/collaborators/index.ts", "src/sections/teams/index.ts"), - ]), - ), - ).toBe("collaborators,teams"); - // A deleted scenario can leave a route cold, so its section still runs. - expect( - renderSelection(sectionsForFiles(removed("test/sections/labels/scenarios/labels-apply.yml"))), - ).toBe("labels"); - // The CLI diffs with --no-renames, so folding a flat suite into its directory is a D plus an A: the deletion is - // no misplaced file and the added file carries the section. - expect( - renderSelection( - sectionsForFiles([ - ...removed("test/sections/labels-schema.test.ts"), - ...changed("test/sections/labels/schema.test.ts"), - ]), - ), - ).toBe("labels"); - expect(renderSelection(sectionsForFiles(removed("test/sections/labels.test.ts")))).toBe("none"); - expect(sectionsForFiles(removed("src/sections/registry.ts")).kind).toBe("all"); - expect(() => sectionsForFiles(removed("src/sections/labels.ts"))).toThrow( - /matches no selector rule/, - ); - }); - - test("a deleted shared file whose importers now resolve to its sibling spelling selects that sibling's sections", () => - withTempDir("changed-sections-", (dir) => { - // foo.ts and foo/index.ts are interchangeable to an importer of "./foo.js", so deleting one leaves the importers unchanged and typecheck green. - const fanOut = deriveSharedFanOut( - syntheticRepo(dir, { - "src/sections/shared/a/index.ts": "export const a = 1;\n", - "src/sections/shared/b.ts": "export const b = 1;\n", - "src/sections/labels/index.ts": - 'import { a } from "../shared/a.js";\nexport default a;\n', - "src/sections/teams/index.ts": 'import { b } from "../shared/b.js";\nexport default b;\n', - }), - ); - const select = (files: ChangedFile[]) => - renderSelection(sectionsForFiles(files, () => fanOut)); - expect(select(removed("src/sections/shared/a.ts"))).toBe("labels"); - expect(select(removed("src/sections/shared/b/index.ts"))).toBe("teams"); - expect(select(removed("src/sections/shared/c.ts"))).toBe("none"); - // Only .ts files borrow a sibling spelling, so a deleted "a.js" cannot borrow a/index.ts; a deleted file of - // any other kind under shared/ has nothing left to smoke and adds nothing, while the same path changed in - // place still throws. - expect(select(removed("src/sections/shared/a.js"))).toBe("none"); - expect(select(removed("src/sections/shared/notes.md"))).toBe("none"); - expect(() => select(changed("src/sections/shared/notes.md"))).toThrow( - /matches no selector rule/, - ); - })); - - test("parseNameStatus reads NUL-delimited records raw and throws on any other shape", () => { - // -z keeps a path with a tab, a quote, and a backslash verbatim; git would C-quote it otherwise and the test/sections/ prefix would go unmatched. - const odd = 'test/sections/labels/scenarios/tab\there "quoted" back\\slash.yml'; - expect( - parseNameStatus( - `A\0src/sections/labels/index.ts\0M\0README.md\0D\0src/sections/shared/roles.ts\0T\0lib/settings.schema.json\0M\0${odd}\0`, - ), - ).toEqual([ - ...changed("src/sections/labels/index.ts", "README.md"), - ...removed("src/sections/shared/roles.ts"), - ...changed("lib/settings.schema.json", odd), - ]); - // Every status --no-renames can emit is a record; only D means deleted. - expect(parseNameStatus("A\0a\0D\0d\0M\0m\0T\0t\0U\0u\0X\0x\0B\0b\0")).toEqual([ - ...changed("a"), - ...removed("d"), - ...changed("m", "t", "u", "x", "b"), - ]); - expect(parseNameStatus("")).toEqual([]); - // A rename score means --no-renames was lost and its two paths misalign the fields; Q is a letter git never uses. - expect(() => parseNameStatus("R100\0old.ts\0new.ts\0")).toThrow(/unparseable/); - expect(() => parseNameStatus("R\0old.ts\0")).toThrow(/unparseable/); - expect(() => parseNameStatus("Q\0q.ts\0")).toThrow(/unparseable/); - expect(() => parseNameStatus("M\0")).toThrow(/unparseable/); - expect(() => parseNameStatus("src/sections/labels/index.ts\0")).toThrow(/unparseable/); - // A cut-off stream (no terminator on the last field) is not a record. - expect(() => parseNameStatus("M\0README.md")).toThrow(/not NUL-terminated/); - }); - - test("an unrecognized section-shaped path throws instead of silently selecting nothing, even beside a cross-cutting path", () => { - // registry.ts and docs-registry.ts are the only flat files src/sections/ allows; a section directory must spell its - // key under src/sections/ and test/sections/ alike; a flat file under test/sections/ named after a section is - // misplaced; under shared/ only mapped .ts files are known; docs/sections/ holds .docs.yml and - // shared.docs.yml and nothing else. - for (const stray of [ - "src/sections/labels.ts", - "src/sections/not_a_key/index.ts", - "src/sections/shared/unmapped.ts", - "src/sections/shared/notes.yml", - "src/sections/shared/shared.docs.yml", - "test/sections/label/mock.ts", - "test/sections/not_a_key/scenarios/x.yml", - "test/sections/shared/helper.ts", - "test/sections/labels.test.ts", - "test/sections/labels-schema.test.ts", - "test/sections/secret-scanning-custom-patterns-schema.test.ts", - "test/sections/actions_secrets.ts", - "test/sections/interaction_limits_schema.test.ts", - "test/sections/interaction-limits_schema.test.ts", - "docs/sections/labels.md", - "docs/sections/not_a_key.docs.yml", - "docs/sections/labels/labels.docs.yml", - ]) { - expect(() => sectionsForFiles(changed(stray)), `${stray} must throw`).toThrow( - /matches no selector rule/, - ); - // Every src/sections/ path is resolved before answering "all", so the stray path cannot ride along. - expect( - () => sectionsForFiles(changed("src/schema.ts", stray)), - `${stray} beside a core path`, - ).toThrow(/matches no selector rule/); - } - }); -});