From 48fab7beb61eb9b4158b0d59c45cf61911c5a3d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 01:39:59 +0000 Subject: [PATCH] feat(scripts): gate `repository.directory` against the manifest's own directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm renders `repository.directory` as a package page's "source" deep link, so a value naming a directory this repo does not have publishes a 404 on the one link a consumer follows to read the code. Nothing in this repo read the field: measured over the tracked tree, zero reads of `.directory` in any `.mjs`/`.mts`, and `check-published-files.mjs` — which has exactly the right population and already parses every manifest — mentions `repository` not once. The notch is a category rather than an off-by-one: this repo gated published package CONTENTS and did not gate published package PROVENANCE METADATA. The new gate is a state check over every tracked manifest that DECLARES the field: the value must be well-formed, must name a directory that is IN the repository (read off `git ls-files`, because the deep link is served from the repository and not from the author's working tree), and must be the manifest's own directory. Stateless over the population on purpose — one of the three commits behind the known residue moved nothing at all, it edited `repository.url` in the same JSON object with the stale `directory` line as visible hunk context, so a rule that fired only on directory MOVES would have missed it exactly as the reviewers did. ⛔ It judges only manifests that declare the field. Whether declaring is mandatory for a publishable package is an open policy question and a maintainer's to answer, so silence is counted, listed and never a finding. Anti-vacuity, because this population is `git ls-files` plus a field predicate and empties silently from either side: two control probes drive the same tracked-directory predicate the invariants use in BOTH directions (a directory derived from the population must report EXISTS, an assembled impossible one must report MISSING) and no verdict prints unless both fire; MEASURED pins the census on a named commit with floors under it; and the `--self-test` carries a battery roster whose names and per-battery case floors red when a battery stops registering cases. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .github/workflows/lint.yml | 23 + package.json | 1 + .../check-manifest-repository-directory.mjs | 1004 +++++++++++++++++ 3 files changed, 1028 insertions(+) create mode 100644 scripts/check-manifest-repository-directory.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8f48537093..f7f2263d7e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3597,6 +3597,29 @@ jobs: - name: Published-files whitelist guard run: pnpm check:published-files + # The PROVENANCE half of the same packaging hygiene (#15991). The guard + # above owns published package CONTENTS — what the tarball contains — and + # says nothing about where the tarball claims its source lives. npm renders + # `repository.directory` as the package page's "source" deep link, and + # until this gate NOTHING in this repo read the field: two + # `packages/triggers/*` manifests shipped a value naming + # `packages/plugins/plugin-trigger-*`, a path removed when the family was + # promoted, so both npm pages published a 404 on the one link a consumer + # follows to read the code. The history is why it is a state check over the + # whole population rather than a rule about moves: one of the three commits + # involved moved nothing at all — it edited `repository.url` in the same + # JSON object with the stale `directory` line as visible hunk context, and + # it shipped. Humans looked and missed it twice. ⛔ It judges only manifests + # that DECLARE the field; whether declaring is mandatory for a publishable + # package is an open policy question and a maintainer's to answer, so + # silence is counted and never a finding. Runs its own --self-test first: + # this population is `git ls-files` plus a field predicate, so a changed + # glob or a renamed field empties it silently and the production run would + # print exactly what a clean tree prints. Static read of tracked manifests; + # no build, no network, sub-second. + - name: Manifest repository.directory guard + run: pnpm check:manifest-repository-directory + # Engine test-double contract gate (#4550, from #4434). A test double # LOOSER than the implementation it replaces turns a green suite into no # suite at all, silently, on exactly the paths a double was introduced diff --git a/package.json b/package.json index 4154ce7292..e48c127a5c 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs", "check:keyed-text-bounds": "node scripts/check-keyed-text-bounds.mjs --self-test && node scripts/check-keyed-text-bounds.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", + "check:manifest-repository-directory": "node scripts/check-manifest-repository-directory.mjs --self-test && node scripts/check-manifest-repository-directory.mjs", "check:published-readme-exports": "node scripts/check-published-readme-exports.mjs --self-test && node scripts/check-published-readme-exports.mjs", "check:published-readme-links": "node scripts/check-published-readme-links.mjs --self-test && node scripts/check-published-readme-links.mjs", "check:dual-build-cjs-loads": "node scripts/check-dual-build-cjs-loads.mjs --self-test && node scripts/check-dual-build-cjs-loads.mjs", diff --git a/scripts/check-manifest-repository-directory.mjs b/scripts/check-manifest-repository-directory.mjs new file mode 100644 index 0000000000..1bba38464b --- /dev/null +++ b/scripts/check-manifest-repository-directory.mjs @@ -0,0 +1,1004 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-manifest-repository-directory (#15991) -- a manifest that DECLARES + * `repository.directory` must declare its own directory. + * + * node scripts/check-manifest-repository-directory.mjs # the gate + * node scripts/check-manifest-repository-directory.mjs --list # every manifest and its verdict + * node scripts/check-manifest-repository-directory.mjs --self-test # verify the checker + * + * ## The bug it exists to prevent + * + * npm renders `repository.directory` as the package page's "source" deep link: + * the field is concatenated onto the repository URL, so a value that names a + * directory this repo does not have publishes a 404 on the one link a consumer + * follows to read the code. It is a published promise made of a hand-copied + * string, and until this gate NOTHING in this repo read the field -- measured + * over the tracked tree, zero reads of `.directory` in any `.mjs`/`.mts`, and + * `check-published-files.mjs` (which has exactly the right population and + * already parses every manifest) mentions `repository` not once. Its six + * invariants are all about the `files` whitelist: what the tarball CONTAINS. + * The notch is a category rather than an off-by-one -- this repo gated + * published package CONTENTS and did not gate published package PROVENANCE + * METADATA. + * + * ## The measured history, and why the trigger is "the manifest CHANGED" + * + * Two `packages/triggers/*` manifests carried a `repository.directory` naming + * `packages/plugins/plugin-trigger-*` -- a path that has not existed since the + * promotion to a first-class `packages/triggers/`. Reconstructed from the + * commits rather than from the filing, because the first account of it was + * wrong in the direction that matters: + * + * f15d6f6f6 NOT a rename. A 26-file COPY (+2222/-19) that edited each + * manifest's `name` and left `directory` on the old path. The old + * directories still existed for another six minutes, so the field + * RESOLVED and pointed at someone else's package. + * ea4941ad8 The pure rename -- 16 files, 0 insertions, 0 deletions. No + * content-touching hunk existed for a reviewer to read. + * 9a43e042f Later edited `repository.url` INSIDE THE SAME OBJECT, with the + * stale `directory` line sitting beside it as visible hunk + * context. It shipped. + * + * ⇒ Humans did look, and missed it twice, in one JSON block. That is the + * argument for a mechanical check rather than against one: a rule earns its + * keep precisely where attention has already been shown to fail. + * + * ⛔ And it is why this gate is STATELESS over the whole population rather than + * a rule that fires on directory MOVES. `9a43e042f` moved nothing -- it edited + * a sibling key -- so a move-triggered rule would have missed it entirely, + * which is the same miss the humans made. This gate re-judges every declaring + * manifest on every run, so the finding is present on the copy commit, on the + * rename commit, on the sibling-field commit, and on every run in between. The + * dispatch derivation carries the same shape one level up: this gate declares + * the manifest FILE KIND under every root that holds one, so a card that + * TOUCHES a manifest at all is told to run it, whatever the edit was. + * + * ## What it checks, per manifest that declares the field + * + * WELL-FORMED the value is a repo-root-relative POSIX directory path: no + * absolute path, no backslash separator, no `..` segment, no + * empty segment, no surrounding whitespace. None of those + * resolve on the npm page, and each fails differently enough to + * be worth naming. + * RESOLVES the declared directory is IN THIS REPOSITORY -- some tracked + * file lives under it. Not `existsSync`: the deep link is + * served by GitHub from the repository, so a build directory or + * a gitignored scratch dir that exists on the author's disk is + * exactly the case that 404s for the consumer. Reading the + * question off `git ls-files` also makes it case-exact on a + * case-insensitive filesystem, where `existsSync` is not. + * OWN the declared directory IS this manifest's own directory. + * + * ## Why OWN is a separate invariant from RESOLVES, with the measurement + * + * A resolves-only gate would have been GREEN on `f15d6f6f6`: the copy left the + * field naming `packages/plugins/plugin-trigger-*`, and those directories were + * still tracked for six more minutes. The gate would then have turned red on + * an unrelated commit -- #1751, which deleted the originals -- naming a defect + * introduced somewhere else entirely. OWN is the invariant that reports the + * defect at the commit that caused it, and the two verdicts are reported apart + * because they tell an author two different things: STALE means the path is + * gone, MISPLACED means the link points a consumer at somebody else's package. + * + * ## ⛔ What this gate deliberately does NOT decide + * + * Whether declaring `repository.directory` is MANDATORY for a publishable + * package is an open policy question (#15991), and it is a maintainer's to + * answer -- 14 publishable packages declare nothing at all, and every one of + * those npm pages currently has NO source link rather than a broken one. + * Mandatory turns that into a 14-manifest backfill; silence-allowed leaves them + * permanently unlinked. This gate implements the second half ONLY: a + * consistency check over whoever opts in. A manifest that declares nothing is + * `undeclared`, is counted, is listed by `--list`, and is NOT a finding. + * + * ⛔ Do not "extend" this gate to red on silence without that ruling. The + * change is one line and the decision is not one line. + * + * ## The anti-vacuity design, which is the whole risk of this population + * + * The population is `git ls-files` plus a `repository.directory` predicate, so + * it empties SILENTLY in two independent ways: the manifest glob stops matching + * (0 manifests, 0 findings, a clean green over nothing) or the field is renamed + * upstream (81 manifests, 0 declarers, the same clean green). Neither shows up + * as an error, and a gate that can only ever say "all clear" says it loudest + * when it has read nothing. Three instruments, all of which must fire before + * any verdict is printed: + * + * 1. CONTROL PROBES, BOTH DIRECTIONS. The same `isTrackedDirectory` predicate + * the invariants use is driven against a directory the run KNOWS is there + * (derived from the population itself -- the directory of a manifest this + * run just read) and against one assembled at runtime that cannot be (that + * directory's own root plus a nonsense segment). EXISTS and MISSING must BOTH + * come back correct. One direction alone is worthless: a predicate stuck + * on `true` passes the EXISTS probe and silences every finding; a + * predicate stuck on `false` passes the MISSING probe and reddens the + * whole tree. + * 2. PINNED COUNTS. `MEASURED` records the census on a named commit and + * `floorProblem` refuses a run that falls below the derived floors. The + * counts are the ones a reader can reproduce, and a refusal names the + * count, the floor, the record and the commit it was taken on. + * 3. A SELF-TEST WITH A BATTERY ROSTER. `SELF_TEST_BATTERIES` pins the + * registered battery NAMES and each one's case floor, so a self-test that + * runs zero cases cannot print a success line -- the shape #15410 + * measured at 20 of 178 self-tests in this repo, and the one this gate was + * filed with an explicit instruction not to become the 179th of. + * + * ⛔ A run below a floor is NOT repaired by moving the floor. The floors are + * derived from a census on a named commit; a run under one means this gate + * stopped READING, and the thing to find is what stopped being read -- a glob + * that no longer matches, a walk that returns early, a predicate that no longer + * recognises the field. + * + * ## Where it runs + * + * A static read of tracked manifests -- no build, no install, no network -- so + * it is a step in `Lint & Repo Gates` beside the published-files whitelist + * guard, whose static half of packaging hygiene it extends from CONTENTS to + * PROVENANCE. With no `git ls-files` answer it exits 3 (`PREREQUISITE NOT + * MET`) and ⛔ never degrades to a silent green. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +// ── The self-test's own battery roster and floor ─────────────────────────── +// +// What is pinned is the registered NAMES, not a total. Every section opens with +// `battery('')`, every assertion is attributed to the battery most +// recently opened, and the floor requires the OPENED set to equal the DECLARED +// set with each battery at or above its own count. +// +// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 +// keeps a total "right" the moment a sibling grows. A set difference says WHICH +// battery stopped registering; a count says only that something did. +// +// The counts are a FLOOR, not an equality -- adding cases is ordinary work and +// must not red. A battery BELOW its floor means cases stopped running. +// +// The machinery lives HERE, at module scope, rather than inside the self-test: +// the assertion sink is a concise arrow in the self-test's body, so there is no +// in-body helper to thread a per-run ledger through, and the self-test runs +// once per process. +const SELF_TEST_BATTERIES = Object.freeze({ + 'the population declaration itself': 5, + 'the value normaliser': 12, + 'the tracked-directory index': 7, + 'the judgement, per manifest': 9, + 'the measured defect shapes, replayed': 5, + 'the control probes, both directions': 8, + 'the vacuity floors, each driven to zero': 11, + 'provenance: the record must stay reproducible, and visibly so': 5, + 'the live tree: the shipped record against the real population': 6, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned too. +const SELF_TEST_BATTERY_FLOOR = 9; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference rather than silently +// inflating whichever battery happened to run last. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +/** Cases registered per battery: `battery()` opens one, `registerCase()` files into it. */ +const batteryCases = new Map(); +let openBattery = null; + +/** Open a battery. Every assertion after this line is attributed to it. */ +function battery(name) { + openBattery = name; +} + +/** Called by the self-test's own assertion sink, once per assertion. */ +function registerCase() { + const name = openBattery ?? UNATTRIBUTED_BATTERY; + batteryCases.set(name, (batteryCases.get(name) ?? 0) + 1); +} + +/** + * The floor: every declared battery RAN, and ran its cases. + * + * Evaluated after every battery has had its chance and BEFORE the verdict, so + * the success line can only be printed by a run in which the set of batteries + * that registered assertions EQUALS the set declared. + */ +function batteryFloorFailures() { + const declared = Object.keys(SELF_TEST_BATTERIES); + const problems = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + problems.push( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ` + + `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, + ); + } + for (const [name, count] of batteryCases) { + if (declared.includes(name)) continue; + problems.push( + `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declared) { + const count = batteryCases.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + problems.push( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (problems.length) { + problems.push( + 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + + 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + + 'skips) and restore it.', + ); + } + return problems; +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..'); + +const EXIT_OK = 0; +const EXIT_FINDINGS = 1; +const EXIT_REFUSE = 2; +const EXIT_PREREQ = 3; + +/** + * This gate's population, written in the syntax `scripts/pm/dispatch-gates.mjs` + * can read. + * + * The population is a FILE KIND rather than a subtree -- every tracked + * `package.json`, wherever it sits -- so it is declared as that kind under each + * root that holds one, plus the repo-root manifest in the root-FILE spelling. + * Measured against the derivation's own `hintCovers`, this declaration covers + * `packages/triggers/trigger-schedule/package.json`, `apps/docs/package.json`, + * `examples/crm/package.json` and the bare `package.json`, and covers neither + * `packages/spec/src/index.ts` nor `apps/docs/next.config.mjs`. + * + * ⚠️ The obvious one-line spelling does not work and is worth naming so nobody + * retries it: `**\/package.json` is judged correctly by `hintCovers` and is + * never SEEN, because `extractWatchHints` admits a literal only if it starts + * with a word character, a dot or an `@`. A leading glob is dropped at + * admission, so that declaration extracts to zero hints -- the exact silent + * drop `check:watch-hint-literal` exists for, arrived at through the value + * rather than through the spelling. Measured while writing this gate: the + * one-line form extracted `[]`; the four below extract all four. + * + * ⛔ NOT the workspace-root spelling `check-published-files.mjs` uses + * (`packages/*`, `apps/*`, …). That declaration is honest THERE -- it walks + * every non-build file of every member and judges 91.3% of them -- and would be + * a fabrication here: this gate opens one file per package and would otherwise + * be named for every `.ts` card in the tree. A fabricated lead costs more than + * a missing one. + * + * Nothing in this gate reads these arrays; `manifestsIn` filters the real + * `git ls-files` listing. The self-test reconciles the two against the live + * tree as a SET EQUALITY in both directions, so a root that starts holding + * manifests -- or one that stops -- reds here rather than leaving the + * declaration quietly describing a tree that moved. + */ +const ROOT_DIR_WATCH_HINTS = ['packages/**/package.json', 'apps/**/package.json', 'examples/**/package.json']; + +/** The repo-root manifest, in the root-FILE spelling the same extractor reads. */ +const ROOT_FILE_WATCH_HINTS = ['package.json/**']; + +/** + * Does a declared hint cover a repo-relative path, under the TWO hint shapes + * this gate declares? + * + * A local reading, and deliberately not an import of the derivation's + * `hintCovers`: this exists so the self-test can reconcile THIS declaration + * against THIS scan as a set equality, and a gate that pulled the whole + * derivation in to answer it would make its own self-test depend on the tool it + * is declaring itself to. The agreement it reproduces was measured against + * `hintCovers` while this gate was written, on the six paths named above. + * + * ⛔ It handles no other hint shape and refuses rather than approximating one: + * an unrecognised hint covers nothing, so widening either declaration without + * teaching this reads as a dead hint and reds the reconciliation below rather + * than passing quietly. + * + * @param {string} hint + * @param {string} path + * @returns {boolean} + */ +export function hintCoversPath(hint, path) { + if (hint.endsWith('/**')) { + const base = hint.slice(0, -3); + if (base === '' || base.includes('*')) return false; + return path === base || path.startsWith(`${base}/`); + } + const at = hint.indexOf('/**/'); + if (at <= 0) return false; + const root = hint.slice(0, at); + const tail = hint.slice(at + 4); + if (tail === '' || root.includes('*') || tail.includes('*')) return false; + return path.startsWith(`${root}/`) && path.endsWith(`/${tail}`); +} + +/** + * The census this gate's floors are derived from. + * + * Immutable, so the record stays reproducible forever as `main` moves away from + * it. ⛔ Never repoint it without re-running every count. Reproduce with: + * + * git checkout && node scripts/check-manifest-repository-directory.mjs --list + * + * `manifests` is every tracked `package.json`; `declaring` is those that + * declare `repository.directory`; `trackedDirectories` is the size of the + * directory index the RESOLVES invariant is answered from. + */ +const MEASURED = Object.freeze({ + ref: '3e7ef9c238', + manifests: 81, + declaring: 57, + trackedDirectories: 527, +}); + +// The floors, each `MEASURED` minus this gate's declared headroom (~11%). They +// are inequalities rather than equalities on purpose: this population moves in +// both directions for good reasons -- a package is deleted, a private fixture +// arrives, a manifest drops a declaration -- and an equality would red on every +// legitimate move. What no legitimate move does is take a count to (nearly) +// zero, which is the only thing a floor decides. +const MIN_MANIFESTS = 72; +const MIN_DECLARING = 50; +const MIN_TRACKED_DIRECTORIES = 460; + +// Both control probes must fire, in both directions, before any verdict. +const REQUIRED_CONTROLS = 2; + +/** + * The leaf segment of the MISSING control probe's target, assembled at runtime + * from words that are not path-shaped literals. + * + * Spelled this way deliberately, twice over. A quoted path literal in a gate's + * module body is read by the dispatch derivation as part of that gate's + * population, and a literal naming a directory that exists in no repo is + * exactly the dead declaration `check:declared-population-live` refuses; a + * quoted literal naming a REAL top-level directory is the invisible bare-root + * species `scripts/pm/bare-root-worklist.mjs` sweeps for. Assembling the value + * keeps the probe adversarial while declaring nothing that is not true. + */ +const CONTROL_ABSENT_LEAF = ['no', 'such', 'directory', 'this', 'gate', 'invents'].join('-'); + +/** + * The directory the MISSING probe asks about, placed UNDER the root of the + * EXISTS probe's subject. + * + * Derived rather than written down, for the reason above and for a stronger + * one: a directory nested under a root this tree really has is the probe that + * catches an ancestor-prefix bug -- a predicate answering "tracked" for + * anything beneath a tracked directory passes a root-level probe and fails this + * one, and over-matching is the direction that SILENCES findings. + * + * @param {string} [presentDirectory] the EXISTS probe's subject + * @returns {string} + */ +export function controlAbsentDirectory(presentDirectory) { + if (typeof presentDirectory !== 'string' || presentDirectory === '') return CONTROL_ABSENT_LEAF; + const root = presentDirectory.includes('/') ? presentDirectory.slice(0, presentDirectory.indexOf('/')) : presentDirectory; + return `${root}/${CONTROL_ABSENT_LEAF}`; +} + +// --------------------------------------------------------------------------- +// Population +// --------------------------------------------------------------------------- + +/** + * Every tracked path, from git. + * + * `git ls-files -z` rather than a filesystem walk: the RESOLVES invariant asks + * what GitHub will serve, and GitHub serves the repository, not the working + * tree. Returns `null` when git cannot answer, which the caller turns into + * `PREREQUISITE NOT MET` -- ⛔ never into an empty population, which would + * print the clean green this whole file is written against. + * + * @param {string} root + * @returns {string[] | null} + */ +export function trackedPaths(root) { + let out; + try { + out = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + } catch { + return null; + } + const paths = out.split('\0').filter((p) => p !== ''); + return paths.length ? paths : null; +} + +/** + * The manifests, out of a tracked listing. Pure, so the self-test drives it + * with a synthetic listing and no tree. + * + * @param {string[]} paths + * @returns {string[]} + */ +export function manifestsIn(paths) { + return (paths ?? []).filter((p) => p === 'package.json' || p.endsWith('/package.json')).sort(); +} + +/** + * Every directory that CONTAINS a tracked file, including all ancestors and the + * repo root (spelled `''`). Pure. + * + * An empty directory cannot be tracked by git at all, so "has a tracked file + * under it" is the same question as "GitHub can render a page for it". + * + * @param {string[]} paths + * @returns {Set} + */ +export function trackedDirectoriesIn(paths) { + const dirs = new Set(['']); + for (const p of paths ?? []) { + const segments = p.split('/'); + segments.pop(); + let acc = ''; + for (const segment of segments) { + acc = acc === '' ? segment : `${acc}/${segment}`; + dirs.add(acc); + } + } + return dirs; +} + +// --------------------------------------------------------------------------- +// The invariants +// --------------------------------------------------------------------------- + +/** + * Normalise a declared `repository.directory` value, or say why it is not a + * repo-root-relative POSIX directory path. Pure. + * + * `./x` and `x/` are accepted and normalised -- both name the same directory + * and npm resolves them -- while the shapes that genuinely do not resolve are + * named apart, because "absolute path" and "Windows separator" are two + * different mistakes with two different repairs. `.` normalises to the repo + * root, which is the only value the root manifest could honestly declare. + * + * @param {unknown} raw + * @returns {{value: string | null, problem: string | null}} + */ +export function normaliseDeclared(raw) { + if (typeof raw !== 'string') return { value: null, problem: `it is ${raw === null ? 'null' : typeof raw}, not a string` }; + if (raw.trim() !== raw) return { value: null, problem: 'it carries surrounding whitespace' }; + if (raw === '') return { value: null, problem: 'it is empty' }; + if (raw.includes('\\')) return { value: null, problem: 'it uses a backslash separator, which resolves on no forge' }; + if (raw.startsWith('/')) return { value: null, problem: 'it is an absolute path, and the field is resolved relative to the repository root' }; + const cleaned = raw.replace(/^\.\/+/, '').replace(/\/+$/, ''); + if (cleaned === '' || cleaned === '.') return { value: '', problem: null }; + const segments = cleaned.split('/'); + if (segments.some((s) => s === '')) return { value: null, problem: 'it contains an empty path segment' }; + if (segments.some((s) => s === '.' || s === '..')) return { value: null, problem: 'it contains a relative segment, and the field is a plain path from the repository root' }; + return { value: cleaned, problem: null }; +} + +/** + * The declared value, or `undefined` when this manifest declares none. Pure. + * + * A string-shorthand `repository` carries no directory by construction, and is + * `undefined` here rather than a finding: the shorthand is a legitimate + * spelling for a package that IS the repository root. + * + * @param {unknown} manifest + * @returns {unknown} + */ +export function declaredDirectory(manifest) { + if (manifest === null || typeof manifest !== 'object') return undefined; + const repository = /** @type {{repository?: unknown}} */ (manifest).repository; + if (repository === null || typeof repository !== 'object') return undefined; + return /** @type {{directory?: unknown}} */ (repository).directory; +} + +/** + * Judge ONE manifest. Pure -- the tracked-directory question arrives as a + * predicate, so the self-test drives every verdict with no tree at all. + * + * @param {{path: string, ownDirectory: string, declared: unknown}} entry + * @param {(dir: string) => boolean} isTrackedDirectory + * @returns {{verdict: 'undeclared'|'ok'|'malformed'|'stale'|'misplaced', finding: string | null}} + */ +export function judgeManifest(entry, isTrackedDirectory) { + const { path, ownDirectory, declared } = entry; + if (declared === undefined) return { verdict: 'undeclared', finding: null }; + + const own = ownDirectory === '' ? '(the repository root)' : ownDirectory; + const { value, problem } = normaliseDeclared(declared); + if (problem !== null) { + return { + verdict: 'malformed', + finding: `${path}: repository.directory is ${JSON.stringify(declared)} — ${problem}. ` + + `It must name this manifest's own directory, as a plain path from the repository root: '${own}'.`, + }; + } + if (value === ownDirectory) return { verdict: 'ok', finding: null }; + if (!isTrackedDirectory(value)) { + return { + verdict: 'stale', + finding: `${path}: repository.directory declares '${value}', which is NOT a directory in this repository. ` + + `npm renders that value as this package's "source" link, so the link 404s. This manifest lives at '${own}'.`, + }; + } + return { + verdict: 'misplaced', + finding: `${path}: repository.directory declares '${value}', which IS a real directory in this repository — ` + + `but not this manifest's. This manifest lives at '${own}', so the package's "source" link sends a consumer ` + + 'to another package\'s code. This is the shape a directory COPY leaves behind: the name was edited, the ' + + 'directory was not, and the old path was still there to resolve.', + }; +} + +/** + * The two control probes, run against the SAME predicate the invariants use. + * + * The EXISTS side is derived from the population rather than written down: the + * directory of a manifest this run has just read is known to be tracked + * BECAUSE the run read a tracked file out of it, so the probe cannot go stale + * with the tree. The MISSING side is assembled (see `controlAbsentDirectory`). + * + * ⛔ Both directions or neither. A predicate stuck on `true` silences every + * finding and passes an EXISTS-only control; a predicate stuck on `false` + * reddens the whole tree and passes a MISSING-only one. + * + * @param {string[]} manifestPaths + * @param {(dir: string) => boolean} isTrackedDirectory + * @returns {{fired: number, problems: string[], present: string | null, absent: string}} + */ +export function controlProbes(manifestPaths, isTrackedDirectory) { + const problems = []; + let fired = 0; + + const present = (manifestPaths ?? []) + .map((p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')) + .find((d) => d !== '') ?? null; + + if (present === null) { + problems.push( + 'the EXISTS control probe has no subject — no manifest in the population sits in a directory, so ' + + 'nothing in this run is KNOWN to be tracked and the predicate went unexercised in that direction.', + ); + } else if (isTrackedDirectory(present)) { + fired += 1; + } else { + problems.push( + `the EXISTS control probe FAILED: '${present}' holds a manifest this run just read, and the tracked-directory ` + + 'predicate reports it MISSING. Every declaration would be scored stale — the index is the bug, not the tree.', + ); + } + + const absent = controlAbsentDirectory(present ?? undefined); + if (!isTrackedDirectory(absent)) { + fired += 1; + } else { + problems.push( + `the MISSING control probe FAILED: '${absent}' is assembled by this gate and cannot be in any repository, ` + + 'and the tracked-directory predicate reports it present. A predicate that answers yes to everything ' + + 'silences every finding this gate exists to make.', + ); + } + + return { fired, problems, present, absent }; +} + +/** + * The first floor a run falls below, as a refusal message -- or `null` when + * every count clears. Pure, so the self-test drives every floor with no tree. + * + * @param {{manifests?: number, declaring?: number, trackedDirectories?: number, controls?: number}} counts + * @returns {string | null} + */ +export function floorProblem(counts) { + const rows = [ + [counts?.manifests ?? 0, MIN_MANIFESTS, MEASURED.manifests, 'tracked package manifest(s)', + 'This is the whole population. The manifest filter matched (almost) nothing, so every invariant below it was satisfied over an empty set and this gate printed what a clean tree prints.'], + [counts?.declaring ?? 0, MIN_DECLARING, MEASURED.declaring, 'manifest(s) declaring repository.directory', + 'Manifests were found but none of them declares the field this gate reads. That is what a renamed field looks like from in here: a full population, an empty subject, and a green line.'], + [counts?.trackedDirectories ?? 0, MIN_TRACKED_DIRECTORIES, MEASURED.trackedDirectories, 'tracked directory(ies) indexed', + 'This is the index the RESOLVES invariant is answered from. A collapsed index cannot tell a live directory from a deleted one.'], + [counts?.controls ?? 0, REQUIRED_CONTROLS, REQUIRED_CONTROLS, 'control probe(s) fired', + 'The probes are what prove the tracked-directory predicate still answers in BOTH directions. Unfired, nothing establishes that this run could have produced a finding at all.'], + ]; + for (const [got, min, measured, what, why] of rows) { + if (got >= min) continue; + return `measured only ${got} ${what}, below the floor of ${min} (${measured} on ${MEASURED.ref}).\n` + + ` ${why}\n` + + ' ⛔ NOT a pass: nothing, or nearly nothing, was read. Find what stopped being read — the floor is\n' + + ' derived from a census on a named commit and is not the thing that moved.'; + } + return null; +} + +/** + * The provenance footer for a PASSING run: the census this run read, the floors + * it cleared, the census those floors were derived from, and the commit that + * census belongs to -- side by side. + * + * The floors are inequalities on purpose, so no passing run can ever contradict + * the record; without this line the record could stop describing the tree and + * every green log would look identical either way. The delta is INFORMATION and + * never a verdict: this population moves in both directions for good reasons, + * and only the floors decide anything. + * + * Pure, so the self-test drives it with no tree. + * + * @param {{manifests?: number, declaring?: number, trackedDirectories?: number}} counts + * @returns {string} + */ +export function provenanceLine(counts) { + const got = [counts?.manifests ?? 0, counts?.declaring ?? 0, counts?.trackedDirectories ?? 0]; + const rec = [MEASURED.manifests, MEASURED.declaring, MEASURED.trackedDirectories]; + const floors = [MIN_MANIFESTS, MIN_DECLARING, MIN_TRACKED_DIRECTORIES]; + const delta = got.map((g, i) => (g === rec[i] ? '=' : `${g > rec[i] ? '+' : ''}${g - rec[i]}`)); + return ` provenance — manifests/declaring/trackedDirectories: this run ${got.join('/')}` + + ` · floors ${floors.join('/')} · derived from ${rec.join('/')} measured on ${MEASURED.ref}` + + ` (${delta.join('/')} vs the record).\n` + + ' ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons,' + + ' and only the floors decide.'; +} + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +/** + * Judge a whole population. Takes the tracked listing rather than reading git + * itself, so the self-test drives it over a fixture listing. + * + * `readManifest` is injected for the same reason. A manifest that cannot be + * read is a FINDING rather than a skip: an unreadable manifest scored as a + * clean one is the vacuity this file is written against, one row at a time. + * + * @param {string[]} paths a tracked-path listing + * @param {(relPath: string) => unknown} readManifest + */ +export function scan(paths, readManifest) { + const manifestPaths = manifestsIn(paths); + const dirs = trackedDirectoriesIn(paths); + const isTrackedDirectory = (dir) => dirs.has(dir); + + const controls = controlProbes(manifestPaths, isTrackedDirectory); + const rows = []; + const findings = []; + + for (const path of manifestPaths) { + const ownDirectory = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + let manifest; + try { + manifest = readManifest(path); + } catch (error) { + findings.push( + `${path}: this manifest could not be read (${error instanceof Error ? error.message : String(error)}). ` + + 'It is in the population and it was NOT judged — a manifest this gate cannot read is never scored clean.', + ); + rows.push({ path, ownDirectory, verdict: 'unreadable', declared: undefined }); + continue; + } + const declared = declaredDirectory(manifest); + const { verdict, finding } = judgeManifest({ path, ownDirectory, declared }, isTrackedDirectory); + if (finding) findings.push(finding); + rows.push({ path, ownDirectory, verdict, declared }); + } + + const counts = { + manifests: manifestPaths.length, + declaring: rows.filter((r) => r.declared !== undefined).length, + trackedDirectories: dirs.size, + controls: controls.fired, + }; + return { rows, findings, counts, controls }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function readManifestFrom(root) { + return (relPath) => JSON.parse(readFileSync(join(root, relPath), 'utf8')); +} + +function main(argv) { + const root = REPO_ROOT; + const paths = trackedPaths(root); + if (paths === null) { + console.error('PREREQUISITE NOT MET — `git ls-files` produced no listing, so this gate has no population.'); + console.error(' It reads the tracked tree, deliberately: the npm "source" deep link is served from the'); + console.error(' repository, not from the working tree. Run it inside a git checkout.'); + console.error(' ⛔ This is NOT a pass: nothing was measured.'); + return EXIT_PREREQ; + } + + const { rows, findings, counts, controls } = scan(paths, readManifestFrom(root)); + + if (argv.includes('--list')) { + for (const r of rows) { + console.log(`${r.verdict.padEnd(11)} ${r.ownDirectory === '' ? '(repo root)' : r.ownDirectory} ` + + `${r.declared === undefined ? '' : `→ ${JSON.stringify(r.declared)}`}`); + } + console.log(`\n${counts.manifests} tracked manifest(s); ${counts.declaring} declare repository.directory; ` + + `${counts.trackedDirectories} tracked director(ies) indexed.`); + return EXIT_OK; + } + + // ⛔ Before any verdict: a run whose controls did not fire, or that read + // (almost) nothing, must refuse rather than report the clean tree. + for (const p of controls.problems) console.error(`check:manifest-repository-directory REFUSES — ${p}`); + const floor = floorProblem(counts); + if (controls.problems.length || floor !== null) { + if (floor !== null) console.error(`check:manifest-repository-directory REFUSES — ${floor}`); + return EXIT_REFUSE; + } + + if (findings.length) { + console.error(`✗ check:manifest-repository-directory — ${findings.length} finding(s) across ` + + `${counts.declaring} declaring manifest(s):`); + for (const f of findings) console.error(` ✗ ${f}`); + console.error(''); + console.error('`repository.directory` is a published promise: npm concatenates it onto the repository'); + console.error('URL and renders the result as the package page\'s "source" link. A value naming a directory'); + console.error('this repo does not have publishes a 404 to every consumer who follows it, and nothing in'); + console.error('this repo\'s own tests, build or typecheck can see it — the field is read by npm and by'); + console.error('nobody here. Correct the value to the manifest\'s own directory.'); + console.error(''); + console.error('⛔ Not repaired by deleting the declaration: whether a publishable package MUST declare'); + console.error('this field is an open policy question (#15991) and is a maintainer\'s to answer. This gate'); + console.error('judges only manifests that declare it; it says nothing about the ones that do not.'); + return EXIT_FINDINGS; + } + + console.log( + `✓ check:manifest-repository-directory — ${counts.declaring} of ${counts.manifests} tracked manifest(s) ` + + `declare repository.directory, and every one of them names its own directory; ` + + `${counts.manifests - counts.declaring} declare none (not judged: policy question #15991); ` + + `${counts.controls} control probe(s) fired, EXISTS on '${controls.present}' and MISSING on '${controls.absent}'.`, + ); + console.log(provenanceLine(counts)); + return EXIT_OK; +} + +// --------------------------------------------------------------------------- +// Self-test +// --------------------------------------------------------------------------- + +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed. The self-test's own exit code stays load-bearing, so the handshake is +// a flag rather than a returned sentinel. +let selfTestReachedVerdict = false; + +export function selfTest() { + const cases = []; + const t = (name, ok, detail) => { + registerCase(); + return cases.push({ name, ok: Boolean(ok), detail }); + }; + + // ── the population declaration itself ───────────────────────────────────── + battery('the population declaration itself'); + const selfSource = readFileSync(fileURLToPath(import.meta.url), 'utf8'); + const declaredHints = [...ROOT_DIR_WATCH_HINTS, ...ROOT_FILE_WATCH_HINTS]; + t('the watch hints are spelled as literals the extractor can read', + declaredHints.every((h) => selfSource.includes(`'${h}'`))); + t('⛔ no hint opens with a glob — the extractor drops those at admission, unseen', + declaredHints.every((h) => /^[\w.@]/.test(h))); + t('every hint names the manifest file kind, never a bare subtree', + ROOT_DIR_WATCH_HINTS.every((h) => h.endsWith('/package.json')) + && ROOT_FILE_WATCH_HINTS.every((h) => h === 'package.json/**')); + t('⛔ no hint collapses to a workspace root — that declaration belongs to check-published-files', + !declaredHints.some((h) => /^(packages|apps|examples)\/\*+$/.test(h))); + t('the MISSING probe target is assembled, never a quoted literal in this module', + !selfSource.includes(`'${CONTROL_ABSENT_LEAF}'`) && !selfSource.includes(`'${controlAbsentDirectory('alpha/beta')}'`)); + t('the MISSING probe is nested under the EXISTS subject\'s own root, so an ancestor-prefix bug cannot pass it', + controlAbsentDirectory('alpha/beta') === `alpha/${CONTROL_ABSENT_LEAF}` + && controlAbsentDirectory('alpha') === `alpha/${CONTROL_ABSENT_LEAF}` + && controlAbsentDirectory() === CONTROL_ABSENT_LEAF); + + // ── the value normaliser ────────────────────────────────────────────────── + battery('the value normaliser'); + t('a plain path normalises to itself', normaliseDeclared('packages/spec').value === 'packages/spec'); + t('a leading ./ is stripped', normaliseDeclared('./packages/spec').value === 'packages/spec'); + t('a trailing slash is stripped', normaliseDeclared('packages/spec/').value === 'packages/spec'); + t('a lone dot is the repository root', normaliseDeclared('.').value === ''); + t('an absolute path is refused, and named as such', + normaliseDeclared('/packages/spec').value === null && /absolute/.test(normaliseDeclared('/packages/spec').problem)); + t('a backslash separator is refused', + normaliseDeclared('packages\\spec').value === null && /backslash/.test(normaliseDeclared('packages\\spec').problem)); + t('a `..` segment is refused', normaliseDeclared('packages/../packages/spec').value === null); + t('an empty segment is refused', normaliseDeclared('packages//spec').value === null); + t('surrounding whitespace is refused rather than trimmed', + normaliseDeclared(' packages/spec').value === null && /whitespace/.test(normaliseDeclared(' packages/spec').problem)); + t('an empty string is refused', normaliseDeclared('').value === null); + t('a non-string is refused and names its own type', /number/.test(normaliseDeclared(3).problem)); + t('null is refused and is not reported as an object', /null/.test(normaliseDeclared(null).problem)); + + // ── the tracked-directory index ─────────────────────────────────────────── + battery('the tracked-directory index'); + const listing = ['package.json', 'packages/spec/package.json', 'packages/spec/src/index.ts', 'apps/docs/package.json']; + const dirs = trackedDirectoriesIn(listing); + t('every ancestor of a tracked file is indexed', dirs.has('packages') && dirs.has('packages/spec') && dirs.has('packages/spec/src')); + t('the repository root is indexed as the empty string', dirs.has('')); + t('⛔ a tracked FILE is not a directory', !dirs.has('packages/spec/package.json')); + t('a directory nothing tracks is absent', !dirs.has('packages/nothing-here')); + t('an empty listing indexes only the root', trackedDirectoriesIn([]).size === 1); + t('the manifest filter takes the root manifest and every nested one', + manifestsIn(listing).join(',') === 'apps/docs/package.json,package.json,packages/spec/package.json'); + t('⛔ the manifest filter does not take a file that merely ENDS in the name', + manifestsIn(['packages/x/not-package.json', 'packages/x/package.json']).join(',') === 'packages/x/package.json'); + + // ── the judgement, per manifest ─────────────────────────────────────────── + battery('the judgement, per manifest'); + const tracked = (set) => (dir) => set.has(dir); + const live = tracked(new Set(['', 'packages', 'packages/spec', 'packages/plugins', 'packages/plugins/plugin-trigger-schedule'])); + const judge = (path, declared) => judgeManifest({ path, ownDirectory: path.slice(0, path.lastIndexOf('/')), declared }, live); + t('a manifest that declares its own directory is ok', + judge('packages/spec/package.json', 'packages/spec').verdict === 'ok'); + t('a manifest that declares nothing is `undeclared`, and is NOT a finding', + judge('packages/spec/package.json', undefined).verdict === 'undeclared' + && judge('packages/spec/package.json', undefined).finding === null); + t('⛔ the policy half stays undecided — silence never produces a finding', + judgeManifest({ path: 'packages/x/package.json', ownDirectory: 'packages/x', declared: undefined }, live).finding === null); + t('a `repository` STRING shorthand declares no directory', declaredDirectory({ repository: 'github:o/r' }) === undefined); + t('a `repository` object with a directory declares it', + declaredDirectory({ repository: { url: 'x', directory: 'packages/spec' } }) === 'packages/spec'); + t('a value naming a directory that is not tracked is STALE', + judge('packages/triggers/trigger-schedule/package.json', 'packages/plugins/plugin-trigger-gone').verdict === 'stale'); + t('a value naming a real but different directory is MISPLACED', + judge('packages/triggers/trigger-schedule/package.json', 'packages/plugins/plugin-trigger-schedule').verdict === 'misplaced'); + t('a malformed value is MALFORMED and quotes what was declared', + judge('packages/spec/package.json', '/packages/spec').verdict === 'malformed' + && judge('packages/spec/package.json', '/packages/spec').finding.includes('"/packages/spec"')); + t('the root manifest declaring a subdirectory is a finding, not a pass', + judgeManifest({ path: 'package.json', ownDirectory: '', declared: 'packages/spec' }, live).verdict === 'misplaced'); + + // ── the measured defect shapes, replayed ────────────────────────────────── + // The three commits in this file's header, each as the verdict this gate + // gives it. These are the cases that decide whether the gate is the one the + // history asks for, rather than a plausible neighbour of it. + battery('the measured defect shapes, replayed'); + const copyEra = tracked(new Set(['', 'packages', 'packages/plugins', 'packages/plugins/plugin-trigger-schedule', 'packages/triggers', 'packages/triggers/trigger-schedule'])); + const afterDeletion = tracked(new Set(['', 'packages', 'packages/triggers', 'packages/triggers/trigger-schedule'])); + const copyShape = { path: 'packages/triggers/trigger-schedule/package.json', ownDirectory: 'packages/triggers/trigger-schedule', declared: 'packages/plugins/plugin-trigger-schedule' }; + t('THE COPY (f15d6f6f6): the old directory still exists, and the gate reds anyway', + judgeManifest(copyShape, copyEra).verdict === 'misplaced'); + t('⛔ a RESOLVES-ONLY gate would have been green there — that is why OWN is a separate invariant', + copyEra('packages/plugins/plugin-trigger-schedule') === true); + t('THE RENAME (ea4941ad8): once the old directory is gone the same manifest reds as STALE', + judgeManifest(copyShape, afterDeletion).verdict === 'stale'); + t('THE SIBLING-FIELD EDIT (9a43e042f): the manifest changed and `directory` did not — still red', + judgeManifest({ ...copyShape, declared: 'packages/plugins/plugin-trigger-schedule' }, afterDeletion).finding !== null); + t('⛔ the verdict never depends on what a diff touched — this gate is stateless over the population', + judgeManifest(copyShape, afterDeletion).verdict === judgeManifest({ ...copyShape }, afterDeletion).verdict); + + // ── the control probes, both directions ─────────────────────────────────── + battery('the control probes, both directions'); + const honest = controlProbes(['packages/spec/package.json'], tracked(new Set(['', 'packages', 'packages/spec']))); + t('both probes fire against an honest predicate', honest.fired === REQUIRED_CONTROLS && honest.problems.length === 0); + t('the EXISTS probe is derived from the population, not written down', honest.present === 'packages/spec'); + t('a predicate stuck on TRUE fails the MISSING probe', + controlProbes(['packages/spec/package.json'], () => true).fired === 1); + t('… and says which direction failed', /MISSING control probe FAILED/.test(controlProbes(['packages/spec/package.json'], () => true).problems[0])); + t('a predicate stuck on FALSE fails the EXISTS probe', + controlProbes(['packages/spec/package.json'], () => false).fired === 1); + t('… and says which direction failed', /EXISTS control probe FAILED/.test(controlProbes(['packages/spec/package.json'], () => false).problems[0])); + t('⛔ a predicate stuck on TRUE also silences every finding — the probe is the only witness', + judgeManifest(copyShape, () => true).verdict === 'misplaced' + && judgeManifest({ ...copyShape, declared: 'packages/plugins/plugin-trigger-gone' }, () => true).verdict === 'misplaced'); + t('a population with no directoried manifest leaves the EXISTS probe unfired and SAYS so', + controlProbes(['package.json'], () => true).fired === 0 + && controlProbes(['package.json'], () => true).problems.some((p) => /no subject/.test(p))); + + // ── the vacuity floors, each driven to zero ─────────────────────────────── + battery('the vacuity floors, each driven to zero'); + const full = { manifests: MEASURED.manifests, declaring: MEASURED.declaring, trackedDirectories: MEASURED.trackedDirectories, controls: REQUIRED_CONTROLS }; + t('FLOOR — the values in the record clear every floor', floorProblem(full) === null, JSON.stringify(floorProblem(full))); + t('FLOOR — a dead manifest filter refuses (every invariant satisfied over nothing)', floorProblem({ ...full, manifests: 0 }) !== null); + t('FLOOR — a renamed field refuses (a full population, an empty subject)', floorProblem({ ...full, declaring: 0 }) !== null); + t('FLOOR — a collapsed directory index refuses', floorProblem({ ...full, trackedDirectories: 0 }) !== null); + t('FLOOR — unfired controls refuse', floorProblem({ ...full, controls: 0 }) !== null); + t('FLOOR — ONE fired control is not enough', floorProblem({ ...full, controls: 1 }) !== null); + t('FLOOR — a missing count is zero, not "unmeasured but fine"', floorProblem({}) !== null); + t('FLOOR — the refusal names the count, the floor and the commit the record is from', + /below the floor of/.test(floorProblem({ ...full, manifests: 0 })) && floorProblem({ ...full, manifests: 0 }).includes(MEASURED.ref)); + t('FLOOR — the refusal says it is NOT a pass', + /NOT a pass/.test(floorProblem({ ...full, declaring: 0 }))); + t('FLOOR — the refusal never offers moving the floor as the repair', + /Find what stopped being read/.test(floorProblem({ ...full, manifests: 0 }))); + t('FLOOR — every floor sits at or below the value it was measured from', + MIN_MANIFESTS <= MEASURED.manifests && MIN_DECLARING <= MEASURED.declaring + && MIN_TRACKED_DIRECTORIES <= MEASURED.trackedDirectories); + + // ── provenance ──────────────────────────────────────────────────────────── + battery('provenance: the record must stay reproducible, and visibly so'); + const provExact = provenanceLine(full); + const provDrifted = provenanceLine({ ...full, manifests: MEASURED.manifests + 3 }); + t('PROVENANCE — an exact run reports no delta', provExact.includes('(=/=/= vs the record)'), provExact); + t('PROVENANCE — a drifted run reports the signed delta', provDrifted.includes('+3'), provDrifted); + t('PROVENANCE — the record\'s commit is quoted', provExact.includes(MEASURED.ref)); + t('PROVENANCE — the PASS path actually prints it (a line nothing calls is a record nothing reconciles)', + selfSource.includes(`console.log(${'provenanceLine'}(counts))`)); + t('PROVENANCE — the delta is marked as information, never as a verdict', + /not a verdict/.test(provDrifted) && !/✗|REFUSES/.test(provDrifted), provDrifted); + + // ── the live tree ───────────────────────────────────────────────────────── + // The shipped record against the REAL population — the half a fixture listing + // cannot give, and the one that goes stale silently. + battery('the live tree: the shipped record against the real population'); + const livePaths = trackedPaths(REPO_ROOT); + t('the tracked listing is readable at all', Array.isArray(livePaths) && livePaths.length > 0); + const liveManifests = manifestsIn(livePaths ?? []); + const liveDirs = trackedDirectoriesIn(livePaths ?? []); + t('the live population clears the manifest floor', liveManifests.length >= MIN_MANIFESTS, String(liveManifests.length)); + t('the live directory index clears its floor', liveDirs.size >= MIN_TRACKED_DIRECTORIES, String(liveDirs.size)); + // The declaration reconciled against the SCAN, over the live tracked listing, + // as a set equality in both directions. A declaration narrower than the scan + // hides cards from the derivation; a declaration wider than it fabricates + // leads on cards this gate never opens. Both are silent without this. + const liveHints = [...ROOT_DIR_WATCH_HINTS, ...ROOT_FILE_WATCH_HINTS]; + const declaredPaths = (livePaths ?? []).filter((p) => liveHints.some((h) => hintCoversPath(h, p))); + t('DECLARATION ⊇ SCAN — every manifest the scan takes is covered by a declared hint', + liveManifests.every((p) => declaredPaths.includes(p)), + JSON.stringify(liveManifests.filter((p) => !declaredPaths.includes(p)).slice(0, 5))); + t('DECLARATION ⊆ SCAN — every path a declared hint covers is a manifest the scan takes', + declaredPaths.every((p) => liveManifests.includes(p)), + JSON.stringify(declaredPaths.filter((p) => !liveManifests.includes(p)).slice(0, 5))); + t('⛔ the declaration does not reach a non-manifest source file', + !liveHints.some((h) => hintCoversPath(h, 'packages/spec/src/index.ts')) + && !liveHints.some((h) => hintCoversPath(h, 'apps/docs/next.config.mjs'))); + t('every declared hint reaches at least one live manifest — no dead declaration', + liveHints.every((h) => liveManifests.some((p) => hintCoversPath(h, p)))); + t('both control probes fire on the LIVE tree', + controlProbes(liveManifests, (d) => liveDirs.has(d)).fired === REQUIRED_CONTROLS); + const liveProbes = controlProbes(liveManifests, (d) => liveDirs.has(d)); + t('the MISSING probe target really is absent from the live tree, and sits under a root the live tree HAS', + !liveDirs.has(liveProbes.absent) + && liveDirs.has(liveProbes.absent.slice(0, liveProbes.absent.indexOf('/')))); + + // The floor runs BEFORE the verdict below, so a success line can only be + // printed by a run in which every declared battery registered its cases. + for (const message of batteryFloorFailures()) cases.push({ name: message, ok: false }); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ check-manifest-repository-directory self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log(`✓ check-manifest-repository-directory self-test: ${cases.length} cases pass (the three measured defect ` + + 'shapes replayed as verdicts, both control probes driven in both directions with the stuck-predicate cases that ' + + 'motivate them, every vacuity floor driven to zero against its green control, the record reproducible and printed ' + + 'on the pass path, and the declared population reconciled against the live tree).'); + + selfTestReachedVerdict = true; + return EXIT_OK; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + let code; + if (argv.includes('--self-test')) { + code = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-manifest-repository-directory self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + } else { + code = main(argv); + } + process.exit(code); +}