From bd84e6cc23ecb3dccc9e0de480612f38e13d3d65 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:27:36 -0400 Subject: [PATCH] ci(checks): run the full e2e and coverage on every PR; drop the diff-aware section selector Every pull request now runs the whole scenario corpus, 25 fuzz iterations, and the endpoint-coverage tripwire; the e2e-smoke and endpoint-coverage jobs lose their diff step, their skip conditions, and the full-history checkout only the diff needed. The selector script and its test are deleted, and the workflow test that pinned its printed tokens goes with them. arch-lint.ts absorbs the import scanner and resolver it shared with the selector, reading every load form from the one oxc parse it already makes, so Bun.Transpiler and the shebang special case go. The layout census the selector's test carried moves to test/layout.test.ts: src/ holds code only, and a file named after a section lives in the mirror directory of its key, never flat beside the directories. The mirror root has one owner, SECTIONS_TEST_ROOT in test/e2e/schema.ts, which scenarioRoots() defaults to and the census imports. CONTRIBUTING.md and the three shared-module headers that named the selector are updated. --- .github/scripts/arch-lint.ts | 128 +++++-- .github/scripts/changed-sections.ts | 473 ----------------------- .github/workflows/checks.yml | 49 +-- .github/workflows/nightly.yml | 2 +- CONTRIBUTING.md | 2 +- src/sections/shared/repo-secrets.ts | 3 +- src/sections/shared/repo-variables.ts | 3 +- src/sections/shared/schema-helpers.ts | 1 - test/architecture/architecture.test.ts | 48 ++- test/docs/checks-sections-gate.test.ts | 28 -- test/e2e/schema.ts | 6 +- test/layout.test.ts | 69 ++++ test/scripts/changed-sections.test.ts | 497 ------------------------- 13 files changed, 229 insertions(+), 1080 deletions(-) delete mode 100644 .github/scripts/changed-sections.ts delete mode 100644 test/docs/checks-sections-gate.test.ts create mode 100644 test/layout.test.ts delete mode 100644 test/scripts/changed-sections.test.ts 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/); - } - }); -});