Skip to content

Commit 12a1aba

Browse files
test(ui/scripts): take the dead-keys sweep's whole-repo readings once, at collection (#27437)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ89F6xaEdNtTsXsYJyUBby9i)_ Fixes #27423 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`check-i18n-dead-keys.test.ts` 里读真实仓库的四个读数(`sweep`、`analyze`、`collectEnKeys`、间接模板头)每个文件只算一次,在 describe 体(收集阶段)里取,三个用例不再在自己的 15 秒预算里重扫整个仓库。 - 为什么改:main 上 `Test UI (10/16)` 在用例「keeps every key it makes reachable OUT of both tiers」超时 15 秒;同一 sha 在合并队列里是绿的,属于负载下的间歇红。按裁决不调超时、不跳过、不重试、不改断言。 - 风险与回滚:只动一个测试文件,断言逐条不变,读数被冻结,用例之间无法互相污染;回滚即 revert 本 PR。 - 先看:`ui/scripts/__tests__/check-i18n-dead-keys.test.ts` 顶部新增的 `realTree` 备忘块,以及两个 `on the real tree` / `measured on THIS repository` describe 体里的取值行。 - 两分钟验证:`pnpm --dir ui exec vitest run scripts/__tests__/check-i18n-dead-keys.test.ts`,预期 146 passed,三个原慢用例各在几毫秒内完成。 - 需要你做的:无,席位落地。 ## What changes `ui/scripts/__tests__/check-i18n-dead-keys.test.ts` only. - A module-level `realTree` memo takes each whole-repository reading once per file: `sweep(REPO_ROOT)`, `analyze(REPO_ROOT)`, `collectEnKeys(REPO_ROOT)` and `collectIndirectTemplateHeads(REPO_ROOT, …)`. Each value is frozen (arrays and plain objects, recursively), so a case that sorted or spliced one in place would throw instead of leaking into the next case. - The two describes that read the real tree (`the one-hop indirect template leg › on the real tree` and `the applied dynamic heads are REPORTED, not filtered › measured on THIS repository`) now take those values in their describe bodies, which run at collection. Three cases lose their in-case repository walks: - `keeps every key it makes reachable OUT of both tiers` (the red one): its own `sweep(repoRoot)`. - `⛔ does NOT enrol its heads in the call-site gate’s family registry`: its own `analyze(repoRoot)`. - `reports one row per head the sweep actually applied — no head goes unreported`: its own `analyze(repoRoot)` plus `collectIndirectTemplateHeads(repoRoot, collectEnKeys(repoRoot))`. - Three more `collectEnKeys(repoRoot).leaves` reads in the second describe read the shared `packKeys` instead. - `scripts/check-i18n-dead-keys.mjs` is untouched: no seam was needed. ## Why The ruling carried over from #27076 holds: the timeout is not raised, no case is skipped, no retry is added, and no assertion changes. The cost was the test's own, so the cost moved out of the cases. Collection is the right home. `ui/AGENTS.md` (测试纪律) puts warm-up cost at module scope because no test or hook timeout bounds it, and it rules out a `beforeAll`, whose 10 s `hookTimeout` is narrower than the 15 s `testTimeout`. The second describe already took its `sweep(repoRoot)` in its body; this PR makes both describes share that one reading. In `@vitest/runner` 4.1.12, `withTimeout` wraps test bodies and hooks only, not the suite collect path. ## Tests Measured on this container (4 cores, a shared box). "Load" means four `node -e 'for(;;){}'` CPU burners beside the run. Every run went through `scripts/pm/os-verify-lock.sh`. Durations come from vitest's JSON reporter (per case) and its `Duration` line (per file). | leg | red case | `reports one row per head…` | `does NOT enrol…` | file `Duration` (import / tests) | result | |---|---|---|---|---|---| | before, first run after install (cold) | **15305 ms, timed out** | 6482 ms | 4439 ms | lock held 45 s | 145/146, red | | before, idle | 8378 ms | 5909 ms | 4709 ms | 32.52 s (11.40 / 20.74) | 146/146 | | before, load | 10507 ms | 7544 ms | 5504 ms | 44.90 s (17.87 / 26.40) | 146/146 | | after, first run | 2 ms | 0 ms | 0 ms | lock held 22 s | 146/146 | | after, idle | 3 ms | 0 ms | 0 ms | 20.82 s (18.32 / 2.04) | 146/146 | | after, load | 5 ms | 1 ms | 0 ms | 31.34 s (27.51 / 3.15) | 146/146 | The cold "before" run reproduces the main-red: the same case, `Error: Test timed out in 15000ms`. After the change, the slowest case in the file under load is 404 ms (`both control groups from the card … POSITIVE`). The collection phase grows by the one extra `analyze()` reading, about 7 s idle and 10 s loaded. No timeout bounds that phase. The file's total wall drops by about 12 s idle and 13.5 s loaded. The before legs ran the file restored from `HEAD~1` by `git restore --source=HEAD~1`, with a trap that restores `HEAD`. Restore proof: `git hash-object` = `git rev-parse HEAD:…` = `9c08a7b0`, and `git status --porcelain` is empty. ## Acceptance notes - Finding, not fixed here (the dispatch order asked for a read-only measurement): `ui/scripts/__tests__/check-vi-mock-inherit.test.ts` has the same in-case repository-sweep shape. Idle on this box, three cases take 6.1 to 6.8 s each: `puts the census in the verdict…` (it spawns `node scripts/check-vi-mock-inherit.mjs` over the whole tree) and both `the sweep — every @object-ui/i18n factory inherits…` cases, which each call `swept()` (a `scan(repoRoot, …)`) in-case. The file runs 29.17 s (import 7.05 s, tests 21.77 s). About 2x load would put them near the 15 s budget. - The card's "Done when" needs two consecutive green push runs and the next scheduled full run. Those happen after merge and are the seat's to read. ## Verification Gates derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head. The exit codes and the `--ran` reconciliation are in the os-dev report on #27423. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI owns the lint run) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset. This one publishes nothing: it is a test file under the private `ui/` root, so the PR carries `skip-changeset`. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is the repo's Chinese summary convention.) --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYeSmhiCsoJFL2RkQS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e24d4eb commit 12a1aba

1 file changed

Lines changed: 52 additions & 11 deletions

File tree

‎ui/scripts/__tests__/check-i18n-dead-keys.test.ts‎

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,45 @@ import { rootWorkflow, rootWorkflowFiles } from './helpers/root-ci';
4444
* evidence that the key is referenced.
4545
*/
4646

47+
/**
48+
* objectstack#27423 — the whole-repository readings, each taken ONCE per file
49+
* and shared by every case that reads the real tree.
50+
*
51+
* One `sweep()` of this repository costs seconds, and a case that ran its own
52+
* inside its 15 s budget timed out on a loaded CI shard. So each `describe` that
53+
* reads the real tree asks for these in its BODY, which runs at collection, and
54+
* no test or hook timeout bounds collection (`ui/AGENTS.md`, 测试纪律: a
55+
* `beforeAll` would sit under the narrower 10 s hook budget instead). The cases
56+
* only read the values; their arrays and plain objects are frozen so a case
57+
* that sorted or spliced one in place would throw rather than leak into the
58+
* next case.
59+
*/
60+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
61+
62+
function frozen<T>(value: T): T {
63+
if (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype) {
64+
for (const member of Object.values(value as object)) {
65+
if (member !== null && typeof member === 'object') frozen(member);
66+
}
67+
Object.freeze(value);
68+
}
69+
return value;
70+
}
71+
72+
function once<T>(read: () => T): () => T {
73+
let held: { value: T } | undefined;
74+
return () => (held ??= { value: frozen(read()) }).value;
75+
}
76+
77+
const realEnKeys = once(() => collectEnKeys(REPO_ROOT));
78+
79+
const realTree = {
80+
enKeys: realEnKeys,
81+
gate: once(() => analyze(REPO_ROOT)),
82+
indirect: once(() => collectIndirectTemplateHeads(REPO_ROOT, realEnKeys())),
83+
sweep: once(() => sweep(REPO_ROOT)),
84+
};
85+
4786
const tempRoots: string[] = [];
4887

4988
/**
@@ -1677,8 +1716,11 @@ describe('the one-hop indirect template leg (objectui#8754)', () => {
16771716
field: 'type',
16781717
};
16791718

1680-
const packKeys = collectEnKeys(repoRoot);
1681-
const collected = collectIndirectTemplateHeads(repoRoot, packKeys);
1719+
// Read at collection, never inside a case (objectstack#27423).
1720+
const packKeys = realTree.enKeys();
1721+
const collected = realTree.indirect();
1722+
const result = realTree.sweep();
1723+
const gate = realTree.gate();
16821724
const headsAtHopSite = [...collected.heads]
16831725
.filter(([, sites]) => sites.some((site) => site.file === HOP_SITE))
16841726
.map(([head]) => head)
@@ -1715,7 +1757,6 @@ describe('the one-hop indirect template leg (objectui#8754)', () => {
17151757
// The end-to-end reading, and the one a reviewer can check against the
17161758
// CLI: a key under a collected head must be neither CONFIRMED nor
17171759
// NEEDS-REVIEW, because it is not a candidate at all.
1718-
const result = sweep(repoRoot);
17191760
const reachable = [...packKeys.leaves].filter((key) => [...collected.heads.keys()].some((head) => key.startsWith(head)));
17201761
expect(reachable.length, 'the heads hold no leaves — the cross-check would be vacuous').toBeGreaterThan(0);
17211762
const stillCandidate = reachable.filter(
@@ -1730,7 +1771,6 @@ describe('the one-hop indirect template leg (objectui#8754)', () => {
17301771
// `undeclared-dynamic-family`, a RED finding; objectui#7592 measured that
17311772
// widening and objectui#7844 calls the registry entry a decision with its
17321773
// own blast radius. This leg buys reachability and stops.
1733-
const gate = analyze(repoRoot);
17341774
for (const head of collected.heads.keys()) {
17351775
if (gate.dynamicHeads.has(head)) continue; // already a head by its own argument position
17361776
expect(
@@ -2008,12 +2048,13 @@ export function Widget({ id }: { id: string }) {
20082048
});
20092049

20102050
describe('measured on THIS repository', () => {
2011-
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
2012-
const result = sweep(repoRoot);
2051+
// Read at collection, never inside a case (objectstack#27423).
2052+
const result = realTree.sweep();
2053+
const gate = realTree.gate();
2054+
const indirect = realTree.indirect();
2055+
const packKeys = realTree.enKeys();
20132056

20142057
it('reports one row per head the sweep actually applied — no head goes unreported', () => {
2015-
const gate = analyze(repoRoot);
2016-
const indirect = collectIndirectTemplateHeads(repoRoot, collectEnKeys(repoRoot));
20172058
const applied = new Set([...gate.dynamicHeads, ...indirect.heads.keys()]);
20182059
expect(applied.size, 'no heads at all — the whole cross-check would be vacuous').toBeGreaterThan(0);
20192060
expect(result.appliedHeads.map((row) => row.head).sort()).toEqual([...applied].sort());
@@ -2024,7 +2065,7 @@ export function Widget({ id }: { id: string }) {
20242065
// claim about it. A row whose keys were still candidates would be worse
20252066
// than the silence it replaces.
20262067
const candidates = new Set([...result.confirmed, ...result.needsReview.map((entry) => entry.key)]);
2027-
const leaves = [...collectEnKeys(repoRoot).leaves];
2068+
const leaves = [...packKeys.leaves];
20282069
for (const row of result.appliedHeads) {
20292070
const under = leaves.filter((key) => key.startsWith(row.head));
20302071
expect(under.length, `${row.head} holds no leaves — its row would be vacuous`).toBe(row.leavesUnder);
@@ -2037,7 +2078,7 @@ export function Widget({ id }: { id: string }) {
20372078
// collected head whatever its depth; the report says so and changes
20382079
// nothing. `heldLive` summing to the keys held out of the tiers is the
20392080
// arithmetic a reviewer can redo from the printed table.
2040-
const leaves = [...collectEnKeys(repoRoot).leaves];
2081+
const leaves = [...packKeys.leaves];
20412082
const heldByAHead = leaves.filter((key) => result.appliedHeads.some((row) => key.startsWith(row.head)));
20422083
const candidates = new Set([...result.confirmed, ...result.needsReview.map((entry) => entry.key)]);
20432084
expect(heldByAHead.some((key) => candidates.has(key))).toBe(false);
@@ -2051,7 +2092,7 @@ export function Widget({ id }: { id: string }) {
20512092
// decision and this test is where it gets re-argued.
20522093
const rootOnly = result.appliedHeads.filter((row) => row.ownSegments < 2);
20532094
expect(rootOnly.length, 'no namespace-wide head on the tree — the pin has nothing to hold').toBeGreaterThan(0);
2054-
const leaves = [...collectEnKeys(repoRoot).leaves];
2095+
const leaves = [...packKeys.leaves];
20552096
const candidates = new Set([...result.confirmed, ...result.needsReview.map((entry) => entry.key)]);
20562097
for (const row of rootOnly) {
20572098
const under = leaves.filter((key) => key.startsWith(row.head));

0 commit comments

Comments
 (0)