feat(scripts): refuse an undeclared mode-160000 gitlink in the index - #18414
os-try-charles wants to merge 2 commits into
Conversation
Nothing in this repository read index modes, so `git add -A` over a nested git repository -- a linked worktree, a nested clone, a vendored checkout -- staged one mode-160000 entry at exit 0 with only a `warning:` line, and every clone afterwards carried a submodule pointer to a commit that exists in no clone of this repository. `check:gitlink-declared` enumerates the index and refuses any entry at mode 160000 that no tracked `.gitmodules` row declares. A real submodule passes untouched: `git submodule add` writes the pointer and the declaration in one act. The declaration is read out of the index blob rather than the working tree, because a `.gitmodules` that never gets staged does not reach the clone that receives the pointer. The self-test runs in both directions over throwaway repos: the card's own measurement (a nested repo plus a literal `git add -A`) is refused and names the path, and the same index with the declaration added is green. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
… reading Three follow-ups on the gate, none of them behavioural for a clean index: - the dispatch-gates population marker is one line, because the marker is matched line-by-line and a wrapped reason printed to a seat cut off mid-sentence; - `findOffenders` deduplicates by path, so a conflicted index -- which carries the same path at stages 1, 2 and 3 -- is one finding and not three; - the header's zero-submodule reading now names the tree it was taken against. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
PM review — ACCEPTReviewed against GitHub and against a detached worktree at this PR's head The gate, exercised by me rather than by its own harness
⭐ 1a → 1b is the reading that makes this gate worth having, and it is the design decision I would have got wrong: only the index changed between them. The gate reads the declaration with
Also re-derived here, independently: The two shape deviations — both acceptedThe card's suggested shape was report-only-then-refusing. This ships refusing from day one, with the report half as Fences, checked one by one
The declared narrowing, and why it stands5 of the 63 derived families exited 3 — PREREQUISITE NOT MET, read as NOT MEASURED rather than as a pass: ⭐ And the slow gate was not allowed to become a NOT MEASURED: Two things the dev handed me, and what I did with each
Landing⛔ This PR is human-merge only and I am not arming it: the diff touches PM seat Generated by Claude Code |
Ready for human merge — ⛔ this seat cannot arm it@hotlong — review requested on GitHub, and the request is read back from the API rather than merely stated here ( Why you and not the queue: the diff touches State, all three read after the ready flip — marking ready re-triggers What it is: a new ⭐ The one reading worth your time before merging. The gate reads the declaration from the index blob (
A gate that read the worktree copy would go green on exactly the tree that ships the dangling pointer. Full review with every re-derived number: comment PM seat Generated by Claude Code |
两条巡查行的答复,连同它们各自要求的读数本轮 H36 —— 与 PR #18503 共享
|
| PR | lint.yml 的改动 |
|---|---|
| #18414(本 PR) | 1 file changed, **22 insertions(+)** — 新增一个 step |
| #18503 | 1 file changed, **32 deletions(-)** — 移除若干 step |
git merge-tree A B 是用两者的 merge base 做的真三方合并,⛔ 不是"先把 #18503 并进 main 再并本 PR"的逐步模拟。但三种配对都干净,且 merge-tree 本就是三方合并,所以「谁先落地都不会撞」这个结论在文本层面成立。⛔ 它不保证语义层面:若 #18503 移除的 step 正是本 PR 新增 step 所依赖的东西,那是语义冲突,merge-tree 看不见。⇒ 谁后落地,重跑一次门禁而不是只看这个 exit 0。
H12 —— 「orphan landing … auto-merge unarmed」:⛔ 不是孤儿,是按设计不可武装
巡查行给了三个处置(re-queue / fix / park as draft with a stated reason)。
本席武装不了它,而且这不是失误。 它的 diff 触 .github/workflows/**,本席的 token 缺 workflows scope ⇒ auto_merge 恒 HTTP 422。⛔ 这是 GitHub 侧的真 422,不是会话分类器的限制,⛔ 也不重试。这一条在卡 #17472 的认领评论里派发前就写明了,不是复核时才发现的。
⇒ 三个选项逐一不适用:
- ⛔ re-queue —— 进不去,422;
- ⛔ fix —— 没有东西要修:本 head
298e245de8上53 raw → 34 去重 → NOT-GREEN **0**,mergeable_state: **clean**; - ⛔ park as draft —— 转回 draft 会把它从人工合并的视野里拿掉,降低它落地的概率,与本行的意图相反。
实际状态:绿 · clean · ready · requested_reviewers: ['hotlong'](已从 API 回读,⛔ 不是"报告里点名")· reviews: NONE(尚无人提交复审)。⇒ 只差一次人工合并。
PM seat domain:devx · session session_017ef78bLdybu3AffehKkhfk · round 11 · ⛔ 未重试武装,未改状态 · 2026-09-16T17:17Z
Generated by Claude Code
Part of #17472.
Authored by the Claude Code session
session_017ef78bLdybu3AffehKkhfk(https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk).Nothing in this repository read index modes, so
git add -Aover a nested git repository — a linked worktree, a nested clone, a vendored checkout — staged exactly one entry at mode160000at exit 0 with only awarning:line, and every clone afterwards carried a submodule pointer to a commit that exists in no clone of this repository. This adds the gate that refuses it.⛔ This is not the untracked-signal half; PR #17468 owns that one and closed it by ignoring
.worktrees/. This is the stage, and it is path-blind on purpose: the class generalises past any path, so there is no path list here to fall out of date.The shape chosen, and the ones rejected
scripts/check-gitlink-declared.mjsenumerates the index (git ls-files --stage -z) and refuses any entry at mode160000that no tracked.gitmodulesrow declares. Rootpackage.jsongainscheck:gitlink-declaredin the house spelling (--self-testthen the live run), and.github/workflows/lint.ymlgains an unconditional step inLint & Repo Gates, besideRaw control-byte guard— its structural sibling: whole-index population, a hygiene property of what a clone receives, and a defect whose only native signal is a warning on a command that exits 0.Four decisions, each with the alternative it beat:
git submodule addwrites the declaration and the pointer in one act, so a real submodule passes the day it is added — no exemption, no allowlist, no flag. It also makes the card's acceptance shape possible at all: a flat ban has no passing direction to test.git config --blob :.gitmodules, not off the working tree. A.gitmodulespresent on disk and never staged would otherwise vouch for a gitlink — and that combination is the clone-side hazard: the clone receives the pointer and not the file that explains it. A declaration that does not travel with the commit declares nothing. The self-test pins this direction separately.--list. The card suggested "report-only, then refusing". A staged rollout buys time to clear a backlog, and there is no backlog — the tree carries zero gitlinks — so a report-only phase would be a phase in which the gate refuses nothing and finds nothing, and the day it started refusing would be the first day it was ever exercised.--listprints every gitlink and how each is judged (including a declaration with no gitlink beside it, reported and never a finding), whether or not anything is a finding..githooks/pre-commitwiring. That is the earliest possible refusal point and it is outside the dispatched file surface (scripts/, rootpackage.json,.github/workflows/lint.yml). The gate is written so the wiring is a one-liner if the maintainers want it:git()deliberately does not scrub the ambient git environment, so an inheritedGIT_INDEX_FILE— the index a hook is being asked about — is the index it judges.No new runtime dependency:
gititself is the.gitmodulesparser (it is git config syntax, and a second reader of line continuations, quoting and subsection escaping would be a second dialect to keep in step). The manual floor was not hit.Self-test, both directions, as output
The gate ships a two-direction self-test over throwaway repositories under a temp dir — never inside this checkout — driven through the same
scan()the live run calls.Its forward fixture is the card's own measurement, reproduced with a literal
git add -Arather than a hand-assembled index, and the fixture is asserted before the gate is (a run in whichgit add -Astaged nothing would otherwise look exactly like a gate that works). The six batteries and their floors are declared in the script; the floor requires the set of batteries that registered assertions to equal the set declared, so a section that stops running names itself instead of going quiet.The same two directions on the production path — the script's own
main(), its failure text and its exit code — measured in a throwaway repo outside every checkout:The nested repository sits at
vendor/thingrather than under.worktrees/deliberately: the ignore PR #17468 landed covers that one path, and this gate is about the class.On this repository the live run is:
The gitlink count is printed unconditionally,
0included: a summary that named gitlinks only when it found some would make "there are none" and "I did not look" render identically.The card's citation, corrected
The card body and its triage comment both state that
160000"appears in the tree only as two skip comments inscripts/check-nul-bytes.mjs". Re-derived at7358c1c5bwith controls taken from the probed tree itself:git grep 160000across the treegit grep -i gitlinkscripts/check-nul-bytes.mjs:193,:521, both prose skip commentsgit grep -ic nul scripts/check-nul-bytes.mjsgit grep -i gitmodules.gitmodulesfile existsgit ls-files --stagemode histogram100644× 8691,100755× 34 — no other modeThe substance holds and holds harder than the card claimed; the citation form does not. ⛔
160000cannot be used as a firing control when re-deriving that zero — it gives a double zero.Changeset:
skip-changeset, measured againstfiles[]The sole criterion is whether anything published moves, so this was measured rather than argued from the path names, over the 83 tracked manifests at
298e245de:package.jsonmanifestsprivate: true)files[](i.e. shipping their whole directory)files[]entries escaping their own package directory (../or a leading slash)files[]entries namingscriptsat all@objectstack/specfiles[]dist,json-schema,liveness,prompts,llms.txt,README.md,src/**/*.zod.ts,CHANGELOG.md,api-surface,spec-changes.jsonThe root manifest is
@objectstack/spec-monorepo,private: true— it is never published. Every published package declares afiles[]and none of them can reach a repo-root path, so none of this PR's three paths can be inside any tarball. ⇒skip-changeset, applied on the PR.Verification
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 63 families for this change set; the new gate discovers itself and is placed under the always-runs whole-tree heading with its liveness spelling vouched (a git ls-files enumeration of the tracked corpus).All 63 ran, at
bfa686a61for the sweep and298e245defor the two re-runs named below. 58 exit 0. The other five exit 3 —PREREQUISITE NOT MET, which is NOT MEASURED and neither a pass nor a finding:check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closure,check:sourcemap-no-sources-contentandcheck:type-check-debtall read built package output, and no package was built in this worktree. They are derived only because the diff touches the root manifest; this diff adds no package source and moves nofiles[]content, so it cannot move them, and CI runs them after a build. That narrowing is declared here rather than smoothed over.Beyond the derived set:
pnpm lint— the full repo-wide run, not a narrowed one, re-run on the final commit298e245de:eslint . --no-inline-config --format jsonover 6790 files, 0 errors, 0 warnings, exit 0. The file count is read off eslint's own--format jsonoutput and the population is eslint's own config resolution, not a guess; this repo's singleeslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules) for any file, so nothing in this diff can move the verdict on a file it does not touch.node scripts/check-ci-filter-parity.mjs --self-test— exit 0, 47 assertions.node scripts/pr-labels.mjs --self-test— exit 0,VERDICT: pr-labels self-test PASSED.pnpm check:nul-bytes,pnpm check:entry-guard,pnpm check:parse-guard— exit 0 (all three are inside the derived 63).pnpm check:pm-dispatch-gates— exit 0,dispatch-gates self-test: 1730 cases pass. Run twice, once per commit; on the final commit298e245dethe battery took 753.4s on this box.No verify lock was taken: nothing here builds or tests a package, so no command needed
scripts/pm/os-verify-lock.sh. There is no VERDICT line to quote, and that is a fact about this diff rather than a step skipped.Acceptance notes
-zdelimiter by writing the backslash-u escape for the NUL byte as a string literal. The editing tool materialised that escape into a raw NUL byte on disk, and the byte-discipline self-scan found it at line 113 — the accident sourcecheck-nul-bytes.mjs's header documents, landing on the very file being written about a git-plumbing delimiter, and a case thatcheck:nul-byteswould have caught at push time had the self-scan not. Fixed the way that header prescribes: built from the byte value withString.fromCharCode(0), never written as a literal anywhere in the file..gitmodulesrow with no gitlink beside it is the mirror defect (a declared submodule that is not in the index). It is reported by--listand deliberately not a finding: it is a different subject, and this gate refuses exactly one thing. Noted, not filed — no PR or person is heading for it, and nothing in the tree can produce one today..githooks/pre-commitis the earliest refusal point and is outside this PR's file surface; see decision 4 above for why the gate is nonetheless written to be wired there without a change.summarise()counts index ENTRIES, so a conflicted index (the same path at stages 1, 2 and 3) inflates its gitlink count while the finding list stays one row per path. The number a reader acts on is the finding count, andfindOffendersis pinned on that shape.Generated by Claude Code