Skip to content

Commit e1b31d6

Browse files
committed
fix(scripts): name-fold loader gate's rule 2 resolves the fixtures directory, not one spelling of it
Rule 2 of check-position-name-fold-loaders counted a file only when its text held the repo-relative literal packages/metadata/src/__fixtures__, so a relative climb from the file that holds it, a case variant or a backslash-separated spelling, with neither hotcrm nor .artifact on the line, was never a reference. Each path spelling on a line carrying __fixtures__ is now folded (case, backslash, ., .. and // segments) and read two ways: as written, holding the directory's four segments in a row, and joined to the directory of the file that holds it. Segments are compared, never substrings, so packages/services/service-metadata/src/__fixtures__ stays another package's directory. The literal substring is still asked first. referencesArtifact now takes the holding file's repo-relative path and throws without it, since a dropped path would lose every climb silently. Rule 3's in-the-guarded-directory test uses the same folded comparison. The self-test gains nine matcher cases (floor 20 -> 29), the header's soundness sentence now says what the scan checks and what it cannot follow, and the tree scan reads the same eight references as before. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e43b7b commit e1b31d6

1 file changed

Lines changed: 164 additions & 33 deletions

File tree

‎scripts/check-position-name-fold-loaders.mjs‎

Lines changed: 164 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,17 @@
3838
* ## What counts as a loader
3939
*
4040
* Anything that names the artifact — an `import`, a `readFileSync`, a glob
41-
* whose pattern names the file or its directory. All of them must SPELL the
42-
* file or the fixtures directory somewhere in source, so a text scan over the
43-
* tracked tree is a sound instrument for the question. Two dispositions are
44-
* allowed and every other reference fails:
41+
* whose pattern names the file or its directory. The scan reads source TEXT,
42+
* so what it checks is a spelling ({@link referencesArtifact}): the file's
43+
* basename; a path that resolves to the fixtures directory once case, `\`,
44+
* `.`, `..` and `//` are folded and a relative path is joined to the
45+
* directory of the file that holds it; or `__fixtures__` with `hotcrm` or
46+
* `.artifact` on one line. A text scan cannot follow a COMPUTED path — a
47+
* concatenation, a `.replace`, a variable, a base other than the holding
48+
* file's directory or the repository root — and this one does not expand a
49+
* wildcard in a directory segment, so a loader that reaches the artifact only
50+
* that way, with neither marker on its line, is outside what this gate sees.
51+
* Two dispositions are allowed and every other reference fails:
4552
*
4653
* TEST a `*.test.ts` / `*.spec.ts` file, or a file under a `test/`,
4754
* `tests/` or `__tests__/` directory. A test composes nothing.
@@ -84,6 +91,9 @@ const ARTIFACT_BASENAME = 'hotcrm-17.1-built-permissions.artifact.json';
8491
/** The directory the artifact lives in: a reference to it is a reference to the artifact. */
8592
const FIXTURES_DIR = 'packages/metadata/src/__fixtures__';
8693

94+
/** {@link FIXTURES_DIR} in the form rule 2 compares a folded spelling against. */
95+
const FIXTURES_DIR_FOLDED = FIXTURES_DIR.toLowerCase();
96+
8797
/** The two permission-set names that make this artifact load-bearing for #13419. */
8898
const FOLDED_NAMES = ['sales_rep', 'sales_manager'];
8999

@@ -137,7 +147,9 @@ export function classifyReference(relPath) {
137147
* only one of them is a literal path:
138148
*
139149
* 1. the basename — an `import`, a `readFileSync`, a `join(HERE, '…')`;
140-
* 2. the repo-relative fixtures directory — a config or workflow path;
150+
* 2. the fixtures directory in any spelling that RESOLVES to it
151+
* ({@link namesFixturesDir}) — a config or workflow path, a relative
152+
* climb from the file that holds it, a case variant;
141153
* 3. `__fixtures__` on the SAME LINE as `hotcrm` or `.artifact` — the glob
142154
* shape (`__fixtures__/*.artifact.json`), which names neither the file nor
143155
* the full directory and is invisible to 1 and 2.
@@ -161,32 +173,96 @@ export function classifyReference(relPath) {
161173
* - no `..` segment;
162174
* - after `path.posix.normalize` (so `packages/./metadata/…` and
163175
* `packages/metadata/./src/…` are read as the directories they are), it
164-
* does not lie in this package's fixtures directory;
176+
* does not lie in this package's fixtures directory, judged by the one
177+
* folded comparison rule 2 uses ({@link liesInFixturesDir});
165178
* - the normalized token is one literal file path, `packages/<dirs>/src/
166179
* __fixtures__/<file>` with only word characters, `.` and `-` in its
167180
* segments — so a token carrying `*`, `?`, `[`, `{` or `${` is never set
168181
* aside.
169182
*
170183
* Only such a token leaves the line; the rest of the line is still judged, so
171-
* a bare glob beside it still counts. This package's own directory, spelled
172-
* literally, is rule 2's, before rule 3 is asked at all.
184+
* a bare glob beside it still counts. This package's own directory, in any
185+
* spelling rule 2 resolves, is rule 2's, before rule 3 is asked at all.
173186
*/
174187
const PATH_TOKEN = /(?<=^|[\s'"`,[(])packages\/[^\s'"`,\])]*/g;
175188
const LITERAL_OTHER_FIXTURE_FILE = /^packages\/(?:[\w.-]+\/)+src\/__fixtures__\/[\w.-]+(?:\/[\w.-]+)*$/;
176189

177190
export function isOtherPackageFixturePath(token) {
178191
if (token.split('/').includes('..')) return false;
179192
const normalized = posix.normalize(token);
180-
if (normalized === FIXTURES_DIR || normalized.startsWith(`${FIXTURES_DIR}/`)) return false;
193+
if (liesInFixturesDir(foldPath(normalized))) return false;
181194
return LITERAL_OTHER_FIXTURE_FILE.test(normalized);
182195
}
183196

184197
function withoutOtherPackageFixturePaths(line) {
185198
return line.replace(PATH_TOKEN, (token) => (isOtherPackageFixturePath(token) ? '' : token));
186199
}
187200

188-
export function referencesArtifact(text) {
189-
if (text.includes(ARTIFACT_BASENAME) || text.includes(FIXTURES_DIR)) return true;
201+
/**
202+
* Rule 2: does this text spell a path that resolves to the fixtures directory,
203+
* or into it?
204+
*
205+
* The repo-relative literal is one spelling of the directory among several,
206+
* and the others reach it just the same: `../../metadata/src/__fixtures__`
207+
* from a file in `packages/objectql/src/`, `packages/Metadata/src/__fixtures__`
208+
* on a case-insensitive filesystem, `packages\metadata\src\__fixtures__` on
209+
* Windows. So every path spelling ({@link PATH_SPELLING}) on a line that
210+
* carries `__fixtures__` is FOLDED ({@link foldPath}) and read two ways:
211+
*
212+
* - as written: the folded spelling holds the directory's four segments in a
213+
* row — a repo-relative path, `ROOT + '/packages/…'`, a URL, a vendored
214+
* prefix, a climb that comes back in;
215+
* - from the file that holds it: a relative spelling, joined to that file's
216+
* own directory, lands on the directory or under it. A relative climb can
217+
* only be resolved against the file that contains it, which is why this
218+
* rule takes `relPath`.
219+
*
220+
* Segments are compared, never substrings:
221+
* `packages/services/service-metadata/src/__fixtures__` contains the string
222+
* `metadata/src/__fixtures__` and is another package's directory. The literal
223+
* repo-relative substring is still asked first, unchanged, so the rule counts
224+
* every file it counted before it learned to fold.
225+
*/
226+
const PATH_SPELLING = /[^\s'"`,;:=+()[\]{}<>|&]+/g;
227+
228+
/** One path spelling in the form rule 2 compares: `\` read as `/`, `.`, `..` and `//` resolved, case folded. */
229+
function foldPath(spelling) {
230+
return posix.normalize(spelling.replace(/\\+/g, '/')).toLowerCase();
231+
}
232+
233+
/** Is this folded path the fixtures directory, or something under it? */
234+
function liesInFixturesDir(folded) {
235+
return folded === FIXTURES_DIR_FOLDED || folded.startsWith(`${FIXTURES_DIR_FOLDED}/`);
236+
}
237+
238+
function namesFixturesDir(text, relPath) {
239+
if (text.includes(FIXTURES_DIR)) return true;
240+
if (!/__fixtures__/i.test(text)) return false;
241+
const holder = posix.dirname(relPath);
242+
for (const line of text.split('\n')) {
243+
if (!line.toLowerCase().includes('__fixtures__')) continue;
244+
for (const spelling of line.match(PATH_SPELLING) ?? []) {
245+
const folded = foldPath(spelling);
246+
if (`/${folded}/`.includes(`/${FIXTURES_DIR_FOLDED}/`)) return true;
247+
if (!folded.startsWith('/') && liesInFixturesDir(foldPath(`${holder}/${spelling}`))) return true;
248+
}
249+
}
250+
return false;
251+
}
252+
253+
/**
254+
* `relPath` is the repo-relative path of the file `text` was read from, and it
255+
* is required: rule 2 resolves a relative spelling against it, so a caller
256+
* that dropped it would lose every relative climb without a word.
257+
*/
258+
export function referencesArtifact(text, relPath) {
259+
if (typeof relPath !== 'string' || relPath === '') {
260+
throw new TypeError(
261+
'referencesArtifact(text, relPath): relPath, the repo-relative path of the file holding the text, '
262+
+ 'is required. A relative climb can only be resolved against the file that contains it.',
263+
);
264+
}
265+
if (text.includes(ARTIFACT_BASENAME) || namesFixturesDir(text, relPath)) return true;
190266
for (const raw of text.split('\n')) {
191267
if (!raw.includes('__fixtures__')) continue;
192268
const line = withoutOtherPackageFixturePaths(raw);
@@ -231,8 +307,8 @@ export function scanTree(root = REPO_ROOT) {
231307
} catch {
232308
continue;
233309
}
234-
if (!referencesArtifact(text)) continue;
235310
const rel = relative(root, abs).split(sep).join('/');
311+
if (!referencesArtifact(text, rel)) continue;
236312
references.push({ file: rel, disposition: classifyReference(rel) });
237313
}
238314
references.sort((a, b) => a.file.localeCompare(b.file));
@@ -356,7 +432,7 @@ let selfTestReachedVerdict = false;
356432
const SELF_TEST_BATTERIES = Object.freeze({
357433
'REVERSE CONTROL. A zero from a scan that cannot report anything is not a': 3,
358434
'The allowed dispositions, each for its own stated reason.': 5,
359-
'The text matcher, both directions. A matcher that never matches turns': 20,
435+
'The text matcher, both directions. A matcher that never matches turns': 29,
360436
'The permission-set extractor, on the real artifact shape.': 2,
361437
});
362438

@@ -430,90 +506,145 @@ function selfTest() {
430506
// every loader into a silent pass, and it is the half a clean tree cannot
431507
// exercise from the negative side alone.
432508
battery('The text matcher, both directions. A matcher that never matches turns');
433-
check('matcher sees the basename', referencesArtifact(`join(HERE, '__fixtures__/${ARTIFACT_BASENAME}')`), true);
434-
check('matcher sees a fixtures-directory glob', referencesArtifact(`glob('${FIXTURES_DIR}/*.artifact.json')`), true);
435-
check('matcher ignores an unrelated hotcrm mention', referencesArtifact("// the HotCRM shape from hotcrm#788"), false);
436-
check('matcher sees a bare glob sweep of the fixtures dir', referencesArtifact("await glob('**/__fixtures__/*.artifact.json')"), true);
509+
// The file a case's text is read as coming from. Rule 2 resolves a relative
510+
// spelling against it, so a case that is ABOUT where the text lives names its
511+
// own file instead.
512+
const PROBE_FILE = 'packages/objectql/src/loader.ts';
513+
check('matcher sees the basename', referencesArtifact(`join(HERE, '__fixtures__/${ARTIFACT_BASENAME}')`, PROBE_FILE), true);
514+
check('matcher sees a fixtures-directory glob', referencesArtifact(`glob('${FIXTURES_DIR}/*.artifact.json')`, PROBE_FILE), true);
515+
check('matcher ignores an unrelated hotcrm mention', referencesArtifact("// the HotCRM shape from hotcrm#788", PROBE_FILE), false);
516+
check('matcher sees a bare glob sweep of the fixtures dir', referencesArtifact("await glob('**/__fixtures__/*.artifact.json')", PROBE_FILE), true);
437517
check(
438518
'matcher does NOT credit __fixtures__ and hotcrm on separate lines',
439-
referencesArtifact("import x from './__fixtures__/widget.json';\n// unrelated: hotcrm#788\n"),
519+
referencesArtifact("import x from './__fixtures__/widget.json';\n// unrelated: hotcrm#788\n", PROBE_FILE),
440520
false,
441521
);
442522
check(
443523
"matcher does NOT count a full path to another package's fixture",
444-
referencesArtifact(" 'packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json',"),
524+
referencesArtifact(" 'packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json',", PROBE_FILE),
445525
false,
446526
);
447527
check(
448528
"matcher does NOT count a full path to a nested package's fixture",
449-
referencesArtifact("readFileSync('packages/services/service-x/src/__fixtures__/hotcrm-1.artifact.json')"),
529+
referencesArtifact("readFileSync('packages/services/service-x/src/__fixtures__/hotcrm-1.artifact.json')", PROBE_FILE),
450530
false,
451531
);
452532
check(
453533
'matcher still sees a bare fixtures glob',
454-
referencesArtifact("globs: ['__fixtures__/*.artifact.json'],"),
534+
referencesArtifact("globs: ['__fixtures__/*.artifact.json'],", PROBE_FILE),
455535
true,
456536
);
457537
check(
458538
'matcher still sees a path into this package\'s own fixtures directory',
459-
referencesArtifact("readFileSync('packages/metadata/src/__fixtures__/x.json')"),
539+
referencesArtifact("readFileSync('packages/metadata/src/__fixtures__/x.json')", PROBE_FILE),
460540
true,
461541
);
462542
check(
463543
'matcher still sees a bare glob beside another package\'s fixture path on one line',
464-
referencesArtifact("['packages/objectql/src/__fixtures__/a.json', '**/__fixtures__/*.artifact.json']"),
544+
referencesArtifact("['packages/objectql/src/__fixtures__/a.json', '**/__fixtures__/*.artifact.json']", PROBE_FILE),
465545
true,
466546
);
467547
check(
468548
'matcher still sees another package\'s path that climbs back toward this directory',
469-
referencesArtifact("glob('packages/objectql/src/__fixtures__/../../../metadata/src/__fixtures__/*.artifact.json')"),
549+
referencesArtifact("glob('packages/objectql/src/__fixtures__/../../../metadata/src/__fixtures__/*.artifact.json')", PROBE_FILE),
470550
true,
471551
);
472552
check(
473553
'matcher still sees a wildcard package path',
474-
referencesArtifact("glob('packages/*/src/__fixtures__/*.artifact.json')"),
554+
referencesArtifact("glob('packages/*/src/__fixtures__/*.artifact.json')", PROBE_FILE),
475555
true,
476556
);
477557
check(
478558
'matcher still sees a `.`-segment path to this directory, in a template literal',
479-
referencesArtifact("readFileSync(`packages/./metadata/src/__fixtures__/hotcrm-17.1-${kind}.artifact.json`)"),
559+
referencesArtifact("readFileSync(`packages/./metadata/src/__fixtures__/hotcrm-17.1-${kind}.artifact.json`)", PROBE_FILE),
480560
true,
481561
);
482562
check(
483563
'matcher still sees a `.`-segment glob into this directory (package segment)',
484-
referencesArtifact("glob('packages/./metadata/src/__fixtures__/hotcrm-*.artifact.json')"),
564+
referencesArtifact("glob('packages/./metadata/src/__fixtures__/hotcrm-*.artifact.json')", PROBE_FILE),
485565
true,
486566
);
487567
check(
488568
'matcher still sees a `.`-segment glob into this directory (src segment)',
489-
referencesArtifact("glob('packages/metadata/./src/__fixtures__/hotcrm-*.artifact.json')"),
569+
referencesArtifact("glob('packages/metadata/./src/__fixtures__/hotcrm-*.artifact.json')", PROBE_FILE),
490570
true,
491571
);
492572
check(
493573
'matcher still sees another package\'s fixtures path that a glob continues',
494-
referencesArtifact("glob('packages/objectql/src/__fixtures__/a*.artifact.json')"),
574+
referencesArtifact("glob('packages/objectql/src/__fixtures__/a*.artifact.json')", PROBE_FILE),
495575
true,
496576
);
497577
check(
498578
'matcher still sees another package\'s fixtures path that a template continues',
499-
referencesArtifact("readFileSync(`packages/objectql/src/__fixtures__/${name}.artifact.json`)"),
579+
referencesArtifact("readFileSync(`packages/objectql/src/__fixtures__/${name}.artifact.json`)", PROBE_FILE),
500580
true,
501581
);
502582
check(
503583
'matcher still sees a fixtures path that does not START a token',
504-
referencesArtifact("readFileSync('vendor/packages/objectql/src/__fixtures__/hotcrm.artifact.json')"),
584+
referencesArtifact("readFileSync('vendor/packages/objectql/src/__fixtures__/hotcrm.artifact.json')", PROBE_FILE),
505585
true,
506586
);
507587
check(
508588
'matcher still sees a `.`-segment LITERAL path into this directory (only normalization reads it)',
509-
referencesArtifact("readFileSync('packages/./metadata/src/__fixtures__/hotcrm-17.1-built-bare-root-predicates.artifact.json')"),
589+
referencesArtifact("readFileSync('packages/./metadata/src/__fixtures__/hotcrm-17.1-built-bare-root-predicates.artifact.json')", PROBE_FILE),
510590
true,
511591
);
512592
check(
513593
"matcher does NOT count a `.`-segment literal path to another package's fixture",
514-
referencesArtifact(" 'packages/./objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json',"),
594+
referencesArtifact(" 'packages/./objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json',", PROBE_FILE),
515595
false,
516596
);
597+
check(
598+
'matcher sees a relative climb into this directory, resolved from the file that holds it',
599+
referencesArtifact("readdirSync(resolve(HERE, '../../../metadata/src/__fixtures__'))", 'packages/objectql/src/loaders/catalog.ts'),
600+
true,
601+
);
602+
check(
603+
'matcher does NOT count the same climb from a file where it lands in another directory',
604+
referencesArtifact("readdirSync(resolve(HERE, '../../../metadata/src/__fixtures__'))", 'packages/services/service-metadata/src/loaders/catalog.ts'),
605+
false,
606+
);
607+
check(
608+
'matcher sees a case variant of this directory',
609+
referencesArtifact("readdirSync('packages/Metadata/src/__fixtures__')", PROBE_FILE),
610+
true,
611+
);
612+
check(
613+
'matcher sees a backslash-separated spelling of this directory',
614+
referencesArtifact("readdirSync('packages\\\\metadata\\\\src\\\\__fixtures__')", PROBE_FILE),
615+
true,
616+
);
617+
check(
618+
"matcher does NOT count service-metadata's fixtures directory, whose path contains the string metadata/src/__fixtures__",
619+
referencesArtifact("readdirSync('packages/services/service-metadata/src/__fixtures__')", PROBE_FILE),
620+
false,
621+
);
622+
// Rule 3's token runs PAST `${`, `?`, `[` and `{`. Each of the four below
623+
// puts its character in the MIDDLE of a file name: cut there, the token is
624+
// `…/__fixtures__/hotcrm-` (or `x-`), a literal file path that is set aside,
625+
// and the marker left behind has no `__fixtures__` beside it. Right after
626+
// `__fixtures__/` a cut would leave a token ending in `/`, which the literal
627+
// test keeps anyway, so the case would pin nothing.
628+
check(
629+
"matcher still sees a `${kind}` in the middle of a file name in another package's fixtures path",
630+
referencesArtifact("readFileSync(`packages/objectql/src/__fixtures__/hotcrm-${kind}.artifact.json`)", PROBE_FILE),
631+
true,
632+
);
633+
check(
634+
"matcher still sees a `hotcrm-?` glob in another package's fixtures path",
635+
referencesArtifact("glob('packages/objectql/src/__fixtures__/hotcrm-?.artifact.json')", PROBE_FILE),
636+
true,
637+
);
638+
check(
639+
"matcher still sees a `hotcrm-[ab]` glob in another package's fixtures path",
640+
referencesArtifact("glob('packages/objectql/src/__fixtures__/hotcrm-[ab].artifact.json')", PROBE_FILE),
641+
true,
642+
);
643+
check(
644+
"matcher still sees a `{a,hotcrm}` glob in another package's fixtures path",
645+
referencesArtifact("glob('packages/objectql/src/__fixtures__/x-{a,hotcrm}.artifact.json')", PROBE_FILE),
646+
true,
647+
);
517648

518649
// ── The permission-set extractor, on the real artifact shape.
519650
battery('The permission-set extractor, on the real artifact shape.');

0 commit comments

Comments
 (0)