Skip to content

Commit a2cf0f1

Browse files
os-billclaude
andauthored
fix(scripts): check-bash32-floor refuses a census with a hole in it instead of printing a shrunken one (#18846)
Fixes #18465 Clause-②: no `listPopulation` enumerates the population from the **git index** (`git ls-files`) and judges each member from the **disk** (`readFileSync`). Every path the index listed and the disk could not supply was dropped in a silent `catch { continue }` at exit 0, so a sparse checkout, a partially materialised worktree, a `--root` pointed at one, or a deletion that is not staged yet made the gate print a **shrunken-but-plausible census as its verdict**. The skip stays. A deleted-but-indexed path is genuinely not a script to judge, and the existing comment saying so is correct about the judgement. What goes is the silence. ## What changed `scripts/check-bash32-floor.mjs`, one file: - `listPopulation` returns `unreadable` — one entry per path, carrying the errno reason the OS gave (`ENOENT: no such file or directory, open '...'`). ⛔ Per path with its reason, never a count: a count is the silent skip with a number attached, which is the same quiet pass #4690 refuses. `unsupportedConstructs` in this file already takes exactly that disposition toward a skip, and `report()` names a file, a line and a spelling for the same reason. - `scanTree` carries it through; `main()` refuses on a non-empty `unreadable`. - The refusal is ordered **before** the empty-population branch, deliberately. When every indexed path is unreadable the population is empty too, and #4690's message — "the walk found nothing" — would then be a false sentence: the walk found plenty and read none. Two different answers, two different remedies, and a self-test case pins them apart. - The docblock gains the index-vs-disk section, with the measurement that chose refusal. - One new self-test battery, 11 cases, and `SELF_TEST_BATTERY_FLOOR` 18 → 19 so the new battery cannot be deleted without reddening. ## Refuse, or speak and pass? The reading decided Refusing reddens normal CI immediately if the skip count is ever non-zero on a complete checkout, so that was measured first rather than preferred: | leg | reading | |:---|:---| | four independent **complete** checkouts (`objectstack`, this worktree, `-18592`, `-18745`) | indexed 400/402/402/402 — **skipped 0** in every one | | every indexed path under the walk roots, at `origin/main`, `HEAD` and the four refs the card names (`8a70e1bf6`, `f836fb209`, `ce7bae8b44`, `e915c190e`) | **100644 / 100755 only** — no `120000` symlink, no `160000` gitlink | | `.gitmodules` | absent — the repo declares no submodule | | who runs this gate | `lint.yml:678` alone (`pnpm check:bash32-floor`); no hook, no other caller | ⇒ Nothing but a genuinely incomplete tree can produce a skip here, and the only caller does a fresh complete checkout. Refusal is safe, and it is what "Absence must be loud — prefer failing to falling back" asks for. The one shape where a developer sees a new red is a local `rm` whose deletion is not staged yet; the refusal names that cause first and says how to clear it. ## The two controls Both run the **same fixture** against both gates — 7 shell files indexed (6 by `.sh`, 1 by shebang), 4 removed from the **disk only**, index never touched (the card's leg B). **LIT — the behaviour flip.** Matching exit codes would prove nothing, so here is the flip: ``` BEFORE index lists 7 EXIT 0 stdout: ✓ check-bash32-floor: 3 tracked shell file(s) ... census: 2 by .sh extension, 1 by shebang alone; 19 constructs checked stderr: (empty) AFTER index lists 7 EXIT 1 stdout: (empty — the green census line is never printed) stderr: ✗ check-bash32-floor: 4 path(s) ... are listed in the git index but could not be read from disk, so this census has a hole in it and is not a verdict. scripts/c.sh ENOENT: no such file or directory, open '.../scripts/c.sh' scripts/d.sh ENOENT: ... (and e.sh, f.sh, each with its reason) ``` The lit control is non-zero — the shrunken census really was produced — so the reading is not void. **DARK — must read 0, and does.** The identical fixture complete on disk, plus the real checkout, before and after: ``` fixture complete 7 tracked shell file(s), census 6 + 1, EXIT 0 identical real checkout 31 tracked shell file(s), census 29 + 2, EXIT 0 identical ``` Byte-identical, not merely equivalent: `sha256` of both DARK transcripts is `4406e0e791002c16bafa4dc50f5c925cf8938ac1c7c97c9911f6e9e27dff1ca5`, `diff` exit 0, 841 bytes each. **No new output, no new refusal on a complete tree.** ## Ablation — the new battery can be shown to fail Run from the committed state, mutation proven on disk before the run (injected text present 1, removed text present 0), restored with `git checkout HEAD -- THE-PATH` under a `trap`: - reverting the reporting half (`unreadable.push(...)` → a bare skip) turns the self-test **red at 6 of 177 cases**, naming exactly the lit half; - the three DARK cases and "the SKIP itself is kept" stay **green** — the ablation removes the reporting, not the skip, and precisely the reporting cases speak. That is the discrimination, not just a red. - restore proven by blob hash, not by an exit code: `git hash-object` = `git rev-parse HEAD:...` = `8c8eda638a116c118e2d8dab33ea1ace40473f7d`, `git diff HEAD` empty, porcelain clean. This ablation earned its keep: the **first** run crashed the battery at its third case with a `TypeError` instead of failing it, which killed every later case — including both DARK legs — before the battery floor and the verdict handshake could speak. The assertions now read through an optional binding, and the reason is written at the site. ## objectui#8694 — audited, both directions, no coupling The card records this as UNAUDITED because that repo was not checked out. It is checked out here, so it was read rather than guessed: - `scripts/upstream-port-pin.json` registers `scripts/check-bash32-floor.mjs` at ref `61362932b5ad4c85b39169e70cf9be64d4332ce5`, digest `0da45049c680c2b2d58d52d637f17f7c45421a46a92a8dd0e6385dadb384dffa`. Verified from this side: `git show 6136293:scripts/check-bash32-floor.mjs | sha256sum` gives that exact digest, so the pin is real and points at an immutable blob. - `check-upstream-port-parity.mjs` ⛔ **fetches nothing** — its own docblock says so, and `grep` for `fetch(`/`api.github`/network verbs finds only that sentence. It verifies objectui's ported bytes by reversing the declared divergences and comparing against the **pinned digest**; the pinned digest IS its input. ⇒ A change here at a **new** commit cannot redden that gate: ref `61362932b` is untouched by this PR (and is an ancestor of this branch, `merge-base --is-ancestor` exit 0, with a control commit also reading 0). Nothing in this repo reads objectui's ledger either. The real consequence is ordinary port drift — objectui's copy falls one change further behind, and a future `--resync` there carries this along with a re-declared divergence list. That is objectui's own maintenance; objectui#8694 is already closed, and this PR deliberately does not widen into that repo. ## Verification `origin/main` was merged in before this PR opened; the delta versus the merge base is still exactly one file, and no regeneration was deferred (`os-regen-pending` absent). - `pnpm check:bash32-floor` (self-test + real run) — green both before and after that merge: **177 cases**, real tree 31 shell files (29 by extension, 2 by shebang), 0 findings. - **All 28 derived gate families run, all green.** Derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` and reconciled with `--ran`, recording each family's exit code captured **before** any pipe. Derivations taken before and after the merge produced an identical 28-command list, and the post-merge derivation carries no STALE TREE note. Two families needed a second run and are reported as such rather than as passes: `check-comment-mask-corpus` first exited **3** (PREREQUISITE NOT MET — a fresh worktree with no `node_modules`; NOT MEASURED, not a finding) and is green after `pnpm install` (6846 files, 0 disagree); `check:pm-dispatch-gates` first exited **124** (a `timeout` wrapper fired, so no verdict was reached) and was re-run with a real budget. - `grep -naP` over the changed file for control bytes: none, beside `check:nul-bytes` itself. - **Lint, as a declared narrowing.** The repo-wide `pnpm lint` (`eslint . --no-inline-config`) is CI's run. Targeted here, with the three readings that make the narrowing a measurement rather than a skip: ① the universe is **6846 files**, enumerated by ESLint's own walk over its own config (`new ESLint({ ruleFilter: () => false })`, so the walk runs and no rule does), and this file is in it; ② the targeted run linted **1 file**, counted from `--format json`, **0 errors / 0 warnings**; ③ invariance — this repo enables no type-aware linting, in the config's own words: *"this repo runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not"* (`eslint.config.mjs`). With no cross-file type program, a one-file diff cannot move the verdict on any untouched file, so the 6845 files not linted here could not have changed. ## Acceptance notes - **noted, not filed:** the `--root` flag takes `process.argv[rootFlag + 1]` with no check that an argument follows, so `--root` as the last token reads `undefined` and fails later with a less direct message. Out of scope here, mechanically unrelated to the census, and no PR or person is heading for that line. Successor: none. - Nothing publishes from this diff — measured, not assumed: the root package is `private: true`, no package's `files[]` escapes its own directory (so no tarball can contain a repo-root `scripts/` path), and `check-bash32-floor` has **0** references under `packages/` against a positive control (`defineStack`) that matches there. Hence `skip-changeset` rather than a changeset, and `Clause-②: no` above. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88aa326 commit a2cf0f1

1 file changed

Lines changed: 226 additions & 8 deletions

File tree

‎scripts/check-bash32-floor.mjs‎

Lines changed: 226 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,35 @@
174174
* Discovery reads the git index, so an ignored or generated file is never
175175
* scanned and a newly tracked script is scanned the moment it is staged.
176176
* An empty population is a REFUSAL, not a quiet pass (#4690).
177+
*
178+
* ## The census is enumerated from the INDEX and judged from the DISK (#18465)
179+
*
180+
* Those are two different trees, and the gap between them is a HOLE in the
181+
* census rather than a smaller population. A sparse checkout, a partially
182+
* materialised worktree, a `--root` pointed at one, or a deletion that is not
183+
* staged yet all leave paths the index lists and the disk cannot supply.
184+
* Skipping one is CORRECT — a deleted-but-indexed path is genuinely not a
185+
* script to judge — so the skip stays. What is not correct is doing it
186+
* quietly.
187+
*
188+
* The green line prints the census as a VERDICT, so a silently shrunken count
189+
* is an assertion a reader acts on, and it is strictly more dangerous than the
190+
* empty population the paragraph above refuses: an empty census is visibly
191+
* absurd, a plausible smaller one reads as a fact. Measured on a 7-file
192+
* fixture with four paths removed from the disk alone, the gate printed
193+
* `3 tracked shell file(s) ... census: 2 by .sh extension, 1 by shebang alone`
194+
* at exit 0 while `git ls-files` still listed 7. It has already cost time: a
195+
* 33 to 29 to 33 swing sat unreconciled for six days, because the run that
196+
* read 29 had no way to say what it had not read.
197+
*
198+
* So an unreadable indexed path is a REFUSAL carrying its reason, PER PATH —
199+
* the same disposition `unsupportedConstructs` takes toward a skip above, and
200+
* ⛔ never a count, which is the silent skip with a number attached.
201+
*
202+
* ⚠️ Refusing rather than merely warning is safe because the skip is
203+
* measurably ZERO on a complete checkout: every path the index lists under
204+
* these roots is a regular blob — no symlink, no gitlink, and this repo
205+
* declares no submodule — so nothing but an incomplete tree produces one.
177206
*/
178207

179208
import { spawnSync } from 'node:child_process';
@@ -789,9 +818,31 @@ export function unsupportedConstructs(label, text, caps) {
789818
});
790819
}
791820

821+
/**
822+
* Why one indexed path could not be read, in the terms the OS gave.
823+
*
824+
* Node's `fs` message already opens with the errno and ends with the path
825+
* (`ENOENT: no such file or directory, open '...'`), which is the whole reason
826+
* this is carried rather than summarised: the shapes a reader must tell apart
827+
* — absent (a sparse checkout or an unstaged deletion), a directory here, a
828+
* mode this process cannot read — differ only in that code.
829+
*
830+
* @param {unknown} err
831+
*/
832+
function unreadableReason(err) {
833+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : null;
834+
const reason = err instanceof Error && err.message ? err.message : String(err);
835+
return { code, reason };
836+
}
837+
792838
/**
793839
* The population, read from the git index under the derived walk roots.
794840
*
841+
* Also returns `unreadable`: the paths the index listed and the disk could not
842+
* supply, each with its reason (#18465). The skip is kept — a deleted-but-
843+
* indexed path is not a script to judge — but it leaves a hole in the census,
844+
* so the caller can refuse instead of printing a shrunken number as a verdict.
845+
*
795846
* @param {string} root
796847
*/
797848
export function listPopulation(root) {
@@ -803,30 +854,36 @@ export function listPopulation(root) {
803854
throw new Error(`git ls-files failed under ${root}: ${(out.stderr || '').trim()}`);
804855
}
805856
const population = [];
857+
/** @type {{ rel: string, code: string|null, reason: string }[]} */
858+
const unreadable = [];
806859
let byExtension = 0;
807860
let byShebang = 0;
808861
for (const rel of out.stdout.split('\0').filter(Boolean)) {
809862
let text;
810863
try {
811864
text = readFileSync(join(root, rel), 'utf8');
812-
} catch {
813-
continue; // a deleted-but-indexed path is not a script to judge
865+
} catch (err) {
866+
// Still skipped: a deleted-but-indexed path is not a script to judge.
867+
// But RECORDED, because the population was enumerated from the index and
868+
// this path is in it — the silence, not the skip, was the defect (#18465).
869+
unreadable.push({ rel, ...unreadableReason(err) });
870+
continue;
814871
}
815872
const verdict = isShell(rel, text);
816873
if (!verdict.shell) continue;
817874
if (verdict.by === 'extension') byExtension += 1;
818875
else byShebang += 1;
819876
population.push({ rel, text, by: verdict.by });
820877
}
821-
return { population, byExtension, byShebang };
878+
return { population, byExtension, byShebang, unreadable };
822879
}
823880

824881
/** Scan a whole tree. Returns findings plus the census the green line prints. */
825882
export function scanTree(root) {
826-
const { population, byExtension, byShebang } = listPopulation(root);
883+
const { population, byExtension, byShebang, unreadable } = listPopulation(root);
827884
const findings = [];
828885
for (const { rel, text } of population) findings.push(...scanText(rel, text));
829-
return { findings, population, byExtension, byShebang };
886+
return { findings, population, byExtension, byShebang, unreadable };
830887
}
831888

832889
function report(findings) {
@@ -846,6 +903,48 @@ function report(findings) {
846903
);
847904
}
848905

906+
/**
907+
* The census has a HOLE in it: name every path, and why each one (#18465).
908+
*
909+
* ⛔ Per path with its reason, never a count. A count is the silent skip with
910+
* a number attached, and the defect was precisely that the number was
911+
* plausible — `report()` above names a file, a line and a spelling for the
912+
* same reason.
913+
*
914+
* @param {{ rel: string, code: string|null, reason: string }[]} unreadable
915+
* @param {{ rel: string }[]} population what the readable remainder came to
916+
* @param {{ file: string }[]} findings the remainder's findings, if any
917+
*/
918+
function reportUnreadable(unreadable, population, findings) {
919+
console.error(
920+
'✗ check-bash32-floor: ' + unreadable.length + ' path(s) under ' + POPULATION_ROOTS.join(', ')
921+
+ ' are listed in the\n git index but could not be read from disk, so this census has a hole in '
922+
+ 'it and is not a verdict.\n',
923+
);
924+
for (const u of unreadable) {
925+
console.error(' ' + u.rel);
926+
console.error(' ' + u.reason + '\n');
927+
}
928+
console.error(
929+
'The population is enumerated from the INDEX and judged from the DISK. Skipping a path that\n'
930+
+ 'cannot be read is correct — a deleted-but-indexed path is not a script to judge — but doing it\n'
931+
+ 'quietly would have printed "' + population.length + ' tracked shell file(s)" as a verdict while '
932+
+ unreadable.length + ' path(s) the index\nlists were never read at all; an unread path cannot even be '
933+
+ 'classified as shell, so the census\ncannot say whether it belonged in the count. A plausible smaller '
934+
+ 'number reads as a fact where\nan empty one would read as absurd — this is the neighbouring refusal '
935+
+ 'completed (#4690).\n\n'
936+
+ 'Usual causes, in the order they occur: a deletion that is not staged yet (git add -A, or\n'
937+
+ 'git rm), a sparse or partially materialised checkout (git sparse-checkout disable), or a\n'
938+
+ '--root pointed at one. Re-run against a complete tree.',
939+
);
940+
if (findings.length > 0) {
941+
console.error(
942+
'\nThe readable remainder also carries ' + findings.length + ' finding(s), reported below. That is a\n'
943+
+ 'reading of the REMAINDER, never of the population.\n',
944+
);
945+
}
946+
}
947+
849948
// ---------------------------------------------------------------------------
850949

851950
/**
@@ -902,14 +1001,15 @@ const SELF_TEST_BATTERIES = Object.freeze({
9021001
'population membership': 5,
9031002
'⭐ the declaration, and the two obligations it makes unreachable': 5,
9041003
'⭐ end to end, through the real discovery path': 5,
1004+
'⭐ an indexed path the DISK cannot supply is a REFUSAL': 11,
9051005
'⭐ the bash-4 capability reading, pinned in BOTH directions': 12,
9061006
'⭐ the instrument is real: the flagged construct really does break': 5,
9071007
'the real tree': 2,
9081008
});
9091009

9101010
// DELETING an entry silences that battery's floor exactly as effectively as
9111011
// zeroing it, so the roster's own size is pinned too.
912-
const SELF_TEST_BATTERY_FLOOR = 18;
1012+
const SELF_TEST_BATTERY_FLOOR = 19;
9131013

9141014
// The key an assertion is filed under when no battery is open. It is not a
9151015
// declared battery, so it reds by the same set difference rather than silently
@@ -1276,6 +1376,112 @@ function selfTest() {
12761376
`${emptyRun.stdout}${emptyRun.stderr}`.slice(0, 300),
12771377
);
12781378

1379+
// --- ⭐ an indexed path the DISK cannot supply is a REFUSAL (#18465) -----
1380+
//
1381+
// The population is enumerated from the INDEX and judged from the DISK, so a
1382+
// path the index lists and the disk cannot supply is a HOLE in the census
1383+
// rather than a smaller population. Before this battery the gate dropped it
1384+
// in a silent `continue` and printed the shrunken number as its verdict at
1385+
// exit 0 — measured on a 7-file fixture with four paths removed from the disk
1386+
// alone: `3 tracked shell file(s) ... census: 2 by .sh extension, 1 by
1387+
// shebang alone`, while `git ls-files` still listed 7.
1388+
//
1389+
// ⚠️ Both directions are pinned and the DARK half is the load-bearing one: a
1390+
// refusal that also fired on a COMPLETE checkout would redden every normal
1391+
// run, and it would pass every lit case below while doing it.
1392+
battery('⭐ an indexed path the DISK cannot supply is a REFUSAL');
1393+
const holed = {
1394+
'scripts/present.sh': '#!/usr/bin/env bash\necho present\n',
1395+
'scripts/absent.sh': '#!/usr/bin/env bash\necho absent\n',
1396+
'.githooks/pre-push': '#!/bin/sh\nnow="${EPOCHSECONDS:-$(date +%s)}"\n',
1397+
};
1398+
const completeRepo = fixtureRepo(holed);
1399+
const partialRepo = fixtureRepo(holed);
1400+
// Removed from the DISK only. The index is never touched, which is the whole
1401+
// shape — and the first case proves the fixture really is that shape, because
1402+
// a fixture whose index also lost the path would make every case below pass
1403+
// by testing nothing.
1404+
rmSync(join(partialRepo, 'scripts/absent.sh'));
1405+
const stillIndexed = spawnSync('git', ['-C', partialRepo, 'ls-files', '--', ...WALK_ROOTS], { encoding: 'utf8' });
1406+
t(
1407+
'the fixture really is INDEX-vs-DISK: the index still lists the removed path',
1408+
stillIndexed.stdout.includes('scripts/absent.sh'),
1409+
stillIndexed.stdout.trim(),
1410+
);
1411+
const partial = listPopulation(partialRepo);
1412+
// ⛔ Read through an optional binding, never `partial.unreadable[0].code`
1413+
// directly. An assertion that THROWS instead of returning false takes every
1414+
// later case in this battery with it — including both DARK legs, which are
1415+
// the load-bearing half — and it dies before the battery floor and the
1416+
// verdict handshake can speak. Measured: ablating the reporting half crashed
1417+
// this battery at its third case, so the remaining eight never ran.
1418+
const hole = partial.unreadable[0] ?? null;
1419+
t(
1420+
'an unreadable indexed path is REPORTED, not dropped in silence',
1421+
partial.unreadable.length === 1 && hole?.rel === 'scripts/absent.sh',
1422+
JSON.stringify(partial.unreadable),
1423+
);
1424+
t(
1425+
'and it carries its REASON — a count is the silent skip with a number attached',
1426+
hole?.code === 'ENOENT' && /no such file/i.test(hole?.reason ?? ''),
1427+
JSON.stringify(hole),
1428+
);
1429+
t(
1430+
'the SKIP itself is kept: a deleted-but-indexed path is still not a script to judge',
1431+
partial.population.every((p) => p.rel !== 'scripts/absent.sh') && partial.population.length === 2,
1432+
JSON.stringify(partial.population.map((p) => p.rel)),
1433+
);
1434+
const partialRun = spawnSync(process.execPath, [SELF, '--root', partialRepo], { encoding: 'utf8' });
1435+
const partialOut = `${partialRun.stdout}${partialRun.stderr}`;
1436+
t(
1437+
'end to end, the gate REFUSES rather than printing a shrunken census at exit 0',
1438+
partialRun.status === 1,
1439+
partialOut.slice(0, 400),
1440+
);
1441+
t(
1442+
'and the refusal NAMES the path it could not read',
1443+
partialOut.includes('scripts/absent.sh'),
1444+
partialOut.slice(0, 400),
1445+
);
1446+
t(
1447+
'⛔ and the green census line is never printed — that line IS the shrunken verdict',
1448+
!/tracked shell file\(s\) under/.test(partialRun.stdout),
1449+
JSON.stringify(partialRun.stdout.slice(0, 300)),
1450+
);
1451+
// A tree whose whole population is unreadable found PLENTY and read NONE, so
1452+
// #4690's "the walk found nothing" would be a false sentence about it. The
1453+
// two refusals are different answers with different remedies.
1454+
const allAbsentRepo = fixtureRepo(holed);
1455+
for (const rel of Object.keys(holed)) rmSync(join(allAbsentRepo, rel));
1456+
const allAbsentRun = spawnSync(process.execPath, [SELF, '--root', allAbsentRepo], { encoding: 'utf8' });
1457+
const allAbsentOut = `${allAbsentRun.stdout}${allAbsentRun.stderr}`;
1458+
t(
1459+
'a wholly unreadable population refuses AS UNREADABLE, not as empty',
1460+
allAbsentRun.status === 1
1461+
&& /could not be read from disk/.test(allAbsentOut)
1462+
&& !/found no shell files/.test(allAbsentOut),
1463+
allAbsentOut.slice(0, 400),
1464+
);
1465+
// ⭐ DARK. Everything above would pass just as well if the refusal fired on
1466+
// every tree; these three are what say it does not.
1467+
const completeRun = spawnSync(process.execPath, [SELF, '--root', completeRepo], { encoding: 'utf8' });
1468+
const completeOut = `${completeRun.stdout}${completeRun.stderr}`;
1469+
t(
1470+
'⭐ DARK: the SAME fixture, complete on disk, stays GREEN',
1471+
completeRun.status === 0,
1472+
completeOut.slice(0, 400),
1473+
);
1474+
t(
1475+
'⭐ DARK: …and reads ZERO unreadable paths, so the new report is not decoration',
1476+
listPopulation(completeRepo).unreadable.length === 0,
1477+
JSON.stringify(listPopulation(completeRepo).unreadable),
1478+
);
1479+
t(
1480+
'⭐ DARK: …and its census is the FULL one the partial run shrank',
1481+
/3 tracked shell file\(s\)/.test(completeRun.stdout) && /census: 2 by \.sh extension, 1 by shebang alone/.test(completeRun.stdout),
1482+
completeRun.stdout,
1483+
);
1484+
12791485
// --- ⭐ the capability reading, pinned in BOTH directions (#17458) ---------
12801486
//
12811487
// The disposition every harness takes from `probeBashCapabilities()` branches
@@ -1449,7 +1655,9 @@ function selfTest() {
14491655
`status=${guardedSim.status} out=${guardedSim.stdout.trim()} err=${guardedSim.stderr.trim()}`,
14501656
);
14511657

1452-
for (const d of [badRepo, cleanRepo, emptyRepo, simDir]) rmSync(d, { recursive: true, force: true });
1658+
for (const d of [badRepo, cleanRepo, emptyRepo, completeRepo, partialRepo, allAbsentRepo, simDir]) {
1659+
rmSync(d, { recursive: true, force: true });
1660+
}
14531661

14541662
// --- the real tree -------------------------------------------------------
14551663
battery('the real tree');
@@ -1542,7 +1750,17 @@ function main() {
15421750
const rootFlag = process.argv.indexOf('--root');
15431751
const root = rootFlag === -1 ? REPO_ROOT : process.argv[rootFlag + 1];
15441752

1545-
const { findings, population, byExtension, byShebang } = scanTree(root);
1753+
const { findings, population, byExtension, byShebang, unreadable } = scanTree(root);
1754+
1755+
// ⛔ Ordered BEFORE the empty-population refusal, deliberately: when every
1756+
// indexed path is unreadable the population is empty too, and #4690's message
1757+
// — "the walk found nothing" — would then be false. The walk found plenty and
1758+
// read none. Two different answers, two different remedies (#18465).
1759+
if (unreadable.length > 0) {
1760+
reportUnreadable(unreadable, population, findings);
1761+
if (findings.length > 0) report(findings);
1762+
process.exit(1);
1763+
}
15461764

15471765
if (population.length === 0) {
15481766
console.error(

0 commit comments

Comments
 (0)