From f897ec41239606b8d4b15554d750a3502a94c6ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 15:39:29 +0000 Subject: [PATCH 1/3] fix(devx): teach check:cross-package-test-inputs the new URL and argument-position path spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector was blind in two independent ways. It recognised only `dirname(fileURLToPath(import.meta.url))` and `__dirname` seeds bound to a declaration, and it judged a binding by its FINAL depth — so a path that climbs past the package root and descends into a sibling scored positive and was never flagged. Adds `new URL(, import.meta.url)` (bare and under fileURLToPath) as a seed and chain step, scans path expressions in argument position to an fs read, and switches the escape criterion to the shallowest depth the path reaches. WIP: declarations for what it now finds still to come. --- scripts/check-cross-package-test-inputs.mjs | 290 +++++++++++++++++--- 1 file changed, 251 insertions(+), 39 deletions(-) diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 932e3b33db..e4009179f1 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -217,6 +217,30 @@ export function matchesAny(path, globs) { const FS_READ = /\b(readFileSync|readdirSync|statSync|existsSync|globSync|opendirSync|execFileSync)\b/; const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.next', '.git']); +/** + * Reads whose FIRST argument is a path, for the argument-position scan below. + * `execFileSync` is deliberately absent from this list though it is in FS_READ: + * its first argument is a binary to run, not a file to read. + */ +const PATH_ARG_READS = ['readFileSync', 'readdirSync', 'statSync', 'lstatSync', 'existsSync', 'globSync', 'opendirSync']; + +/** + * The path spellings this gate can SEE, in the words an author would write them. + * Printed in the failure text and mirrored in AGENTS.md, because the detector is + * a source scan: a spelling that is not on this list yields no flag, so a read + * written that way goes undeclared silently. Anything added here needs a + * `--self-test` case in the same edit, or the next refactor drops it unnoticed. + */ +export const RECOGNISED_PATH_SPELLINGS = [ + "const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM)", + 'const HERE = __dirname; // seed (CJS)', + "const P = resolve(HERE, ''); // join() and the path.* forms too", + "const P = fileURLToPath(new URL('', import.meta.url));", + "const P = new URL('', import.meta.url);", + "readFileSync(resolve(HERE, '')) // the same expressions in argument", + "readFileSync(new URL('', import.meta.url)) // position", +]; + function walkTests(dir, out = []) { let entries; try { @@ -243,52 +267,179 @@ function packageRootOf(file) { } /** - * Depth, BELOW the package root, of every directory-valued binding in `src`. - * A binding at depth < 0 addresses something outside the package — which, in a - * file that also reads the filesystem, is precisely the #7802 shape. + * Walk a relative path literal from `base` (a depth below the package root), + * reporting where it ENDS, the SHALLOWEST point it passes through, and whether + * it steps into an installed dependency. * - * Deliberately a source scan and not a real parse: the shape it looks for - * (`dirname(fileURLToPath(import.meta.url))` seeds, `resolve`/`join` chains off - * them) is how every one of the 20 files it finds today is written, and a - * detector with no dependencies cannot itself fail to resolve in CI. It errs - * toward flagging: an unrecognised spelling yields no binding and no flag, so - * the accompanying `--self-test` pins the shapes that must keep flagging. + * `min` is the load-bearing number, and `end` alone is a trap: a literal that + * climbs past the package root and then descends into a SIBLING package ends at + * a perfectly positive depth while addressing another package entirely. + * `join(HERE, '..', '..', 'spec', 'src', 'rls.zod.ts')` from `/src` ends at + * +4 and reads `packages/spec` — the exact #7802 shape, invisible to a test on + * the final depth. Final depth is only sound for a binding that STOPS at the top + * of its ascent, which is what a `REPO_ROOT` const happens to be and what the + * other spellings are not. */ -export function escapingBindings(src, hereDepth) { - const depth = new Map(); - const DECL = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+(?:\n\s*[^;\n]*)??)\s*;/g; - for (const m of src.matchAll(DECL)) { - const name = m[1]; - const expr = m[2].trim(); - if (/^(?:path\.)?dirname\(\s*fileURLToPath\(\s*import\.meta\.url\s*\)\s*\)$/.test(expr)) { - depth.set(name, hereDepth); - continue; +function walkLiteral(base, literal) { + let end = base; + let min = base; + let vendored = false; + for (const seg of literal.split('/').filter(Boolean)) { + if (seg === '..') end -= 1; + else if (seg !== '.') { + end += 1; + // An installed dependency is not a repo source input: turbo cannot hash + // `node_modules/**` as a source glob, and the walk above skips it anyway. + // A read that lands there escapes the package but declares nothing. + if (seg === 'node_modules') vendored = true; } - if (expr === '__dirname') { - depth.set(name, hereDepth); + if (end < min) min = end; + } + return { end, min, vendored }; +} + +/** Split an argument list on its TOP-LEVEL commas — `new URL(x, import.meta.url)` has one of its own. */ +function splitTopLevel(text) { + const out = []; + let depth = 0; + let quote = null; + let start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quote) { + if (c === '\\') i += 1; + else if (c === quote) quote = null; continue; } - const call = expr.match(/^(?:path\.)?(?:resolve|join)\(([\s\S]*)\)$/); - if (!call) continue; - const args = call[1].split(',').map((s) => s.trim()); - const first = args[0]; - let base; - if (/^(?:path\.)?dirname\(\s*fileURLToPath\(/.test(first)) base = hereDepth; - else if (first === '__dirname') base = hereDepth; - else if (/^[A-Za-z_$][\w$]*$/.test(first)) base = depth.get(first); - if (base === undefined) continue; - let d = base; - for (const a of args.slice(1)) { - const lit = a.match(/^(['"`])([^'"`]*)\1$/); - if (!lit) continue; - for (const seg of lit[2].split('/').filter(Boolean)) { - if (seg === '..') d -= 1; - else if (seg !== '.') d += 1; + if (c === "'" || c === '"' || c === '`') quote = c; + else if (c === '(' || c === '[' || c === '{') depth += 1; + else if (c === ')' || c === ']' || c === '}') depth -= 1; + else if (c === ',' && depth === 0) { + out.push(text.slice(start, i).trim()); + start = i + 1; + } + } + out.push(text.slice(start).trim()); + return out; +} + +const PATH_LITERAL = /^(['"`])([^'"`]*)\1$/; +const NEW_URL_LITERAL = /^new\s+URL\(\s*(['"`])([^'"`]*)\1\s*,\s*import\.meta\.url\s*\)$/; + +/** + * Resolve one path expression to `{ end, min, vendored }`, or `undefined` when + * the spelling is not one of RECOGNISED_PATH_SPELLINGS. Recursive so that every + * recognised form composes with every other: a `new URL` seed may sit under a + * `fileURLToPath`, inside a `resolve()`, in a read's argument — each layer is + * peeled by the same function rather than by a separate special case. + */ +function pathExpression(expr, hereDepth, known) { + expr = expr.trim(); + + // `fileURLToPath(x)` does not move the path, only its spelling. + const unwrapped = expr.match(/^(?:url\.)?fileURLToPath\(([\s\S]*)\)$/); + if (unwrapped) return pathExpression(unwrapped[1], hereDepth, known); + + if (/^(?:path\.)?dirname\(\s*(?:url\.)?fileURLToPath\(\s*import\.meta\.url\s*\)\s*\)$/.test(expr)) { + return { end: hereDepth, min: hereDepth, vendored: false }; + } + if (expr === '__dirname') return { end: hereDepth, min: hereDepth, vendored: false }; + + // A `new URL(rel, import.meta.url)` resolves against the importing FILE, so + // its base is the file's directory — the same base as the two seeds above. + const url = expr.match(NEW_URL_LITERAL); + if (url) return walkLiteral(hereDepth, url[2]); + + if (/^[A-Za-z_$][\w$]*$/.test(expr)) return known.get(expr); + + const call = expr.match(/^(?:path\.)?(?:resolve|join)\(([\s\S]*)\)$/); + if (!call) return undefined; + const args = splitTopLevel(call[1]); + const base = pathExpression(args[0], hereDepth, known); + if (!base) return undefined; + let { end, min, vendored } = base; + for (const a of args.slice(1)) { + const lit = a.match(PATH_LITERAL); + if (!lit) continue; + const step = walkLiteral(end, lit[2]); + end = step.end; + min = Math.min(min, step.min); + vendored = vendored || step.vendored; + } + return { end, min, vendored }; +} + +/** The argument list of every fs read whose first argument is a path, paren-balanced. */ +function* readArgumentLists(src) { + const re = new RegExp(String.raw`\b(?:${PATH_ARG_READS.join('|')})\s*\(`, 'g'); + for (const m of src.matchAll(re)) { + const from = m.index + m[0].length; + let depth = 1; + let quote = null; + let i = from; + for (; i < src.length; i++) { + const c = src[i]; + if (quote) { + if (c === '\\') i += 1; + else if (c === quote) quote = null; + continue; } + if (c === "'" || c === '"' || c === '`') quote = c; + else if (c === '(') depth += 1; + else if (c === ')' && --depth === 0) break; } - depth.set(name, d); + if (depth === 0) yield src.slice(from, i); } - return [...depth.entries()].filter(([, d]) => d < 0).map(([n, d]) => ({ name: n, depth: d })); +} + +/** + * Every path in `src` that addresses something outside the package — which, in a + * file that also reads the filesystem, is precisely the #7802 shape. + * + * Deliberately a source scan and not a real parse: a detector with no + * dependencies cannot itself fail to resolve in CI, which is what keeps this + * gate un-mutable. The price is that it only sees the spellings it knows, so the + * list it knows is published (RECOGNISED_PATH_SPELLINGS, printed in the failure + * text and mirrored in AGENTS.md) instead of being an implementation detail an + * author has to reverse-engineer from a silent pass. + * + * Two positions are scanned, because a path is as often nested straight into the + * read as it is bound to a name first: + * const SRC = readFileSync(resolve(HERE, '../../other/src/x.ts'), 'utf8'); + * binds `SRC` to file CONTENTS, never to a path, so a declaration-only scan sees + * no path at all in the line that does the escaping. + * + * `--self-test` pins the shapes that must keep flagging AND the shapes that must + * not; an added spelling without an added case is the next silent regression. + */ +export function escapingBindings(src, hereDepth) { + const known = new Map(); + const found = []; + const report = (name, info) => { + // `vendored`: the read escapes the package but lands in an installed + // dependency, which no declaration can name. Not a cross-package input. + if (!info || info.vendored || info.min >= 0) return; + found.push({ name, depth: info.min }); + }; + + const DECL = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+(?:\n\s*[^;\n]*)??)\s*;/g; + for (const m of src.matchAll(DECL)) { + const info = pathExpression(m[2].trim(), hereDepth, known); + if (!info) continue; + known.set(m[1], info); + report(m[1], info); + } + + let n = 0; + for (const args of readArgumentLists(src)) { + n += 1; + const first = splitTopLevel(args)[0]; + // A bare binding here was already judged at its declaration; reporting it a + // second time would only duplicate the finding under a less useful name. + if (known.has(first)) continue; + report(`read #${n} argument`, pathExpression(first, hereDepth, known)); + } + return found; } /** @@ -438,7 +589,16 @@ function verify() { console.error( 'Why this gate exists: a test whose real inputs are wider than its package is\n' + 'invisible to BOTH the affected-subset filter and the turbo cache, so it can go\n' + - 'red on `main` while every PR reports green (#7802).', + 'red on `main` while every PR reports green (#7802).\n', + ); + console.error( + 'How this gate SEES a read, and its limit: it is a source scan, so it recognises\n' + + 'these spellings and only these. A path written any other way yields no flag —\n' + + 'which means no declaration, silently. Write escaping reads as:\n' + + RECOGNISED_PATH_SPELLINGS.map((s) => ` ${s}`).join('\n') + + '\n Reaching for a spelling that is not here? Add it to the detector (with a\n' + + ' --self-test case) rather than working around it — an unseen read is the\n' + + ' defect above, not a style question.', ); process.exit(1); } @@ -534,6 +694,58 @@ function selfTest() { ), ); + // The ascent-then-descent shape. Every case below ends at a NON-NEGATIVE + // depth while addressing a sibling package, so each one passes a test on the + // final depth and is caught only by the shallowest point reached. + ok( + 'flags a one-literal climb into a sibling package (formula -> spec)', + at("const HERE = dirname(fileURLToPath(import.meta.url));\nconst Z = join(HERE, '..', '..', 'spec', 'src', 'rls.zod.ts');", 1), + ); + ok( + 'flags a fileURLToPath(new URL()) seed naming a sibling package', + at("const SRC = fileURLToPath(new URL('../../../other-pkg/src/x.ts', import.meta.url));", 2), + ); + ok( + 'flags a new URL() seed with no fileURLToPath around it', + at("const SRC = new URL('../../../other-pkg/src/x.ts', import.meta.url);", 2), + ); + ok( + 'flags a new URL() nested straight into a read (no path binding exists)', + at("const SRC = readFileSync(new URL('../../../scripts/gate.mjs', import.meta.url), 'utf8');", 1), + ); + ok( + 'flags a read whose argument is a multi-line new URL()', + at("const c = readFileSync(\n new URL('../../../scripts/gate.mjs', import.meta.url),\n 'utf8',\n);", 1), + ); + ok( + 'flags a resolve() nested straight into a read', + at( + "const HERE = dirname(fileURLToPath(import.meta.url));\n" + + "const SRC = readFileSync(resolve(HERE, '../../../other-pkg/src/x.ts'), 'utf8');", + 2, + ), + ); + ok( + 'flags a fileURLToPath(new URL()) chained through resolve()', + at("const P = resolve(fileURLToPath(new URL('..', import.meta.url)), '../../other-pkg/src');", 2), + ); + ok( + 'does NOT flag a new URL() that stays inside the package', + !at("const SRC = readFileSync(new URL('../sibling-dir/x.ts', import.meta.url), 'utf8');", 2), + ); + ok( + 'does NOT flag a new URL() naming the package root itself', + !at("const PKG = fileURLToPath(new URL('../../package.json', import.meta.url));", 2), + ); + ok( + 'does NOT flag a climb into node_modules (no glob can declare an installed dep)', + !at("const HERE = dirname(fileURLToPath(import.meta.url));\nconst L = resolve(HERE, '../../../node_modules/tsx/dist/loader.mjs');", 1), + ); + ok( + 'does NOT flag a read argument that is an unrecognised expression', + !at('const SRC = readFileSync(somewhereElse(x), \'utf8\');', 2), + ); + const failed = cases.filter((c) => !c.cond); for (const c of cases) console.log(`${c.cond ? 'ok ' : 'FAIL'} ${c.label}`); if (failed.length) { From ab7ef7e414356e09809d39f732581994c65da9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 15:42:09 +0000 Subject: [PATCH 2/3] wip: declarations + AGENTS.md --- AGENTS.md | 36 ++++++++++++++++++ scripts/check-cross-package-test-inputs.mjs | 28 +++++++++++++- turbo.json | 42 ++++++++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb728cb96a..dbb31b0e64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,42 @@ Three principles the ratchet's invariants encode, worth knowing before you fight missing its `.js` extension does not resolve and every symbol it names becomes `any`. Fix the extension first and re-measure. +### A test that reads outside its own package must be spelled so the gate can see it + +`pnpm check:cross-package-test-inputs` keeps CI's idea of a test's inputs equal to its +real inputs. A test whose reads escape its package is invisible to **both** +`turbo ls --affected` and the `test` task's input hashing, so a pin written to catch +cross-package drift sits green through exactly the drift it exists to catch — on `main`, +with every PR reporting green. The gate finds those tests by **scanning source text**, +deliberately: a detector with no dependencies cannot itself fail to resolve in CI. + +The price of a source scan is that it sees only the spellings it knows, and an +unrecognised one produces no flag — which means no declaration, **silently**. So the +recognised list is published rather than left inside the implementation. Seed from +`import.meta.url` or `__dirname`, and write the escaping path as one of: + +```ts +const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM) +const HERE = __dirname; // seed (CJS) +const P = resolve(HERE, ''); // join() and path.* too +const P = fileURLToPath(new URL('', import.meta.url)); +const P = new URL('', import.meta.url); +readFileSync(resolve(HERE, '')) // the same expressions +readFileSync(new URL('', import.meta.url)) // in argument position +``` + +The gate prints this list in its failure text too, and `--self-test` pins every entry. +Reaching for a spelling that is not here? **Extend the detector and add a `--self-test` +case in the same edit** — never route around it. An unseen read is the defect above, not +a style question, and a newly recognised shape with no pin is the next silent regression. + +Two things it deliberately does not flag: a path that climbs out and lands in +`node_modules` (an installed dependency is not a repo source input, and no turbo glob can +name it), and a path that climbs out and comes straight back in. What it *does* flag is +judged on the **shallowest** point a path reaches, not where it ends — a literal that +climbs past the package root and then descends into a sibling ends at a positive depth +while addressing another package entirely. + ### Running the dev server | Scenario | Command | Notes | diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index e4009179f1..09388ae7b4 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -91,8 +91,9 @@ const CROSS_PACKAGE_TEST_INPUTS = { 'packages/**/*.object.ts', // src/identity/position-delegatable-enforcer.pin.test.ts reads the lint rule sources 'packages/lint/src/**', - // scripts/root-index.test.ts - 'content/docs/references/index.mdx', + // scripts/root-index.test.ts reads the index; scripts/category-title.test.ts and + // scripts/file-description.test.ts walk the whole references tree by category. + 'content/docs/references/**', // scripts/dist-freshness.test.ts stages a fixture around the root scripts dir 'scripts/**', // scripts/liveness/evidence.test.ts resolves the evidence paths the @@ -169,8 +170,31 @@ const CROSS_PACKAGE_TEST_INPUTS = { // flow-trigger / validation conformance pin spec's zod schemas. 'packages/spec/src/automation/**', 'packages/spec/src/data/**', + // showcase-declarative-*.dogfood.test.ts chdir into the showcase app and + // compile it, so the app IS an input, and they assert on the artifact the + // compile pipeline and the metadata plugin produce. + 'examples/app-showcase/**', + 'packages/cli/src/commands/**', + 'packages/metadata/src/**', ], }, + '@objectstack/formula': { + // src/rls-predicate.test.ts pins spec's RLS zod source against the + // predicate compiler; src/skill-catalog-sync.test.ts pins the published + // formula skill's stdlib table against the implementation. + globs: ['packages/spec/src/security/rls.zod.ts', 'skills/objectstack-formula/**'], + }, + '@objectstack/metadata-protocol': { + // src/sys-metadata-repository.draft-drain.test.ts reads the durability + // log-level gate's own source to pin that the repository stays inside it. + globs: ['scripts/check-durability-degradation-log-level.mjs'], + }, + '@objectstack/downstream-contract': { + // test/source-resolution.pin.test.ts resolves every spec specifier a + // downstream consumer can import, against spec's real source tree and the + // `exports` map in its package.json. + globs: ['packages/spec/src/**', 'packages/spec/package.json'], + }, 'create-objectstack': { // src/template-consistency.test.ts reads doc frontmatter by repo-relative // path to decide which templates are internal. diff --git a/turbo.json b/turbo.json index 8b9e564068..1458df49f6 100644 --- a/turbo.json +++ b/turbo.json @@ -32,7 +32,7 @@ "!.turbo/**", "$TURBO_ROOT$/packages/**/*.object.ts", "$TURBO_ROOT$/packages/lint/src/**", - "$TURBO_ROOT$/content/docs/references/index.mdx", + "$TURBO_ROOT$/content/docs/references/**", "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/packages/runtime/src/**", "$TURBO_ROOT$/packages/objectql/src/validation/**", @@ -128,7 +128,45 @@ "$TURBO_ROOT$/packages/runtime/src/**", "$TURBO_ROOT$/packages/services/service-realtime/src/**", "$TURBO_ROOT$/packages/spec/src/automation/**", - "$TURBO_ROOT$/packages/spec/src/data/**" + "$TURBO_ROOT$/packages/spec/src/data/**", + "$TURBO_ROOT$/examples/app-showcase/**", + "$TURBO_ROOT$/packages/cli/src/commands/**", + "$TURBO_ROOT$/packages/metadata/src/**" + ] + }, + "@objectstack/formula#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/spec/src/security/rls.zod.ts", + "$TURBO_ROOT$/skills/objectstack-formula/**" + ] + }, + "@objectstack/metadata-protocol#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/scripts/check-durability-degradation-log-level.mjs" + ] + }, + "@objectstack/downstream-contract#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/spec/src/**", + "$TURBO_ROOT$/packages/spec/package.json" ] }, "create-objectstack#test": { From 7f605e5435c9802d32e91a9413f14acd368e8763 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 15:46:46 +0000 Subject: [PATCH 3/3] feat(devx): recognise import.meta.dirname/.filename seeds, and declare what the widened detector finds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the same class one spelling further: import.meta.dirname is the modern form of the two existing seeds and no test uses it yet, which is exactly why it is worth accepting now — the first author to reach for it would otherwise get silence rather than a declaration. Declares the reads the widened detector newly sees (formula, metadata-protocol, downstream-contract; wider globs for spec and dogfood) with matching turbo.json inputs, and publishes the recognised spellings in the failure text + AGENTS.md. --- AGENTS.md | 1 + scripts/check-cross-package-test-inputs.mjs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dbb31b0e64..e5e3e2ece5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ recognised list is published rather than left inside the implementation. Seed fr ```ts const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM) const HERE = __dirname; // seed (CJS) +const HERE = import.meta.dirname; // and dirname(import.meta.filename) const P = resolve(HERE, ''); // join() and path.* too const P = fileURLToPath(new URL('', import.meta.url)); const P = new URL('', import.meta.url); diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 09388ae7b4..c5f734aa72 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -258,6 +258,7 @@ const PATH_ARG_READS = ['readFileSync', 'readdirSync', 'statSync', 'lstatSync', export const RECOGNISED_PATH_SPELLINGS = [ "const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM)", 'const HERE = __dirname; // seed (CJS)', + 'const HERE = import.meta.dirname; // and dirname(import.meta.filename)', "const P = resolve(HERE, ''); // join() and the path.* forms too", "const P = fileURLToPath(new URL('', import.meta.url));", "const P = new URL('', import.meta.url);", @@ -368,6 +369,13 @@ function pathExpression(expr, hereDepth, known) { return { end: hereDepth, min: hereDepth, vendored: false }; } if (expr === '__dirname') return { end: hereDepth, min: hereDepth, vendored: false }; + // `import.meta.dirname` / `.filename` (Node >= 20.11) are the modern spelling of + // the two seeds above. No test uses them TODAY — which is the reason to accept + // them now: the first author who reaches for them would otherwise get silence. + if (expr === 'import.meta.dirname') return { end: hereDepth, min: hereDepth, vendored: false }; + if (/^(?:path\.)?dirname\(\s*import\.meta\.filename\s*\)$/.test(expr)) { + return { end: hereDepth, min: hereDepth, vendored: false }; + } // A `new URL(rel, import.meta.url)` resolves against the importing FILE, so // its base is the file's directory — the same base as the two seeds above. @@ -769,6 +777,18 @@ function selfTest() { 'does NOT flag a read argument that is an unrecognised expression', !at('const SRC = readFileSync(somewhereElse(x), \'utf8\');', 2), ); + ok( + 'flags an import.meta.dirname seed (no file uses it yet — that is the point)', + at("const HERE = import.meta.dirname;\nconst SRC = resolve(HERE, '../../other-pkg/src/x.ts');", 1), + ); + ok( + 'flags a dirname(import.meta.filename) seed', + at("const HERE = dirname(import.meta.filename);\nconst SRC = resolve(HERE, '../../other-pkg/src/x.ts');", 1), + ); + ok( + 'does NOT flag an import.meta.dirname seed that stays inside the package', + !at("const HERE = import.meta.dirname;\nconst FIX = resolve(HERE, '../fixtures');", 2), + ); const failed = cases.filter((c) => !c.cond); for (const c of cases) console.log(`${c.cond ? 'ok ' : 'FAIL'} ${c.label}`);