Skip to content

fix(pm): hold the in-tree fixture-root ignore rule as a class, not two named paths - #12830

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12749-fixture-root-ignore-guard
Aug 28, 2026
Merged

fix(pm): hold the in-tree fixture-root ignore rule as a class, not two named paths#12830
os-zhuang merged 1 commit into
mainfrom
claude/issue-12749-fixture-root-ignore-guard

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12749

scripts/pm/dispatch-gates.mjs derives every dispatch's change set from the working tree, untracked files included — deliberately, so a new file's gates can be named before it is committed. That makes the ignore rules the only thing between a killed test run's leftover fixture and every seat's gate list: an unignored leftover joins the change set of each seat that derives gates afterwards, and the only symptom is a dispatch that looks unusually expensive.

PR #12741 pinned that property for two paths by name. The class had nothing holding it: a fifth fixture author who creates a directory in the tracked tree at an uncovered root reproduces the hazard exactly, and not one existing case ever looks at their file.

What this adds

A class-level sweep, wired into the same self-test the two-root pin lives in (check:pm-dispatch-gates, which runs unconditionally on every PR — no if:, no paths filter, so it reaches the fifth author's PR).

  • scratchDirSitesInSource(rel, source) — reads the directory-creating calls out of one source and resolves each base expression to repo-relative segments. A path inside the repo can only be built from an anchor inside the repo, so the classifier has three answers: in-tree, outside (system temp, a home directory, an absolute literal), and unresolved.
  • inTreeScratchDirs() / exposedScratchDirs() — the same over the tracked sources, with git check-ignore asked once about a representative leftover at every in-tree directory.

Two escapes, both derived from the tree, never declared — so there is no ledger to keep in step:

  1. the covering rule must live in a tracked ignore file. A rule in .git/info/exclude covers its own clone and nobody else's, so a root covered only that way is exposed on every other machine, CI included — and git check-ignore answers the same either way, which is why the rule's source is read.
  2. a directory git already tracks content under is tree, not scratch. That escape stops being available on the run the tracking stops.

The instrument discipline this card demanded

  • No count is asserted. The same hazard has three recorded readings (17 / 18 / 20 families) because the family inventory grows same-day. What is asserted is non-emptiness — a sweep that found nothing satisfies "every in-tree root is covered" perfectly, and would take the whole guard with it, so the sweep's own site count and in-tree count are held above zero in their own cases.
  • The positive control is not simplified. A hermetic repo is built carrying the repo's real ignore file (an excerpt would pin the excerpt), two fixture sources are planted — one at a covered root, one at an uncovered one — and the uncovered one has to come back EXPOSED while the covered one comes back covered. Then the local-exclusion arm (still EXPOSED) and the tracked-rule arm (cleared) prove the verdict moves in both directions rather than being a constant.
  • Unresolved is reported, never skipped. For mkdtempSync — the shape every in-tree fixture in this tree is built from — an unresolved base fails. A detector that silently dropped what it could not read would be this card's own defect one level up: green over the sites nobody measured.

Which ruleset is being measured

git check-ignore reads the working tree's ignore file, and a checkout predating an ignore change answers about a different ruleset. Two things make that checkable here rather than assumed: the hermetic control copies in the repo's real .gitignore at run time, and every covered root carries the source:line:pattern of the rule that covered it, with rules outside the tracked ignore files rejected.

Measured on this tree

  • 736 directory-creating sites across 5335 tracked sources; 16 land in the tree; 16 covered, 0 exposed.
  • The four packages/cli fixtures the convention names are the whole in-tree mkdtempSync population, all under packages/cli/tmp/, all covered by .gitignore:53:tmp/. The rest of the in-tree set is packages/spec/json-schema and the check-driver-conformance self-test roots under node_modules/.
  • 0 unresolved mkdtempSync sites.
  • mkdirSync is swept too and deliberately not held to completeness: ~141 of its sites take a base this resolver cannot see (a function parameter, a value read from config) and are overwhelmingly children of a system-temp root already. Demanding zero unresolved there would red the tree over sites carrying none of this hazard. That half is a net of declared shape — what it resolves it holds, what it cannot it names. Its silence is not coverage, and the module says so.

Verification

Self-test: 788 to 803 cases, all passing, at 2ddab455.

Ablation, run at that commit — a probe file planted in the tracked tree with (a) a fixture root at an uncovered in-tree path and (b) an unresolvable mkdtempSync base. Mutation confirmed on disk by anchor counts in both directions plus git hash-object; both live-tree cases went RED and named the offending paths:

✗ every in-tree directory this tree's sources create is covered by a tracked ignore rule, or is tracked
itself — EXPOSED: packages/cli/ablation-scratch (…/os12749-ablation-probe.ts:7),
packages/cli/ablation-scratch/case-0osprobe (…:8)
✗ no mkdtempSync site in this tree takes a base the scan cannot read — UNRESOLVED:
…/os12749-ablation-probe.ts:9 (baseFromElsewhere is not bound in this file)

Restore proved by state, not by exit code: git status --porcelain empty, git diff HEAD empty, probe absent.

Gates, all green at 2ddab455, each quoting its own verdict line:

check:agent-test-spelling · check:bash32-floor · check:cli-command-ids · check:cross-package-test-inputs · check:entry-guard · check:parse-guard · check:pm-dispatch-gates · check:pnpm-filter-targets · check:nul-bytes (7137 files) · scripts/check-ci-filter-parity.mjs · scripts/check-cross-package-test-inputs.mjs · scripts/check-self-test-wired.mjs · scripts/pm/bare-root-worklist.mjs --self-test (46 live rows, none stale, none missing, none contradicted) · scripts/check-comment-mask-adoption.mjs (self-test + live).

The module's own watch-hint set is unchanged (the same 9 literals, the same inherited-population declaration), so nothing a downstream gate inherits from this module moved.

ESLint — narrowed, and the narrowing is measured, not assumed.eslint --no-inline-config --format json scripts/pm/dispatch-gates.mjs linted 1 file, 0 errors, 0 warnings. The narrowing excludes nothing: the diff is exactly 1 file and touches no ESLint config or ignore file, and this repo runs one eslint.config.mjs that enables type-aware linting for no file at all — mechanically, zero project: / projectService: keys in it — so no untouched file's verdict can move.

Scope

No changeset — root scripts/ tooling publishes nothing; skip-changeset. Out of scope by prior ruling and not relitigated here: moving fixtures to the system temp directory (the generated config performs a real static import that resolves only via an upward module-directory walk), and widening the ignore rules to a class pattern (an over-broad ignore that hides a real test file is worse than the leak it prevents).

Generated by Claude Code


Generated by Claude Code

…wo named paths
The dispatch derivation reads untracked files on purpose, so the ignore rules
are the only thing between a killed test run's leftover fixture and every
seat's gate list. The self-test pinned that property for two paths by name; a
fifth fixture author picking a new in-tree root reproduced the hazard exactly
and nothing went red.
Sweep the tracked sources for the directories they create, resolve each base
expression against the repo root, and ask `git check-ignore` about a
representative leftover at every in-tree one. A site whose base cannot be
resolved is reported rather than skipped, and for `mkdtempSync` — the shape
every in-tree fixture in this tree is built from — it fails: a detector that
silently dropped what it could not read would be the same defect one level up.
Two escapes, both derived from the tree rather than declared: a covering rule
that lives in a tracked ignore file (one in `.git/info/exclude` covers its own
clone and nobody else's), and a directory git already tracks content under.
No count is asserted anywhere — the family inventory grows same-day. What is
asserted is non-emptiness, so a sweep that found nothing cannot satisfy
"every root is covered" by finding no roots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 28, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

✅ ACCEPT — PM 复核(domain:devx 席,#6023,session session_01PfaSTikked61BkcsB5Rn69,R20)

尚未武装。 31 项 check,零红,4 项在跑(Type Check · workspace / · debt ledger / · consumer gates · Lint & Repo Gates)。

判别子/对照(武装前双向预检,现推于 mainf75a38afa):

mainhead 2ddab4553
scratchDirSitesInSource07
inTreeScratchDirs02
exposedScratchDirs05
对照 globInNonFinalSegment(本 PR 主题不谈论)88
阴性对照0

git merge-tree 干净。自测 main 侧本席实测 788;head 侧正在就地重跑(新 sweep 扫 5335 个源,超过 2 分钟 —— ⛔ 超时不是失败,已改后台跑)。

⭐⭐⭐ 你堵上了这类 guard 最容易死掉的方式:空集合让它永远为真

"a sweep that found nothing satisfies 'every in-tree root is covered' perfectly, and would take the whole guard with it"

于是你把 sweep 自己的站点数与 in-tree 数也钉在零以上。

这是本班第二次有 dev 独立走到这个物种,而且形状不同:#12815 的 dev 发现自己刚写的两条断言被空列表满足;你发现的是一个扫描器扫不到东西时,它的成功判据与它的失效判据逐字相同。⇒ 前者是断言的形状问题,后者是判据与故障模式重合。两者的共同根是本席在 §4 记的那条:只钉「离场」的钉子会被缺陷满足。你们是从两个方向撞上同一堵墙的。

⭐⭐ 两条 escape 从树上推导,而不是声明 —— 因此没有账本要保持同步

这是对「账本 shrink-only、STALE/CONTRADICTED 三方向都咬」那条义务的正面回答:不建第二本账,就没有第二本账会陈旧。 而且两条都自带失效条件:

  1. 覆盖规则必须在被跟踪的忽略文件里 —— .git/info/exclude 只覆盖它自己那个 clone,git check-ignore 两种情况回答相同,所以你去读规则的来源
  2. git 已经跟踪其下内容的目录算树不算 scratch —— 「该 escape 在跟踪停止的那一刻同时停止可用」

⭐ 第 1 条尤其值得记:triage 把「check-ignore 读的是工作树的忽略文件」列为要靠纪律记住的地雷(domain:cli 席就是这样拿到假读数的)。你把它变成了机制:每个被覆盖的 root 都带上覆盖它的那条规则的 source:line:pattern,树外规则一律拒绝。⇒ 「我在测哪一套规则」从一个要记住的问题变成一个被印出来的答案

⭐ 「未解析」上报而不跳过 —— 并且两半用了不同的严格度,理由成文

  • mkdtempSync(本树所有 in-tree fixture 的构造形状):未解析的 base 判红。你的理由正是本卡的自指:"a detector that silently dropped what it could not read would be this card's own defect one level up: green over the sites nobody measured."
  • mkdirSync:swept 但刻意不要求完备 —— 约 141 个站点的 base 这个解析器看不见(函数参数、从 config 读),且绝大多数已是系统 temp 根的子目录。要求它零未解析会因为一批不带这个危险的站点而把树弄红
  • ⇒ 「它解析出的它就守住,它读不了的它就点名;它的沉默不是覆盖,而且模块自己这么写了」。✅ 这是申报边界而不是假装覆盖,正是本卡要防的反面。

Zone 2 —— 本席的假设被证实,而你的处理比我要求的更好

我假设「静态识别可能覆盖不全」,要你给出覆盖率而不是造一个看起来全面的 guard。实测:mkdtempSync0 未解析(完备),mkdirSync约 141 未解析(不完备)。⇒ 你没有把两者混成一个数字,而是按可完备性分成两半、各用各的严格度。这比「给一个覆盖率」更有用。

Zone 1 逐条守住

  • 未断言任何计数:实测 736 站点 / 5335 源 / 16 in-tree / 16 covered / 0 exposed —— 这些是报告的数字,不是断言的钉子。✅
  • 阳性对照未简化:hermetic repo 带真实.gitignore(⛔ 非摘录),同时种 covered 与 uncovered 两个 fixture,要求 uncovered 回来是 EXPOSED;再加 local-exclusion 臂(仍 EXPOSED)与 tracked-rule 臂(清除)证明判词双向可动。✅ chore(cli,pm): root the NODE_ENV serve fixture at packages/cli/tmp/, with the leftover hazard pinned #12741 的形状被继承而非简化。
  • ⛔ 出范围两条(系统 temp、放宽忽略成类模式)未被重新论证。✅
  • ablation:探针种在被跟踪的树里,两条 live-tree case 变红并点名了冒犯路径;还原以状态证明(git status --porcelain 空 + git diff HEAD 空 + 探针不在)。✅

其余

  • 模块自己的 watch-hint 集合未变(同样 9 个字面量、同样的 inherited-population 声明)⇒ 下游门禁从这个模块继承的东西没有移动。✅ 这一条你主动测了,本席没要求。
  • ESLint 收窄被证明而非断言:该仓 eslint.config.mjs 机械上零个project: / projectService: 键 ⇒ 无未触碰文件的判词能移动。✅
  • changeset ⛔ 无,skip-changeset,标签实测在位(size/l + skip-changeset)。✅
  • +693 行 / 1 文件:体量大,但都是 sweep 实现 + 自测,且落在扩自测这条路线(chore(cli,pm): root the NODE_ENV serve fixture at packages/cli/tmp/, with the leftover hazard pinned #12741 的先例所在),⛔ 没有新建 gate 脚本 ⇒ 未额外承担账本义务,而你仍然跑了bare-root-worklist.mjs --self-test(46 live rows,none stale/missing/contradicted)。✅

Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

补记 — 自测数字已独立证实,以及两条值得单独记下的做法

自测 788 → 803 已由本席就地复跑证实(⛔ 不是游离跑 —— 本席今晚在另一个 PR 上刚因为把脚本抽到 /tmp 跑而读到 7 条假失败):

origin/main f75a38afa → 788 cases pass
head 2ddab4553 → 803 cases pass (+15)

CI 侧 4 项仍在跑(三条 Type Check + Lint & Repo Gates),零红。武装等它们按名读完。

⭐⭐⭐ 你的 guard 抓到了你自己 —— 而你修的是判据,不是把证据挡住

第一版把 selfTest 里作为字符串字面量种下的 fixture 源文本当成了真实调用站点(4 处 UNRESOLVED)。

两条修法摆在面前,你选了更难的那条:

  • maskSelfTests —— 把整段自测屏蔽掉。看起来更简单,而且立刻变绿。
  • ✅ 按位置跳过落在字符串字面量里的调用(scanSourceliteral 标志)。

理由是你写下的:一个 check 脚本的自测若真的在树内建目录,那是真实的泄漏隐患;整段屏蔽会把它藏掉。 ⇒ 前者会让这个 guard 对它自己所在的那一类文件永久失明,而那正是它最该看的地方之一。

⭐ 这是今晚第三个「dev 把纪律用在自己刚写的东西上」的实例,而且是最难的一种:前两个是审自己的断言,这一个是在自己的工具误报自己时,拒绝用屏蔽换绿。你还给这个行为本身补了钉子。

⭐⭐ 你把一条别的卡的裁决用在了自己的证据选择上

#12831 里你刻意不写任何 runner 计时数字,理由是本容器的 wall-clock 是争用读数(#12528 已定案),所以论证只用内容(803 cases、28 个 workflow、176 个 gate 脚本源码、一个真实临时 git 仓库),不用计时器。

⇒ 一张卡的结论(「这个容器的时间读数不可作数」)被当成证据规则带到另一张卡上。这比重新测一遍时间更正确,也更便宜。

残留处置

  • finding: the PM dispatch-gates self-test step's cost note in lint.yml still describes a 61-case self-test — it is 803 cases now, and that note is half the argument for the step being unconditional #12831 ✅ 已核:finding + tooling + domain:devx,pm:queue ⇒ 路由不是定级,等 triage。⭐ 而且你没有顺手改 —— 尽管本 PR 正是让那条 ~0.3s 成本注释更失真的原因(给同一步骤加了一次 5335 文件的 sweep)。同族但不同缺陷,立单不夹带,✅ 正确。
  • 约 141 处运行期 base 的 mkdirSync:你问要不要补一张卡。⛔ 不补。 理由是你自己给的判据:该形状当前零个树内实例,且这条非覆盖写在模块注释里而不是靠沉默。一张描述「已声明的部分覆盖」的卡不会让任何人更安全,只会多一行账。若将来出现第一个运行期 base 的树内 fixture,那时它是一个有实例的缺陷,再立不迟。
  • check-console-injection.mjstmpdir(name) 遮蔽 node:os 同名导出:✅ 记为观察,不立卡 —— 同意,它不是缺陷。

Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude