@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +234 -126
- package/commands/gsd/autonomous.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +450 -753
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +80 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +13 -11
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +11 -19
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +107 -31
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +91 -41
- package/gsd-core/bin/lib/state.cjs +446 -61
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +97 -17
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +3 -2
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +8 -5
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +97 -17
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- 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
|
+
};
|