@opengsd/gsd-core 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -0,0 +1,964 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Reproduces the CI figures in tests/helpers/timeouts.cjs TIMEOUT_EVIDENCE (the entries sourced
6
+ * from CI_SOURCE / CI_WINDOW) from data that lives in the repo and in downloaded CI artifacts.
7
+ *
8
+ * READS
9
+ * --artifacts <dir> A directory of downloaded `test-timings-<os>-job<N>` artifacts laid out as
10
+ * <dir>/<runId>/<artifactName>/*.jsonl or <dir>/<runId>/*.jsonl (the lane's
11
+ * os is read from the artifact folder name, else the file name: ubuntu =
12
+ * linux, windows = win32, macos = darwin; an undeterminable os, or no lane
13
+ * file at all, exits 2). Each
14
+ * jsonl line of type `seam:spawn-stall` is one record (file, command, argv,
15
+ * durationMs, timeoutMs, startedAt); each `test:summary` line names a test
16
+ * file the lane ran.
17
+ * --repo <root> The checkout whose tests/**\/*.cjs are scanned (default: this script's
18
+ * repo root). tests/helpers/timeouts.cjs of that checkout supplies the
19
+ * constant names and values.
20
+ *
21
+ * DOES
22
+ * 1. Census: parses every tests/**\/*.cjs with espree and records each `timeout` / `timeoutMs`
23
+ * object property whose value references a timeouts.cjs export.
24
+ * 2. Classification: decides per call site whether the call is routed through
25
+ * tests/helpers/process-seam.cjs (SEAM, visible to the CI spawn log) or calls child_process
26
+ * directly (DIRECT, invisible). The callee binding is resolved in its own file and through
27
+ * relative requires; sites the resolver cannot resolve use MANUAL_RESOLUTIONS below.
28
+ * 3. Attribution: each seam:spawn-stall record is credited to a constant when the record's
29
+ * test file (or a helper it requires, only if the file has no in-file candidate) has a seam
30
+ * site of a constant whose value equals the record's timeoutMs and whose callee is
31
+ * compatible with the record's command. A site reached only through a helper must also show
32
+ * one of its own string literals (3+ characters, not a flag) in the record's argv when it
33
+ * has any; records failing either test are unmatched. Remaining ties are narrowed by argv
34
+ * literals; a record still matching two constants is ambiguous and credited to neither.
35
+ * 4. Summary per constant: p50 / p99 / max / samples per platform, worst platform, lanes that
36
+ * ran the constant's seam sites, seam and raw site counts, ambiguous record count.
37
+ *
38
+ * PRINTS (stdout only; no file is written)
39
+ * --format json (default) the full evidence object
40
+ * --format md the same figures as markdown tables
41
+ * --raw-sites the non-seam call sites grouped by constant, one
42
+ * `- [ ] tests/x.test.cjs:LINE (CONSTANT)` line per site
43
+ *
44
+ * DOWNLOADING IS A SEPARATE STEP. This script never talks to GitHub. Fetch the artifacts first,
45
+ * for example: list successful test.yml runs of the push and merge_group events created after the
46
+ * window start (`gh run list --workflow test.yml --event push --status success --limit 400
47
+ * --json databaseId,createdAt`), then per run id
48
+ * `gh run download <id> --pattern 'test-timings-*' --dir <dir>/<id>`; with several matching artifacts gh creates one
49
+ * `<dir>/<id>/<artifactName>/` folder per artifact, with a single match it extracts straight into
50
+ * `<dir>/<id>/`; both layouts are read here. Expired
51
+ * artifacts cannot be downloaded and are skipped.
52
+ *
53
+ * Exported functions are pure (filesystem access goes through a `view` object) so tests can feed
54
+ * synthetic inputs.
55
+ */
56
+
57
+ const fs = require('fs');
58
+ const path = require('path');
59
+ const espree = require('espree');
60
+
61
+ const PLATFORMS = ['win32', 'darwin', 'linux'];
62
+ const SPAWN_CALLS = new Set(['execFileSync', 'spawnSync', 'execSync', 'spawn', 'execFile', 'exec']);
63
+ const TIMEOUTS_REL = 'tests/helpers/timeouts.cjs';
64
+
65
+ // Constants whose evidence comes from the local harness, not from CI records.
66
+ const HARNESS_ONLY = new Set(['SCAN_USAGE_ERROR_TIMEOUT_MS', 'MALFORMED_INPUT_HOOK_TIMEOUT_MS', 'REAL_REPO_GIT_TIMEOUT_MS']);
67
+
68
+ /**
69
+ * CI records carry the timeoutMs the tested commit used, not the value at the checkout being
70
+ * measured. A constant whose value changed after the CI window lists the values it had during the
71
+ * window here, so those records still reach it (and are narrowed against the other constants that
72
+ * share the value by command and argv, exactly as when the values are equal).
73
+ * LOOP_HOOK_POINT_CLI_TIMEOUT_MS was 60000 in the window; runGsdTools (tests/helpers.cjs) ran at
74
+ * the process seam's 60000 fallback then and is bound to this constant (now 80000) at HEAD.
75
+ */
76
+ const HISTORICAL_VALUES = { LOOP_HOOK_POINT_CLI_TIMEOUT_MS: [60000] };
77
+
78
+ const EXCLUSION_KINDS = ['UNDER_TEST', 'EXEMPT'];
79
+ const GIT_OR_THROW = 'tests/helpers/git-fixture.cjs gitOrThrow forwards options.timeoutMs to runGit (process seam)';
80
+
81
+ /**
82
+ * Sites the callee resolver cannot classify, resolved by reading the source. An entry applies to
83
+ * every census site in `file` whose census callee text equals `callee` (a bare callee name, or
84
+ * `(not-direct-arg)` plus ` var=<name>` when the options object is held in a variable). The
85
+ * resolution replaces the AST result for those sites. Sites left unresolved and entries that
86
+ * match nothing are reported in the output (`unresolvedSites`, `unusedResolutions`).
87
+ *
88
+ * A resolution carries either `cls` (SEAM | DIRECT, with `resolvedCallee` and `note`) or `kind`:
89
+ * UNDER_TEST a call into production code whose own spawning is the subject of the test;
90
+ * EXEMPT a direct spawn kept deliberately.
91
+ * A `kind` resolution REQUIRES a non-empty `reason` (validateResolutions throws without one). Its
92
+ * sites are counted neither as seam nor as raw/unseen and are reported in `excludedSites` with kind
93
+ * and reason. An optional `optionKey` narrows a resolution to the sites whose options object has
94
+ * that property (e.g. `stdio`), so one deliberate site does not swallow its file's other sites.
95
+ */
96
+ const MANUAL_RESOLUTIONS = [
97
+ { file: 'tests/commit-docs-bypass.test.cjs', callee: 'gitOrThrow', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: GIT_OR_THROW },
98
+ { file: 'tests/gsd-statusline.test.cjs', callee: 'gitOrThrow', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: GIT_OR_THROW },
99
+ { file: 'tests/worktree-cleanup.test.cjs', callee: 'gitOrThrow', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: GIT_OR_THROW },
100
+ { file: 'tests/fixtures/index.cjs', callee: '(not-direct-arg) var=gitOpts', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: 'gitOpts variable is passed to gitOrThrow' },
101
+ { file: 'tests/gsd-agent-isolation-guard.test.cjs', callee: '(not-direct-arg) var=gitOpts', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: 'gitOpts variable is passed to gitOrThrow' },
102
+ { file: 'tests/state.test.cjs', callee: '(not-direct-arg) var=gitOpts', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: 'gitOpts variable is passed to gitOrThrow' },
103
+ { file: 'tests/verification-status.test.cjs', callee: '(not-direct-arg) var=gitOpts', cls: 'SEAM', resolvedCallee: 'gitOrThrow', note: 'gitOpts variable is passed to gitOrThrow' },
104
+ { file: 'tests/hooks-crash-policy.test.cjs', callee: '(not-direct-arg)', cls: 'SEAM', resolvedCallee: 'runHookSeam', note: 'default parameter of runHook, forwarded as opts.timeoutMs to runHookSeam' },
105
+ { file: 'tests/lint-compiled-artifact-sync.test.cjs', callee: '(not-direct-arg) var=options', cls: 'SEAM', resolvedCallee: 'runGit|runNode', note: 'run() helper forwards options to runGit / runNode' },
106
+ { file: 'tests/install-regressions.test.cjs', callee: '(not-direct-arg) var=runOpts', cls: 'SEAM', resolvedCallee: 'runNode', note: 'runOpts is passed to runNode' },
107
+ { file: 'tests/install-regressions.test.cjs', callee: '(not-direct-arg)', cls: 'SEAM', resolvedCallee: 'runNode', note: 'nested options object is passed to runNode' },
108
+ { file: 'tests/helpers/opencode-real.cjs', callee: '(not-direct-arg)', cls: 'DIRECT', resolvedCallee: 'spawn', note: 'realRun default timeoutMs is passed to child_process spawn as {timeout}' },
109
+ { file: 'tests/loop-walk.qa.test.cjs', callee: 'execFileAsync', cls: 'DIRECT', resolvedCallee: 'execFileAsync', note: 'execFileAsync = promisify(execFile) from child_process' },
110
+ { file: 'tests/review-lane-availability.test.cjs', callee: 'run', cls: 'DIRECT', resolvedCallee: 'run', note: 'run = promisify(execFile) from child_process' },
111
+ { file: 'tests/review-lane-no-redirect-5433.test.cjs', callee: 'run', cls: 'DIRECT', resolvedCallee: 'run', note: 'run = promisify(execFile) from child_process' },
112
+ { file: 'tests/criterion-replay.test.cjs', callee: 'projection.execArgvQuarantined', kind: 'UNDER_TEST', reason: 'the test asserts execArgvQuarantined\'s own refused/not_found/timed_out/overflow outcomes, so the call is the production spawner under test' },
113
+ { file: 'tests/agent-skills.test.cjs', callee: 'spawnSync', optionKey: 'stdio', kind: 'EXEMPT', reason: 'passes stdio [\'ignore\', fd, \'pipe\'] to reproduce the exit-before-flush truncation bug; the process seam does not forward stdio and a pipe would defeat the test' },
114
+ ];
115
+
116
+ // ---------------------------------------------------------------------------------------------
117
+ // Constants taken from timeouts.cjs
118
+
119
+ /**
120
+ * Splits timeouts.cjs exports into the census set (every export that can bound a spawn or
121
+ * a test: kinds measured, mirror, test-backstop) and the evidence set (measured and mirror).
122
+ * Fixture-data exports are not timeouts and are excluded from both.
123
+ */
124
+ function constantsFromTimeouts(T) {
125
+ const ev = T.TIMEOUT_EVIDENCE || {};
126
+ const values = {};
127
+ const censusNames = [];
128
+ const evidenceNames = [];
129
+ for (const [name, entry] of Object.entries(ev)) {
130
+ if (entry.kind === 'fixture-data') continue;
131
+ values[name] = T[name];
132
+ censusNames.push(name);
133
+ if (entry.kind === 'measured' || entry.kind === 'mirror') evidenceNames.push(name);
134
+ }
135
+ return { values, censusNames, evidenceNames };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------------------------
139
+ // Census
140
+
141
+ function parseSource(src) {
142
+ const base = { ecmaVersion: 'latest', loc: true };
143
+ try { return espree.parse(src, { ...base, sourceType: 'commonjs' }); } catch { return espree.parse(src, { ...base, sourceType: 'module' }); }
144
+ }
145
+
146
+ function keyName(p) {
147
+ if (p.computed) return null;
148
+ if (p.key.type === 'Identifier') return p.key.name;
149
+ if (p.key.type === 'Literal') return String(p.key.value);
150
+ return null;
151
+ }
152
+
153
+ function calleeText(c) {
154
+ if (!c) return null;
155
+ if (c.type === 'Identifier') return c.name;
156
+ if (c.type === 'MemberExpression') {
157
+ const o = c.object.type === 'Identifier' ? c.object.name : c.object.type === 'ThisExpression' ? 'this' : (calleeText(c.object) || '?');
158
+ const pr = c.computed ? '[computed]' : (c.property.name || String(c.property.value));
159
+ return o + '.' + pr;
160
+ }
161
+ if (c.type === 'CallExpression') return calleeText(c.callee) + '()';
162
+ return c.type;
163
+ }
164
+
165
+ function childNodes(node) {
166
+ const out = [];
167
+ for (const k of Object.keys(node)) {
168
+ if (k === 'loc' || k === 'range') continue;
169
+ const v = node[k];
170
+ if (Array.isArray(v)) { for (const x of v) if (x && typeof x.type === 'string') out.push(x); } else if (v && typeof v.type === 'string') out.push(v);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ function refsIn(node, names, found) {
176
+ if (!node || typeof node.type !== 'string') return;
177
+ if (node.type === 'Identifier' && names.has(node.name)) found.push(node.name);
178
+ if (node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier' && names.has(node.property.name)) {
179
+ found.push(node.property.name);
180
+ refsIn(node.object, names, found);
181
+ return;
182
+ }
183
+ for (const c of childNodes(node)) refsIn(c, names, found);
184
+ }
185
+
186
+ /**
187
+ * Census of one source string: every `timeout` / `timeoutMs` property whose value references one
188
+ * of `names`, and every file-local redefinition of one of `names`.
189
+ * @returns {{propRows: object[], localRedefs: object[], parseError: string|null}}
190
+ */
191
+ function censusSource(src, rel, names) {
192
+ const nameSet = names instanceof Set ? names : new Set(names);
193
+ let ast;
194
+ try { ast = parseSource(src); } catch (e) { return { propRows: [], localRedefs: [], parseError: e.message }; }
195
+ const propRows = [];
196
+ const localRedefs = [];
197
+ const isRequire = (init) => init && ((init.type === 'CallExpression' && init.callee.type === 'Identifier' && init.callee.name === 'require') ||
198
+ (init.type === 'MemberExpression' && init.object.type === 'CallExpression' && init.object.callee.type === 'Identifier' && init.object.callee.name === 'require'));
199
+ function walk(node, anc) {
200
+ if (node.type === 'Property') {
201
+ const kn = keyName(node);
202
+ if (kn === 'timeout' || kn === 'timeoutMs') {
203
+ const found = [];
204
+ refsIn(node.value, nameSet, found);
205
+ if (found.length) {
206
+ const obj = anc[anc.length - 1];
207
+ const holder = anc[anc.length - 2];
208
+ let callee;
209
+ if (holder && (holder.type === 'CallExpression' || holder.type === 'NewExpression') && holder.arguments.includes(obj)) {
210
+ callee = calleeText(holder.callee);
211
+ } else {
212
+ callee = '(not-direct-arg)';
213
+ if (holder && holder.type === 'VariableDeclarator' && holder.id.type === 'Identifier') callee += ' var=' + holder.id.name;
214
+ }
215
+ const optionKeys = obj && obj.type === 'ObjectExpression' ? obj.properties.map((p) => (p.type === 'Property' ? keyName(p) : null)).filter(Boolean) : [];
216
+ propRows.push({ file: rel, line: node.loc.start.line, constants: [...new Set(found)], callee, optionKeys, valueIsBare: node.value.type === 'Identifier' && nameSet.has(node.value.name) });
217
+ }
218
+ }
219
+ }
220
+ if (node.type === 'VariableDeclarator') {
221
+ const check = (nameNode, line) => {
222
+ if (nameNode && nameNode.type === 'Identifier' && nameSet.has(nameNode.name) && !isRequire(node.init)) {
223
+ localRedefs.push({ file: rel, line, name: nameNode.name, initType: node.init ? node.init.type : null });
224
+ }
225
+ };
226
+ if (node.id.type === 'Identifier') check(node.id, node.loc.start.line);
227
+ else if (node.id.type === 'ObjectPattern') {
228
+ for (const p of node.id.properties) if (p.type === 'Property') check(p.value.type === 'AssignmentPattern' ? p.value.left : p.value, p.loc.start.line);
229
+ }
230
+ }
231
+ anc.push(node);
232
+ for (const c of childNodes(node)) walk(c, anc);
233
+ anc.pop();
234
+ }
235
+ walk(ast, []);
236
+ return { propRows, localRedefs, parseError: null };
237
+ }
238
+
239
+ /** Census of every file the view lists under tests/ (timeouts.cjs itself excluded). */
240
+ function censusRepo(view, names) {
241
+ const nameSet = new Set(names);
242
+ const files = view.listFiles().filter((f) => f !== TIMEOUTS_REL).sort();
243
+ const propRows = [];
244
+ const localRedefs = [];
245
+ const parseFailures = [];
246
+ for (const rel of files) {
247
+ const r = censusSource(view.read(rel) || '', rel, nameSet);
248
+ if (r.parseError) { parseFailures.push({ file: rel, error: r.parseError }); continue; }
249
+ propRows.push(...r.propRows);
250
+ localRedefs.push(...r.localRedefs);
251
+ }
252
+ return { fileCount: files.length, parseFailures, propRows, localRedefs };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------------------------
256
+ // Seam vs raw classification
257
+
258
+ function requiredModule(x) {
259
+ if (!x) return null;
260
+ if (x.type === 'CallExpression' && x.callee.type === 'Identifier' && x.callee.name === 'require' && x.arguments[0] && x.arguments[0].type === 'Literal') return String(x.arguments[0].value);
261
+ if (x.type === 'MemberExpression') return requiredModule(x.object);
262
+ return null;
263
+ }
264
+
265
+ function resolveRelative(fromRel, mod) {
266
+ if (!mod.startsWith('.')) return { builtin: mod };
267
+ let p = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), mod));
268
+ if (!/\.(c?js|json)$/.test(p)) p += '.cjs';
269
+ return { rel: p };
270
+ }
271
+
272
+ function modKind(mod) {
273
+ if (mod === 'child_process' || mod === 'node:child_process') return 'cp';
274
+ if (/process-seam(\.cjs)?$/.test(mod)) return 'seam';
275
+ return null;
276
+ }
277
+
278
+ function collectBindings(ast) {
279
+ const bindings = {};
280
+ const add = (name, b) => { (bindings[name] = bindings[name] || []).push(b); };
281
+ (function collect(n) {
282
+ if (n.type === 'FunctionDeclaration' && n.id) add(n.id.name, { kind: 'fn', node: n, line: n.loc.start.line });
283
+ if (n.type === 'VariableDeclarator') {
284
+ const init = n.init;
285
+ if (n.id.type === 'Identifier') {
286
+ if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) add(n.id.name, { kind: 'fn', node: init, line: n.loc.start.line });
287
+ else {
288
+ const r = requiredModule(init);
289
+ if (r) {
290
+ const prop = init.type === 'MemberExpression' && !init.computed ? init.property.name : null;
291
+ add(n.id.name, { kind: 'req', mod: r, prop, line: n.loc.start.line });
292
+ } else if (init && init.type === 'MemberExpression' && init.object.type === 'Identifier') {
293
+ add(n.id.name, { kind: 'alias', obj: init.object.name, prop: init.property.name, line: n.loc.start.line });
294
+ } else if (init && init.type === 'Identifier') {
295
+ add(n.id.name, { kind: 'idalias', to: init.name, line: n.loc.start.line });
296
+ }
297
+ }
298
+ } else if (n.id.type === 'ObjectPattern') {
299
+ const r = requiredModule(init);
300
+ for (const p of n.id.properties) {
301
+ if (p.type !== 'Property' || p.key.type !== 'Identifier') continue;
302
+ const local = p.value.type === 'AssignmentPattern' ? p.value.left : p.value;
303
+ if (local.type !== 'Identifier') continue;
304
+ if (r) add(local.name, { kind: 'req', mod: r, prop: p.key.name, line: p.loc.start.line });
305
+ else if (init && init.type === 'Identifier') add(local.name, { kind: 'alias', obj: init.name, prop: p.key.name, line: p.loc.start.line });
306
+ }
307
+ }
308
+ }
309
+ for (const c of childNodes(n)) collect(c);
310
+ })(ast);
311
+ return bindings;
312
+ }
313
+
314
+ function bodyCalls(fnNode) {
315
+ const calls = [];
316
+ (function w(n) {
317
+ if (n.type === 'CallExpression') calls.push(n);
318
+ for (const c of childNodes(n)) w(c);
319
+ })(fnNode.body);
320
+ return calls;
321
+ }
322
+
323
+ /**
324
+ * Builds a classifier for callee names over `view`. classify(row) returns
325
+ * {cls: SEAM|DIRECT|MIXED|NONE|UNKNOWN|UNCLASSIFIED, ev}.
326
+ */
327
+ function createClassifier(view) {
328
+ const cache = {};
329
+ function load(rel) {
330
+ if (cache[rel]) return cache[rel];
331
+ const src = view.read(rel);
332
+ if (src === null) throw new Error('cannot read ' + rel);
333
+ const ast = parseSource(src);
334
+ return (cache[rel] = { rel, ast, bindings: collectBindings(ast) });
335
+ }
336
+ function classifyName(rel, name, depth, seen) {
337
+ if (depth > 4) return { cls: 'UNKNOWN', ev: 'depth' };
338
+ const key = rel + '::' + name;
339
+ if (seen.has(key)) return { cls: 'UNKNOWN', ev: 'cycle' };
340
+ seen.add(key);
341
+ const f = load(rel);
342
+ const bs = f.bindings[name];
343
+ if (!bs) {
344
+ if (SPAWN_CALLS.has(name)) return { cls: 'DIRECT', ev: `${rel}: bare ${name} (no binding; assumed child_process global)` };
345
+ return { cls: 'UNKNOWN', ev: `${rel}: no binding for ${name}` };
346
+ }
347
+ const results = [];
348
+ for (const b of bs) {
349
+ if (b.kind === 'req') {
350
+ const mk = modKind(b.mod);
351
+ if (mk === 'cp') results.push({ cls: 'DIRECT', ev: `${rel}:${b.line} ${name} from require('${b.mod}')` });
352
+ else if (mk === 'seam') results.push({ cls: 'SEAM', ev: `${rel}:${b.line} ${name} from require('${b.mod}')` });
353
+ else {
354
+ const r = resolveRelative(rel, b.mod);
355
+ if (r.rel && b.prop && view.read(r.rel) !== null) {
356
+ const sub = classifyName(r.rel, b.prop, depth + 1, seen);
357
+ results.push({ cls: sub.cls, ev: `${rel}:${b.line} ${name}<-${b.mod}; ${sub.ev}` });
358
+ } else results.push({ cls: 'UNKNOWN', ev: `${rel}:${b.line} ${name} from ${b.mod}` });
359
+ }
360
+ } else if (b.kind === 'alias') {
361
+ const sub = classifyMember(rel, b.obj, b.prop, depth + 1, seen);
362
+ results.push({ cls: sub.cls, ev: `${rel}:${b.line} ${name}=${b.obj}.${b.prop}; ${sub.ev}` });
363
+ } else if (b.kind === 'idalias') {
364
+ const sub = classifyName(rel, b.to, depth + 1, seen);
365
+ results.push({ cls: sub.cls, ev: `${rel}:${b.line} ${name}=${b.to}; ${sub.ev}` });
366
+ } else if (b.kind === 'fn') {
367
+ const subs = [];
368
+ for (const c of bodyCalls(b.node)) {
369
+ let r = null;
370
+ if (c.callee.type === 'Identifier' && c.callee.name !== name) r = classifyName(rel, c.callee.name, depth + 1, new Set(seen));
371
+ else if (c.callee.type === 'MemberExpression' && c.callee.object.type === 'Identifier' && !c.callee.computed) r = classifyMember(rel, c.callee.object.name, c.callee.property.name, depth + 1, new Set(seen));
372
+ else if (c.callee.type === 'MemberExpression' && c.callee.object.type === 'CallExpression' && !c.callee.computed) r = classifyMember(rel, null, c.callee.property.name, depth + 1, new Set(seen), c.callee.object);
373
+ if (r && (r.cls === 'SEAM' || r.cls === 'DIRECT')) subs.push({ ...r, line: c.loc.start.line });
374
+ }
375
+ if (!subs.length) results.push({ cls: 'NONE', ev: `${rel}:${b.line} fn ${name} body has no seam/child_process call` });
376
+ else {
377
+ const set = new Set(subs.map((s) => s.cls));
378
+ const cls = set.size > 1 ? 'MIXED' : subs[0].cls;
379
+ results.push({ cls, ev: `${rel}:${b.line} fn ${name} -> ${subs.map((s) => s.cls + '@' + s.line + ' ' + s.ev).join(' | ').slice(0, 300)}` });
380
+ }
381
+ }
382
+ }
383
+ const set = new Set(results.map((r) => r.cls));
384
+ return { cls: set.size === 1 ? results[0].cls : 'MIXED', ev: results.map((r) => r.ev).join(' || ') };
385
+ }
386
+ function classifyMember(rel, objName, prop, depth, seen, objNode) {
387
+ if (SPAWN_CALLS.has(prop) && objName && /^(cp|childProcess|child_process|cpMod)$/.test(objName)) return { cls: 'DIRECT', ev: `${objName}.${prop}` };
388
+ if (objNode) {
389
+ if (objNode.type === 'CallExpression' && objNode.callee.type === 'Identifier' && objNode.callee.name === 'require' && objNode.arguments[0]) {
390
+ const mod = String(objNode.arguments[0].value);
391
+ const mk = modKind(mod);
392
+ if (mk === 'cp') return { cls: 'DIRECT', ev: `require('${mod}').${prop}` };
393
+ if (mk === 'seam') return { cls: 'SEAM', ev: `require('${mod}').${prop}` };
394
+ }
395
+ return { cls: 'UNKNOWN', ev: `call-obj.${prop}` };
396
+ }
397
+ const f = load(rel);
398
+ for (const b of f.bindings[objName] || []) {
399
+ if (b.kind === 'req' && !b.prop) {
400
+ const mk = modKind(b.mod);
401
+ if (mk === 'cp') return { cls: 'DIRECT', ev: `${rel}:${b.line} ${objName}=require('${b.mod}').${prop}` };
402
+ if (mk === 'seam') return { cls: 'SEAM', ev: `${rel}:${b.line} ${objName}=require('${b.mod}').${prop}` };
403
+ const r = resolveRelative(rel, b.mod);
404
+ if (r.rel && view.read(r.rel) !== null) return classifyName(r.rel, prop, depth + 1, seen);
405
+ }
406
+ }
407
+ if (SPAWN_CALLS.has(prop)) return { cls: 'DIRECT', ev: `${objName}.${prop} (unbound object, child_process method name)` };
408
+ return { cls: 'UNKNOWN', ev: `${objName}.${prop} unresolved` };
409
+ }
410
+ function classify(row) {
411
+ const c = row.callee;
412
+ if (c === 'test') return { cls: 'NONE', ev: `${row.file}: test(name,{timeout}) node:test runner option` };
413
+ if (c.startsWith('(not-direct-arg)')) return { cls: 'UNCLASSIFIED', ev: 'options object not a direct call argument' };
414
+ if (c.endsWith('()')) return { cls: 'UNKNOWN', ev: 'call-chain callee' };
415
+ if (/^require\(\)\./.test(c)) {
416
+ const prop = c.split('.')[1];
417
+ let found = null;
418
+ (function w(n) {
419
+ if (found) return;
420
+ if (n.type === 'CallExpression' && n.callee.type === 'MemberExpression' && n.callee.object.type === 'CallExpression' && n.callee.object.callee.name === 'require' && n.callee.property.name === prop && n.loc.start.line <= row.line && n.loc.end.line >= row.line) found = n;
421
+ for (const ch of childNodes(n)) w(ch);
422
+ })(load(row.file).ast);
423
+ if (found) return classifyMember(row.file, null, prop, 0, new Set(), found.callee.object);
424
+ return { cls: 'UNKNOWN', ev: 'require() inline callee not located' };
425
+ }
426
+ if (c.includes('.')) {
427
+ const parts = c.split('.');
428
+ const p = parts.pop();
429
+ return classifyMember(row.file, parts.join('.'), p, 0, new Set());
430
+ }
431
+ return classifyName(row.file, c, 0, new Set());
432
+ }
433
+ return { classify };
434
+ }
435
+
436
+ const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0;
437
+
438
+ /**
439
+ * Throws, naming the entry by file and callee, when a resolution is not exactly one of
440
+ * `{kind, reason}` (exclusion) or `{cls, resolvedCallee, note}` (SEAM | DIRECT classification).
441
+ */
442
+ function validateResolutions(resolutions) {
443
+ for (const o of resolutions) {
444
+ const id = `resolution ${o.file} ${o.callee}`;
445
+ const hasKind = o.kind !== undefined;
446
+ const hasCls = o.cls !== undefined;
447
+ if (hasKind && hasCls) throw new Error(`${id}: sets both kind and cls`);
448
+ if (!hasKind && !hasCls) throw new Error(`${id}: sets neither kind nor cls`);
449
+ if (hasKind) {
450
+ if (!EXCLUSION_KINDS.includes(o.kind)) throw new Error(`${id}: unknown kind ${JSON.stringify(o.kind)}`);
451
+ if (!isNonEmptyString(o.reason)) throw new Error(`${id}: kind ${o.kind} requires a non-empty reason`);
452
+ } else {
453
+ if (o.cls !== 'SEAM' && o.cls !== 'DIRECT') throw new Error(`${id}: unknown cls ${JSON.stringify(o.cls)}`);
454
+ if (!isNonEmptyString(o.resolvedCallee)) throw new Error(`${id}: cls ${o.cls} requires a non-empty resolvedCallee`);
455
+ if (!isNonEmptyString(o.note)) throw new Error(`${id}: cls ${o.cls} requires a non-empty note`);
456
+ }
457
+ }
458
+ }
459
+
460
+ /** One matched resolution as `{ cls, callee, via, exclusion }`; `exclusion` is `{kind, reason}` or null. */
461
+ function normalizeResolution(o) {
462
+ if (o.kind) return { cls: o.kind, callee: o.resolvedCallee, via: 'manual:' + o.reason, exclusion: { kind: o.kind, reason: o.reason } };
463
+ return { cls: o.cls, callee: o.resolvedCallee, via: 'manual:' + o.note, exclusion: null };
464
+ }
465
+
466
+ /**
467
+ * Turns census rows into call sites: classify once per (file, callee text), apply manual
468
+ * resolutions, drop sites of file-local redefinitions and node:test runner options.
469
+ */
470
+ function buildSites({ propRows, localRedefs }, classifier, resolutions = MANUAL_RESOLUTIONS) {
471
+ validateResolutions(resolutions);
472
+ const excluded = [];
473
+ const classmap = new Map();
474
+ const used = new Set();
475
+ const sites = [];
476
+ const dropped = [];
477
+ for (const r of propRows) {
478
+ const key = r.file + '\t' + r.callee;
479
+ if (!classmap.has(key)) classmap.set(key, classifier.classify(r));
480
+ const m = classmap.get(key);
481
+ const ovIdx = resolutions.findIndex((o) => o.file === r.file && o.callee === r.callee && (!o.optionKey || (r.optionKeys || []).includes(o.optionKey)));
482
+ if (ovIdx >= 0) used.add(ovIdx);
483
+ const ov = ovIdx >= 0 ? normalizeResolution(resolutions[ovIdx]) : null;
484
+ const cls = ov ? ov.cls : m.cls;
485
+ const base = { file: r.file, line: r.line, constants: r.constants, callee: ov ? (ov.callee ?? r.callee) : r.callee, cls, via: ov ? ov.via : 'ast' };
486
+ const redef = localRedefs.filter((x) => x.file === r.file && r.constants.includes(x.name));
487
+ if (redef.length) { dropped.push({ ...base, why: 'file-local redefinition at line ' + redef[0].line }); continue; }
488
+ if (ov && ov.exclusion) { excluded.push({ file: r.file, line: r.line, constants: r.constants, callee: r.callee, kind: ov.exclusion.kind, reason: ov.exclusion.reason }); continue; }
489
+ if (cls === 'NONE') { dropped.push({ ...base, why: 'node:test runner option, not a spawn' }); continue; }
490
+ sites.push(base);
491
+ }
492
+ return {
493
+ sites,
494
+ dropped,
495
+ excludedSites: excluded,
496
+ unresolvedSites: sites.filter((s) => s.cls !== 'SEAM' && s.cls !== 'DIRECT'),
497
+ unusedResolutions: resolutions.filter((_, i) => !used.has(i)),
498
+ };
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------------------------
502
+ // Attribution
503
+
504
+ const baseName = (s) => String(s).split(/[\\/]/).pop();
505
+ const osOfArtifact = (name) => (/ubuntu/.test(name) ? 'linux' : /windows/.test(name) ? 'win32' : /macos/.test(name) ? 'darwin' : 'other');
506
+
507
+ /** Parses one lane jsonl text into its stall records and the test files it ran; `skippedLines` counts lines that name a record type but are not valid JSON. */
508
+ function parseLane(text, meta) {
509
+ const lane = { os: meta.os, runId: meta.runId, art: meta.art, stalls: [], files: new Set(), skippedLines: 0, startedMin: null, startedMax: null };
510
+ for (const line of text.split('\n')) {
511
+ if (!line) continue;
512
+ let j;
513
+ if (line.includes('seam:spawn-stall')) {
514
+ try { j = JSON.parse(line); } catch { lane.skippedLines++; continue; }
515
+ if (j.type === 'seam:spawn-stall') {
516
+ lane.stalls.push(j);
517
+ if (j.startedAt) {
518
+ if (!lane.startedMin || j.startedAt < lane.startedMin) lane.startedMin = j.startedAt;
519
+ if (!lane.startedMax || j.startedAt > lane.startedMax) lane.startedMax = j.startedAt;
520
+ }
521
+ }
522
+ } else if (line.includes('test:summary')) {
523
+ try { j = JSON.parse(line); } catch { lane.skippedLines++; continue; }
524
+ if (j.type === 'test:summary' && j.data && j.data.file) lane.files.add(baseName(j.data.file));
525
+ }
526
+ }
527
+ return lane;
528
+ }
529
+
530
+ /** Percentile of an ascending-sorted list (nearest rank); null for an empty list. */
531
+ function percentile(sorted, p) {
532
+ return sorted.length ? sorted[Math.max(0, Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1))] : null;
533
+ }
534
+
535
+ /** {records, p50, p99, max} of a list of durations in any order. */
536
+ function summarizeDurations(durations) {
537
+ const d = durations.slice().sort((a, b) => a - b);
538
+ return { records: d.length, p50: percentile(d, 50), p99: percentile(d, 99), max: d.length ? d[d.length - 1] : null };
539
+ }
540
+
541
+ /** Lazy per-file AST access, relative-require closures and site shapes over `view`. */
542
+ function createRepoIndex(view) {
543
+ const astCache = new Map();
544
+ function fileAst(rel) {
545
+ if (!astCache.has(rel)) {
546
+ const ast = parseSource(view.read(rel));
547
+ const consts = {};
548
+ (function w(n) {
549
+ if (n.type === 'VariableDeclarator' && n.id.type === 'Identifier' && n.init) consts[n.id.name] = n.init;
550
+ for (const c of childNodes(n)) w(c);
551
+ })(ast);
552
+ astCache.set(rel, { ast, consts });
553
+ }
554
+ return astCache.get(rel);
555
+ }
556
+ function literalsOf(node, consts, depth, out) {
557
+ if (!node || typeof node.type !== 'string' || depth > 3) return;
558
+ if (node.type === 'Literal' && typeof node.value === 'string') out.add(node.value);
559
+ else if (node.type === 'TemplateLiteral') node.quasis.forEach((q) => q.value.cooked && out.add(q.value.cooked));
560
+ else if (node.type === 'Identifier' && consts[node.name]) literalsOf(consts[node.name], consts, depth + 1, out);
561
+ else if (node.type === 'ObjectExpression') return;
562
+ else for (const c of childNodes(node)) literalsOf(c, consts, depth + 1, out);
563
+ }
564
+ const calleeName = (c) => (c.type === 'Identifier' ? c.name : c.type === 'MemberExpression' && !c.computed ? c.property.name : '');
565
+ const shapeCache = new Map();
566
+ /** The innermost call around a site: its callee name and the string literals of its arguments. */
567
+ function siteShape(s) {
568
+ const key = s.file + ':' + s.line;
569
+ if (shapeCache.has(key)) return shapeCache.get(key);
570
+ const { ast, consts } = fileAst(s.file);
571
+ let best = null;
572
+ (function w(n) {
573
+ if ((n.type === 'CallExpression' || n.type === 'NewExpression') && n.loc.start.line <= s.line && n.loc.end.line >= s.line) {
574
+ if (!best || n.loc.start.line > best.loc.start.line || (n.loc.start.line === best.loc.start.line && (n.end - n.start) < (best.end - best.start))) best = n;
575
+ }
576
+ for (const c of childNodes(n)) w(c);
577
+ })(ast);
578
+ const lits = new Set();
579
+ let name = s.callee;
580
+ if (best) { name = calleeName(best.callee) || s.callee; for (const a of best.arguments) literalsOf(a, consts, 0, lits); }
581
+ const shape = { callee: name, lits: [...lits].filter(isDistinctiveLiteral) };
582
+ shapeCache.set(key, shape);
583
+ return shape;
584
+ }
585
+ const reqCache = {};
586
+ function relRequires(rel) {
587
+ if (reqCache[rel]) return reqCache[rel];
588
+ const out = new Set();
589
+ let ast;
590
+ try { ast = fileAst(rel).ast; } catch { return (reqCache[rel] = out); }
591
+ (function w(n) {
592
+ if (n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require' && n.arguments[0] && n.arguments[0].type === 'Literal' && typeof n.arguments[0].value === 'string' && n.arguments[0].value.startsWith('.')) {
593
+ const p = path.posix.normalize(path.posix.join(path.posix.dirname(rel), n.arguments[0].value));
594
+ for (const t of [p, p + '.cjs', p + '.js', path.posix.join(p, 'index.cjs')]) {
595
+ if (t.startsWith('tests/') && view.read(t) !== null) { out.add(t); break; }
596
+ }
597
+ }
598
+ for (const c of childNodes(n)) w(c);
599
+ })(ast);
600
+ return (reqCache[rel] = out);
601
+ }
602
+ const closureCache = {};
603
+ /** Every tests/ file reachable from `testRel` through relative requires. */
604
+ function helperClosure(testRel) {
605
+ if (closureCache[testRel]) return closureCache[testRel];
606
+ const seen = new Set();
607
+ const q = [testRel];
608
+ while (q.length) { const f = q.pop(); for (const r of relRequires(f)) if (!seen.has(r)) { seen.add(r); q.push(r); } }
609
+ return (closureCache[testRel] = seen);
610
+ }
611
+ const testsByBase = {};
612
+ for (const f of view.listFiles()) (testsByBase[baseName(f)] = testsByBase[baseName(f)] || []).push(f);
613
+ return { fileAst, siteShape, helperClosure, testsByBase };
614
+ }
615
+
616
+ /** A site literal that can tell two sites apart in argv: at least 3 characters and not a flag. */
617
+ function isDistinctiveLiteral(x) {
618
+ return x.length >= 3 && !x.startsWith('-');
619
+ }
620
+
621
+ function cmdCompatible(callee, command) {
622
+ const c = baseName(command || '').replace(/\.exe$/i, '');
623
+ const n = callee.toLowerCase();
624
+ if (/git/.test(n)) return c === 'git';
625
+ if (/runnode|installrun|node/.test(n) && !/hook/.test(n)) return c === 'node';
626
+ // Hook runners spawn node, bash, sh, python and others: any command is compatible.
627
+ if (/hook/.test(n)) return true;
628
+ return true;
629
+ }
630
+
631
+ /**
632
+ * Credits each stall record to one constant, or to none.
633
+ * @param lanes parsed lanes
634
+ * @param ctx {sites, values, index, historicalValues} `historicalValues` maps a constant to
635
+ * extra timeoutMs values that count as that constant (see HISTORICAL_VALUES).
636
+ */
637
+ function attributeRecords(lanes, { sites, values, index, historicalValues = {} }) {
638
+ const hasValue = (c, ms) => values[c] === ms || (historicalValues[c] || []).includes(ms);
639
+ const seamSites = sites.filter((s) => s.cls === 'SEAM');
640
+ const seamByFile = {};
641
+ const seamByRel = {};
642
+ for (const s of seamSites) {
643
+ (seamByFile[baseName(s.file)] = seamByFile[baseName(s.file)] || []).push(s);
644
+ (seamByRel[s.file] = seamByRel[s.file] || []).push(s);
645
+ }
646
+ const helperSitesFor = (fileBase) => {
647
+ const out = [];
648
+ for (const rel of index.testsByBase[fileBase] || []) {
649
+ for (const h of index.helperClosure(rel)) if (!/\.test\.cjs$/.test(h)) for (const s of seamByRel[h] || []) out.push(s);
650
+ }
651
+ return out;
652
+ };
653
+ const credited = {};
654
+ const ambiguous = [];
655
+ const unmatched = [];
656
+ let total = 0;
657
+ let narrowed = 0;
658
+ let viaHelper = 0;
659
+ for (const lane of lanes) {
660
+ for (const j of lane.stalls) {
661
+ total++;
662
+ const rec = { os: lane.os, runId: lane.runId, file: j.file, command: j.command, argv: j.argv || [], durationMs: j.durationMs, timeoutMs: j.timeoutMs, startedAt: j.startedAt };
663
+ const fb = baseName(j.file || '');
664
+ const has = (c) => hasValue(c, j.timeoutMs);
665
+ let cands = (seamByFile[fb] || []).filter((s) => s.constants.some(has));
666
+ let helper = false;
667
+ if (!cands.length) {
668
+ cands = helperSitesFor(fb).filter((s) => s.constants.some(has));
669
+ helper = cands.length > 0;
670
+ }
671
+ if (!cands.length) { unmatched.push(rec); continue; }
672
+ const argvStr = rec.argv.join(' ');
673
+ const constsOf = (list) => {
674
+ const out = new Set();
675
+ for (const s of list) for (const c of s.constants) if (has(c)) out.add(c);
676
+ return out;
677
+ };
678
+ const allConsts = constsOf(cands);
679
+ let comp = cands.filter((s) => cmdCompatible(index.siteShape(s).callee, rec.command));
680
+ if (helper) {
681
+ // A helper is shared by many tests: require the record's argv to show one of the site's own literals.
682
+ comp = comp.filter((s) => { const l = index.siteShape(s).lits; return !l.length || l.some((x) => argvStr.includes(x)); });
683
+ }
684
+ if (!comp.length) { unmatched.push(rec); continue; }
685
+ if (helper) { viaHelper++; rec.viaHelper = true; }
686
+ const consts = constsOf(comp);
687
+ let winner = null;
688
+ if (consts.size === 1) winner = [...consts][0];
689
+ else {
690
+ const scored = comp.map((s) => ({ s, score: index.siteShape(s).lits.filter((l) => argvStr.includes(l)).length }));
691
+ const maxScore = scored.reduce((a, b) => Math.max(a, b.score), 0);
692
+ const top = maxScore > 0 ? scored.filter((x) => x.score === maxScore) : scored;
693
+ const tc = constsOf(top.map((x) => x.s));
694
+ if (tc.size === 1) winner = [...tc][0];
695
+ }
696
+ if (winner && allConsts.size > 1) narrowed++;
697
+ if (!winner) { rec.candidates = [...consts]; ambiguous.push(rec); continue; }
698
+ rec.constant = winner;
699
+ (credited[winner] = credited[winner] || []).push(rec);
700
+ }
701
+ }
702
+ return { credited, ambiguous, unmatched, total, narrowed, viaHelper };
703
+ }
704
+
705
+ /** Lanes per platform that ran at least one test file whose seam sites include this constant's. */
706
+ function laneCoverage(lanes, seamSitesOfConstant, index) {
707
+ const siteFiles = new Set(seamSitesOfConstant.map((s) => baseName(s.file)));
708
+ const siteRels = new Set(seamSitesOfConstant.map((s) => s.file));
709
+ const covers = (f) => {
710
+ if (siteFiles.has(f)) return true;
711
+ for (const rel of index.testsByBase[f] || []) for (const h of index.helperClosure(rel)) if (siteRels.has(h)) return true;
712
+ return false;
713
+ };
714
+ const out = {};
715
+ for (const p of PLATFORMS) out[p] = 0;
716
+ for (const l of lanes) {
717
+ for (const f of l.files) if (covers(f)) { out[l.os] = (out[l.os] || 0) + 1; break; }
718
+ }
719
+ return out;
720
+ }
721
+
722
+ const floor2 = (x) => Math.floor(x * 100) / 100;
723
+
724
+ /** Per-constant evidence from attribution results. */
725
+ function summarizeConstants({ names, values, sites, lanes, index, attribution }) {
726
+ const { credited, ambiguous } = attribution;
727
+ const constants = {};
728
+ for (const c of names) {
729
+ const val = values[c];
730
+ const cs = sites.filter((s) => s.constants.includes(c));
731
+ const seamN = cs.filter((s) => s.cls === 'SEAM');
732
+ const rawN = cs.filter((s) => s.cls === 'DIRECT');
733
+ const rs = credited[c] || [];
734
+ const per = {};
735
+ let worst = null;
736
+ for (const p of PLATFORMS) {
737
+ const a = rs.filter((r) => r.os === p).sort((x, y) => x.durationMs - y.durationMs);
738
+ const sum = summarizeDurations(a.map((r) => r.durationMs));
739
+ const w = a.length ? a[a.length - 1] : null;
740
+ per[p] = { ...sum, worst: w ? { file: w.file, command: w.command, argv: w.argv, ms: w.durationMs, runId: w.runId, startedAt: w.startedAt } : null };
741
+ if (w && (!worst || w.durationMs > worst.ms)) worst = { platform: p, ...per[p].worst };
742
+ }
743
+ constants[c] = {
744
+ value: val,
745
+ siteOnly: HARNESS_ONLY.has(c),
746
+ seamSites: seamN.length,
747
+ rawSites: rawN.length,
748
+ seamFiles: new Set(seamN.map((s) => baseName(s.file))).size,
749
+ rawFiles: new Set(rawN.map((s) => s.file)).size,
750
+ laneCoverage: laneCoverage(lanes, seamN, index),
751
+ perPlatform: per,
752
+ worst,
753
+ recordsTotal: rs.length,
754
+ viaHelper: rs.filter((r) => r.viaHelper).length,
755
+ margin: worst ? floor2(val / worst.ms) : null,
756
+ ambiguousCandidateCount: ambiguous.filter((r) => r.candidates.includes(c)).length,
757
+ };
758
+ }
759
+ return constants;
760
+ }
761
+
762
+ /** Everything the CLI prints, from already-loaded inputs. */
763
+ function buildEvidence({ lanes, runDirectories, view, names, evidenceNames, values, census, historicalValues = HISTORICAL_VALUES }) {
764
+ const index = createRepoIndex(view);
765
+ const built = buildSites(census, createClassifier(view));
766
+ const attribution = attributeRecords(lanes, { sites: built.sites, values, index, historicalValues });
767
+ const runIds = new Set(lanes.map((l) => l.runId));
768
+ let dmin = null;
769
+ let dmax = null;
770
+ for (const l of lanes) {
771
+ if (l.startedMin && (!dmin || l.startedMin < dmin)) dmin = l.startedMin;
772
+ if (l.startedMax && (!dmax || l.startedMax > dmax)) dmax = l.startedMax;
773
+ }
774
+ const lanesByPlatform = {};
775
+ for (const l of lanes) lanesByPlatform[l.os] = (lanesByPlatform[l.os] || 0) + 1;
776
+ const durations = lanes.flatMap((l) => l.stalls.map((j) => j.durationMs));
777
+ const ambiguousByCandidates = {};
778
+ for (const r of attribution.ambiguous) {
779
+ const k = r.candidates.slice().sort().join('+') + ' @' + r.timeoutMs;
780
+ ambiguousByCandidates[k] = (ambiguousByCandidates[k] || 0) + 1;
781
+ }
782
+ const unmatchedByTimeout = {};
783
+ for (const r of attribution.unmatched) unmatchedByTimeout[r.timeoutMs] = (unmatchedByTimeout[r.timeoutMs] || 0) + 1;
784
+ return {
785
+ window: { runDirectories, runs: runIds.size, laneArtifacts: lanes.length, lanesByPlatform, firstSpawnStartedAt: dmin, lastSpawnStartedAt: dmax, skippedMalformedLines: lanes.reduce((a, l) => a + (l.skippedLines || 0), 0) },
786
+ totalRecords: attribution.total,
787
+ creditedRecords: Object.values(attribution.credited).reduce((a, b) => a + b.length, 0),
788
+ ambiguousRecords: attribution.ambiguous.length,
789
+ unmatchedRecords: attribution.unmatched.length,
790
+ narrowedByArgv: attribution.narrowed,
791
+ viaHelper: attribution.viaHelper,
792
+ minDurationMs: durations.length ? Math.min(...durations) : null,
793
+ census: { fileCount: census.fileCount, parseFailures: census.parseFailures, propRows: census.propRows.length, keptSites: built.sites.length, droppedSites: built.dropped.length, localRedefs: census.localRedefs },
794
+ unresolvedSites: built.unresolvedSites,
795
+ unusedResolutions: built.unusedResolutions,
796
+ excludedSites: built.excludedSites,
797
+ constants: summarizeConstants({ names: evidenceNames, values, sites: built.sites, lanes, index, attribution }),
798
+ droppedSites: built.dropped,
799
+ ambiguousByCandidates,
800
+ unmatchedByTimeout,
801
+ sites: built.sites,
802
+ names,
803
+ };
804
+ }
805
+
806
+ // ---------------------------------------------------------------------------------------------
807
+ // Rendering
808
+
809
+ /** One `- [ ] file:line (CONSTANT)` line per non-seam site, grouped by constant. */
810
+ function rawSiteLines(sites, names) {
811
+ const raw = sites.filter((s) => s.cls === 'DIRECT').slice().sort((x, y) => x.file.localeCompare(y.file) || x.line - y.line);
812
+ const out = [];
813
+ for (const c of names) {
814
+ const rows = raw.filter((s) => s.constants.includes(c));
815
+ if (!rows.length) continue;
816
+ out.push(`## ${c}`, '', ...rows.map((s) => `- [ ] ${s.file}:${s.line} (${c})`), '');
817
+ }
818
+ return out.join('\n');
819
+ }
820
+
821
+ function renderMarkdown(ev, evidenceNames) {
822
+ const L = [];
823
+ const w = ev.window;
824
+ L.push('# CI timeout evidence (per-call-site attribution)', '');
825
+ L.push(`${w.runDirectories} run directories, ${w.runs} with spawn-stall data; ${w.laneArtifacts} lane artifacts (win32 ${w.lanesByPlatform.win32 || 0}, darwin ${w.lanesByPlatform.darwin || 0}, linux ${w.lanesByPlatform.linux || 0}); spawn startedAt ${w.firstSpawnStartedAt} .. ${w.lastSpawnStartedAt}. Smallest durationMs: ${ev.minDurationMs}.`, '');
826
+ L.push(`Records: ${ev.totalRecords}; credited ${ev.creditedRecords}; ambiguous ${ev.ambiguousRecords}; unmatched ${ev.unmatchedRecords}; narrowed by command/argv ${ev.narrowedByArgv}; credited via helper ${ev.viaHelper}; malformed lines skipped ${w.skippedMalformedLines}.`, '');
827
+ L.push('| constant | value | seam sites | raw sites | records (win32/darwin/linux) | lanes (win32/darwin/linux) | worst platform | worst max ms | margin | via helper | ambiguous cand. |', '|---|---|---|---|---|---|---|---|---|---|---|');
828
+ for (const c of evidenceNames) {
829
+ const r = ev.constants[c];
830
+ L.push(`| ${c} | ${r.value} | ${r.seamSites} | ${r.rawSites} | ${PLATFORMS.map((p) => r.perPlatform[p].records).join('/')} | ${PLATFORMS.map((p) => r.laneCoverage[p]).join('/')} | ${r.worst ? r.worst.platform : 'n/a'} | ${r.worst ? r.worst.ms : 'none'} | ${r.margin === null ? 'n/a' : r.margin} | ${r.viaHelper} | ${r.ambiguousCandidateCount} |`);
831
+ }
832
+ L.push('', '## Per platform', '', '| constant | platform | records | p50 | p99 | max |', '|---|---|---|---|---|---|');
833
+ for (const c of evidenceNames) {
834
+ for (const p of PLATFORMS) {
835
+ const x = ev.constants[c].perPlatform[p];
836
+ L.push(`| ${c} | ${p} | ${x.records} | ${x.p50 === null ? '-' : x.p50} | ${x.p99 === null ? '-' : x.p99} | ${x.max === null ? '-' : x.max} |`);
837
+ }
838
+ }
839
+ L.push('', '## Dropped sites', '');
840
+ for (const d of ev.droppedSites) L.push(`- ${d.file}:${d.line} ${d.constants.join()} - ${d.why}`);
841
+ L.push('', '## Ambiguous by candidate-constant set', '');
842
+ for (const [k, v] of Object.entries(ev.ambiguousByCandidates).sort((a, b) => b[1] - a[1])) L.push(`- ${k}: ${v}`);
843
+ return L.join('\n') + '\n';
844
+ }
845
+
846
+ // ---------------------------------------------------------------------------------------------
847
+ // I/O
848
+
849
+ function diskView(root) {
850
+ const read = (rel) => {
851
+ try {
852
+ const p = path.join(root, rel);
853
+ return fs.statSync(p).isFile() ? fs.readFileSync(p, 'utf8') : null;
854
+ } catch { return null; }
855
+ };
856
+ const listFiles = () => {
857
+ const out = [];
858
+ (function walk(dir) {
859
+ for (const e of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
860
+ const rel = dir + '/' + e.name;
861
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '.git') walk(rel); } else if (e.isFile() && e.name.endsWith('.cjs')) out.push(rel);
862
+ }
863
+ })('tests');
864
+ return out.sort();
865
+ };
866
+ return { read, listFiles };
867
+ }
868
+
869
+ /**
870
+ * Reads every `*.jsonl` under `artifactsDir`. Two layouts are accepted:
871
+ * `<dir>/<runId>/<artifact>/<file>.jsonl` and `<dir>/<runId>/<file>.jsonl` (what `gh run download
872
+ * --pattern X --dir <dir>/<runId>` writes when X matches a single artifact). The lane's os comes
873
+ * from the artifact folder name, else from the file name; a file whose os cannot be determined, or
874
+ * that sits outside `<runId>/`, throws, as does a directory holding no lane file. Directory entries
875
+ * are visited in sorted order.
876
+ */
877
+ function loadLanes(artifactsDir) {
878
+ const lanes = [];
879
+ (function walk(d) {
880
+ const entries = fs.readdirSync(d, { withFileTypes: true }).sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0));
881
+ for (const e of entries) {
882
+ const p = path.join(d, e.name);
883
+ if (e.isDirectory()) walk(p);
884
+ else if (e.name.endsWith('.jsonl')) {
885
+ const parts = path.relative(artifactsDir, p).split(path.sep);
886
+ if (parts.length < 2) throw new Error('lane file outside a <runId>/ directory: ' + p);
887
+ const art = parts.length >= 3 ? parts[1] : e.name.replace(/\.jsonl$/, '');
888
+ let os = osOfArtifact(art);
889
+ if (os === 'other') os = osOfArtifact(e.name);
890
+ if (os === 'other') throw new Error('cannot determine the os of lane file ' + p + ' (artifact name must contain ubuntu, windows or macos)');
891
+ lanes.push(parseLane(fs.readFileSync(p, 'utf8'), { os, runId: parts[0], art }));
892
+ }
893
+ }
894
+ })(artifactsDir);
895
+ if (!lanes.length) throw new Error('no lane *.jsonl files found under ' + artifactsDir);
896
+ return lanes;
897
+ }
898
+
899
+ function parseArgs(argv) {
900
+ const opts = { artifacts: null, repo: path.resolve(__dirname, '..'), format: 'json', rawSites: false };
901
+ const valueOf = (flag, i) => {
902
+ const v = argv[i + 1];
903
+ if (v === undefined || v.startsWith('--')) throw new Error(flag + ' requires a value');
904
+ return v;
905
+ };
906
+ for (let i = 0; i < argv.length; i++) {
907
+ const a = argv[i];
908
+ if (a === '--artifacts') opts.artifacts = valueOf(a, i++);
909
+ else if (a === '--repo') opts.repo = path.resolve(valueOf(a, i++));
910
+ else if (a === '--format') opts.format = valueOf(a, i++);
911
+ else if (a === '--raw-sites') opts.rawSites = true;
912
+ else throw new Error('unknown argument: ' + a);
913
+ }
914
+ if (!opts.artifacts) throw new Error('--artifacts <dir> is required');
915
+ if (opts.format !== 'json' && opts.format !== 'md') throw new Error('--format must be json or md');
916
+ return opts;
917
+ }
918
+
919
+ function main(argv) {
920
+ const opts = parseArgs(argv);
921
+ const T = require(path.join(path.resolve(opts.repo), TIMEOUTS_REL));
922
+ const { values, censusNames, evidenceNames } = constantsFromTimeouts(T);
923
+ const view = diskView(opts.repo);
924
+ const census = censusRepo(view, censusNames);
925
+ const artifactsDir = path.resolve(opts.artifacts);
926
+ const lanes = loadLanes(artifactsDir);
927
+ const runDirectories = fs.readdirSync(artifactsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
928
+ const ev = buildEvidence({ lanes, runDirectories, view, names: censusNames, evidenceNames, values, census });
929
+ if (opts.rawSites) process.stdout.write(rawSiteLines(ev.sites, censusNames) + '\n');
930
+ else if (opts.format === 'md') process.stdout.write(renderMarkdown(ev, evidenceNames));
931
+ else process.stdout.write(JSON.stringify(ev, null, 1) + '\n');
932
+ }
933
+
934
+ if (require.main === module) {
935
+ try { main(process.argv.slice(2)); } catch (e) {
936
+ process.stderr.write('timeout-evidence-from-ci: ' + e.message + '\n');
937
+ process.exitCode = 2;
938
+ }
939
+ }
940
+
941
+ module.exports = {
942
+ MANUAL_RESOLUTIONS,
943
+ HISTORICAL_VALUES,
944
+ constantsFromTimeouts,
945
+ censusSource,
946
+ censusRepo,
947
+ createClassifier,
948
+ buildSites,
949
+ validateResolutions,
950
+ parseLane,
951
+ percentile,
952
+ summarizeDurations,
953
+ createRepoIndex,
954
+ attributeRecords,
955
+ laneCoverage,
956
+ summarizeConstants,
957
+ buildEvidence,
958
+ rawSiteLines,
959
+ renderMarkdown,
960
+ diskView,
961
+ loadLanes,
962
+ parseArgs,
963
+ osOfArtifact,
964
+ };