Skip to content

feat(scripts): gate repository.directory against the manifest's own directory - #16446

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-15991-manifest-repository-directory-gate
Sep 7, 2026
Merged

feat(scripts): gate repository.directory against the manifest's own directory#16446
os-warren merged 2 commits into
mainfrom
claude/issue-15991-manifest-repository-directory-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #15991 — deliberately not a closing relation. This implements one half of that card; the policy question it raises is untouched and must stay open.

What this adds

scripts/check-manifest-repository-directory.mjs, wired as check:manifest-repository-directory and run as a step in Lint & Repo Gates beside the published-files whitelist guard.

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 here read the field. Re-derived on this branch rather than taken from the card: zero reads of .directory in any tracked .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, which is what the tarball CONTAINS. The notch is a category, not an off-by-one: this repo gated published package CONTENTS and did not gate published package PROVENANCE METADATA.

Per manifest that DECLARES the field:

  • WELL-FORMED — a repo-root-relative POSIX directory path. No absolute path, no backslash separator, no .. segment, no empty segment, no surrounding whitespace.
  • RESOLVES — the declared directory is IN THIS REPOSITORY, read off git ls-files rather than existsSync. The deep link is served by GitHub from the repository, so a gitignored or build directory that exists on the author's disk is exactly the case that 404s for the consumer; reading it from git also makes the question case-exact on a case-insensitive filesystem.
  • OWN — the declared directory IS this manifest's own directory.

⛔ The policy half is deliberately NOT implemented

The card asks whether declaring repository.directory is mandatory for a publishable package or whether silence is allowed. That is a maintainer's call — 14 publishable packages declare nothing, and each of those npm pages has NO source link rather than a broken one. This PR implements the consistency check over whoever opts in, and nothing else: a manifest that declares nothing is counted, listed by --list, and is never a finding. The gate's header and its failure text both say so, and a self-test case pins that silence produces no finding, so "extend it to red on silence" cannot happen by accident.

⚠️ Landing order — this must land after PR #15993, and that is a request to the merger

Measured on this branch's tree, the gate reports 2 findings. Both name the two packages/triggers/* manifests whose repository.directory still points at packages/plugins/plugin-trigger-*, which is precisely what PR #15993 corrects. Those two manifests are that PR's fence and are deliberately untouched here.

Two options were on the table for handling that, and this PR takes the first:

  1. Write the gate, record the ordering dependency, let the merger sequence it (taken). No exemption machinery exists in the gate at all, the red is a true reading of the tree, and the ordering enforces itself: this cannot land ahead of the correction without the gate saying so.
  2. An expiring record for the two known entries. Rejected on a concrete failure mode rather than on taste: a shrink-only row that reds once the entry passes would move the dependency to the other side — the day fix(triggers): point repository.directory at the directory the trigger packages actually live in #15993 lands, the stale rows red on the default branch until someone deletes them, which is a worse failure than the one it avoids. A row that did NOT red when it went stale would be the permanent waiver the dispatch forbids.

The census, re-measured on this branch (the card's numbers are days old)

this branch the card's "before" column
tracked package.json 81 81
declare repository.directory 57 57
declared value resolves AND equals own directory 55 55
mismatched 2 2
declare nothing 24 (10 private, 14 publishable) 24

The card's "after #15478" column is a prediction of a landing that has not happened: the two mismatches are still on the default branch, so the "before" column is the live state. Reproduce with node scripts/check-manifest-repository-directory.mjs --list.

The trigger is "this manifest changed at all", not "this manifest moved"

The gate is a state check over the whole population on every run and reads no diff, which is the shape the corrected history asks for. f15d6f6f6 was a 26-file COPY that edited each manifest's name and left directory on the old path — which still existed, so the field RESOLVED and pointed at another package. ea4941ad8 was the pure rename. 9a43e042f then edited repository.url INSIDE THE SAME OBJECT with the stale directory line as visible hunk context, and shipped. A rule that fired only on directory moves would have missed the third entirely, exactly as the reviewers did. The dispatch derivation carries the same shape one level up: the declared population is the manifest file kind under every root that holds one, so a card that touches a manifest at all is told to run this gate.

That history is also why OWN is a separate invariant from RESOLVES rather than folded into it. A resolves-only gate would have been GREEN during the copy window and would then have reddened on the unrelated commit that deleted the originals, naming a defect introduced somewhere else. The two are reported apart because they tell an author different things: STALE means the path is gone, MISPLACED means the link sends a consumer to somebody else's package.

The anti-vacuity anchor

The population is git ls-files plus a field predicate, so it empties silently from either side. Three instruments, and no verdict prints unless all of them are satisfied:

  1. Control probes, both directions. The same tracked-directory predicate the invariants use is driven against a directory derived from the population (a manifest's own directory, known tracked because a tracked file was just read out of it) and against one assembled at runtime under that directory's own root. EXISTS and MISSING must BOTH come back correct — one direction alone is worthless, because a predicate stuck on true passes an EXISTS-only control while silencing every finding.
  2. Pinned counts. MEASURED records manifests/declaring/trackedDirectories on a named commit, with floors under each and a provenance line printed on every pass so the record cannot silently stop describing the tree. A run below a floor REFUSES with its own exit code and never offers moving the floor as the repair.
  3. A self-test battery roster. SELF_TEST_BATTERIES pins the battery NAMES and each battery's case floor, evaluated before the verdict, so a self-test that runs zero cases cannot print a success line.

Proven red before its green was believed. Four ablations, each mutation confirmed on disk before the reading was taken and each restored from HEAD with git diff HEAD empty and the blob hash equal to HEAD's:

ablation result
manifest filter returns nothing (a changed glob) REFUSES, exit 2, names the manifests floor and the EXISTS probe having no subject
one self-test battery stops registering cases self-test exit 1: battery ... DID NOT RUN — 0 cases registered, 11 pinned
the field is renamed (predicate stops recognising it) REFUSES, exit 2, "a full population, an empty subject"
tracked-directory predicate stuck on true REFUSES, exit 2, MISSING control probe FAILED

Every one of those would have been a clean green without the instrument that caught it.

And proven green, on the real population. The shipped scan was driven over the live tracked listing with only the two fenced manifests patched IN MEMORY, on disk untouched: 2 findings before, 0 after, both control probes fired, verdict census 57 ok / 24 undeclared.

Verification

Gate family derived mechanically on the final head and run in full: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands — 55 commands, 54 green, the one non-green being this PR's own new gate on the two fenced manifests described above. Exit codes captured immediately after a single redirect, never through a pipe. pnpm check:dts-closure, check:dual-build-cjs-loads and check:type-check-debt first answered exit 3 PREREQUISITE NOT MET; they were re-run to real readings after pnpm build (type-check-debt under an 8192 MB heap) and are green. pnpm lint (eslint . --no-inline-config, whole tree) is green, so no narrowing was needed and none is claimed. pnpm check:pm-dispatch-gates — 1534 cases, all pass. Union run at 48fab7beb.

Two derivation-visibility repairs came out of that run and are worth naming, because both were silent failures:

  • The one-line declaration **/package.json is judged correctly by the derivation's hintCovers and is never SEEN: extractWatchHints admits a literal only if it starts with a word character, a dot or an @, so a leading glob is dropped at admission and the declaration extracts to zero hints. The four literals now declared extract all four, and the gate is MATCHED for a manifest card and silent for packages/spec/src/index.ts.
  • The MISSING probe's target is assembled at runtime instead of written down. A quoted literal naming a real top-level directory is the invisible bare-root species scripts/pm/bare-root-worklist.mjs sweeps for, and it reddened that sweep's self-test until the value was derived from the population instead.

Changeset

skip-changeset. The diff is a new repo-internal gate script, a check: script in the private root manifest, and a workflow step. Nothing here is inside a publishable package and no package's published surface moves, so this PR declares no release of its own.

Note on the branch name

The claim comment names claude/issue-15991-repository-directory-gate. That branch was pushed first and its initial commit carried a Refs card-relation trailer, which check:partof-closing-keyword RULE 2 makes a finding — and that gate's own header forbids repairing it by rewriting pushed history. The corrected history therefore went to this branch instead; the first branch has no PR and can be deleted.


Generated by Claude Code

… directory

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants