/** * mjs-files.mjs — the one definition of "every .mjs file in this repository". * * Two things syntax-check this repo: `scripts/check-syntax.mjs` (run as * `npm run lint`) and section 1 of `test-all.mjs`. They used to disagree about * what "every file" meant, and only one of them said so. * * `test-all.mjs` read the repository root with a NON-recursive `readdirSync`, * so its gate covered 121 of the ~575 `.mjs` files here — and it printed one * `{file} syntax OK` line per file, so a reader watching 121 green lines had no * way to tell that the directory holding 263 of them was never opened. Worse, * the gate NARROWED every time a file moved out of the root and never * mentioned it: #3306 moved eleven suites into `tests/` and #3388 moved nine, * and each one silently left the gate. The shortfall was eventually noticed * only as a two-check arithmetic discrepancy in an unrelated PR (#3411), which * is not a way to find out (#3419). * * Sharing the walker is the fix rather than copying the recursion into * `test-all.mjs`: two independently maintained definitions of the same set is * exactly the drift that caused this, and a second copy would be free to * re-diverge the next time one of them learned about a directory. * * Two exports, answering two DIFFERENT questions — which is not the drift * above, and the distinction is worth stating so nobody collapses them: * * `collectMjsFiles` — every `.mjs` in the WORKING TREE, skip-list and all. * What the syntax gates want: the file you just wrote and * have not `git add`ed yet still has to parse. * `trackedFiles` — every file the INDEX holds, at any depth, no skip-list. * What the repo-scanning guards want: they grade what the * repository ships, and git already knows which trees are * not that. Four guards each kept their own walk with * their own skip-list until #3890; every one of them * carried the same silent narrowing as the gate above. */ import { execFileSync } from 'child_process'; import { lstatSync } from 'fs'; import { join } from 'path'; import { walkTree } from './walk-tree.mjs'; // Re-exported so the historical import site keeps working; `lib/walk-tree.mjs` // owns the predicate now, because the walk that consults it lives there. export { isNestedCheckout } from './walk-tree.mjs'; /** * Every file `root` TRACKS, as absolute paths, sorted. * * The enumerator for repo-scanning guards, as distinct from `collectMjsFiles` * below, which walks the working tree. The two answer different questions on * purpose and the difference is not drift: a syntax gate wants the file you * just wrote and have not added yet, and a guard grading what the repository * SHIPS wants the index — which is also the only definition that needs no * skip-list. `node_modules`, `output/`, `data/`, a gitignored worktree and a * half-finished scratch file are all excluded because git already knows they * are not repository content, not because four separate walkers each remembered * to name them (#3890). * * Every hand-maintained walker in this repo carried the #3419 defect in some * form: it scanned directories it should not, it missed newly tracked files * under a directory its skip-list excluded, and a green run looked identical * either way. A skip-list narrows silently. An index does not. * * THE INDEX, THOUGH — NOT THE COMMIT, AND NOT THE WORKING TREE. The sharp edge * of grading what the repository ships is that a file you have written but not * `git add`ed is not yet part of what it ships, so it does not exist to any * guard built on this: the new suite that skips its own `main` guard, the new * mode file carrying mojibake, the new scanner with a private walk. The guard * does not fail on it and does not mention it — it reports a clean sweep of the * files that WERE staged, which is the exact shape of green-that-checked-less * this module was written to remove, arriving through the fix rather than the * bug. It is the deliberate trade and not a defect to route around: the * alternative is the skip-list, and the failure lands one `git add` later * rather than never. But it means a pre-commit run and a CI run legitimately * disagree, and a contributor debugging that discrepancy should find the reason * written down here rather than infer it from `ls-files`. Stage first, then * trust the guard. `collectMjsFiles` is the export that does not care, which is * why the syntax gates use it. * * `filter` receives the repo-relative path with FORWARD slashes on every * platform (`ls-files` emits POSIX separators), so `rel.startsWith('modes/')` * is written once rather than per-OS. It is applied before the lstat below, so * a caller scoping to a subtree does not pay a stat for the whole tree. * * Only regular files come back: a symlink, a submodule directory and a path the * index names but the tree does not have are each skipped with a warning that * says which. See the comment on the loop for why each one is not readable. * * @param {string} root - Absolute path to a git working tree. * @param {(rel: string) => boolean} [filter] - Tested against the relative path. * @returns {string[]} Absolute paths, lexicographically sorted. */ /** * Stat failures that mean "this indexed path is not in the working tree", and * are therefore skipped rather than fatal. `throwIfNoEntry: false` already * suppresses ENOENT; these two are the same condition arriving by a different * errno. Every other code is a filesystem this scan could not read, and it ends * the scan — see the catch below. */ const UNSTATTABLE_SKIP_CODES = new Set(['ENOTDIR', 'EACCES']); export function trackedFiles(root, filter = () => true) { // What is graded is the INDEX, not the working tree: a file that exists on // disk but has not been `git add`ed is invisible to every guard routed through // here. CI never sees that case, because it checks out a commit where // everything is staged by definition — so the contributor who hits it is // always running locally, and `git add` is the fix. // // Deliberately uncaught. A `git` that cannot run, or a `root` that is not a // working tree, is a scan that did not happen — and the one thing this module // exists to prevent is a scan that did not happen reading as a scan that // passed. The same reasoning as the missing-root throw in collectMjsFiles. const out = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], }); // De-duplicated because `ls-files` prints one line PER INDEX STAGE, so a path // in an unresolved merge arrives two or three times. Left alone, every hit // inside a conflicted file would be reported once per stage and every // "n files scanned" figure would be wrong for the duration of the merge. const listed = [...new Set(out.split('\0').filter(Boolean))]; // A repository-local `core.hooksPath` is not consulted here: this shells out to // `git ls-files`, which reads the index and runs no hook. // Empty is never "nothing to check" — it means this call could not look. // `ls-files` exits 0 with no output when run from an untracked directory, // which is exactly how the SYSTEM_PATHS coverage guard printed // "0 tracked files covered" and exited 0 for its entire life while the tree // really did have an unregistered file in it. Callers filter afterwards, so // this is the LISTING being empty, not the caller's slice of it. if (listed.length === 0) { throw new Error( `git ls-files listed no tracked files under ${root} — this scan could not inspect anything. ` + 'An empty listing usually means the caller is running from an untracked directory ' + '(a temp copy, a fixture dir), where git reports nothing and the guard is meaningless.', ); } // Only REGULAR FILES are returned, and `lstatSync` rather than `existsSync` // is what decides that. The index names three things a caller cannot simply // open, and the walkers being replaced here excluded all three for free — // `readdirSync` told them what each entry was: // // MISSING Mid-merge the index holds stages for a path the working tree // may not have: a delete/modify conflict whose deletion is the // resolution being accepted, still unstaged, is a `DU` path // that `ls-files` names and `readFileSync` cannot open. An // ordinary state during conflict resolution, not a corrupt // repository — a sparse checkout and an uninitialised // submodule land here too. A guard that crashes while you are // resolving a merge is a guard people learn to skip, which // costs more than the guard was worth (#3890). // SYMLINK `ls-files` lists tracked symlinks (this repo has seven: the // per-CLI `SKILL.md` links to the canonical one), and // `existsSync`/`readFileSync` follow them. Following costs // twice: the target is usually tracked in its own right, so // one offender gets reported once per link, and a link // pointing outside the checkout puts source this repository // does not own under a guard that grades it — the #3499 hazard // `isNestedCheckout` exists for. `collectMjsFiles` below skips // them for exactly this reason; so does this. // DIRECTORY An initialised submodule is a gitlink: one index entry, a // directory on disk. `existsSync` says yes and `readFileSync` // throws EISDIR. // // UNSTATTABLE `throwIfNoEntry: false` suppresses ENOENT only. On POSIX the // index naming `a/b` while the tree has `a` as a regular file // throws ENOTDIR, and an unreadable parent directory throws // EACCES. Both are the MISSING case wearing a different errno, // so both are skipped rather than allowed to end the scan. // Windows reports the first of those as ERROR_PATH_NOT_FOUND, // which libuv maps to ENOENT, so there it arrives through the // MISSING branch below instead (#3904). Same outcome either // way, which is why the branches share one warning. // // Skipped LOUDLY in every case, and named with its reason — a scan quietly // covering less than it claims is the defect this module is named after. const files = []; const skipped = []; for (const rel of listed) { if (!filter(rel)) continue; let stat; try { stat = lstatSync(join(root, rel), { throwIfNoEntry: false }); } catch (err) { // `throwIfNoEntry: false` suppresses ENOENT and nothing else, so the // UNSTATTABLE case above still arrives as a throw. Named with the errno, // since the reason is the errno. // // Only the codes that mean "not in the working tree" are survivable. An // unrecognised failure — EIO on a failing disk, ENAMETOOLONG, anything // else — is a path this scan could not read, and skipping it would turn // a scan that could not look into a scan that found nothing. That is the // defect this module is named after, so it ends the scan instead. if (!UNSTATTABLE_SKIP_CODES.has(err.code)) throw err; skipped.push(`${rel} (${err.code})`); continue; } if (stat?.isFile()) { files.push(join(root, rel)); continue; } const why = stat === undefined ? 'not in the working tree' : stat.isSymbolicLink() ? 'a symlink' : 'not a regular file'; skipped.push(`${rel} (${why})`); } if (skipped.length > 0) { console.warn( `warning: skipped ${skipped.length} tracked path(s) that are not readable regular files ` + `(unresolved merge, sparse checkout, submodule, symlink): ${skipped.join(', ')}`, ); } return files.sort(); } /** * Directories excluded from the walk BY NAME. * * `.git` and `node_modules` are not repository source. `output`, `data`, * `coverage` and `test-results` hold generated or user content, so including * them would make the result depend on what a given checkout happens to have * run — a clean clone and a working install would disagree about how many * files were checked, and a stray `.mjs` dropped in `output/` could fail the * lint of a repository whose source is fine. * * A name is not enough to keep git's storage out on its own — see * `isNestedCheckout` in `lib/walk-tree.mjs`, which the walk consults on every * descent whether a caller remembers it or not. */ export const SKIP_DIRS = new Set(['.git', 'node_modules', 'output', 'data', 'coverage', 'test-results']); /** * Every `.mjs` file under `root`, recursively, sorted by full path. * * Sorted because both callers report per-file results in iteration order and a * run-to-run reordering of that output is noise in a diff — the readdir order * is not guaranteed across platforms or filesystems. * * @param {string} root - Absolute path to walk. * @returns {string[]} Absolute paths, lexicographically sorted. */ export function collectMjsFiles(root) { return walkTree(root, { skip: (entry) => SKIP_DIRS.has(entry.name), // Symlinked directories can point outside the checkout or back into it; // neither should make the walk recurse unpredictably. (walkTree's default, // named here because this walk's result is a gate's file list.) links: 'skip', // A scratch copy left by an interrupted `test-all.mjs` (no `.git`, named // unpredictably) is skipped by walkTree itself, beside the nested-checkout // guard, so every walker inherits it rather than rediscovering #3940. // Below the root, ENOENT is a race with readdir — a concurrent checkout, a // branch switch, a test tearing down a temp tree. At the ROOT it throws, // which is walkTree's contract and the one this gate needs: an empty list // would make the syntax check report "0 .mjs files" and pass (#3419). onError: 'ignore', }) .filter((f) => f.endsWith('.mjs')) // Sorted by FULL PATH, not left in walkTree's per-level entry order: both // callers report one line per file in iteration order, and a run-to-run // reordering of that output is noise in a diff. .sort(); }