Skip to content

Commit fc5e70e

Browse files
test(runtime): the action-owner-key tree pin walks packages/ once, reading only the .ts files it judges, so a loaded Test Core shard stops timing it out (#25319)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DD9wftyCasRGvePSJjV8owb)_ Fixes #25298 Clause-②: no Metadata-protocol: none The hourly full run `39853791549` (`main` `49185cdb8a`) went red in `Test Core (6/6)` on two packages. This PR makes the first one robust and records why the second cannot be named from this session. 1. **`@objectstack/runtime#test:repo`.** `src/action-owner-key-single-source.test.ts` took 86.24 s on the loaded shard against its 60 s scan budget (0.3 s locally). Half D now walks `packages/` once, reading only the `.ts` files it judges, instead of three full walks. `SCAN_TIMEOUT_MS` stays `60_000`, and what the scan proves does not narrow. 2. **`@objectstack/driver-sqlite-wasm#test`.** The failing test sits in the part of job `120086810803`'s log this session cannot read. What was tried is listed below. The red did not repeat: the next three scheduled full runs are green. Test-only; `skip-changeset` (the file is under `src/` and is not in `@objectstack/runtime`'s `files[]`, which ships `dist`). ## Item 1: what the scan cost, and what changed Half D asks three questions of the tree: which files name the dead member (test 1), whether the scan sees this pin (test 2) and whether it reaches the live helper outside this package (test 2). Each question was its own `git grep --untracked` over **every file** under `packages/`, filtered to `.ts` afterwards. So one run read the whole root three times. Now: - **One `git grep`** carries both hunted symbols (`-e actionObjectKey -e standaloneActionOwnerKey`). It runs once in the describe's `beforeAll`, which carries the unchanged `SCAN_TIMEOUT_MS`, and both tests read its answer. The candidate files (11 today) are read to attribute each symbol and line. If git lists a file and the read finds neither needle, the scan throws: a broken scan, never a clean file. - **The pathspec is the declared glob.** `SCANNED_PATHSPECS` derives `:(glob)packages/**/*.ts` from `SCANNED_ROOTS` and `SCANNED_EXTENSION`. That is the same glob the package declares in `scripts/cross-package-test-inputs.mjs`, so git reads only the files the scan judges. It no longer reads the other quarter of the bytes (the `CHANGELOG.md`s alone are 55 MB) just to throw them away. - **The reach control gets tighter.** Test 2 now checks the very scan whose emptiness test 1 reads, not a separate run of it. If pathspec magic were ever turned off (`GIT_LITERAL_PATHSPECS`), the scan would match nothing and test 2 would go red. ### Readings (local container, under `scripts/pm/os-verify-lock.sh`; base `21a25dd9`, head `0a77f624`) | reading | before | after | | :-- | --: | --: | | `git grep` invocations per file run | 3 | 1 | | bytes git reads per file run (`rchar` from `/proc/PID/io`, the test's own arguments) | 3 × 212.6 MB = 637.8 MB | 151.2 MB | | read syscalls per file run | 3 × 10,061 | 9,345 | | file wall, vitest JSON reporter, 3 runs each | 311 / 332 / 327 ms | 122 / 123 / 114 ms | | half D tests | 101–107 ms and 197–219 ms | 1 ms and 1 ms (the scan runs in `beforeAll`) | | `vitest run --project repo` (3 files) | 808 passed, 15.47 s | 808 passed, 17.07 s | The bytes read drop to a quarter (−76%). The local wall clock can't reproduce the shard's slowdown: the same scan takes 0.1 s here and about 29 s per full walk there (86.24 s across three walks). **Estimate, not measured on a shard:** if the shard's cost scales with bytes read, one `.ts` walk there is about 20 s, a third of the unchanged 60 s budget. ### The scanned set did not narrow: measured The old judged set was an unscoped `git grep` plus `.endsWith('.ts')`. The new one is the `:(glob)packages/**/*.ts` grep. The two were compared per symbol on this tree, with untracked plants added at `packages/zz-eq-25298/top.ts` (depth 1), `…/a/b/deep.d.ts` (depth 3), `…/a/b/note.md`, `…/a/b/x.tsx` and `…/a/b/y.mts`: | symbol | old set | new set | identical | | :-- | --: | --: | :-- | | `actionObjectKey` | 4 | 4 | yes | | `standaloneActionOwnerKey` | 11 | 11 | yes | Both sets keep the two `.ts` plants (`.d.ts` included) and both leave out the `.md`, `.tsx` and `.mts` plants. All plants were removed afterwards, and `git status` was clean. ### A planted occurrence in a still-scanned path goes red Predicted direction: red, naming the site. Each run was the pin file alone under the verify lock, after the fix was committed. | leg | mutation | result | | :-- | :-- | :-- | | tracked file | `scripts/ablation-replace.mjs` plants the dead name in a comment of `packages/objectql/src/action-governance.ts` (anchor 1 → 0, blob `1d91722f355a` → `d7eb77aaf2ee`) | 1 failed, 15 passed; the failure lists `packages/objectql/src/action-governance.ts:41`. Restored: blob equals HEAD (`1d91722f355a`), `git diff HEAD` empty | | untracked, three levels deep | `packages/plugins/plugin-auth/src/zz-ablation-25298/deep/planted.ts` naming the dead member | 1 failed, 15 passed; the failure lists `…/deep/planted.ts:1` | | control: same text, `.md` | the same directory, `planted.md` only | 16 passed, as on base (the base filtered to `.ts` too) | The plant directory was removed by a trap, and `git status` was clean afterwards. ## Item 2: `driver-sqlite-wasm`, the failing test not named **Not identified.** The turbo summary and the annotations name the task, not the test. What was tried: - **MCP `get_job_logs`** returns the last 4,999 of the log's 43,666 lines, from 21:20:45Z onward. That part is `@objectstack/runtime:test`'s output, then turbo's summary. `driver-sqlite-wasm`'s own output comes earlier. - **The job log's signed URL** (`productionresultssa2.blob.core.windows.net`, by the API's 302 or the MCP URL) is refused by this session's egress proxy: `CONNECT tunnel failed, response 403`. - **The run's log archive** (302 to `results-receiver.actions.githubusercontent.com`) is refused the same way. A WebFetch of the signed URL is refused as too long. - **The check-run annotations** (`GET /check-runs/120086810803/annotations`, answered 200) carry only turbo's two `exited (1)` lines and the exit-code line. No vitest annotation is there. - **The summary job's timing table** (job `120098319014`) lists only the slowest 20 files, and no `driver-sqlite-wasm` file is among them. The full merged table (artifact `test-core-timing-table`, ID `12460740336`, kept 14 days) and shard 6's capture (`test-core-timings-6-of-6`, kept 1 day) download from the same blocked blob host. **What the reachable part does say:** `check-test-completeness: OK (14 of 14 scheduled package(s) reported, 0 had nothing to run, 0 never reached; 33419 test(s) declared and all accounted for)`. So `driver-sqlite-wasm`'s vitest run reached its own summary: it was not a worker crash before the summary. Whether it was a failed assertion, a timeout or an unhandled error is in the unread part. **Still recoverable:** from a session whose egress allows `productionresultssa2.blob.core.windows.net`, or from the Actions web UI while signed in. The job log is kept about 90 days, and the merged timing artifact until about 2026-11-05. **Recurrence:** scheduled `CI` runs `39861781058` (22:01Z, `5801a4f2c9`), `39868952638` (23:00Z, `09e07c259a`) and `39875645832` (00:02Z, `258f4e6aaa`) all concluded `success`. ## Local verification (head `0a77f624`) - The dependency closure was built first, `pnpm --workspace-concurrency=2 --filter '@objectstack/runtime^...' build`: `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime exec vitest run --project repo`: `VERDICT command-exit 0`, 3 files, 808 passed. - `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit 0` (`tsc --noEmit`; `check:test-typecheck` OK, 27 files / 190 errors / 68 pinned signatures held). - `eslint --no-inline-config` on the changed file: exit 0. No control bytes. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 53 families; 52 exit 0. `check-plugin-teardown-shape --self-test` first exited 3 (its pinned fixture commit was outside the shallow clone) and exited 0 once that commit was fetched. `check:dual-build-cjs-loads` exits 3, PREREQUISITE NOT MET: it needs every package's `dist`, and this container built only runtime's closure. **NOT MEASURED locally**; its verdict is this PR's `Lint & Repo Gates` check. - `--ran` reconciliation: `53 derived famil(ies) accounted for — 52 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - `origin/main` has moved to `9dc1f42d` since the base. The only change it brought under `packages/runtime` or `packages/objectql` is a 13-line change to `src/security/resolve-execution-context.test.ts`, and on that tree the scan's sets read the same (the two pins; `lint.ts` and `action-governance.ts` reached), so no merge was made. ## Acceptance notes - **The `@objectstack/objectql` twin has the same three-walk shape.** `packages/objectql/src/action-owner-key-single-source.test.ts` (half D at about `:261` to `:383`) runs the same three unscoped walks under the same 60 s budget. It did not time out in this run, but it carries the same exposure. It is outside this card's file surface and is left untouched. The pins are kept as two copies on purpose, so the same edit there keeps them parallel. - **Base readings for the card:** no open PR touches the pin file. Of the 16 open PRs, read by title, only `#25318` changes `packages/runtime/src`; its file list (read) is `action-execution.ts` (which halves B and C read), a test and a changeset, and its added lines carry no bare object-less literal. The file's last change on `main` is `a186aea996` (2026-09-29), per GitHub's path history; `2354058` does not touch it. - **Branch name.** This PR's branch is `claude/project-thread-m55hn0`, the name the session was given, not `claude/issue-25298-…`. The Duplicate Fix Guard may say so. The claim (comment 6354110086) names it. --- _Generated by [Claude Code](https://claude.ai/code/session_01AQLt9RhDuqVWK3Zt4aAwsE)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AQLt9RhDuqVWK3Zt4aAwsE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a48ae8 commit fc5e70e

1 file changed

Lines changed: 104 additions & 22 deletions

File tree

‎packages/runtime/src/action-owner-key-single-source.test.ts‎

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { execFileSync } from 'node:child_process';
4545
import { readFileSync } from 'node:fs';
4646
import { dirname, join, resolve } from 'node:path';
4747
import { fileURLToPath } from 'node:url';
48-
import { describe, it, expect } from 'vitest';
48+
import { beforeAll, describe, it, expect } from 'vitest';
4949
import { GLOBAL_ACTION_OBJECT_KEY, standaloneActionOwnerKey } from '@objectstack/objectql';
5050
import {
5151
standaloneActionObjectName,
@@ -325,11 +325,34 @@ const SCANNED_ROOTS: readonly string[] = ['packages'];
325325
const SCANNED_EXTENSION = '.ts';
326326

327327
/**
328-
* Generous on purpose. The scan is one `git grep` and a handful of file reads,
328+
* The scanned roots as git pathspecs: every `SCANNED_EXTENSION` file at any
329+
* depth under each root, which is the very glob this package declares in
330+
* `scripts/cross-package-test-inputs.mjs`. Handed to git rather than applied to
331+
* its answer, so git reads only the files the scan judges. The set is the one
332+
* the `.endsWith('.ts')` filter on git's output used to keep, file for file, so
333+
* nothing the scan proves narrows; what changes is that the other quarter of
334+
* the bytes under `packages/` (the `CHANGELOG.md`s alone are 55 MB) is no
335+
* longer read just to be thrown away.
336+
*
337+
* The `glob` magic is spelled out so `GIT_GLOB_PATHSPECS` and
338+
* `GIT_NOGLOB_PATHSPECS` cannot change what it matches. A setting that turns
339+
* pathspec magic off altogether makes the scan match nothing, and the reach
340+
* control in half D reds on that.
341+
*/
342+
const SCANNED_PATHSPECS: readonly string[] = SCANNED_ROOTS.map(
343+
(root) => `:(glob)${root}/**/*${SCANNED_EXTENSION}`,
344+
);
345+
346+
/**
347+
* The budget of the one tree scan, which `beforeAll` runs (see `scanTree`).
348+
* Generous on purpose: the scan is one `git grep` and a handful of file reads,
329349
* so this is headroom against a merge-queue runner doing a full monorepo build
330-
* at the same time, not a budget. A pin that times out before its assertion runs
331-
* reports nothing, and reporting nothing is indistinguishable from finding
332-
* nothing.
350+
* at the same time. A pin that times out before its assertion runs reports
351+
* nothing, and reporting nothing is indistinguishable from finding nothing.
352+
*
353+
* ⛔ Not a dial. When a loaded runner pushes the scan toward this figure, cut
354+
* the bytes the scan reads; raising the figure only lets a slower scan hide
355+
* for longer.
333356
*/
334357
const SCAN_TIMEOUT_MS = 60_000;
335358

@@ -349,41 +372,102 @@ function git(args: string[]): string[] {
349372
return stdout.split('\0').filter((entry) => entry.length > 0);
350373
}
351374

375+
/** Every symbol half D asks about: the dead member, and the reach control's live spelling. */
376+
const HUNTED_SYMBOLS: readonly string[] = [DELETED_PLUGIN_MEMBER, CANONICAL_HELPER];
377+
378+
/** Per hunted symbol: every scanned file that mentions it, with the 1-based lines that do. */
379+
type TreeScan = ReadonlyMap<string, ReadonlyMap<string, readonly number[]>>;
380+
352381
/**
353-
* Every scanned file that so much as mentions `symbol`. Tracked files PLUS
354-
* untracked ones with ignored paths excluded (`--untracked`) — exactly the files
355-
* a human authored, never build output. A file written but not yet `git add`ed
356-
* still reddens, which keeps this a local-loop guard.
382+
* ONE walk of the scanned tree answers every question half D asks.
383+
*
384+
* Each question used to pay its own walk: three `git grep`s per run, each
385+
* reading every file under `packages/`, 3 × 212.6 MB. On a quiet machine that
386+
* is a fraction of a second; on the most loaded Test Core shard it cost
387+
* 86.24 s for this file against the 60 s budget. A tree scan costs the bytes it
388+
* reads, and the bytes are what a loaded runner slows, so the remedy is fewer
389+
* bytes: one `git grep` for every hunted symbol at once, over
390+
* `SCANNED_PATHSPECS`, is 151.2 MB, four times less (both measured with the
391+
* scan's own arguments at 21a25dd9). Reading it once also tightens the reach
392+
* control, which now proves the very scan whose emptiness the first test
393+
* reads, not a sibling run of it.
394+
*
395+
* Scans tracked files PLUS untracked ones with ignored paths excluded
396+
* (`--untracked`): exactly the files a human authored, never build output. A
397+
* file written but not yet `git add`ed still reddens, which keeps this a
398+
* local-loop guard.
357399
*/
358-
function filesMentioning(symbol: string): string[] {
359-
return git([
400+
function scanTree(): TreeScan {
401+
const candidates = git([
360402
'grep',
361403
'--files-with-matches',
362404
'-z',
363405
'--untracked',
364406
'--text',
365407
'--fixed-strings',
366-
'-e',
367-
symbol,
408+
...HUNTED_SYMBOLS.flatMap((symbol) => ['-e', symbol]),
368409
'--',
369-
...SCANNED_ROOTS,
370-
]).filter((file) => file.endsWith(SCANNED_EXTENSION));
410+
...SCANNED_PATHSPECS,
411+
]);
412+
const scan = new Map<string, Map<string, number[]>>();
413+
for (const symbol of HUNTED_SYMBOLS) scan.set(symbol, new Map());
414+
for (const file of candidates) {
415+
const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n');
416+
let attributed = false;
417+
for (const [symbol, files] of scan) {
418+
const hits = lines.flatMap((text, index) => (text.includes(symbol) ? [index + 1] : []));
419+
if (hits.length === 0) continue;
420+
files.set(file, hits);
421+
attributed = true;
422+
}
423+
// git matched this file, so a read that finds none of the needles
424+
// means the two disagree about its bytes: a broken scan, never a clean
425+
// file.
426+
if (!attributed) {
427+
throw new Error(
428+
`git grep listed ${file} as mentioning one of ${HUNTED_SYMBOLS.join(', ')}, `
429+
+ 'and reading it finds none of them.',
430+
);
431+
}
432+
}
433+
return scan;
434+
}
435+
436+
/** Filled by half D's `beforeAll`; read by both of its tests. */
437+
let treeScan: TreeScan | undefined;
438+
439+
function mentionsOf(symbol: string): ReadonlyMap<string, readonly number[]> {
440+
const mentions = treeScan?.get(symbol);
441+
if (!mentions) {
442+
throw new Error(
443+
treeScan
444+
? `${symbol} is not one of the symbols the tree scan hunts (HUNTED_SYMBOLS).`
445+
: 'Half D read the tree scan before its beforeAll ran it.',
446+
);
447+
}
448+
return mentions;
449+
}
450+
451+
/** Every scanned file that so much as mentions `symbol`. */
452+
function filesMentioning(symbol: string): string[] {
453+
return [...mentionsOf(symbol).keys()];
371454
}
372455

373456
/** `<file>:<line>` for every mention that no rule above excuses. */
374457
function staleMentionSites(symbol: string): string[] {
375458
const sites: string[] = [];
376-
for (const file of filesMentioning(symbol)) {
459+
for (const [file, lines] of mentionsOf(symbol)) {
377460
if (NOT_A_STALE_MENTION.some((rule) => rule.covers(file))) continue;
378-
const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n');
379-
lines.forEach((text, index) => {
380-
if (text.includes(symbol)) sites.push(`${file}:${index + 1}`);
381-
});
461+
for (const line of lines) sites.push(`${file}:${line}`);
382462
}
383463
return sites;
384464
}
385465

386466
describe('standalone-action owner key — half D: the deleted member is dead TREE-WIDE (#14878)', () => {
467+
beforeAll(() => {
468+
treeScan = scanTree();
469+
}, SCAN_TIMEOUT_MS);
470+
387471
it(
388472
'is named nowhere outside the release record and the two pins',
389473
() => {
@@ -413,7 +497,6 @@ describe('standalone-action owner key — half D: the deleted member is dead TRE
413497
].join('\n'),
414498
).toEqual([]);
415499
},
416-
SCAN_TIMEOUT_MS,
417500
);
418501

419502
it(
@@ -434,6 +517,5 @@ describe('standalone-action owner key — half D: the deleted member is dead TRE
434517
expect(reached).toContain('packages/cli/src/commands/lint.ts');
435518
expect(reached).toContain('packages/objectql/src/action-governance.ts');
436519
},
437-
SCAN_TIMEOUT_MS,
438520
);
439521
});

0 commit comments

Comments
 (0)