diff --git a/.changeset/8953-test-path-roots-gate.md b/.changeset/8953-test-path-roots-gate.md new file mode 100644 index 0000000000..66840e62ab --- /dev/null +++ b/.changeset/8953-test-path-roots-gate.md @@ -0,0 +1,24 @@ +--- +--- + +Tooling and test-only: `scripts/check-test-path-roots.mjs` rejects a test that +resolves a path below `process.cwd()`, closing a class that produced 13 defects +in one day (objectui#7799) and that root `AGENTS.md` has taught with nothing +behind it since PR objectui#8952 (objectui#8953). + +Nothing ships. No runtime source changed; the three repaired files are test +files, and the gate and its pin live under `scripts/`. + +The detector is not a `process.cwd` grep, deliberately: one of objectui#7799's +own 13 defects was invisible to that card's census regex because it spelled the +read through `(globalThis as unknown as {…}).process.cwd()`. The scan starts at +the FILESYSTEM CALL and resolves what its path argument is rooted at, following +the file's own bindings, so a root laundered through a `const` — the shape of +both `examples/schema-catalog` instances, whose read lines carry no `cwd` at all +— is caught where a text search finds nothing. It classifies every root it can +and PRINTS the number it cannot, so a clean run is never read as a claim about +the whole class. + +Readings on `87f174c00`: 1880 filesystem calls across 386 of 3036 test files; +8 cwd-rooted reads in 3 files, all repaired here and green under BOTH +invocations afterwards (repo root 611 tests, package directories 599 + 12). diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3d8ec231d8..4acb42ecde 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -408,6 +408,27 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: node scripts/check-vi-mock-override-shape.mjs + # ── Test path roots (objectui#8953) ─────────────────────────────────── + # A test that resolves a repository path from `process.cwd()` reads a + # different tree under a package's own `test` script than under the form + # CI runs, so the same assertion reaches two verdicts. AGENTS.md has + # taught that rule since PR objectui#8952 and nothing enforced it; + # objectui#7799 repaired 13 instances of it in a single day. + # + # This step is a SECOND signal, not the gate. The gate is + # `scripts/__tests__/check-test-path-roots.test.ts`, which runs inside + # `Test (shard N/4)` — a required context that subscribes `merge_group`, + # so it blocks a queue build. This step reaches the same verdict sooner + # and with a message aimed at the author. + # + # After `pnpm install`, unlike the entry-guard step above: the scan parses + # every test file with the TypeScript compiler, because the defect is a + # RESOLVED ROOT and not a spelling — one of objectui#7799's own 13 was + # invisible to that card's census regex. + - name: Verify no test resolves a repository path from the process cwd + if: steps.relevant.outputs.should_run == 'true' + run: node scripts/check-test-path-roots.mjs + # ── The cross-repo closer's outcome contract (#5261) ────────────────── # `cross-repo-issue-closer.yml` carries ~250 lines of inline diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 34d1651354..28db30bc03 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -318,6 +318,35 @@ first, which is what stops a scanner that recognises nothing from reporting a cl reads declared return types with the TypeScript parser, and every pre-install gate's import graph is held to node builtins plus local modules ([#8903](https://github.com/objectstack-ai/objectui/issues/8903)). +- Then `scripts/check-test-path-roots.mjs` — a test that reads the filesystem inside an assertion + must root its paths on **its own file**, never on `process.cwd()`. The cwd is not one place here: + a package's own `test` script moves Vitest's root up to the repository root and leaves + `process.cwd()` down in the package directory, so a path assembled from the cwd reads a + **different tree** depending on which invocation started it, and a single assertion reaches two + verdicts — measured at `7 passed` from the repository root and `2 failed / 5 passed` from the + package directory, cwd the only variable + ([#7791](https://github.com/objectstack-ai/objectui/issues/7791)). Root `AGENTS.md` had taught + that rule with nothing behind it, and what a taught-only rule costs is also measured: + [#7799](https://github.com/objectstack-ai/objectui/issues/7799) repaired thirteen instances of the + class in a single day without closing it, and `gridArrayArmOrderby-8973.test.tsx` — written + **after** that sweep — arrived carrying the same defect + ([#8953](https://github.com/objectstack-ai/objectui/issues/8953)). It is deliberately not a + `process.cwd` grep, because one of those thirteen was invisible to the census regex that found the + other twelve: it spelled the read through a `globalThis` cast, to dodge a browser `process` shim. + So the scan starts at the **filesystem call** and resolves what its path argument is rooted at + through the file's own bindings — which catches a root laundered through a `const`, on a line that + holds no `cwd` at all — and it decides what counts as a filesystem call by **import provenance** + rather than by name, because a test that declares its own `writeFile` into a temporary directory + is twelve false positives for anything reading the spelling. Across the tree, name-matching + produced 28 violations and provenance produces 8. +- ⚠️ **Read that gate's green for what it is: it declares its own blind spot on every run.** The + census line ends with `N root(s) NOT CLASSIFIED` — 376 as this was written, enumerated by + `--blind` — because root resolution stops at the module edge, so a root arriving as a function + parameter or from an import is invisible to it. That is the largest gap and it is structural: one + of the three files repaired alongside the gate handed `process.cwd()` straight to a helper that + did the reads, and was found by a human reading the file, not by the gate. A clean run is a + verdict on the roots this gate can classify, never a clean bill of health for the class — which is + the same over-reading the card itself is about. - Then `scripts/check-cross-repo-closer-outcome.mjs` — it extracts the ~250 lines of inline `github-script` out of `cross-repo-issue-closer.yml` with a real parser, never a retyped copy, runs it under doubles the way `actions/github-script` does, and pins each exit's outcome: which diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index ef6e7af483..c7e9eb4243 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -149,6 +149,32 @@ import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@obje import fs from 'node:fs'; import path from 'node:path'; import { allExamples } from '../src/index.js'; + +/** + * The repo root, derived from THIS FILE's own location — never from + * `process.cwd()` (objectui#7799, and the gate that closed the class, + * objectui#8953). + * + * What stood at the read sites below was `path.join(process.cwd(), …)` + * under the comment "`process.cwd()` is the repo root by construction: + * `scripts/vitest-invocation-guard.mjs` refuses any run whose Vitest root is + * not it". THAT PREMISE IS FALSE. The guard rejects a run whose VITEST root is + * not the repo root; this package's own `test` script — `vitest run --root ../.. + * examples/schema-catalog/`, which is what `pnpm --filter … test` and + * `turbo run test` both run — sets that root correctly while leaving + * `process.cwd()` in the package directory. The guard passes and the cwd is the + * package, so `apps/site/app/components` resolved to a path that does not + * exist and every read below threw. + * + * Spelled in string operations, copying the landed precedent of objectui#7791 + * (PR #7796) and objectui#7799 (PR #7806): only BARE `import.meta.url` is read + * here and taken apart by hand. + */ +const SELF_DEPTH_BELOW_REPO_ROOT = 4; // examples / schema-catalog / test / this file +const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + .split('/') + .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + .join('/'); // Plain-JS CI helper; types are inferred from the `.mjs` source (`allowJs`), the // same route `scripts/__tests__/known-schema-types-derivation-5115.test.ts` // takes. objectui#6024 reuses this derivation rather than re-deriving: a second @@ -732,9 +758,8 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () * reads the host and pins what the sweep assumes about it. */ describe('the docs-site gallery host registers the same set', () => { - // `process.cwd()` is the repo root by construction: `scripts/vitest- - // invocation-guard.mjs` refuses any run whose Vitest root is not it. - const siteDir = path.join(process.cwd(), 'apps/site/app/components'); + // Rooted at this file, never at the cwd — see `REPO_ROOT` above. + const siteDir = path.join(REPO_ROOT, 'apps/site/app/components'); const read = (f: string) => fs.readFileSync(path.join(siteDir, f), 'utf8'); it('loads every package this pin loads, in this pin’s order', () => { @@ -923,10 +948,12 @@ const PLUGIN_CATEGORIES = [ ].sort(); /** - * `process.cwd()` is the repo root by construction — `scripts/vitest- - * invocation-guard.mjs` refuses any run whose Vitest root is not it. + * Rooted at this file, never at the cwd — see `REPO_ROOT` above. This one is + * the shape objectui#8953's gate states it cannot see: the repository path is + * resolved by the HELPER, on this file's behalf, so no filesystem call here + * carries the cwd and nothing scanning this file's own reads would find it. */ -const derivedRegistry = deriveRegistryKeys(process.cwd()); +const derivedRegistry = deriveRegistryKeys(REPO_ROOT); /** * category → the key set `packages/` registers, joined on the @@ -1332,7 +1359,7 @@ describe('objectui#6025 — the gallery DECLARES the packages its entries need', * this file green while the docs page went back to an empty view. */ describe('objectui#5113 — the docs-site hosts supply the same fixture', () => { - const siteDir = path.join(process.cwd(), 'apps/site/app/components'); + const siteDir = path.join(REPO_ROOT, 'apps/site/app/components'); const read = (f: string) => fs.readFileSync(path.join(siteDir, f), 'utf8'); it('the host fixture exposes every method this mirror implements', () => { @@ -1486,7 +1513,7 @@ describe('objectui#6317 — a `select` field declares the options its rows use', it('the host fixture declares the SAME field surface, options included', () => { const hostSource = fs.readFileSync( - path.join(process.cwd(), 'apps/site/app/components/galleryDataSource.ts'), + path.join(REPO_ROOT, 'apps/site/app/components/galleryDataSource.ts'), 'utf8', ); expect( diff --git a/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx b/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx index 4e2b5e4347..63d7a7dd56 100644 --- a/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx +++ b/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx @@ -71,6 +71,31 @@ import fs from 'node:fs'; import path from 'node:path'; import { examplesByCategory } from '../src/index.js'; +/** + * The repo root, derived from THIS FILE's own location — never from + * `process.cwd()` (objectui#7799, and the gate that closed the class, + * objectui#8953). + * + * What stood at the read site below was `path.join(process.cwd(), …)` under the + * comment "`process.cwd()` is the repo root by construction: + * `scripts/vitest-invocation-guard.mjs` refuses any run whose Vitest root is + * not it". THAT PREMISE IS FALSE. The guard rejects a run whose VITEST root is + * not the repo root; this package's own `test` script — `vitest run --root ../.. + * examples/schema-catalog/` — sets that root correctly while leaving + * `process.cwd()` in the package directory. The guard passes and the cwd is the + * package, so `expect(fs.existsSync(siteDir)).toBe(true)` was asserting against + * a path that does not exist under that invocation. + * + * Spelled in string operations, copying the landed precedent of objectui#7791 + * (PR #7796) and objectui#7799 (PR #7806): only BARE `import.meta.url` is read + * here and taken apart by hand. + */ +const SELF_DEPTH_BELOW_REPO_ROOT = 4; // examples / schema-catalog / test / this file +const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + .split('/') + .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + .join('/'); + registerLayout(); /** `DashboardRenderer`'s retired inline-analytics placeholder (framework#3320). */ @@ -205,9 +230,8 @@ describe('plugin-dashboard catalog entries render in the docs gallery (objectui# * things this pin assumes about them. */ it('the docs-site gallery host still registers the dashboard packages and passes the dataset stub', () => { - // `process.cwd()` is the repo root by construction: `scripts/vitest- - // invocation-guard.mjs` refuses any run whose Vitest root is not it. - const siteDir = path.join(process.cwd(), 'apps/site/app/components'); + // Rooted at this file, never at the cwd — see `REPO_ROOT` above. + const siteDir = path.join(REPO_ROOT, 'apps/site/app/components'); expect(fs.existsSync(siteDir)).toBe(true); const registrations = fs.readFileSync(path.join(siteDir, 'registerCatalogBlocks.ts'), 'utf8'); expect(registrations).toContain('@object-ui/plugin-dashboard'); diff --git a/package.json b/package.json index 25d3f2ab8c..a1ad28128d 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "check:lockfile-integrity": "node scripts/check-lockfile-integrity.mjs", "merge-queue-head": "node scripts/check-merge-queue-head.mjs", "check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs", + "check:test-path-roots": "node scripts/check-test-path-roots.mjs", "check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs", "check:vi-mock-inherit": "node scripts/check-vi-mock-inherit.mjs", "check:vi-mock-override-shape": "node scripts/check-vi-mock-override-shape.mjs", diff --git a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx index 235ba2e76b..8e12917851 100644 --- a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx @@ -67,6 +67,36 @@ import { resetRetiredSortSpellingReports } from '@object-ui/core'; // Registers `object-grid` and its `view:grid` alias. import '../index'; +/** + * The repo root, derived from THIS FILE's own location — never from + * `process.cwd()` (objectui#7799, and the gate that closed the class, + * objectui#8953). + * + * The read below stood on `join(process.cwd(), …)` under the comment "Read off + * the vitest root — this project's `import.meta.url` is not a file URL, so the + * sibling `import.meta`-relative idiom does not work here". BOTH HALVES OF THAT + * ARE FALSE, and each had already been falsified before this file was written: + * + * - `import.meta.url` IS a `file:` URL in this project. objectui#7800 + * (comment 5555131785) measured it across three packages and both cwds; the + * sibling `packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx` + * has derived its root this way since PR #7806. What Vite rewrites is the + * TWO-ARGUMENT `new URL(rel, import.meta.url)`, which is why only the bare + * form is read here and taken apart by hand. + * - "the vitest root" and `process.cwd()` are not the same directory. This + * package's own `test` script — `vitest run --root ../.. packages/plugin-grid/`, + * which is what `pnpm --filter @object-ui/plugin-grid test` and + * `turbo run test` both run — sets the VITEST root to the repo root and + * leaves the cwd in `packages/plugin-grid/`, so the path below resolved to + * `packages/plugin-grid/packages/plugin-grid/src/ObjectGrid.tsx` and the read + * threw (objectui#7791, objectui#7799). + */ +const SELF_DEPTH_BELOW_REPO_ROOT = 5; // packages / plugin-grid / src / __tests__ / this file +const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + .split('/') + .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + .join('/'); + function makeAdapter() { return { find: vi.fn().mockResolvedValue({ @@ -191,9 +221,8 @@ describe('object-grid — the arms this card deliberately does NOT move', () => // documented at the read site as deliberate — it is the shape the server // names in its own error messages and it survives a field name containing // a space. Both shapes are accepted by `normalizeSortNodes`. - // Read off the vitest root — this project's `import.meta.url` is not a - // file URL, so the sibling `import.meta`-relative idiom does not work here. - const src = readFileSync(join(process.cwd(), 'packages/plugin-grid/src/ObjectGrid.tsx'), 'utf8'); + // Rooted at this file, never at the cwd — see `REPO_ROOT` above. + const src = readFileSync(join(REPO_ROOT, 'packages/plugin-grid/src/ObjectGrid.tsx'), 'utf8'); // Instrument check FIRST, in both directions: a probe that silently read // the wrong file (or an empty one) would make every `toContain` below a diff --git a/scripts/__tests__/check-test-path-roots.test.ts b/scripts/__tests__/check-test-path-roots.test.ts new file mode 100644 index 0000000000..288a73b8ff --- /dev/null +++ b/scripts/__tests__/check-test-path-roots.test.ts @@ -0,0 +1,257 @@ +/** + * The gate that stops a test resolving a repository path from `process.cwd()` + * (objectui#8953), and the floor under it. + * + * ## Why the enforcement lives HERE and not only in a workflow step + * + * Every test file under `scripts` runs inside `Test (shard N/4)`, which is a + * REQUIRED context and subscribes `merge_group` — so the scan below is what + * actually blocks a queue build. The `Lint` step added alongside it is a second, faster + * signal on the same gate, not the gate itself: a workflow job of one's own + * would report on pull requests and gate nothing. + * + * ## The two sides, and the floor + * + * A gate is only worth its run time if it can FAIL, and a gate that fails on + * things that are fine is worse than none. Both directions are pinned: + * + * - it FIRES on the real historical defects — the spellings objectui#7799 + * repaired, including the one that was invisible to that card's own census + * regex; + * - it is SILENT on the files objectui#7799 MEASURED immune under both cwds, + * which are a false-positive suite with readings already attached; + * - and the population cannot collapse: a walk that finds nothing fails + * instead of reporting clean. + */ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + FLOORS, + KNOWN_CWD_ROOTED, + SUBJECT_IS_THE_CWD, + scan, + selfTest, + sitesIn, +} from '../check-test-path-roots.mjs'; + +/** + * The repo root, derived from THIS FILE's own location — never from + * `process.cwd()`. That is the rule this file's subject enforces, and a test + * for it that broke the rule itself would be its own first violation. + */ +const SELF_DEPTH_BELOW_REPO_ROOT = 3; // scripts / __tests__ / this file +const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + .split('/') + .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + .join('/'); + +const result = scan(REPO_ROOT); + +/** Every filesystem call this gate would flag in `source`. */ +const flagged = (source: string): ReturnType => + sitesIn('fixture/x.test.ts', source).filter((s) => s.kind === 'ambient' && s.appended); + +const FS_HEAD = `import { existsSync, readFileSync, readdirSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\n`; + +describe('check-test-path-roots — the tree', () => { + it('no test resolves a path below the process cwd', () => { + expect( + result.violations.map((v) => `${v.file}:${v.line} ${v.sink}(${v.text}) — rooted at ${v.why}`), + ).toEqual([]); + }); + + it('every registered entry still resolves from the cwd, so the lists cannot outlive what they excuse', () => { + expect(result.stale).toEqual([]); + }); +}); + +describe('check-test-path-roots — the floor (⛔ a gate that scans nothing must not read as clean)', () => { + it('the real walk is above every floor', () => { + expect(result.vacuous).toEqual([]); + for (const [counter, floor] of Object.entries(FLOORS)) { + expect(result.census[counter as keyof typeof result.census]).toBeGreaterThanOrEqual(floor); + } + }); + + it('a walk that finds no files FAILS rather than reporting clean', () => { + const collapsed = scan(REPO_ROOT, { files: [] }); + expect(collapsed.violations).toEqual([]); + // ⭐ The point: zero violations AND zero coverage is a failure, not a pass. + expect(collapsed.vacuous.map((v) => v.counter).sort()).toEqual(Object.keys(FLOORS).sort()); + }); + + it('a walk that finds files but no filesystem call also FAILS', () => { + const noSinks = scan(REPO_ROOT, { files: Array.from({ length: 5000 }, (_, i) => `fake/${i}.test.ts`) }); + expect(noSinks.census.testFiles).toBeGreaterThanOrEqual(FLOORS.testFiles); + expect(noSinks.vacuous.map((v) => v.counter)).toContain('sinkCalls'); + }); +}); + +describe('check-test-path-roots — it FIRES on the real defects (⛔ not only on synthetic ones)', () => { + it('the plain idiom objectui#7799 repaired twelve times', () => { + expect(flagged(`${FS_HEAD}const p = join(process.cwd(), 'scripts/i18n-call-site-key-baseline.json');\nreadFileSync(p);`)).not.toEqual([]); + }); + + it('⭐ the thirteenth defect, which objectui#7799\'s OWN census regex could not see', () => { + // Verbatim from `container-declaration-ratchet.test.tsx` as PR #7806 found + // it. A gate that greps `process.cwd` finds the other twelve and misses + // this one, which is the failure mode this card was filed under. + const hidden = `${FS_HEAD}const BASELINE_PATH = join( + (globalThis as unknown as { process: { cwd(): string } }).process.cwd(), + 'scripts/container-declaration-baseline.json', + ); + readFileSync(BASELINE_PATH, 'utf8');`; + expect(flagged(hidden)).not.toEqual([]); + expect(/process\.cwd\(\)/.test(hidden.replace(/\(globalThis[\s\S]*?\)\.process\.cwd\(\)/, ''))).toBe(false); + }); + + it('the shape found in the tree by this gate and by nothing else: a root laundered through a const', () => { + // `examples/schema-catalog/test/*-gallery-render.test.tsx`, repaired in the + // same change. The line that reads the file carries no `cwd` at all. + const laundered = `${FS_HEAD}const siteDir = join(process.cwd(), 'apps/site/app/components');\nconst read = (f: string) => readFileSync(join(siteDir, f), 'utf8');`; + const sites = flagged(laundered); + expect(sites).not.toEqual([]); + expect(sites[0].why).toContain('siteDir'); + }); + + it('a bare relative path, which names no root and so carries no `cwd` token to grep for', () => { + expect(flagged(`${FS_HEAD}readFileSync('e2e/live/.auth/state.json', 'utf8');`)).not.toEqual([]); + }); + + it('`process.env.PWD`, `resolve()` with a relative argument, and a template literal', () => { + expect(flagged(`${FS_HEAD}readFileSync(join(process.env.PWD as string, 'packages/x/y.ts'));`)).not.toEqual([]); + expect(flagged(`${FS_HEAD}readFileSync(resolve('packages/x/y.ts'));`)).not.toEqual([]); + expect(flagged(`${FS_HEAD}const r = process.cwd();\nreadFileSync(\`\${r}/packages/x/y.ts\`);`)).not.toEqual([]); + }); +}); + +describe('check-test-path-roots — it is SILENT on what objectui#7799 MEASURED immune', () => { + /** + * The false-positive suite, with readings already attached: PR #7806 + * counter-probed all four under both cwds and the `cli` probe really did + * fire, which is what makes the other three greens a reading rather than a + * probe that was never connected. + * + * ⚠️ The card objectui#8953 and its dispatch both say SIX. The measured + * per-file table in PR #7806's report names FOUR: the census was 16 files, + * plus the 17th the regex could not see, of which 13 were repaired. `19 − 13 + * = 6` is arithmetic on a total that was never measured. + */ + const MEASURED_IMMUNE = [ + 'packages/cli/src/__tests__/app-generator.test.ts', + 'packages/plugin-view/src/__tests__/ObjectView.hostOnlyViewTypes.test.tsx', + 'packages/plugin-view/src/__tests__/ViewSwitcher.test.tsx', + 'packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx', + ]; + + it('none of them is an unregistered violation', () => { + const files = new Set(result.violations.map((v) => v.file)); + expect(MEASURED_IMMUNE.filter((f) => files.has(f))).toEqual([]); + }); + + it('all four are still in the tree, or the case above tests nothing', () => { + for (const file of MEASURED_IMMUNE) expect(() => readFileSync(join(REPO_ROOT, file), 'utf8')).not.toThrow(); + }); + + it('⚠️ three of the four are silent because the gate CANNOT CLASSIFY them, which is not the same as a clean verdict', () => { + // The `plugin-view` three probe two candidate paths and take whichever + // exists, so the root arrives out of `Array#find` and `rootOf` stops there. + // Pinning this keeps the distinction visible: `--blind` counts them, and a + // silent gate is not a statement about them. + const probes = result.sites.filter( + (s) => s.file.startsWith('packages/plugin-view/src/__tests__/') && MEASURED_IMMUNE.includes(s.file), + ); + expect(probes.length).toBeGreaterThan(0); + expect(probes.every((s) => s.kind === 'unknown')).toBe(true); + }); + + it('the cwd read PR #7806 deliberately KEPT is not a violation: nothing is appended to it', () => { + // `browser-process-shim-scope.test.ts` exists to compile a `process.cwd()` + // call. It asserts the binding names a real absolute directory and takes + // its repo-root claim from a file-derived root instead. The rule gets that + // right with no entry in any list. + expect(flagged(`${FS_HEAD}const cwd = process.cwd();\nexistsSync(cwd);`)).toEqual([]); + const shim = result.sites.filter((s) => s.file === 'packages/components/src/__tests__/browser-process-shim-scope.test.ts'); + expect(shim.length).toBeGreaterThan(0); + expect(shim.filter((s) => s.kind === 'ambient' && s.appended)).toEqual([]); + }); + + it('the landed repair spelling, the two-argument `new URL` form, `__dirname` and temp dirs are all silent', () => { + expect( + flagged( + `${FS_HEAD}const REPO = decodeURIComponent(new URL(import.meta.url).pathname).split('/').slice(0, -5).join('/');\nreadFileSync(join(REPO, 'packages/x/y.ts'));`, + ), + ).toEqual([]); + expect(flagged(`${FS_HEAD}import { fileURLToPath } from 'node:url';\nreadFileSync(fileURLToPath(new URL('../x.ts', import.meta.url)));`)).toEqual([]); + expect(flagged(`${FS_HEAD}readFileSync(join(__dirname, '../x.ts'));`)).toEqual([]); + expect( + flagged(`${FS_HEAD}import { mkdtempSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nconst d = mkdtempSync(join(tmpdir(), 'x-'));\nreadFileSync(join(d, 'a.txt'));`), + ).toEqual([]); + }); + + it('a LOCAL helper that shares a node:fs name is not a filesystem call', () => { + // `packages/cli/src/__tests__/check-jsonc-parse.test.ts` declares its own + // `writeFile(name, body)` that writes into a `mkdtemp` directory. Matching + // the NAME reports twelve violations there; matching the IMPORT reports + // none, which is the true answer. + expect(flagged(`function writeFile(name: string) { void name; }\nwriteFile('tsconfig.json');`)).toEqual([]); + }); + + it('`require.resolve` through a `createRequire` binding answers from the module graph, not the cwd', () => { + expect( + flagged( + `${FS_HEAD}import { createRequire } from 'node:module';\nconst require_ = createRequire(import.meta.url);\nconst d = require_.resolve('@objectstack/spec/package.json');\nreadFileSync(join(d, 'package.json'));`, + ), + ).toEqual([]); + }); +}); + +describe('check-test-path-roots — the registries', () => { + it('every entry carries a reason, so nothing is silently excused', () => { + for (const entry of [...SUBJECT_IS_THE_CWD, ...KNOWN_CWD_ROOTED]) { + expect(entry).toMatch(/^[\w./@-]+:\d+ -- \S/); + expect(entry.split(' -- ')[1].length).toBeGreaterThan(40); + } + }); + + it('⛔ `KNOWN_CWD_ROOTED` is SHRINK-ONLY — this is the ratchet, in one number', () => { + expect(KNOWN_CWD_ROOTED.length).toBeLessThanOrEqual(1); + }); +}); + +describe('check-test-path-roots — the gate itself', () => { + it('its own self-test passes', () => { + expect(selfTest()).toBe(0); + }); + + it('the run is wired where CI executes it', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { scripts: Record }; + expect(pkg.scripts['check:test-path-roots']).toBe('node scripts/check-test-path-roots.mjs'); + + // `Lint` is in `REQUIRED_CONTEXTS` and subscribes `merge_group`; a step + // inside it gates without adding a context to the queue's required set. + const lint = readFileSync(join(REPO_ROOT, '.github/workflows/lint.yml'), 'utf8') + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); + expect(lint).toContain('node scripts/check-test-path-roots.mjs'); + }); + + it('it goes through the one entry-guard predicate rather than hand-typing one', () => { + const source = readFileSync(join(REPO_ROOT, 'scripts/check-test-path-roots.mjs'), 'utf8'); + expect(source).toContain("import { isEntrypoint } from './invoked-as.mjs';"); + expect(source).not.toContain('process.argv[1]'); + }); + + it('⚠️ it reports its own blind spot on every run, so silence cannot read as coverage', () => { + const printed = execFileSync('node', ['scripts/check-test-path-roots.mjs'], { cwd: REPO_ROOT, encoding: 'utf8' }); + expect(printed).toContain('NOT CLASSIFIED'); + expect(result.census.unclassifiedRoots).toBeGreaterThan(0); + // The census is a partition: every filesystem call lands in exactly one class. + const { selfRooted, absoluteRooted, moduleRooted, ambientRooted, unclassifiedRoots, sinkCalls } = result.census; + expect(selfRooted + absoluteRooted + moduleRooted + ambientRooted + unclassifiedRoots).toBe(sinkCalls); + }); +}); diff --git a/scripts/check-test-path-roots.mjs b/scripts/check-test-path-roots.mjs new file mode 100644 index 0000000000..ccc430f254 --- /dev/null +++ b/scripts/check-test-path-roots.mjs @@ -0,0 +1,728 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Rejects a test that RESOLVES A PATH BELOW THE PROCESS CWD. + * + * node scripts/check-test-path-roots.mjs # scan the tree + * node scripts/check-test-path-roots.mjs --list # every filesystem call, with the root it resolved from + * node scripts/check-test-path-roots.mjs --blind # every root this gate could NOT classify + * node scripts/check-test-path-roots.mjs --json + * node scripts/check-test-path-roots.mjs --self-test # the detector's own cases + * + * Exit: 0 = OK, 1 = an unregistered cwd-rooted read, a stale registry entry, or + * a collapsed population. + * + * ## The rule, and why a rule was not enough (objectui#8953) + * + * Root `AGENTS.md` teaches it: + * + * ⛔ 测试在断言里读文件系统,根定在它自己的文件上,永不定在 `process.cwd()`。 + * + * and the bullet ends by declaring that nothing enforces it. That is this gate. + * + * The mechanism, measured twice. A package's own `test` script is + * `vitest run --root ../.. packages/PKG/` (objectui#3240), which is what + * `pnpm --filter PKG test` and `turbo run test` both run. It moves VITEST's + * root to the repo root and leaves `process.cwd()` in the package directory. + * So a test that builds a path out of the cwd reads a DIFFERENT TREE depending + * on which invocation started it, and the same assertion reaches two verdicts: + * + * objectui#7791 (PR #7796) one file, cwd the only variable: + * repo root `7 passed`, package dir `2 failed / 5 passed` + * objectui#7799 (PR #7806) a census under `packages` of every test mentioning + * `cwd()`, 13 genuinely defective, repaired one at a time + * + * ## Why this is not a `process.cwd` grep, and what that costs + * + * ⛔ The spelling is not the defect. One of objectui#7799's own 13 was + * INVISIBLE to the census regex that found the other twelve, because it spelled + * the read + * + * (globalThis as unknown as { process: { cwd(): string } }).process.cwd() + * + * to dodge a browser `process` shim in `packages/components/src/global.d.ts`. + * A detector a defect can walk around by renaming the call is the failure mode + * this card was filed under, so the shape here is deliberately not textual: + * + * 1. **Sinks, not spellings.** The scan starts at the FILESYSTEM CALL -- + * `readFileSync`, `existsSync`, `readdirSync`, … (`FS_SINKS`) -- and asks + * what its path argument is rooted at. A read that never happens cannot + * reach a wrong tree, and a cwd read that feeds no filesystem call is not + * this defect. + * 2. **Roots are RESOLVED, not matched.** `rootOf` walks the path expression + * to its leftmost term THROUGH the file's own bindings, so the defect is + * caught where it is committed rather than where it is spelled: + * + * const siteDir = path.join(process.cwd(), 'apps/site/app/components'); + * … + * const read = (f) => fs.readFileSync(path.join(siteDir, f), 'utf8'); // ← flagged + * + * The sink line holds no `cwd` at all. Both real instances of that shape in + * this tree were found this way and by nothing else. + * 3. **Any spelling that EVALUATES to the cwd counts.** `rootOf` classifies a + * call whose callee is named `cwd` however it was reached -- bare + * `process.cwd()`, `globalThis.process.cwd()`, the `as unknown as` cast + * above, or an alias bound three hops earlier -- because it reads the AST's + * call target, not the source text. `process.env.PWD` and + * `process.env.INIT_CWD` are the same root under another name and are + * classified with it. So is a BARE RELATIVE STRING handed straight to a + * filesystem call: `readFileSync('e2e/live/.auth/state.json')` names no root + * at all, which is precisely how it gets the ambient one. + * + * ⚠️ What it CANNOT see, named rather than implied -- `--blind` enumerates + * every one of them in the tree as it stands, and the census line prints the + * count on every run so that this gate's silence can never read as a clean + * bill of health for the whole class: + * + * - a root that arrives as a FUNCTION PARAMETER, or from an IMPORT. `rootOf` + * resolves bindings within one file and stops at the module edge, so a + * helper that resolves a repository path on the test's behalf is invisible + * here. That is the largest blind spot and it is structural. + * - a root read from any environment variable other than the two named above. + * - a filesystem call reached dynamically (`fs[name](p)`), or one made by a + * helper module rather than by the test file itself. + * - a cwd-rooted path handed to something that is NOT a filesystem call -- + * a `spawnSync` cwd option, a glob library, `import()`. Deliberate: those + * have their own contracts and this gate does not model them. + * - test-shaped files that do not match `TEST_FILE`, and any file git does + * not track. + * + * ## The verdict, stated exactly + * + * A violation is **an ambient root with at least one path segment appended to + * it** -- that is, a test resolving a path BELOW the cwd. Appending is what + * makes the verdict cwd-dependent; the cwd itself is not: + * + * existsSync(cwd) ← NOT a violation + * existsSync(join(cwd, 'pnpm-workspace.yaml')) ← a violation + * + * The first is `packages/components/src/__tests__/browser-process-shim-scope.test.ts`, + * which exists to compile a `process.cwd()` call and asserts only that the + * binding names a real absolute directory. PR #7806 rewrote it into exactly + * that shape, and it needs no entry in any list here: the rule gets it right. + * + * ⛔ The appended segments are NOT required to look like a repository path. + * Requiring `packages/`-or-similar in the suffix would be the grep thinking + * coming back in through the window -- a path assembled from variables carries + * no such literal, and anything below a cwd that moves is cwd-dependent whether + * or not this file recognises its name. + * + * ## The fix this gate points at + * + * The spelling PR #7796 landed and PR #7806 reused, from BARE `import.meta.url`: + * + * const SELF_DEPTH_BELOW_REPO_ROOT = 5; // packages / pkg / src / __tests__ / this file + * const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + * .split('/') + * .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + * .join('/'); + * + * ⚠️ This gate does NOT flag the two-argument `new URL(rel, import.meta.url)` + * form, and the reason is a measurement rather than a preference. AGENTS.md + * warns that Vite rewrites it to an `http://localhost:3000/@fs/…` URL on which + * `fileURLToPath` throws. Re-measured for objectui#8953 on this tree: 21 live + * call sites use it and all of them pass, from the repo root AND from a package + * directory (`packages/app-shell` probed both ways, 21 and 13 tests green). It + * is self-rooted by construction, so it is not this defect in either case, and + * a gate that reddened 21 green files over a hazard that did not reproduce + * would be a false-positive engine. The warning is left where it is; whether it + * still holds anywhere is a separate question from this one. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +import { isEntrypoint } from './invoked-as.mjs'; + +/** Files this gate calls a test. */ +export const TEST_FILE = /\.(test|spec)\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/; + +/** + * The filesystem calls whose first argument is a path. Sync, callback and + * promise spellings together: they differ in how they return, not in how they + * resolve a relative path. + */ +export const FS_SINKS = Object.freeze([ + 'access', 'accessSync', + 'appendFile', 'appendFileSync', + 'copyFile', 'copyFileSync', + 'cp', 'cpSync', + 'createReadStream', 'createWriteStream', + 'exists', 'existsSync', + 'glob', 'globSync', + 'lstat', 'lstatSync', + 'mkdir', 'mkdirSync', + 'open', 'openSync', + 'opendir', 'opendirSync', + 'readFile', 'readFileSync', + 'readdir', 'readdirSync', + 'readlink', 'readlinkSync', + 'realpath', 'realpathSync', + 'rm', 'rmSync', + 'rmdir', 'rmdirSync', + 'stat', 'statSync', + 'unlink', 'unlinkSync', + 'writeFile', 'writeFileSync', +]); +const SINKS = new Set(FS_SINKS); + +/** `join`/`resolve`-family calls: the root is their FIRST argument. */ +const PATH_COMBINERS = new Set(['join', 'resolve', 'normalize', 'toNamespacedPath']); +/** Calls that pass a path through unchanged, so the root is their first argument. */ +const PATH_PASSTHROUGH = new Set(['fileURLToPath', 'decodeURIComponent', 'decodeURI', 'dirname', 'realpathSync', 'realpath', 'pathToFileURL', 'normalize']); +/** Calls that produce an ABSOLUTE path of their own, whatever the cwd is. */ +const ABSOLUTE_PRODUCERS = new Set(['tmpdir', 'mkdtempSync', 'mkdtemp', 'homedir', 'resolveSync']); +/** The specifiers whose exports are filesystem calls. */ +const FS_MODULES = new Set(['fs', 'node:fs', 'fs/promises', 'node:fs/promises', 'graceful-fs']); + +/** + * Ambient reads whose SUBJECT is the cwd, registered with the reason. ⛔ Not a + * debt list -- re-rooting one of these would break the test it belongs to. + * Every entry is `path:line -- reason`, and a stale entry (the site no longer + * resolves from the cwd) fails the run, so this list cannot quietly outlive + * what it excuses. + */ +export const SUBJECT_IS_THE_CWD = Object.freeze([ + 'packages/cli/src/__tests__/app-generator.test.ts:1161 -- `contextOfCurrentProcess()` mirrors the ambient cwd on BOTH sides of the assertion deliberately: the writer under test derives its context from `process.cwd()` and the expectation derives it the same way, so the pair is self-consistent under either cwd. Re-rooting one side desyncs the mirror and turns a passing test red. The real cost -- that WHICH generator branch those two cases pin is decided by the launch directory -- is filed as objectui#7807 and is closed in the same file by two further cases that name their branch instead of inheriting it.', +]); + +/** + * Cwd-rooted reads owed a repair. ⛔ SHRINK-ONLY: adding a line here is + * admitting a new instance of a class that produced 13 defects in one day. + */ +export const KNOWN_CWD_ROOTED = Object.freeze([ + 'e2e/live/inline-edit-polish-2572.spec.ts:34 -- `readFileSync(\'e2e/live/.auth/state.json\')`. Playwright, not Vitest: this spec runs only in `live-e2e.yml` against a real backend, so the repair cannot be verified from a seat that cannot run it, and whether `import.meta` survives Playwright\'s TS transform here is unmeasured. Tracked as objectui#9188.', +]); + +/** + * The population floors. A gate that scans nothing passes, and that is the + * failure direction this repository has carded more than once -- so a walk that + * collapses (a broken glob, a `git ls-files` that returns nothing, an AST that + * stops matching) fails the run instead of reporting clean. Set with room: they + * catch a collapse, not ordinary movement. + */ +export const FLOORS = Object.freeze({ testFiles: 2000, sinkCalls: 800, selfRooted: 20, filesWithSinks: 100 }); + +// --------------------------------------------------------------------------- +// The detector +// --------------------------------------------------------------------------- + +/** + * @typedef {{ kind: 'ambient' | 'self' | 'absolute' | 'module' | 'unknown', why: string }} Root + */ + +const AMBIENT = (why) => ({ kind: 'ambient', why }); +const SELF = (why) => ({ kind: 'self', why }); +const ABSOLUTE = (why) => ({ kind: 'absolute', why }); +const MODULE = (why) => ({ kind: 'module', why }); +const UNKNOWN = (why) => ({ kind: 'unknown', why }); + +const calleeName = (node) => { + const callee = node.expression; + if (ts.isIdentifier(callee)) return callee.text; + if (ts.isPropertyAccessExpression(callee)) return callee.name.text; + return null; +}; + +/** + * Is `node` a module-resolution call -- `require.resolve`, `import.meta.resolve`, + * or the same through a `createRequire()` binding? Those answer from the module + * graph and return an absolute path, whatever the cwd is. + * + * The `createRequire` owner is resolved THROUGH the file's bindings rather than + * matched by name: this tree really writes `const require_ = createRequire(…)`, + * and a name match on `require` misses it. + */ +const isModuleResolve = (node, src, binds) => { + const callee = node.expression; + if (!ts.isPropertyAccessExpression(callee)) return false; + if (callee.name.text !== 'resolve') return false; + const owner = callee.expression; + const ownerText = owner.getText(src); + if (ownerText === 'require' || ownerText === 'import.meta') return true; + if (ts.isCallExpression(owner) && calleeName(owner) === 'createRequire') return true; + if (binds && ts.isIdentifier(owner)) { + const bound = binds.get(owner.text); + if (bound && ts.isCallExpression(bound) && calleeName(bound) === 'createRequire') return true; + } + return false; +}; + +/** + * The local names that really are filesystem calls in this file, resolved from + * the IMPORT rather than from the call's spelling. + * + * ⭐ This is the other half of "sinks, not spellings", in the opposite + * direction. `packages/cli/src/__tests__/check-jsonc-parse.test.ts` declares its + * own `function writeFile(name, body)` that writes into a `mkdtemp` directory; + * a gate matching the NAME reports twelve violations there and every one of + * them is wrong. Provenance is what tells node's `writeFile` from the file's. + * + * @returns {{ direct: Set, namespaces: Set }} + * `direct` -- local names bound to a named fs export; `namespaces` -- local + * names bound to the whole module, so `.` is a sink. + */ +function fsBindingsOf(src) { + const direct = new Set(); + const namespaces = new Set(); + const note = (specifier, clause) => { + if (!FS_MODULES.has(specifier)) return; + if (!clause) return; + if (ts.isIdentifier(clause)) { namespaces.add(clause.text); return; } // const fs = require('fs') + if (ts.isObjectBindingPattern(clause)) { // const { readFileSync } = require('fs') + for (const el of clause.elements) if (ts.isIdentifier(el.name)) direct.add(el.name.text); + return; + } + if (clause.name) namespaces.add(clause.name.text); // import fs from 'fs' + const named = clause.namedBindings; + if (!named) return; + if (ts.isNamespaceImport(named)) namespaces.add(named.name.text); // import * as fs from 'fs' + else for (const el of named.elements) direct.add(el.name.text); // import { readFileSync } from 'fs' + }; + + const walk = (node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + note(node.moduleSpecifier.text, node.importClause); + } else if (ts.isVariableDeclaration(node) && node.initializer) { + const init = ts.isAwaitExpression(node.initializer) ? node.initializer.expression : node.initializer; + if (ts.isCallExpression(init) && init.arguments.length === 1 && ts.isStringLiteral(init.arguments[0])) { + const fn = calleeName(init); + if (fn === 'require' || fn === 'importActual' || init.expression.kind === ts.SyntaxKind.ImportKeyword) { + note(init.arguments[0].text, node.name); + } + } + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(src, walk); + return { direct, namespaces }; +} + +/** Is this call node a filesystem call, by provenance rather than by name? */ +function fsSinkName(node, src, fsBindings) { + const callee = node.expression; + if (ts.isIdentifier(callee)) { + return SINKS.has(callee.text) && fsBindings.direct.has(callee.text) ? callee.text : null; + } + if (ts.isPropertyAccessExpression(callee)) { + if (!SINKS.has(callee.name.text)) return null; + // `fs.readFileSync(…)`, and `fs.promises.readFile(…)`. + let owner = callee.expression; + while (ts.isPropertyAccessExpression(owner) && (owner.name.text === 'promises' || owner.name.text === 'default')) owner = owner.expression; + return ts.isIdentifier(owner) && fsBindings.namespaces.has(owner.text) ? callee.name.text : null; + } + return null; +} + +/** + * The root a path expression resolves from, and whether anything was appended + * below it. + * + * ⭐ This is the whole detector. It reads the AST's call TARGET rather than the + * source text, which is why a spelling change cannot walk around it, and it + * follows the file's own bindings, which is why a root laundered through a + * `const` three screens up is still found. + * + * @param {ts.Node | undefined} node + * @param {{ src: ts.SourceFile, binds: Map, depth?: number, appended?: boolean }} ctx + * @returns {Root & { appended: boolean }} + */ +export function rootOf(node, ctx) { + const { src, binds } = ctx; + const depth = ctx.depth ?? 0; + const appended = ctx.appended ?? false; + const done = (root) => ({ ...root, appended }); + const recur = (n, extra = {}) => rootOf(n, { src, binds, depth: depth + 1, appended, ...extra }); + + if (!node) return done(UNKNOWN('no path argument')); + // 12 hops is far past anything this tree writes; the bound exists so a + // cyclic binding cannot hang the scan. + if (depth > 12) return done(UNKNOWN('resolution depth exceeded')); + + if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node) || ts.isAwaitExpression(node)) return recur(node.expression); + if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node)) return recur(node.expression); + + if (ts.isCallExpression(node)) { + const name = calleeName(node); + // ⭐ The cwd, however it was reached. `process.cwd()`, `globalThis.process.cwd()`, + // `(globalThis as unknown as {…}).process.cwd()` and any alias of them are + // one node shape here, which is the point. + if (name === 'cwd') return done(AMBIENT(`\`${node.expression.getText(src).replace(/\s+/g, ' ')}()\``)); + if (isModuleResolve(node, src, binds)) return done(MODULE(`${node.expression.getText(src)}(…)`)); + if (ABSOLUTE_PRODUCERS.has(name)) return done(ABSOLUTE(`${name}()`)); + if (PATH_COMBINERS.has(name)) { + // `join(root, 'a', 'b')` appends; `join(root)` does not. `resolve()` with + // no argument at all IS the cwd. + if (node.arguments.length === 0) return done(AMBIENT(`\`${name}()\` with no argument is the cwd`)); + return recur(node.arguments[0], { appended: appended || node.arguments.length > 1 }); + } + if (PATH_PASSTHROUGH.has(name)) return recur(node.arguments[0]); + return done(UNKNOWN(`return value of \`${(name ?? node.expression.getText(src)).slice(0, 60)}()\``)); + } + + if (ts.isNewExpression(node)) { + if (node.expression.getText(src) === 'URL') { + const args = node.arguments ?? []; + // `new URL(rel, base)` is rooted at `base` with `rel` appended. + if (args.length >= 2) return rootOf(args[1], { src, binds, depth: depth + 1, appended: true }); + return recur(args[0]); + } + return done(UNKNOWN(`\`new ${node.expression.getText(src).slice(0, 40)}\``)); + } + + if (ts.isPropertyAccessExpression(node)) { + const text = node.getText(src); + if (/(^|[^.\w])import\.meta\.url$/.test(text)) return done(SELF('`import.meta.url`')); + if (text === 'process.env.PWD' || text === 'process.env.INIT_CWD') return done(AMBIENT(`\`${text}\` is the cwd under another name`)); + // `.pathname` / `.href` on a URL keep whatever the URL was rooted at. + if (node.name.text === 'pathname' || node.name.text === 'href') return recur(node.expression); + return done(UNKNOWN(`property \`${text.slice(0, 60)}\``)); + } + + if (ts.isIdentifier(node)) { + if (node.text === '__dirname' || node.text === '__filename') return done(SELF(`\`${node.text}\``)); + const bound = binds.get(node.text); + if (bound) { + const r = recur(bound); + return { ...r, why: `\`${node.text}\` → ${r.why}` }; + } + return done(UNKNOWN(`binding \`${node.text}\` (parameter, import, or assigned elsewhere)`)); + } + + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + const value = node.text; + if (value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value)) return done(ABSOLUTE(`absolute literal \`${value.slice(0, 50)}\``)); + if (value === '' || value === '.') return done(AMBIENT(`\`${JSON.stringify(value)}\` is the cwd`)); + // ⭐ A bare relative string names no root, which is exactly how it gets the + // ambient one -- and it carries no `cwd` token for any grep to find. + return { kind: 'ambient', why: `relative literal \`${value.slice(0, 60)}\` resolves against the cwd`, appended: true }; + } + + if (ts.isTemplateExpression(node)) { + // `` `${ROOT}/rel` `` -- rooted at the first span, with the rest appended. + if (node.head.text.length > 0) { + const head = node.head.text; + if (head.startsWith('/')) return done(ABSOLUTE(`absolute template head \`${head.slice(0, 40)}\``)); + return { kind: 'ambient', why: `relative template head \`${head.slice(0, 40)}\` resolves against the cwd`, appended: true }; + } + const first = node.templateSpans[0]; + if (!first) return done(UNKNOWN('empty template')); + return rootOf(first.expression, { src, binds, depth: depth + 1, appended: true }); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + return rootOf(node.left, { src, binds, depth: depth + 1, appended: true }); + } + + if (ts.isConditionalExpression(node)) { + // Two roots. Ambient on either arm is ambient. + const a = recur(node.whenTrue); + const b = recur(node.whenFalse); + if (a.kind === 'ambient') return a; + if (b.kind === 'ambient') return b; + return a.kind === b.kind ? a : done(UNKNOWN('conditional with two different roots')); + } + + return done(UNKNOWN(ts.SyntaxKind[node.kind])); +} + +/** + * Every `const`/`let` binding in the file, name → initializer. First + * declaration wins, and a later assignment to a `let` is deliberately NOT + * followed: two initializers is two roots, and this gate answers `unknown` + * rather than guessing which one a sink saw. + */ +function bindingsOf(src) { + const binds = new Map(); + const walk = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && !binds.has(node.name.text)) { + binds.set(node.name.text, node.initializer); + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(src, walk); + return binds; +} + +/** + * Every filesystem call in one file, with the root its path resolves from. + * + * @param {string} file Repo-relative path, used for reporting only. + * @param {string} text The file's source. + */ +export function sitesIn(file, text) { + const src = ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + /\.tsx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const binds = bindingsOf(src); + const fsBindings = fsBindingsOf(src); + /** @type {Array<{ file: string, line: number, sink: string, kind: string, appended: boolean, why: string, text: string }>} */ + const sites = []; + + const walk = (node) => { + if (ts.isCallExpression(node)) { + const name = fsSinkName(node, src, fsBindings); + if (name) { + const arg = node.arguments[0]; + const root = rootOf(arg, { src, binds }); + sites.push({ + file, + line: src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1, + sink: name, + kind: root.kind, + appended: root.appended, + why: root.why, + text: (arg ? arg.getText(src) : '').replace(/\s+/g, ' ').slice(0, 140), + }); + } + } + ts.forEachChild(node, walk); + }; + walk(src); + return sites; +} + +const idOf = (site) => `${site.file}:${site.line}`; +const registeredIds = (entries) => new Set(entries.map((e) => e.split(' -- ')[0].trim())); + +/** + * @param {string} root The repository root. + * @param {{ files?: string[] | null, floors?: Record, subjects?: readonly string[], baseline?: readonly string[] }} [options] + * `files` replaces the `git ls-files` walk (fixtures pass their own list); + * `floors` replaces `FLOORS` -- pass `{}` to switch the collapse check off + * for a fixture tree, which is legitimately far below every repo floor. + */ +export function scan(root, { files = null, floors = FLOORS, subjects = SUBJECT_IS_THE_CWD, baseline = KNOWN_CWD_ROOTED } = {}) { + const testFiles = (files ?? trackedFiles(root)).filter((f) => TEST_FILE.test(f)); + const sites = []; + for (const file of testFiles) { + let text; + try { + text = readFileSync(resolve(root, file), 'utf8'); + } catch { + continue; // a tracked-but-absent path (a stale index) is not this gate's business + } + // Cheap pre-filter: a file that imports no filesystem surface has no sink + // to classify. It is a SPEED filter and nothing else -- every file that + // mentions any of these is parsed in full. + if (!/\bfs\b|node:fs|['"]fs['"]|readFileSync|existsSync|readdirSync|statSync|writeFileSync|mkdirSync/.test(text)) continue; + sites.push(...sitesIn(file, text)); + } + + const subjectIds = registeredIds(subjects); + const baselineIds = registeredIds(baseline); + + const violations = []; + const registered = new Set(); + for (const site of sites) { + if (site.kind !== 'ambient' || !site.appended) continue; + const id = idOf(site); + if (subjectIds.has(id) || baselineIds.has(id)) { + registered.add(id); + continue; + } + violations.push(site); + } + const stale = [...subjectIds, ...baselineIds].filter((id) => !registered.has(id)); + + const census = { + testFiles: testFiles.length, + filesWithSinks: new Set(sites.map((s) => s.file)).size, + sinkCalls: sites.length, + selfRooted: sites.filter((s) => s.kind === 'self').length, + absoluteRooted: sites.filter((s) => s.kind === 'absolute').length, + moduleRooted: sites.filter((s) => s.kind === 'module').length, + ambientRooted: sites.filter((s) => s.kind === 'ambient').length, + ambientNotAppended: sites.filter((s) => s.kind === 'ambient' && !s.appended).length, + // ⚠️ The gate's own blind spot, counted on every run. See `--blind`. + unclassifiedRoots: sites.filter((s) => s.kind === 'unknown').length, + }; + + const vacuous = []; + for (const [counter, floor] of Object.entries(floors)) { + if ((census[counter] ?? 0) < floor) vacuous.push({ counter, value: census[counter] ?? 0, floor }); + } + + return { sites, violations, stale, census, vacuous }; +} + +function trackedFiles(root) { + return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 }) + .toString('utf8') + .split('\0') + .filter(Boolean); +} + +function repoRoot() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +/** + * The census line. ⛔ It names the blind spot in the same breath as the clean + * verdict, on purpose: a gate that prints only what it checked reads as a claim + * about everything. + */ +export function summarise({ census }) { + return ( + `${census.sinkCalls} filesystem call(s) in ${census.filesWithSinks} of ${census.testFiles} test file(s); ` + + `roots: ${census.selfRooted} self, ${census.absoluteRooted} absolute, ${census.moduleRooted} module-resolved, ` + + `${census.ambientRooted} ambient (${census.ambientNotAppended} of them reading the cwd itself, which is not this defect); ` + + `⚠️ ${census.unclassifiedRoots} root(s) NOT CLASSIFIED — this gate does not see those (\`--blind\` lists them)` + ); +} + +// --------------------------------------------------------------------------- +// Self-test -- the spellings a grep loses, as cases +// --------------------------------------------------------------------------- + +/** + * ⭐ Every FIRES case is a spelling that a `process.cwd` grep either misses + * outright or cannot connect to the read it poisons; every SILENT case is a + * shape this tree really writes and must not redden. + */ +export function selfTest() { + const cases = []; + const run = (name, source, want) => { + const sites = sitesIn('fixture/x.test.ts', source); + const fired = sites.filter((s) => s.kind === 'ambient' && s.appended); + const ok = want === 'fires' ? fired.length > 0 : fired.length === 0; + cases.push({ name, ok, detail: ok ? '' : `${sites.length} site(s): ${sites.map((s) => `${s.kind}${s.appended ? '+append' : ''} (${s.why})`).join('; ')}` }); + }; + const head = `import { existsSync, readFileSync, readdirSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\n`; + + // ── FIRES ──────────────────────────────────────────────────────────────── + run('the plain idiom', `${head}readFileSync(join(process.cwd(), 'packages/x/y.ts'), 'utf8');`, 'fires'); + // THE case: objectui#7799's thirteenth defect, invisible to that census's regex. + run( + 'the `globalThis as unknown as {…}` cast that dodged objectui#7799\'s own regex', + `${head}readFileSync(join((globalThis as unknown as { process: { cwd(): string } }).process.cwd(), 'scripts/baseline.json'));`, + 'fires', + ); + run('reached through `globalThis.process`', `${head}readFileSync(join(globalThis.process.cwd(), 'a/b.ts'));`, 'fires'); + run('an alias bound earlier in the file', `${head}const proc = globalThis.process;\nreadFileSync(join(proc.cwd(), 'a/b.ts'));`, 'fires'); + // The laundered root: the sink line has no `cwd` on it at all. + run( + 'a root laundered through a const, so the sink line holds no `cwd`', + `${head}const siteDir = join(process.cwd(), 'apps/site');\nconst read = (f: string) => readFileSync(join(siteDir, f), 'utf8');`, + 'fires', + ); + run('two hops of laundering', `${head}const a = process.cwd();\nconst b = join(a, 'apps');\nreaddirSync(join(b, 'site'));`, 'fires'); + run('a template literal', `${head}const root = process.cwd();\nreadFileSync(\`\${root}/packages/x/y.ts\`);`, 'fires'); + run('string concatenation', `${head}readFileSync(process.cwd() + '/packages/x/y.ts');`, 'fires'); + // No `cwd` token anywhere: the root is ambient because it was never named. + run('a bare relative literal, which names no root at all', `${head}readFileSync('e2e/live/.auth/state.json', 'utf8');`, 'fires'); + run('`process.env.PWD`, the cwd under another name', `${head}readFileSync(join(process.env.PWD as string, 'packages/x'));`, 'fires'); + run('`resolve()` with a relative first argument', `${head}readFileSync(resolve('packages/x/y.ts'));`, 'fires'); + run('`resolve()` with no argument at all', `${head}readdirSync(join(resolve(), 'packages'));`, 'fires'); + run('a promise-API sink', `${head}import { readFile } from 'node:fs/promises';\nawait readFile(join(process.cwd(), 'a/b.ts'));`, 'fires'); + + // ── SILENT ─────────────────────────────────────────────────────────────── + run( + 'the landed repair: bare `import.meta.url` taken apart by hand', + `${head}const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname).split('/').slice(0, -5).join('/');\nreadFileSync(join(REPO_ROOT, 'packages/x/y.ts'));`, + 'silent', + ); + run('the two-argument `new URL` form, which is self-rooted', `${head}import { fileURLToPath } from 'node:url';\nreadFileSync(fileURLToPath(new URL('../x.ts', import.meta.url)));`, 'silent'); + run('`__dirname`', `${head}readFileSync(join(__dirname, '../x.ts'));`, 'silent'); + // The shape PR #7806 rewrote `browser-process-shim-scope.test.ts` INTO. + run('reading the cwd ITSELF, with nothing appended', `${head}const cwd = process.cwd();\nexistsSync(cwd);`, 'silent'); + run('a temp directory', `${head}import { mkdtempSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nconst dir = mkdtempSync(join(tmpdir(), 'x-'));\nreadFileSync(join(dir, 'a.txt'));`, 'silent'); + run('`require.resolve`, which answers from the module graph', `${head}readFileSync(require.resolve('@objectstack/spec/package.json'), 'utf8');`, 'silent'); + run('an absolute literal', `${head}readFileSync('/etc/hostname');`, 'silent'); + run('a root that arrives as a parameter stays UNKNOWN, not a violation', `${head}const read = (root: string) => readFileSync(join(root, 'a.ts'));`, 'silent'); + + // ── the floor itself ───────────────────────────────────────────────────── + const collapsed = scan(repoRoot(), { files: [], floors: FLOORS }); + cases.push({ + name: 'an empty walk FAILS the run rather than reporting clean', + ok: collapsed.vacuous.length === Object.keys(FLOORS).length && collapsed.violations.length === 0, + detail: `vacuous=${collapsed.vacuous.length} of ${Object.keys(FLOORS).length}`, + }); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ check-test-path-roots self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log(`✓ check-test-path-roots self-test: ${cases.length} cases pass (13 spellings that must fire, 8 shapes that must not, and the vacuity floor).`); + return 0; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function main() { + const result = scan(repoRoot()); + const { violations, stale, vacuous } = result; + + if (violations.length === 0 && stale.length === 0 && vacuous.length === 0) { + console.log(`✅ check-test-path-roots: OK (${summarise(result)}).`); + process.exit(0); + } + + if (violations.length > 0) { + console.error(`❌ check-test-path-roots: ${violations.length} test filesystem read(s) resolve a path BELOW THE PROCESS CWD\n`); + for (const v of violations) { + console.error(` - ${v.file}:${v.line} ${v.sink}(${v.text})`); + console.error(` rooted at ${v.why}`); + } + console.error( + '\n The cwd is `packages//` under that package\'s own `test` script and the repo\n' + + ' root under the form CI runs, so this assertion reaches two verdicts (objectui#7799).\n' + + ' Root it at the file instead — the spelling PR #7796 landed and PR #7806 reused:\n\n' + + ' const SELF_DEPTH_BELOW_REPO_ROOT = 5; // packages / pkg / src / __tests__ / this file\n' + + ' const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname)\n' + + ' .split(\'/\')\n' + + ' .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT)\n' + + ' .join(\'/\');\n\n' + + ' If the cwd really is the SUBJECT of the read, register it in `SUBJECT_IS_THE_CWD`\n' + + ' with the reason. ⛔ `KNOWN_CWD_ROOTED` is SHRINK-ONLY.', + ); + } + + if (stale.length > 0) { + console.error(`\n❌ check-test-path-roots: ${stale.length} registered entry/entries no longer resolve from the cwd — delete them\n`); + for (const id of stale) console.error(` - ${id}`); + } + + if (vacuous.length > 0) { + console.error('\n❌ check-test-path-roots: the population COLLAPSED — this run measured nothing\n'); + for (const v of vacuous) console.error(` - ${v.counter}: found ${v.value}, floor is ${v.floor}`); + } + + console.error(`\nCensus: ${summarise(result)}`); + process.exit(1); +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + process.exit(selfTest()); + } else if (process.argv.includes('--json')) { + console.log(JSON.stringify(scan(repoRoot()), null, 2)); + } else if (process.argv.includes('--blind')) { + const result = scan(repoRoot()); + for (const s of result.sites.filter((x) => x.kind === 'unknown')) { + console.log(`${s.file}:${s.line} ${s.sink}(${s.text}) — ${s.why}`); + } + console.log(`\n${result.census.unclassifiedRoots} root(s) this gate cannot classify, out of ${result.census.sinkCalls} filesystem call(s).`); + console.log('⚠️ A clean run says nothing about these. They are the gate\'s stated boundary, not a to-do list.'); + } else if (process.argv.includes('--list')) { + const result = scan(repoRoot()); + for (const s of result.sites) { + console.log(`${(s.kind + (s.appended ? '+append' : '')).padEnd(16)} ${s.file}:${s.line} ${s.sink}(${s.text}) — ${s.why}`); + } + console.log(`\n${summarise(result)}`); + } else { + main(); + } +}