forge-workflow 0.1.0-beta.5 → 0.1.0-beta.7
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/AGENTS.md +4 -0
- package/CHANGELOG.md +48 -0
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/bin/forge.js +12 -1
- package/docs/guides/MIGRATION.md +3 -3
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +4 -4
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/shepherd.md +63 -13
- package/lib/adapters/pr-state-adapter.js +15 -2
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +48 -18
- package/lib/commands/clean.js +57 -1
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +54 -6
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +720 -73
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +2 -0
- package/lib/commands/preflight.js +10 -2
- package/lib/commands/push.js +120 -9
- package/lib/commands/recall.js +95 -61
- package/lib/commands/release.js +23 -2
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +132 -4
- package/lib/commands/shepherd.js +578 -72
- package/lib/commands/ship.js +15 -69
- package/lib/commands/skill.js +8 -0
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +163 -4
- package/lib/commands/validate.js +78 -22
- package/lib/commands/worktree.js +155 -19
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-issues.js +78 -0
- package/lib/gate-events.js +98 -10
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/hook-renderer.js +29 -1
- package/lib/issue-render.js +19 -0
- package/lib/kernel/broker.js +723 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3322 -183
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +94 -27
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +59 -0
- package/lib/merge-rules.js +135 -17
- package/lib/npm-publish-workflow.js +233 -40
- package/lib/package-root.js +2 -0
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +0 -37
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1101 -625
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +121 -24
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +28 -19
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +144 -38
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-shepherd.js +17 -3
- package/lib/project-memory.js +145 -3
- package/lib/protected-state-authority.js +799 -4
- package/lib/protected-state-surfaces.js +181 -3
- package/lib/release-readiness.js +2 -3
- package/lib/review-adapter.js +65 -0
- package/lib/skills-sync.js +65 -32
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/validation-receipt.js +190 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
- package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
- package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
- package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
- package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
- package/node_modules/@forge/contracts/index.js +32 -0
- package/node_modules/@forge/contracts/package.json +35 -0
- package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
- package/node_modules/@forge/contracts/src/baseline.js +23 -0
- package/node_modules/@forge/contracts/src/canonical.js +151 -0
- package/node_modules/@forge/contracts/src/definitions.js +176 -0
- package/node_modules/@forge/contracts/src/identity.js +42 -0
- package/node_modules/@forge/contracts/src/schema.js +72 -0
- package/node_modules/@forge/contracts/src/validate.js +305 -0
- package/node_modules/@forge/flow/index.js +114 -0
- package/node_modules/@forge/flow/package.json +34 -0
- package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
- package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
- package/node_modules/@forge/flow/src/executor.js +279 -0
- package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
- package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
- package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
- package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
- package/node_modules/@forge/memory/index.js +331 -0
- package/node_modules/@forge/memory/package.json +34 -0
- package/node_modules/@forge/memory/src/authority-provider.js +67 -0
- package/node_modules/@forge/memory/src/backend-registry.js +166 -0
- package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
- package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
- package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
- package/package.json +16 -4
- package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
- package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
- package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
- package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
- package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
- package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
- package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
- package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
- package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
- package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
- package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
- package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
- package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
- package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
- package/scripts/commitlint.js +13 -15
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runtime.js +3 -2
- package/scripts/process-tree.js +14 -2
- package/scripts/protected-state-check.js +440 -17
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/test-full-suite.js +704 -18
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +96 -15
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +2 -0
- package/skills/review/evals/scorecard.json +2 -2
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +19 -2
- package/skills/shepherd/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
|
@@ -5,24 +5,58 @@ const fs = require('node:fs');
|
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
7
7
|
const { spawn: defaultSpawn } = require('node:child_process');
|
|
8
|
+
const { stripVTControlCharacters } = require('node:util');
|
|
8
9
|
|
|
9
10
|
const {
|
|
10
11
|
createDurationMap,
|
|
11
12
|
getShardPlan,
|
|
12
13
|
getUnitTestRoots,
|
|
14
|
+
normalizePath,
|
|
13
15
|
readNewestProfile,
|
|
14
16
|
walkTests,
|
|
15
17
|
} = require('./test-ci-shard');
|
|
18
|
+
const {
|
|
19
|
+
buildProfile,
|
|
20
|
+
parseJUnitFiles,
|
|
21
|
+
parseJUnitTestcases,
|
|
22
|
+
walk: walkProfileFiles,
|
|
23
|
+
} = require('./test-profile');
|
|
16
24
|
const { createProcessTree, signalExitCode } = require('./process-tree');
|
|
17
25
|
const { stripGitHookEnv } = require('./test');
|
|
26
|
+
const { redact } = require('../lib/audit-evidence');
|
|
18
27
|
|
|
19
28
|
const rootDir = path.join(__dirname, '..');
|
|
20
29
|
const reportDir = path.join(rootDir, 'test-results');
|
|
30
|
+
const RESOURCE_LANES = new Set(['unit', 'subprocess', 'exclusive']);
|
|
31
|
+
const RESOURCE_LANE_RANK = new Map([
|
|
32
|
+
['unit', 0],
|
|
33
|
+
['subprocess', 1],
|
|
34
|
+
['exclusive', 2],
|
|
35
|
+
]);
|
|
36
|
+
const DEFAULT_SHARD_TIMEOUT_MS = 30000;
|
|
37
|
+
const STDERR_TAIL_LIMIT = 4096;
|
|
38
|
+
const STDERR_OMITTED_LINE = '[stderr line omitted]\n';
|
|
39
|
+
const JS_FAMILY_EXTENSIONS = ['.js', '.cjs', '.mjs', '.jsx', '.ts', '.cts', '.mts', '.tsx'];
|
|
40
|
+
const FORGE_COORDINATION_ENV = new Set([
|
|
41
|
+
'FORGE_ACTOR',
|
|
42
|
+
'FORGE_SESSION_ID',
|
|
43
|
+
'FORGE_WORKTREE_ID',
|
|
44
|
+
'FORGE_LEASE_TTL_MS',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function stripFullSuiteChildEnv(env) {
|
|
48
|
+
const childEnv = stripGitHookEnv(env);
|
|
49
|
+
for (const key of Object.keys(childEnv)) {
|
|
50
|
+
if (FORGE_COORDINATION_ENV.has(key.toUpperCase())) delete childEnv[key];
|
|
51
|
+
}
|
|
52
|
+
return childEnv;
|
|
53
|
+
}
|
|
21
54
|
|
|
22
55
|
function parseArgs(argv) {
|
|
23
56
|
const args = {
|
|
24
57
|
labelPrefix: 'local-full',
|
|
25
58
|
shards: null,
|
|
59
|
+
timeoutMs: DEFAULT_SHARD_TIMEOUT_MS,
|
|
26
60
|
};
|
|
27
61
|
|
|
28
62
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -31,11 +65,20 @@ function parseArgs(argv) {
|
|
|
31
65
|
|
|
32
66
|
if (current === '--label-prefix') args.labelPrefix = next;
|
|
33
67
|
if (current === '--shards') args.shards = Number.parseInt(next, 10);
|
|
68
|
+
if (current === '--timeout') args.timeoutMs = parseTimeoutMs(next);
|
|
34
69
|
}
|
|
35
70
|
|
|
36
71
|
return args;
|
|
37
72
|
}
|
|
38
73
|
|
|
74
|
+
function parseTimeoutMs(value) {
|
|
75
|
+
const timeoutMs = Number(value);
|
|
76
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
77
|
+
throw new Error('--timeout must be a positive integer');
|
|
78
|
+
}
|
|
79
|
+
return timeoutMs;
|
|
80
|
+
}
|
|
81
|
+
|
|
39
82
|
function getDefaultShardCount(cpuCount = os.cpus().length) {
|
|
40
83
|
if (!Number.isInteger(cpuCount) || cpuCount <= 1) return 1;
|
|
41
84
|
return Math.max(2, Math.min(4, cpuCount - 1));
|
|
@@ -129,11 +172,440 @@ function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
|
|
|
129
172
|
return specs;
|
|
130
173
|
}
|
|
131
174
|
|
|
132
|
-
function
|
|
175
|
+
function strongestResourceLane(left, right) {
|
|
176
|
+
return RESOURCE_LANE_RANK.get(left) >= RESOURCE_LANE_RANK.get(right) ? left : right;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tokenizeResourceSyntax(source) {
|
|
180
|
+
const tokens = [];
|
|
181
|
+
let index = 0;
|
|
182
|
+
while (index < source.length) {
|
|
183
|
+
const current = source[index];
|
|
184
|
+
if (/\s/.test(current)) {
|
|
185
|
+
index += 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (current === '/' && source[index + 1] === '/') {
|
|
189
|
+
index = source.indexOf('\n', index + 2);
|
|
190
|
+
if (index === -1) break;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (current === '/' && source[index + 1] === '*') {
|
|
194
|
+
const end = source.indexOf('*/', index + 2);
|
|
195
|
+
index = end === -1 ? source.length : end + 2;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (current === '"' || current === "'") {
|
|
199
|
+
const quote = current;
|
|
200
|
+
let value = '';
|
|
201
|
+
index += 1;
|
|
202
|
+
while (index < source.length && source[index] !== quote) {
|
|
203
|
+
if (source[index] === '\\' && index + 1 < source.length) index += 1;
|
|
204
|
+
value += source[index];
|
|
205
|
+
index += 1;
|
|
206
|
+
}
|
|
207
|
+
index += 1;
|
|
208
|
+
tokens.push({ type: 'string', value });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (current === '`') {
|
|
212
|
+
let dynamic = false;
|
|
213
|
+
let value = '';
|
|
214
|
+
index += 1;
|
|
215
|
+
while (index < source.length && source[index] !== '`') {
|
|
216
|
+
if (source[index] === '\\' && index + 1 < source.length) {
|
|
217
|
+
index += 1;
|
|
218
|
+
} else if (source[index] === '$' && source[index + 1] === '{') {
|
|
219
|
+
dynamic = true;
|
|
220
|
+
}
|
|
221
|
+
value += source[index];
|
|
222
|
+
index += 1;
|
|
223
|
+
}
|
|
224
|
+
index += 1;
|
|
225
|
+
tokens.push({ type: dynamic ? 'dynamic-string' : 'string', value });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (/[A-Za-z_$]/.test(current)) {
|
|
229
|
+
const start = index;
|
|
230
|
+
index += 1;
|
|
231
|
+
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) index += 1;
|
|
232
|
+
tokens.push({ type: 'identifier', value: source.slice(start, index) });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
tokens.push({ type: 'punctuator', value: current });
|
|
236
|
+
index += 1;
|
|
237
|
+
}
|
|
238
|
+
return tokens;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function inspectTestResourceSource(source, file, classifySource) {
|
|
242
|
+
const marker = source.match(/^\s*\/\/\s*forge-test-resource:\s*([^\s]+)\s*$/m);
|
|
243
|
+
let resource = 'unit';
|
|
244
|
+
if (marker) {
|
|
245
|
+
if (!RESOURCE_LANES.has(marker[1])) {
|
|
246
|
+
throw new Error(`Unknown full-suite resource lane ${marker[1]} in ${file}`);
|
|
247
|
+
}
|
|
248
|
+
resource = marker[1];
|
|
249
|
+
}
|
|
250
|
+
if (classifySource) {
|
|
251
|
+
const classified = classifySource(source, file);
|
|
252
|
+
if (!RESOURCE_LANES.has(classified)) {
|
|
253
|
+
throw new Error(`Unknown full-suite resource lane ${classified} in ${file}`);
|
|
254
|
+
}
|
|
255
|
+
resource = strongestResourceLane(resource, classified);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const imports = [];
|
|
259
|
+
const recordImport = (specifier) => {
|
|
260
|
+
if (specifier === 'child_process' || specifier === 'node:child_process') {
|
|
261
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
262
|
+
} else if (specifier.startsWith('.')) {
|
|
263
|
+
imports.push(specifier);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const tokens = tokenizeResourceSyntax(source);
|
|
267
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
268
|
+
const token = tokens[index];
|
|
269
|
+
const previous = tokens[index - 1];
|
|
270
|
+
const next = tokens[index + 1];
|
|
271
|
+
if (token.value === 'Bun'
|
|
272
|
+
&& next?.value === '.'
|
|
273
|
+
&& (tokens[index + 2]?.value === 'spawn' || tokens[index + 2]?.value === 'spawnSync')
|
|
274
|
+
&& tokens[index + 3]?.value === '(') {
|
|
275
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if ((token.value === 'require' || token.value === 'import')
|
|
279
|
+
&& previous?.value !== '.'
|
|
280
|
+
&& next?.value === '(') {
|
|
281
|
+
const argument = tokens[index + 2];
|
|
282
|
+
if (argument?.type === 'string' && tokens[index + 3]?.value === ')') {
|
|
283
|
+
recordImport(argument.value);
|
|
284
|
+
} else {
|
|
285
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (token.value !== 'import' && token.value !== 'export') continue;
|
|
290
|
+
if (next?.type === 'string') {
|
|
291
|
+
recordImport(next.value);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
for (let cursor = index + 1; cursor < tokens.length && tokens[cursor].value !== ';'; cursor += 1) {
|
|
295
|
+
if (tokens[cursor].value === 'from' && tokens[cursor + 1]?.type === 'string') {
|
|
296
|
+
recordImport(tokens[cursor + 1].value);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { imports, resource };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isWithinRoot(root, target) {
|
|
305
|
+
const relative = path.relative(root, target);
|
|
306
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createTestResourceClassifier(options = {}) {
|
|
310
|
+
const root = path.resolve(options.root || rootDir);
|
|
311
|
+
const realRoot = fs.realpathSync(root);
|
|
312
|
+
const readFile = options.readFile || ((target) => fs.readFileSync(target, 'utf8'));
|
|
313
|
+
const moduleCache = new Map();
|
|
314
|
+
const resolutionCache = new Map();
|
|
315
|
+
const resultCache = new Map();
|
|
316
|
+
|
|
317
|
+
const resolveLocalImport = (fromFile, specifier) => {
|
|
318
|
+
const cacheKey = `${fromFile}\0${specifier}`;
|
|
319
|
+
if (resolutionCache.has(cacheKey)) return resolutionCache.get(cacheKey);
|
|
320
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
321
|
+
if (!isWithinRoot(realRoot, base)) {
|
|
322
|
+
resolutionCache.set(cacheKey, null);
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
const candidates = [
|
|
326
|
+
base,
|
|
327
|
+
...JS_FAMILY_EXTENSIONS.map((extension) => base + extension),
|
|
328
|
+
...JS_FAMILY_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)),
|
|
329
|
+
];
|
|
330
|
+
for (const candidate of candidates) {
|
|
331
|
+
let stats;
|
|
332
|
+
try {
|
|
333
|
+
stats = fs.statSync(candidate);
|
|
334
|
+
} catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!stats.isFile()) continue;
|
|
338
|
+
const resolved = fs.realpathSync(candidate);
|
|
339
|
+
if (!isWithinRoot(realRoot, resolved)) {
|
|
340
|
+
resolutionCache.set(cacheKey, null);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
resolutionCache.set(cacheKey, resolved);
|
|
344
|
+
return resolved;
|
|
345
|
+
}
|
|
346
|
+
resolutionCache.set(cacheKey, null);
|
|
347
|
+
return null;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const inspectModule = (absoluteFile) => {
|
|
351
|
+
if (moduleCache.has(absoluteFile)) return moduleCache.get(absoluteFile);
|
|
352
|
+
let inspected;
|
|
353
|
+
try {
|
|
354
|
+
const source = readFile(absoluteFile);
|
|
355
|
+
if (typeof source !== 'string') throw new TypeError('resource source reader must return a string');
|
|
356
|
+
inspected = inspectTestResourceSource(source, path.relative(root, absoluteFile), options.classifySource);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error && /Unknown full-suite resource lane/.test(error.message)) throw error;
|
|
359
|
+
inspected = { imports: [], resource: 'subprocess' };
|
|
360
|
+
}
|
|
361
|
+
moduleCache.set(absoluteFile, inspected);
|
|
362
|
+
return inspected;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const classifyModule = (absoluteFile, visiting) => {
|
|
366
|
+
if (resultCache.has(absoluteFile)) {
|
|
367
|
+
return { complete: true, resource: resultCache.get(absoluteFile) };
|
|
368
|
+
}
|
|
369
|
+
if (visiting.has(absoluteFile)) return { complete: false, resource: 'unit' };
|
|
370
|
+
|
|
371
|
+
visiting.add(absoluteFile);
|
|
372
|
+
const inspected = inspectModule(absoluteFile);
|
|
373
|
+
let resource = inspected.resource;
|
|
374
|
+
let complete = true;
|
|
375
|
+
if (resource !== 'exclusive') {
|
|
376
|
+
for (const specifier of inspected.imports) {
|
|
377
|
+
const resolved = resolveLocalImport(absoluteFile, specifier);
|
|
378
|
+
if (!resolved) {
|
|
379
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const dependency = classifyModule(resolved, visiting);
|
|
383
|
+
resource = strongestResourceLane(resource, dependency.resource);
|
|
384
|
+
complete = complete && dependency.complete;
|
|
385
|
+
if (resource === 'exclusive') {
|
|
386
|
+
complete = true;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
visiting.delete(absoluteFile);
|
|
392
|
+
if (complete) resultCache.set(absoluteFile, resource);
|
|
393
|
+
return { complete, resource };
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return (file) => {
|
|
397
|
+
const absoluteFile = path.resolve(root, file);
|
|
398
|
+
if (!isWithinRoot(root, absoluteFile)) return 'subprocess';
|
|
399
|
+
let realFile;
|
|
400
|
+
try {
|
|
401
|
+
realFile = fs.realpathSync(absoluteFile);
|
|
402
|
+
} catch {
|
|
403
|
+
return 'subprocess';
|
|
404
|
+
}
|
|
405
|
+
if (!isWithinRoot(realRoot, realFile)) return 'subprocess';
|
|
406
|
+
return classifyModule(realFile, new Set()).resource;
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function classifyTestResource(file, options = {}) {
|
|
411
|
+
return createTestResourceClassifier(options)(file);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function loadTestResourceMap(allTests, options = {}) {
|
|
415
|
+
const classify = createTestResourceClassifier(options);
|
|
416
|
+
const uniqueFiles = [...new Set(allTests)];
|
|
417
|
+
const entries = uniqueFiles.map((file) => [file, classify(file)]);
|
|
418
|
+
return new Map(entries);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function buildResourceLanePlan(allTests, shardTotal, durationMap = new Map(), options = {}) {
|
|
422
|
+
const classify = options.classify || ((file) => classifyTestResource(file, options));
|
|
423
|
+
const subprocessShardTotal = Number.isInteger(options.subprocessShardTotal)
|
|
424
|
+
&& options.subprocessShardTotal > 0
|
|
425
|
+
? options.subprocessShardTotal
|
|
426
|
+
: shardTotal;
|
|
427
|
+
const buckets = {
|
|
428
|
+
exclusive: [],
|
|
429
|
+
subprocess: [],
|
|
430
|
+
unit: [],
|
|
431
|
+
};
|
|
432
|
+
for (const file of allTests) {
|
|
433
|
+
const resource = classify(file);
|
|
434
|
+
if (!RESOURCE_LANES.has(resource)) {
|
|
435
|
+
throw new Error(`Unknown full-suite resource lane ${resource} for ${file}`);
|
|
436
|
+
}
|
|
437
|
+
buckets[resource].push(file);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const lanes = [];
|
|
441
|
+
const addShardedLane = (name, shardCap, concurrencyCap = shardCap) => {
|
|
442
|
+
if (buckets[name].length === 0) return;
|
|
443
|
+
const shardCount = Math.min(shardCap, shardTotal, buckets[name].length);
|
|
444
|
+
lanes.push({
|
|
445
|
+
concurrency: Math.min(concurrencyCap, shardCount),
|
|
446
|
+
name,
|
|
447
|
+
shards: buildShardSpecs(buckets[name], shardCount, durationMap),
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
addShardedLane('unit', 4);
|
|
451
|
+
if (buckets.subprocess.length > 0) {
|
|
452
|
+
const shardCount = Math.min(subprocessShardTotal, buckets.subprocess.length);
|
|
453
|
+
lanes.push({
|
|
454
|
+
concurrency: Math.min(3, shardCount),
|
|
455
|
+
name: 'subprocess',
|
|
456
|
+
// Extra shards improve tail balancing without increasing the worker budget.
|
|
457
|
+
shards: buildShardSpecs(buckets.subprocess, shardCount, durationMap),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (buckets.exclusive.length > 0) {
|
|
461
|
+
lanes.push({
|
|
462
|
+
concurrency: 1,
|
|
463
|
+
name: 'exclusive',
|
|
464
|
+
shards: buckets.exclusive.map((file) => ({ files: [file], source: 'exclusive' })),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let nextIndex = 0;
|
|
469
|
+
for (const lane of lanes) {
|
|
470
|
+
for (const shard of lane.shards) {
|
|
471
|
+
shard.index = nextIndex;
|
|
472
|
+
nextIndex += 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
assertExactShardAssignment(allTests, lanes.flatMap((lane) => lane.shards));
|
|
476
|
+
return lanes;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// A subprocess-lane worker owns two OS processes on Windows: the shard's own bun
|
|
480
|
+
// runtime plus the bun.exe grandchild its tests spawn. Counting such a worker as
|
|
481
|
+
// one budget unit oversubscribes small runners (3 workers is ~6 processes on 4
|
|
482
|
+
// vCPU), so weight the grant by real process cost instead of by worker count.
|
|
483
|
+
function laneWorkerCost(laneName, platform = process.platform) {
|
|
484
|
+
if (platform !== 'win32') return 1;
|
|
485
|
+
return laneName === 'unit' ? 1 : 2;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function computeLaneGrants(lanes, options = {}) {
|
|
489
|
+
const platform = options.platform || process.platform;
|
|
490
|
+
const workerBudget = Number.isInteger(options.workerBudget) && options.workerBudget > 0
|
|
491
|
+
? options.workerBudget
|
|
492
|
+
: null;
|
|
493
|
+
const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
|
|
494
|
+
const overlapsSubprocess = sharedLanes.some((lane) => lane.name === 'subprocess');
|
|
495
|
+
const grants = new Map();
|
|
496
|
+
for (const lane of lanes) {
|
|
497
|
+
grants.set(lane, {
|
|
498
|
+
cost: laneWorkerCost(lane.name, platform),
|
|
499
|
+
deferred: false,
|
|
500
|
+
granted: lane.concurrency,
|
|
501
|
+
workerBudget,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (workerBudget === null) {
|
|
505
|
+
for (const lane of sharedLanes) {
|
|
506
|
+
// One unit worker fills otherwise-idle CPU without recreating broad process pressure.
|
|
507
|
+
grants.get(lane).granted = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
|
|
508
|
+
}
|
|
509
|
+
return grants;
|
|
510
|
+
}
|
|
511
|
+
// An explicit shard count is an operator-imposed cap on total concurrent
|
|
512
|
+
// children; reserve the budget for heavier subprocess workers first and
|
|
513
|
+
// defer leftover lanes until capacity frees instead of exceeding it.
|
|
514
|
+
const ordered = [...sharedLanes].sort(
|
|
515
|
+
(left, right) => (left.name === 'subprocess' ? 0 : 1) - (right.name === 'subprocess' ? 0 : 1),
|
|
516
|
+
);
|
|
517
|
+
let reservedCost = 0;
|
|
518
|
+
for (const [position, lane] of ordered.entries()) {
|
|
519
|
+
const entry = grants.get(lane);
|
|
520
|
+
const want = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
|
|
521
|
+
const affordable = Math.floor(Math.max(0, workerBudget - reservedCost) / entry.cost);
|
|
522
|
+
// The strongest lane always keeps one worker so the suite can never stall at zero.
|
|
523
|
+
const granted = position === 0
|
|
524
|
+
? Math.max(1, Math.min(want, affordable))
|
|
525
|
+
: Math.min(want, affordable);
|
|
526
|
+
entry.granted = granted;
|
|
527
|
+
entry.deferred = granted === 0;
|
|
528
|
+
if (entry.deferred) {
|
|
529
|
+
entry.deferredConcurrency = Math.max(
|
|
530
|
+
1,
|
|
531
|
+
Math.min(lane.concurrency, Math.floor(workerBudget / entry.cost)),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
reservedCost += granted * entry.cost;
|
|
535
|
+
}
|
|
536
|
+
return grants;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function runLaneSchedule(lanes, execute, cancel = () => {}, options = {}) {
|
|
540
|
+
const runLane = async (lane, concurrency = lane.concurrency, schedule = { stopped: false }) => {
|
|
541
|
+
const laneResults = new Array(lane.shards.length);
|
|
542
|
+
let nextIndex = 0;
|
|
543
|
+
let stopped = false;
|
|
544
|
+
const worker = async () => {
|
|
545
|
+
while (!stopped && !schedule.stopped) {
|
|
546
|
+
const index = nextIndex;
|
|
547
|
+
nextIndex += 1;
|
|
548
|
+
if (index >= lane.shards.length) return;
|
|
549
|
+
try {
|
|
550
|
+
laneResults[index] = await execute(lane.shards[index], lane);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
stopped = true;
|
|
553
|
+
if (!schedule.stopped) {
|
|
554
|
+
schedule.stopped = true;
|
|
555
|
+
cancel(error);
|
|
556
|
+
}
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const workers = Array.from(
|
|
562
|
+
{ length: Math.min(concurrency, lane.shards.length) },
|
|
563
|
+
() => worker(),
|
|
564
|
+
);
|
|
565
|
+
const settled = await Promise.allSettled(workers);
|
|
566
|
+
const failure = settled.find((result) => result.status === 'rejected');
|
|
567
|
+
if (failure) throw failure.reason;
|
|
568
|
+
return laneResults;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const resultsByLane = new Map();
|
|
572
|
+
const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
|
|
573
|
+
const sharedSchedule = { stopped: false };
|
|
574
|
+
const grants = options.grants || computeLaneGrants(lanes, options);
|
|
575
|
+
const deferredLanes = sharedLanes.filter((lane) => grants.get(lane).deferred);
|
|
576
|
+
const immediateLanes = sharedLanes.filter((lane) => !grants.get(lane).deferred);
|
|
577
|
+
const settledSharedLanes = await Promise.allSettled(immediateLanes.map(async (lane) => {
|
|
578
|
+
resultsByLane.set(lane, await runLane(lane, grants.get(lane).granted, sharedSchedule));
|
|
579
|
+
}));
|
|
580
|
+
const sharedFailure = settledSharedLanes.find((result) => result.status === 'rejected');
|
|
581
|
+
if (sharedFailure) throw sharedFailure.reason;
|
|
582
|
+
// Budget-exhausted lanes start only after reserved capacity has been released.
|
|
583
|
+
const settledDeferredLanes = await Promise.allSettled(deferredLanes.map(async (lane) => {
|
|
584
|
+
resultsByLane.set(lane, await runLane(lane, grants.get(lane).deferredConcurrency, sharedSchedule));
|
|
585
|
+
}));
|
|
586
|
+
const deferredFailure = settledDeferredLanes.find((result) => result.status === 'rejected');
|
|
587
|
+
if (deferredFailure) throw deferredFailure.reason;
|
|
588
|
+
|
|
589
|
+
for (const lane of lanes.filter((candidate) => candidate.name === 'exclusive')) {
|
|
590
|
+
resultsByLane.set(lane, await runLane(lane));
|
|
591
|
+
}
|
|
592
|
+
return lanes.flatMap((lane) => {
|
|
593
|
+
const laneResults = resultsByLane.get(lane);
|
|
594
|
+
if (!laneResults) throw new Error(`Missing results for resource lane ${lane.name}`);
|
|
595
|
+
return laneResults;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildShardTestArgs({
|
|
600
|
+
junitPath,
|
|
601
|
+
files,
|
|
602
|
+
root = rootDir,
|
|
603
|
+
timeoutMs = DEFAULT_SHARD_TIMEOUT_MS,
|
|
604
|
+
}) {
|
|
133
605
|
return [
|
|
134
606
|
'test',
|
|
135
607
|
'--timeout',
|
|
136
|
-
|
|
608
|
+
String(parseTimeoutMs(timeoutMs)),
|
|
137
609
|
'--reporter=junit',
|
|
138
610
|
'--reporter-outfile',
|
|
139
611
|
junitPath,
|
|
@@ -171,6 +643,106 @@ function parseShardReceipt(output) {
|
|
|
171
643
|
return { assertions, errors, failed, passed, skipped, tests };
|
|
172
644
|
}
|
|
173
645
|
|
|
646
|
+
function extractFailedTestCases(output, limit = 20) {
|
|
647
|
+
const failures = [];
|
|
648
|
+
for (const { attrs, body } of parseJUnitTestcases(output)) {
|
|
649
|
+
const type = body.match(/<(failure|error)\b/)?.[1];
|
|
650
|
+
if (!type) continue;
|
|
651
|
+
failures.push({
|
|
652
|
+
file: attrs.file || attrs.classname || 'unknown',
|
|
653
|
+
line: attrs.line || '',
|
|
654
|
+
name: attrs.name || 'unknown',
|
|
655
|
+
type,
|
|
656
|
+
});
|
|
657
|
+
if (failures.length >= limit) break;
|
|
658
|
+
}
|
|
659
|
+
return failures;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function classifyShardFailure(result) {
|
|
663
|
+
if (result?.signal) return 'signal';
|
|
664
|
+
const output = typeof result?.output === 'string' ? result.output : '';
|
|
665
|
+
const stderrTail = typeof result?.stderrTail === 'string' ? result.stderrTail : '';
|
|
666
|
+
const parsed = parseShardReceipt(output);
|
|
667
|
+
const failedReceipt = parsed && (parsed.failed > 0 || parsed.errors > 0);
|
|
668
|
+
const hasTimeoutDiagnostic = (value) => /\b(?:etimedout|timed?\s*out|timeout\s*(?:error|exception))\b/i.test(value);
|
|
669
|
+
const failedOutput = failedReceipt
|
|
670
|
+
? parseJUnitTestcases(output)
|
|
671
|
+
.map(({ body }) => body)
|
|
672
|
+
.filter(body => /<(?:failure|error)\b/.test(body))
|
|
673
|
+
.join('\n')
|
|
674
|
+
: '';
|
|
675
|
+
const stderrDiagnostics = stripVTControlCharacters(stderrTail).split(/\r?\n/)
|
|
676
|
+
.filter(line => !/^\s*\((?:pass|fail|skip|todo)\)(?:\s|$)/i.test(line))
|
|
677
|
+
.join('\n');
|
|
678
|
+
if (hasTimeoutDiagnostic(failedOutput) || hasTimeoutDiagnostic(stderrDiagnostics)) return 'test-timeout';
|
|
679
|
+
if (failedReceipt) return 'test-failure';
|
|
680
|
+
if (parsed) return 'post-junit-exit';
|
|
681
|
+
return 'incomplete-receipt';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function createStderrTailCollector() {
|
|
685
|
+
let tail = '';
|
|
686
|
+
let pending = '';
|
|
687
|
+
let discardingLine = false;
|
|
688
|
+
const append = (value) => {
|
|
689
|
+
tail = (tail + value).slice(-STDERR_TAIL_LIMIT);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
write(chunk) {
|
|
694
|
+
let text = String(chunk);
|
|
695
|
+
if (discardingLine) {
|
|
696
|
+
const newline = text.indexOf('\n');
|
|
697
|
+
if (newline === -1) return;
|
|
698
|
+
text = text.slice(newline + 1);
|
|
699
|
+
discardingLine = false;
|
|
700
|
+
}
|
|
701
|
+
pending += text;
|
|
702
|
+
while (pending) {
|
|
703
|
+
const newline = pending.indexOf('\n');
|
|
704
|
+
if (newline !== -1) {
|
|
705
|
+
const line = pending.slice(0, newline + 1);
|
|
706
|
+
pending = pending.slice(newline + 1);
|
|
707
|
+
append(line.length > STDERR_TAIL_LIMIT ? STDERR_OMITTED_LINE : redact(line));
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (pending.length > STDERR_TAIL_LIMIT) {
|
|
711
|
+
pending = '';
|
|
712
|
+
discardingLine = true;
|
|
713
|
+
append(STDERR_OMITTED_LINE);
|
|
714
|
+
}
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
value() {
|
|
719
|
+
if (!discardingLine && pending) append(redact(pending));
|
|
720
|
+
pending = '';
|
|
721
|
+
return tail;
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function writeDurationProfile({ allTests, label, outputPath, runReportDir }) {
|
|
727
|
+
const files = walkProfileFiles(runReportDir, '.xml');
|
|
728
|
+
if (files.length === 0) return false;
|
|
729
|
+
const metrics = parseJUnitFiles(files);
|
|
730
|
+
const measured = new Set(metrics.allFileDurations.map((entry) => normalizePath(entry.file)));
|
|
731
|
+
if (!allTests.every((file) => measured.has(normalizePath(file)))) return false;
|
|
732
|
+
|
|
733
|
+
const profile = buildProfile({ integrationSkipped: false, label: label || 'local-full' }, metrics);
|
|
734
|
+
const target = path.resolve(outputPath);
|
|
735
|
+
const temporary = `${target}.tmp-${process.pid}`;
|
|
736
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
737
|
+
try {
|
|
738
|
+
fs.writeFileSync(temporary, JSON.stringify(profile, null, 2));
|
|
739
|
+
fs.renameSync(temporary, target);
|
|
740
|
+
} finally {
|
|
741
|
+
fs.rmSync(temporary, { force: true });
|
|
742
|
+
}
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
|
|
174
746
|
function aggregateShardReceipts(receipts, expectedCount) {
|
|
175
747
|
const totals = {
|
|
176
748
|
assertions: 0,
|
|
@@ -223,6 +795,7 @@ function spawnShard(shard, options = {}) {
|
|
|
223
795
|
const targetReportDir = options.reportDirectory || reportDir;
|
|
224
796
|
const platform = options.platform || process.platform;
|
|
225
797
|
const processTree = options.processTree || createProcessTree({ env, platform });
|
|
798
|
+
const stderrStream = options.stderrStream || process.stderr;
|
|
226
799
|
const resolvedReportDir = path.resolve(targetReportDir);
|
|
227
800
|
const junitPath = path.resolve(resolvedReportDir, `${labelPrefix}-shard-${shard.index}.xml`);
|
|
228
801
|
if (path.dirname(junitPath) !== resolvedReportDir) {
|
|
@@ -243,28 +816,44 @@ function spawnShard(shard, options = {}) {
|
|
|
243
816
|
|
|
244
817
|
let child;
|
|
245
818
|
let settled = false;
|
|
246
|
-
|
|
819
|
+
let stderrDrainListener = null;
|
|
820
|
+
const stderrTail = createStderrTailCollector();
|
|
821
|
+
const clearStderrDrainListener = () => {
|
|
822
|
+
if (!stderrDrainListener) return;
|
|
823
|
+
stderrStream.off('drain', stderrDrainListener);
|
|
824
|
+
stderrDrainListener = null;
|
|
825
|
+
};
|
|
826
|
+
const finish = (code, output, signal) => {
|
|
247
827
|
if (settled) return;
|
|
248
828
|
settled = true;
|
|
829
|
+
clearStderrDrainListener();
|
|
249
830
|
processTree.unregisterChild(reservation);
|
|
250
|
-
|
|
831
|
+
const result = { code, index: shard.index, output };
|
|
832
|
+
if (code !== 0) {
|
|
833
|
+
if (signal) result.signal = signal;
|
|
834
|
+
const retainedStderr = stderrTail.value();
|
|
835
|
+
if (retainedStderr) result.stderrTail = retainedStderr;
|
|
836
|
+
}
|
|
837
|
+
resolve(result);
|
|
251
838
|
};
|
|
252
839
|
try {
|
|
253
840
|
fs.rmSync(junitPath, { force: true });
|
|
254
841
|
child = spawn(bunCommand, buildShardTestArgs({
|
|
255
842
|
junitPath,
|
|
256
843
|
files: shard.files,
|
|
844
|
+
timeoutMs: options.timeoutMs,
|
|
257
845
|
}), {
|
|
258
846
|
cwd: rootDir,
|
|
259
847
|
env,
|
|
260
848
|
shell: false,
|
|
261
|
-
stdio: 'inherit',
|
|
849
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
262
850
|
detached: platform !== 'win32',
|
|
263
851
|
windowsHide: true,
|
|
264
852
|
});
|
|
265
853
|
child.on('error', (error) => {
|
|
266
854
|
if (settled) return;
|
|
267
855
|
settled = true;
|
|
856
|
+
clearStderrDrainListener();
|
|
268
857
|
processTree.unregisterChild(reservation);
|
|
269
858
|
reject(error);
|
|
270
859
|
});
|
|
@@ -283,18 +872,37 @@ function spawnShard(shard, options = {}) {
|
|
|
283
872
|
reject(new Error('test shard process could not be registered'));
|
|
284
873
|
return;
|
|
285
874
|
}
|
|
875
|
+
child.stderr?.on('data', (chunk) => {
|
|
876
|
+
const canContinue = stderrStream.write(chunk);
|
|
877
|
+
stderrTail.write(chunk);
|
|
878
|
+
if (
|
|
879
|
+
canContinue === false
|
|
880
|
+
&& !stderrDrainListener
|
|
881
|
+
&& typeof child.stderr.pause === 'function'
|
|
882
|
+
&& typeof child.stderr.resume === 'function'
|
|
883
|
+
&& typeof stderrStream.once === 'function'
|
|
884
|
+
&& typeof stderrStream.off === 'function'
|
|
885
|
+
) {
|
|
886
|
+
child.stderr.pause();
|
|
887
|
+
stderrDrainListener = () => {
|
|
888
|
+
stderrDrainListener = null;
|
|
889
|
+
if (!settled) child.stderr.resume();
|
|
890
|
+
};
|
|
891
|
+
stderrStream.once('drain', stderrDrainListener);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
286
894
|
} catch (error) {
|
|
287
895
|
if (!settled) processTree.unregisterChild(reservation);
|
|
288
896
|
reject(error);
|
|
289
897
|
return;
|
|
290
898
|
}
|
|
291
899
|
|
|
292
|
-
child.on('close', (code) => {
|
|
900
|
+
child.on('close', (code, signal) => {
|
|
293
901
|
let output = null;
|
|
294
902
|
try {
|
|
295
903
|
output = fs.readFileSync(junitPath, 'utf8');
|
|
296
904
|
} catch {}
|
|
297
|
-
finish(code ?? 1, output);
|
|
905
|
+
finish(code ?? 1, output, signal);
|
|
298
906
|
});
|
|
299
907
|
});
|
|
300
908
|
}
|
|
@@ -305,6 +913,7 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
305
913
|
const processTree = deps.processTree || createProcessTree({ env, platform });
|
|
306
914
|
let signal = null;
|
|
307
915
|
let completed = false;
|
|
916
|
+
let scheduleCancelled = false;
|
|
308
917
|
const removeSignalHandlers = processTree.installSignalHandlers((received) => {
|
|
309
918
|
signal = received;
|
|
310
919
|
});
|
|
@@ -314,9 +923,23 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
314
923
|
const shardTotal = Number.isInteger(args.shards) && args.shards > 0
|
|
315
924
|
? args.shards
|
|
316
925
|
: getDefaultShardCount(deps.cpuCount);
|
|
926
|
+
const subprocessShardTotal = Number.isInteger(args.shards) && args.shards > 0
|
|
927
|
+
? shardTotal
|
|
928
|
+
: Math.max(6, shardTotal);
|
|
317
929
|
const profile = deps.profile || readNewestProfile(reportDir);
|
|
318
930
|
const durationMap = deps.durationMap || createDurationMap(profile);
|
|
319
|
-
const
|
|
931
|
+
const resourceMap = deps.classify
|
|
932
|
+
? null
|
|
933
|
+
: await loadTestResourceMap(allTests, {
|
|
934
|
+
classifySource: deps.classifySource,
|
|
935
|
+
readFile: deps.readFile,
|
|
936
|
+
root: deps.root || rootDir,
|
|
937
|
+
});
|
|
938
|
+
const lanePlan = buildResourceLanePlan(allTests, shardTotal, durationMap, {
|
|
939
|
+
classify: deps.classify || ((file) => resourceMap.get(file)),
|
|
940
|
+
subprocessShardTotal,
|
|
941
|
+
});
|
|
942
|
+
const shardSpecs = lanePlan.flatMap((lane) => lane.shards);
|
|
320
943
|
|
|
321
944
|
if (shardSpecs.length === 0) {
|
|
322
945
|
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
@@ -330,20 +953,47 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
330
953
|
const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
|
|
331
954
|
|
|
332
955
|
console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
|
|
333
|
-
const
|
|
956
|
+
const laneGrants = computeLaneGrants(lanePlan, { platform, workerBudget: shardTotal });
|
|
957
|
+
for (const lane of lanePlan) {
|
|
958
|
+
const grant = laneGrants.get(lane);
|
|
959
|
+
const granted = grant.deferred ? grant.deferredConcurrency : grant.granted;
|
|
960
|
+
const files = lane.shards.reduce((total, shard) => total + shard.files.length, 0);
|
|
961
|
+
console.log(`Resource lane ${lane.name}: files=${files} shards=${lane.shards.length} concurrency=${granted} (nominal=${lane.concurrency} cost=${grant.cost} budget=${shardTotal}${grant.deferred ? ' deferred' : ''})`);
|
|
962
|
+
}
|
|
963
|
+
const childEnv = stripFullSuiteChildEnv(
|
|
334
964
|
typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
|
|
335
965
|
);
|
|
336
966
|
let results;
|
|
337
967
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
968
|
+
const nodeExecutable = deps.nodeExecutable ?? (
|
|
969
|
+
process.versions.bun ? globalThis.Bun?.which?.('node') : process.execPath
|
|
970
|
+
);
|
|
971
|
+
if (typeof nodeExecutable !== 'string' || !path.isAbsolute(nodeExecutable)) {
|
|
972
|
+
throw new Error('Full suite requires an absolute Node executable');
|
|
973
|
+
}
|
|
974
|
+
childEnv.FORGE_TEST_NODE_EXECUTABLE = nodeExecutable;
|
|
975
|
+
results = await runLaneSchedule(lanePlan, async (shard, lane) => ({
|
|
976
|
+
...await spawnShard(shard, {
|
|
977
|
+
bunCommand: deps.bunCommand,
|
|
978
|
+
env: childEnv,
|
|
979
|
+
labelPrefix: args.labelPrefix,
|
|
980
|
+
reportDirectory: runReportDir,
|
|
981
|
+
spawn: deps.spawn,
|
|
982
|
+
platform,
|
|
983
|
+
processTree,
|
|
984
|
+
stderrStream: deps.stderrStream,
|
|
985
|
+
timeoutMs: args.timeoutMs,
|
|
986
|
+
}),
|
|
987
|
+
resource: lane.name,
|
|
988
|
+
}), () => {
|
|
989
|
+
if (scheduleCancelled) return;
|
|
990
|
+
scheduleCancelled = true;
|
|
991
|
+
processTree.cleanup('SIGKILL');
|
|
992
|
+
}, {
|
|
993
|
+
grants: laneGrants,
|
|
344
994
|
platform,
|
|
345
|
-
|
|
346
|
-
})
|
|
995
|
+
workerBudget: shardTotal,
|
|
996
|
+
});
|
|
347
997
|
} catch (error) {
|
|
348
998
|
console.error('Full suite shard execution failed:', error);
|
|
349
999
|
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
@@ -352,9 +1002,36 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
352
1002
|
return exitCode;
|
|
353
1003
|
}
|
|
354
1004
|
|
|
1005
|
+
const nonzeroShards = results.filter((result) => result.code !== 0);
|
|
1006
|
+
if (nonzeroShards.length > 0) {
|
|
1007
|
+
console.error(`Full suite non-zero shards: ${nonzeroShards.map((result) => {
|
|
1008
|
+
let diagnostic = `${result.index}:${result.resource}:exit=${result.code} cause=${classifyShardFailure(result)}`;
|
|
1009
|
+
if (result.signal) diagnostic += ` signal=${result.signal}`;
|
|
1010
|
+
const stderrTail = result.stderrTail?.trim();
|
|
1011
|
+
if (stderrTail) diagnostic += ` stderr=${JSON.stringify(stderrTail)}`;
|
|
1012
|
+
return diagnostic;
|
|
1013
|
+
}).join(', ')}`);
|
|
1014
|
+
const failedTests = nonzeroShards.flatMap((result) => extractFailedTestCases(result.output));
|
|
1015
|
+
if (failedTests.length > 0) {
|
|
1016
|
+
console.error(`Full suite failing tests: ${failedTests.map((failure) => `${failure.file}${failure.line ? `:${failure.line}` : ''} (${failure.name})`).join(', ')}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
355
1019
|
const aggregate = aggregateShardReceipts(results, shardSpecs.length);
|
|
356
1020
|
const exitCode = signal ? signalExitCode(signal) : aggregate.exitCode;
|
|
357
1021
|
if (signal) aggregate.status = 'INCOMPLETE';
|
|
1022
|
+
if (aggregate.status !== 'INCOMPLETE') {
|
|
1023
|
+
const labelPrefix = args.labelPrefix || 'local-full';
|
|
1024
|
+
try {
|
|
1025
|
+
(deps.writeDurationProfile || writeDurationProfile)({
|
|
1026
|
+
allTests,
|
|
1027
|
+
label: labelPrefix,
|
|
1028
|
+
outputPath: deps.profileOutputPath || path.join(reportDir, `${labelPrefix}.profile.json`),
|
|
1029
|
+
runReportDir,
|
|
1030
|
+
});
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
console.warn(`Full suite profile was not updated: ${error.message}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
358
1035
|
if (aggregate.status === 'PASS' && exitCode === 0) {
|
|
359
1036
|
fs.rmSync(runReportDir, { force: true, recursive: true });
|
|
360
1037
|
}
|
|
@@ -364,7 +1041,7 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
364
1041
|
return exitCode;
|
|
365
1042
|
} finally {
|
|
366
1043
|
removeSignalHandlers();
|
|
367
|
-
processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
|
|
1044
|
+
if (!scheduleCancelled) processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
|
|
368
1045
|
}
|
|
369
1046
|
}
|
|
370
1047
|
|
|
@@ -386,13 +1063,22 @@ if (require.main === module) {
|
|
|
386
1063
|
module.exports = {
|
|
387
1064
|
aggregateShardReceipts,
|
|
388
1065
|
assertExactShardAssignment,
|
|
1066
|
+
buildResourceLanePlan,
|
|
389
1067
|
buildShardTestArgs,
|
|
390
1068
|
buildShardSpecs,
|
|
1069
|
+
classifyShardFailure,
|
|
1070
|
+
classifyTestResource,
|
|
1071
|
+
computeLaneGrants,
|
|
1072
|
+
extractFailedTestCases,
|
|
1073
|
+
laneWorkerCost,
|
|
391
1074
|
getDefaultShardCount,
|
|
392
1075
|
listAllFullSuiteTests,
|
|
1076
|
+
loadTestResourceMap,
|
|
393
1077
|
main,
|
|
394
1078
|
parseArgs,
|
|
1079
|
+
runLaneSchedule,
|
|
395
1080
|
runFullSuiteInParallel,
|
|
396
1081
|
spawnShard,
|
|
397
1082
|
walkAllTests,
|
|
1083
|
+
writeDurationProfile,
|
|
398
1084
|
};
|