Skip to content

Commit 8d5c724

Browse files
yinlianghuiclaude
andauthored
fix(devx): reject an interpolating template literal in check-cross-package-test-inputs' PATH_LITERAL (#12087)
`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. Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93949f1 commit 8d5c724

1 file changed

Lines changed: 52 additions & 1 deletion

File tree

scripts/check-cross-package-test-inputs.mjs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,27 @@ function splitTopLevel(text) {
557557
const PATH_LITERAL = /^(['"`])([^'"`]*)\1$/;
558558
const NEW_URL_LITERAL = /^new\s+URL\(\s*(['"`])([^'"`]*)\1\s*,\s*import\.meta\.url\s*,?\s*\)$/;
559559

560+
/**
561+
* `PATH_LITERAL`'s character class excludes only quote characters, so a
562+
* BACKTICK-delimited argument containing no quotes matches it even when it is
563+
* an interpolating template — `` `${someVar}` `` reads as the literal segment
564+
* text `${someVar}`, which the walk below would count as ONE ordinary descent
565+
* instead of routing it through the cannot-read branch that exists for exactly
566+
* this case. That is not a lower bound: it biases the depth walk UPWARD and can
567+
* hide an escape the unreadable-argument trade was written to still catch
568+
* (#11487). A single- or double-quoted literal is unaffected — `${` inside one
569+
* of those is ordinary text, never interpolation, so only the backtick
570+
* delimiter needs the extra check. This is the ONE call site `PATH_LITERAL`
571+
* has in this file (inside `pathExpression()`'s `resolve`/`join` argument
572+
* walk), so narrowing it here does not move any other consumer's verdict.
573+
*/
574+
function readablePathLiteral(arg) {
575+
const lit = arg.match(PATH_LITERAL);
576+
if (!lit) return null;
577+
if (lit[1] === '`' && lit[2].includes('${')) return null;
578+
return lit;
579+
}
580+
560581
/**
561582
* A formatter's TRAILING COMMA, dropped from an argument list before it is read.
562583
*
@@ -650,7 +671,7 @@ function pathExpression(expr, hereDepth, known, fileSegs = null) {
650671
if (!base) return undefined;
651672
let { end, min, vendored, segs } = base;
652673
for (const a of args.slice(1)) {
653-
const lit = a.match(PATH_LITERAL);
674+
const lit = readablePathLiteral(a);
654675
if (!lit) {
655676
// An argument this scan cannot read leaves the DEPTH walk where it was —
656677
// deliberately, since the escape verdict is a lower bound and has always
@@ -1940,6 +1961,36 @@ function selfTest() {
19401961
1,
19411962
),
19421963
);
1964+
// ── the INTERPOLATING TEMPLATE argument (#11487) ──────────────────────────
1965+
//
1966+
// `PATH_LITERAL`'s character class excludes only quote characters, so a
1967+
// backtick argument holding no quotes matches it even when it is an
1968+
// interpolating template — `` `${someVar}` `` read as the literal segment
1969+
// text `${someVar}` and walked as ONE ordinary descent, biasing the depth
1970+
// walk UPWARD instead of taking the cannot-read path above. That is the
1971+
// exact inverse of the trade this file relies on everywhere else: an
1972+
// unreadable argument is safe, and a template read as readable was LESS
1973+
// safe than unreadable. Same climb, same file, only the middle argument
1974+
// differs from the unreadable-argument pair just above.
1975+
const TEMPLATE_SEED = 'const HERE = dirname(fileURLToPath(import.meta.url));\n';
1976+
const TEMPLATE_UNREADABLE = TEMPLATE_SEED + "const P = join(HERE, someVar, '../../other-pkg/src/y.ts');";
1977+
const TEMPLATE_INTERP = TEMPLATE_SEED + "const P = join(HERE, `${someVar}`, '../../other-pkg/src/y.ts');";
1978+
ok('(control) the unreadable-argument sibling of the pair below still flags at depth -1', at(TEMPLATE_UNREADABLE, 1));
1979+
ok(
1980+
'an interpolating template argument takes the SAME cannot-read path as an unreadable one — it flags too',
1981+
at(TEMPLATE_INTERP, 1),
1982+
);
1983+
ok(
1984+
'and — like any unreadable argument — yields no name for the path it builds (never a WRONG name)',
1985+
!named(TEMPLATE_INTERP, 1, CO).some((p) => p.endsWith('y.ts')),
1986+
);
1987+
ok(
1988+
'a non-interpolating backtick literal is NOT swept up by the narrowing — still read, still flags, still named',
1989+
(() => {
1990+
const src = TEMPLATE_SEED + "const P = join(HERE, `../../other-pkg/src/y.ts`);";
1991+
return at(src, 1) && named(src, 1, CO).includes('packages/other-pkg/src/y.ts');
1992+
})(),
1993+
);
19431994
ok(
19441995
'a climb ABOVE the repo root yields no name (there is no repo-relative one)',
19451996
named("const OUT = resolve(__dirname, '../../../../../../elsewhere/x.ts');", 1, CO).length === 0,

0 commit comments

Comments
 (0)