From 3a73f5644933e0887a4f1d0e3046b08b118ddb7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 08:18:05 +0000 Subject: [PATCH] fix(devx): reject an interpolating template literal in check-cross-package-test-inputs' PATH_LITERAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PATH_LITERAL`'s character class excludes only quote characters, so a backtick-delimited argument holding no quotes matches it even when it is an interpolating template — `` `${someVar}` `` reads as the literal segment text `${someVar}` and `walkLiteral()` counts it as ONE ordinary descent, biasing the depth walk upward instead of taking the documented cannot-read path. That inverts the file's own stated invariant ("an argument this scan cannot read leaves the DEPTH walk where it was ... since the escape verdict is a lower bound"): an unreadable argument is safe, and a template read as readable was LESS safe than unreadable. `PATH_LITERAL` has exactly one call site in this file (inside `pathExpression()`'s `resolve`/`join` argument walk), so narrowing it moves no other consumer's verdict. The fix wraps that one call in `readablePathLiteral()`, which rejects a backtick literal containing `${` and falls back to the existing unreadable-argument branch (name dropped, depth preserved) — the resolver itself now makes "never a wrong name" true by construction, rather than relying on `findEscapingPackages()`'s downstream `statSync(...).isFile()` filter to drop a fabricated roster entry. A non-interpolating backtick literal is unaffected and continues to be read (and named) exactly as a quoted literal would. Both directions pinned in --self-test with the card's own fixture pair (same climb, same file, only the middle argument differs), plus a control proving the narrowing does not overshoot a plain backtick literal. `NEW_URL_LITERAL` has the identical character-class shape and is filed separately as #12085 — a structurally different call path, out of scope here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 --- scripts/check-cross-package-test-inputs.mjs | 53 ++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 2566c26164..ba9436c288 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -557,6 +557,27 @@ function splitTopLevel(text) { const PATH_LITERAL = /^(['"`])([^'"`]*)\1$/; const NEW_URL_LITERAL = /^new\s+URL\(\s*(['"`])([^'"`]*)\1\s*,\s*import\.meta\.url\s*,?\s*\)$/; +/** + * `PATH_LITERAL`'s character class excludes only quote characters, so a + * BACKTICK-delimited argument containing no quotes matches it even when it is + * an interpolating template — `` `${someVar}` `` reads as the literal segment + * text `${someVar}`, which the walk below would count as ONE ordinary descent + * instead of routing it through the cannot-read branch that exists for exactly + * this case. That is not a lower bound: it biases the depth walk UPWARD and can + * hide an escape the unreadable-argument trade was written to still catch + * (#11487). A single- or double-quoted literal is unaffected — `${` inside one + * of those is ordinary text, never interpolation, so only the backtick + * delimiter needs the extra check. This is the ONE call site `PATH_LITERAL` + * has in this file (inside `pathExpression()`'s `resolve`/`join` argument + * walk), so narrowing it here does not move any other consumer's verdict. + */ +function readablePathLiteral(arg) { + const lit = arg.match(PATH_LITERAL); + if (!lit) return null; + if (lit[1] === '`' && lit[2].includes('${')) return null; + return lit; +} + /** * A formatter's TRAILING COMMA, dropped from an argument list before it is read. * @@ -650,7 +671,7 @@ function pathExpression(expr, hereDepth, known, fileSegs = null) { if (!base) return undefined; let { end, min, vendored, segs } = base; for (const a of args.slice(1)) { - const lit = a.match(PATH_LITERAL); + const lit = readablePathLiteral(a); if (!lit) { // An argument this scan cannot read leaves the DEPTH walk where it was — // deliberately, since the escape verdict is a lower bound and has always @@ -1940,6 +1961,36 @@ function selfTest() { 1, ), ); + // ── the INTERPOLATING TEMPLATE argument (#11487) ────────────────────────── + // + // `PATH_LITERAL`'s character class excludes only quote characters, so a + // backtick argument holding no quotes matches it even when it is an + // interpolating template — `` `${someVar}` `` read as the literal segment + // text `${someVar}` and walked as ONE ordinary descent, biasing the depth + // walk UPWARD instead of taking the cannot-read path above. That is the + // exact inverse of the trade this file relies on everywhere else: an + // unreadable argument is safe, and a template read as readable was LESS + // safe than unreadable. Same climb, same file, only the middle argument + // differs from the unreadable-argument pair just above. + const TEMPLATE_SEED = 'const HERE = dirname(fileURLToPath(import.meta.url));\n'; + const TEMPLATE_UNREADABLE = TEMPLATE_SEED + "const P = join(HERE, someVar, '../../other-pkg/src/y.ts');"; + const TEMPLATE_INTERP = TEMPLATE_SEED + "const P = join(HERE, `${someVar}`, '../../other-pkg/src/y.ts');"; + ok('(control) the unreadable-argument sibling of the pair below still flags at depth -1', at(TEMPLATE_UNREADABLE, 1)); + ok( + 'an interpolating template argument takes the SAME cannot-read path as an unreadable one — it flags too', + at(TEMPLATE_INTERP, 1), + ); + ok( + 'and — like any unreadable argument — yields no name for the path it builds (never a WRONG name)', + !named(TEMPLATE_INTERP, 1, CO).some((p) => p.endsWith('y.ts')), + ); + ok( + 'a non-interpolating backtick literal is NOT swept up by the narrowing — still read, still flags, still named', + (() => { + const src = TEMPLATE_SEED + "const P = join(HERE, `../../other-pkg/src/y.ts`);"; + return at(src, 1) && named(src, 1, CO).includes('packages/other-pkg/src/y.ts'); + })(), + ); ok( 'a climb ABOVE the repo root yields no name (there is no repo-relative one)', named("const OUT = resolve(__dirname, '../../../../../../elsewhere/x.ts');", 1, CO).length === 0,