forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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 +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -5,6 +5,7 @@ const {
|
|
|
5
5
|
assertProtectedWriteAllowed,
|
|
6
6
|
recordProtectedStateAuditEvent,
|
|
7
7
|
} = require('../lib/protected-state-surfaces');
|
|
8
|
+
const { authorizeAndConsumeProtectedStateWrites } = require('../lib/protected-state-authority');
|
|
8
9
|
|
|
9
10
|
function parseNameStatus(output) {
|
|
10
11
|
const files = [];
|
|
@@ -41,35 +42,60 @@ function getStagedFiles() {
|
|
|
41
42
|
return parseNameStatus(output);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
function getStagedContent(file) {
|
|
46
|
+
if (process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON) {
|
|
47
|
+
const contents = JSON.parse(process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON);
|
|
48
|
+
return Object.prototype.hasOwnProperty.call(contents, file) ? contents[file] : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return execFileSync('git', ['show', `:${file}`], {
|
|
53
|
+
encoding: null,
|
|
54
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
55
|
+
});
|
|
56
|
+
} catch (_error) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
function main() {
|
|
61
|
+
async function main() {
|
|
54
62
|
const actor =
|
|
55
63
|
process.env.FORGE_PROTECTED_STATE_ACTOR ||
|
|
56
64
|
process.env.FORGE_ACTOR ||
|
|
57
65
|
process.env.USER ||
|
|
58
66
|
process.env.USERNAME ||
|
|
59
67
|
'unknown';
|
|
60
|
-
const
|
|
61
|
-
const decisions = getStagedFiles()
|
|
68
|
+
const probes = getStagedFiles()
|
|
62
69
|
.map(file => {
|
|
63
70
|
const probe = assertProtectedWriteAllowed(file, { actor, operation: 'staged_edit' });
|
|
64
|
-
if (probe.requiredSurface
|
|
65
|
-
|
|
71
|
+
if (!probe.requiredSurface) return { probe };
|
|
72
|
+
|
|
73
|
+
const content = getStagedContent(probe.path);
|
|
74
|
+
if (content === null) return { probe };
|
|
75
|
+
return {
|
|
76
|
+
probe,
|
|
77
|
+
request: {
|
|
66
78
|
actor,
|
|
67
|
-
operation: 'staged_edit',
|
|
68
|
-
viaForgeApi: true,
|
|
69
79
|
surface: probe.requiredSurface,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
path: probe.path,
|
|
81
|
+
content,
|
|
82
|
+
operation: 'staged_edit',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const protectedProbes = probes.filter(entry => entry.request);
|
|
87
|
+
const authorization = await authorizeAndConsumeProtectedStateWrites(
|
|
88
|
+
process.cwd(),
|
|
89
|
+
protectedProbes.map(entry => entry.request),
|
|
90
|
+
);
|
|
91
|
+
let authorizationIndex = 0;
|
|
92
|
+
const decisions = probes
|
|
93
|
+
.map(entry => {
|
|
94
|
+
if (!entry.request) return entry.probe;
|
|
95
|
+
const trustedDecision = authorization.decisions[authorizationIndex++];
|
|
96
|
+
return trustedDecision.allowed
|
|
97
|
+
? trustedDecision
|
|
98
|
+
: { ...entry.probe, ...trustedDecision, repairHint: entry.probe.repairHint };
|
|
73
99
|
})
|
|
74
100
|
.filter(decision => !decision.allowed);
|
|
75
101
|
|
|
@@ -89,16 +115,15 @@ function main() {
|
|
|
89
115
|
for (const decision of decisions) {
|
|
90
116
|
console.error(` - ${decision.path} [${decision.requiredSurface}]`);
|
|
91
117
|
console.error(` Decision: ${decision.decision}`);
|
|
118
|
+
console.error(` Reason: ${decision.reason}`);
|
|
92
119
|
console.error(` Repair: ${decision.repairHint}`);
|
|
93
120
|
}
|
|
94
121
|
console.error('');
|
|
95
|
-
console.error('Use the owning Forge
|
|
122
|
+
console.error('Use the owning Forge API surface, then stage the generated result if that command explicitly owns it.');
|
|
96
123
|
process.exit(1);
|
|
97
124
|
}
|
|
98
125
|
|
|
99
|
-
|
|
100
|
-
main();
|
|
101
|
-
} catch (error) {
|
|
126
|
+
main().catch(error => {
|
|
102
127
|
console.error(`Protected state check failed: ${error.message}`);
|
|
103
128
|
process.exit(1);
|
|
104
|
-
}
|
|
129
|
+
});
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
} = require('./lib/eval-runner');
|
|
21
21
|
const { gradeTranscript } = require('./lib/grading');
|
|
22
22
|
const { saveEvalResult } = require('./lib/eval-storage');
|
|
23
|
+
const { verifyEvalReplay } = require('./lib/eval-evidence');
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// parseArgs
|
|
@@ -89,6 +90,7 @@ function runShellCommand(command, worktreePath) {
|
|
|
89
90
|
* @param {string} [options._basePath] — eval-logs base path for testing
|
|
90
91
|
* @param {boolean} [options._skipWorktree=false] — skip worktree creation for unit tests
|
|
91
92
|
* @param {Function} [options._executeOverride] — injectable command executor for testing
|
|
93
|
+
* @param {{envelope: object, inputs: {skill: string, tool: string}}} [options.replay] — exact-SHA replay binding
|
|
92
94
|
* @returns {Promise<{ command: string, results: Array, overall_score: number, passed: boolean, duration_ms: number }>}
|
|
93
95
|
*/
|
|
94
96
|
async function runEvalPipeline(evalSetPath, options = {}) {
|
|
@@ -98,6 +100,8 @@ async function runEvalPipeline(evalSetPath, options = {}) {
|
|
|
98
100
|
const execOverride = options._executeOverride || null;
|
|
99
101
|
const invokeGrader = options._invokeGrader || null;
|
|
100
102
|
const basePath = options._basePath || undefined;
|
|
103
|
+
const hasReplay = Object.hasOwn(options, 'replay');
|
|
104
|
+
const replay = options.replay;
|
|
101
105
|
|
|
102
106
|
const startTime = Date.now();
|
|
103
107
|
|
|
@@ -105,10 +109,34 @@ async function runEvalPipeline(evalSetPath, options = {}) {
|
|
|
105
109
|
const evalSet = loadEvalSet(evalSetPath);
|
|
106
110
|
const { command, queries } = evalSet;
|
|
107
111
|
|
|
112
|
+
let replayEvidence = null;
|
|
113
|
+
if (hasReplay) {
|
|
114
|
+
if (!replay || typeof replay !== 'object' || Array.isArray(replay)) throw new Error('replay must be an object');
|
|
115
|
+
for (const field of Object.keys(replay)) {
|
|
116
|
+
if (!['envelope', 'inputs'].includes(field)) throw new Error(`Unknown field 'replay.${field}'`);
|
|
117
|
+
}
|
|
118
|
+
if (!Object.hasOwn(replay, 'envelope')) throw new Error('replay.envelope is required');
|
|
119
|
+
if (!replay.inputs || typeof replay.inputs !== 'object' || Array.isArray(replay.inputs)) {
|
|
120
|
+
throw new Error('replay.inputs must be an object');
|
|
121
|
+
}
|
|
122
|
+
for (const field of Object.keys(replay.inputs)) {
|
|
123
|
+
if (!['skill', 'tool'].includes(field)) throw new Error(`Unknown field 'replay.inputs.${field}'`);
|
|
124
|
+
}
|
|
125
|
+
for (const field of ['skill', 'tool']) {
|
|
126
|
+
if (!Object.hasOwn(replay.inputs, field)) throw new Error(`replay.inputs.${field} is required`);
|
|
127
|
+
}
|
|
128
|
+
replayEvidence = verifyEvalReplay(replay.envelope, {
|
|
129
|
+
evalSet,
|
|
130
|
+
skill: replay.inputs.skill,
|
|
131
|
+
tool: replay.inputs.tool,
|
|
132
|
+
});
|
|
133
|
+
if (skipWorktree) throw new Error('Replay worktree verification cannot be skipped');
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
// 2. Create eval worktree (unless skipped for testing)
|
|
109
137
|
let worktreePath = null;
|
|
110
138
|
if (!skipWorktree) {
|
|
111
|
-
const wt = await createEvalWorktree();
|
|
139
|
+
const wt = await createEvalWorktree(replayEvidence ? replayEvidence.head_sha : undefined);
|
|
112
140
|
worktreePath = wt.path;
|
|
113
141
|
}
|
|
114
142
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pre-commit auto-heal for the D20 bd call-site kill-list artifact.
|
|
6
|
+
*
|
|
7
|
+
* The `d20-audit-artifact-current` release gate compares the checked-in kill-list
|
|
8
|
+
* against a live re-scan, so any commit that shifts the bd call-site census leaves the
|
|
9
|
+
* artifact stale until someone runs `forge release regen-audit`. Five builders in a row
|
|
10
|
+
* forgot, and each found out a full CI round later (kernel issue 4cf2c43d).
|
|
11
|
+
*
|
|
12
|
+
* This removes the regen from the human's list, the same way scripts/sync-agent-skills.js
|
|
13
|
+
* removed the `.agents/skills` mirror regen: gate on a cheap changed-path predicate,
|
|
14
|
+
* regenerate, re-stage, succeed quietly. The release gate stays as the backstop for
|
|
15
|
+
* anything committed without this hook.
|
|
16
|
+
*
|
|
17
|
+
* The predicate is `isBdCensusPath`, exported by the gate's own module, so the hook and
|
|
18
|
+
* the gate always agree on which files count. The artifact itself is not a census path,
|
|
19
|
+
* so re-staging it cannot re-trigger the hook.
|
|
20
|
+
*
|
|
21
|
+
* The guarantee, stated honestly: the auto-heal applies when the counted staged paths
|
|
22
|
+
* match the working tree. It decides from the index but regenerates from the working tree
|
|
23
|
+
* (`auditBdCallSites` walks the filesystem), so those two agree only when the counted paths
|
|
24
|
+
* are staged whole. When any of them is partially staged the hook regenerates nothing, says
|
|
25
|
+
* so on stderr, and defers to a manual `forge release regen-audit` plus the
|
|
26
|
+
* `d20-audit-artifact-current` release gate — which still fails a genuinely stale artifact.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { execFileSync } = require('node:child_process');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { AUDIT_ARTIFACT, isBdCensusPath, writeAuditArtifact } = require('../lib/release-readiness');
|
|
32
|
+
|
|
33
|
+
function repoRoot(exec = execFileSync) {
|
|
34
|
+
try {
|
|
35
|
+
return exec('git', ['rev-parse', '--show-toplevel'], {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
38
|
+
}).trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return path.resolve(__dirname, '..');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parses `git diff --cached --name-status -z`: a flat NUL-delimited token stream of a
|
|
46
|
+
* status followed by its path — `A\0path\0`, and `R100\0old\0new\0` for a rename or copy,
|
|
47
|
+
* which is read from both sides because both shift the census (the old path leaves it and
|
|
48
|
+
* the new one may not enter it).
|
|
49
|
+
*
|
|
50
|
+
* NUL delimiting is what makes this safe. Git's default output quotes and backslash-
|
|
51
|
+
* escapes pathnames with non-ASCII or special characters, and no line/tab split can
|
|
52
|
+
* represent a pathname that itself contains a tab or a newline. Under -z the pathnames are
|
|
53
|
+
* verbatim, so nothing here may trim them — a mis-read path silently drops a
|
|
54
|
+
* census-impacting change and skips the regen this hook exists to perform.
|
|
55
|
+
*/
|
|
56
|
+
function parseStagedNameStatus(output) {
|
|
57
|
+
const tokens = output.split('\0');
|
|
58
|
+
const files = [];
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
61
|
+
const status = tokens[index];
|
|
62
|
+
if (status.length === 0) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const pathCount = /^[RC]/.test(status) ? 2 : 1;
|
|
66
|
+
files.push(...tokens.slice(index + 1, index + 1 + pathCount).filter(Boolean));
|
|
67
|
+
index += pathCount;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...new Set(files)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stagedPaths(projectRoot, exec = execFileSync) {
|
|
74
|
+
return parseStagedNameStatus(
|
|
75
|
+
exec('git', ['-C', projectRoot, 'diff', '--cached', '--name-status', '-z', '--diff-filter=ACMRDT'], {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parses a bare NUL-delimited path list (`git diff --name-only -z`). Same rule as
|
|
84
|
+
* parseStagedNameStatus: under -z the pathnames are verbatim, so nothing here may trim them.
|
|
85
|
+
*/
|
|
86
|
+
function parseNulPaths(output) {
|
|
87
|
+
return output.split('\0').filter(Boolean);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Of the given paths, the ones whose working-tree content differs from their staged content.
|
|
92
|
+
* `git diff` without `--cached` is index-vs-worktree, which is exactly the question the
|
|
93
|
+
* auto-heal must answer before regenerating an artifact it builds from the working tree.
|
|
94
|
+
*/
|
|
95
|
+
function worktreeModifiedPaths(projectRoot, paths, exec = execFileSync) {
|
|
96
|
+
if (paths.length === 0) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parseNulPaths(
|
|
101
|
+
exec('git', ['-C', projectRoot, 'diff', '--name-only', '-z', '--', ...paths], {
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stageArtifact(projectRoot, relativePath) {
|
|
109
|
+
execFileSync('git', ['-C', projectRoot, 'add', '--', relativePath], {
|
|
110
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function run(deps) {
|
|
115
|
+
const { projectRoot, staged, worktreeModified, isCensusPath, writeArtifact, stage, log, warn } = deps;
|
|
116
|
+
|
|
117
|
+
const triggers = staged().filter(filePath => isCensusPath(projectRoot, filePath));
|
|
118
|
+
if (triggers.length === 0) {
|
|
119
|
+
return { regenerated: false, triggers: [], partiallyStaged: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const partiallyStaged = worktreeModified(projectRoot, triggers);
|
|
123
|
+
if (partiallyStaged.length > 0) {
|
|
124
|
+
warn(
|
|
125
|
+
`sync-d20-audit: SKIPPED — the index is partially staged, so ${AUDIT_ARTIFACT} was NOT regenerated.\n` +
|
|
126
|
+
` Staged and separately modified in the working tree: ${partiallyStaged.join(', ')}\n` +
|
|
127
|
+
' The artifact is built from the working tree, so regenerating it now would commit a\n' +
|
|
128
|
+
' census of content this commit does not contain.\n' +
|
|
129
|
+
` Run \`forge release regen-audit\` and stage ${AUDIT_ARTIFACT} yourself if this commit\n` +
|
|
130
|
+
' shifts the bd call-site census; the d20-audit-artifact-current gate checks it either way.',
|
|
131
|
+
);
|
|
132
|
+
return { regenerated: false, triggers, partiallyStaged };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
writeArtifact(projectRoot);
|
|
136
|
+
stage(projectRoot, AUDIT_ARTIFACT);
|
|
137
|
+
log(`sync-d20-audit: regenerated ${AUDIT_ARTIFACT} (census paths staged: ${triggers.join(', ')})`);
|
|
138
|
+
return { regenerated: true, triggers, partiallyStaged: [] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function main() {
|
|
142
|
+
const projectRoot = repoRoot();
|
|
143
|
+
try {
|
|
144
|
+
run({
|
|
145
|
+
projectRoot,
|
|
146
|
+
staged: () => stagedPaths(projectRoot),
|
|
147
|
+
worktreeModified: (root, paths) => worktreeModifiedPaths(root, paths),
|
|
148
|
+
isCensusPath: isBdCensusPath,
|
|
149
|
+
writeArtifact: root => writeAuditArtifact(root),
|
|
150
|
+
stage: stageArtifact,
|
|
151
|
+
log: message => console.log(message),
|
|
152
|
+
warn: message => console.error(message),
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error(`sync-d20-audit: failed to regenerate ${AUDIT_ARTIFACT} — ${error.message}`);
|
|
156
|
+
console.error('Run `forge release regen-audit` and stage the result, or fix the error above.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (require.main === module) {
|
|
162
|
+
main();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
run,
|
|
167
|
+
parseStagedNameStatus,
|
|
168
|
+
parseNulPaths,
|
|
169
|
+
stagedPaths,
|
|
170
|
+
worktreeModifiedPaths,
|
|
171
|
+
repoRoot,
|
|
172
|
+
};
|
|
@@ -13,6 +13,8 @@ const {
|
|
|
13
13
|
readNewestProfile,
|
|
14
14
|
walkTests,
|
|
15
15
|
} = require('./test-ci-shard');
|
|
16
|
+
const { createProcessTree, signalExitCode } = require('./process-tree');
|
|
17
|
+
const { stripGitHookEnv } = require('./test');
|
|
16
18
|
|
|
17
19
|
const rootDir = path.join(__dirname, '..');
|
|
18
20
|
const reportDir = path.join(rootDir, 'test-results');
|
|
@@ -81,6 +83,29 @@ function walkAllTests(dir) {
|
|
|
81
83
|
return results;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
function assertExactShardAssignment(allTests, shardSpecs) {
|
|
87
|
+
const expectedFiles = new Set(allTests);
|
|
88
|
+
const assignedFiles = new Set();
|
|
89
|
+
|
|
90
|
+
for (const shard of shardSpecs) {
|
|
91
|
+
for (const file of shard.files) {
|
|
92
|
+
if (!expectedFiles.has(file)) {
|
|
93
|
+
throw new Error(`Test file ${file} is not part of the full suite`);
|
|
94
|
+
}
|
|
95
|
+
if (assignedFiles.has(file)) {
|
|
96
|
+
throw new Error(`Test file ${file} belongs to more than exactly one shard`);
|
|
97
|
+
}
|
|
98
|
+
assignedFiles.add(file);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const file of expectedFiles) {
|
|
103
|
+
if (!assignedFiles.has(file)) {
|
|
104
|
+
throw new Error(`Test file ${file} was omitted from the shard assignment`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
|
|
85
110
|
const specs = [];
|
|
86
111
|
for (let shardIndex = 0; shardIndex < shardTotal; shardIndex += 1) {
|
|
@@ -100,63 +125,247 @@ function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
|
|
|
100
125
|
source: plan.source,
|
|
101
126
|
});
|
|
102
127
|
}
|
|
128
|
+
assertExactShardAssignment(allTests, specs);
|
|
103
129
|
return specs;
|
|
104
130
|
}
|
|
105
131
|
|
|
132
|
+
function buildShardTestArgs({ junitPath, files, root = rootDir }) {
|
|
133
|
+
return [
|
|
134
|
+
'test',
|
|
135
|
+
'--timeout',
|
|
136
|
+
'30000',
|
|
137
|
+
'--reporter=junit',
|
|
138
|
+
'--reporter-outfile',
|
|
139
|
+
junitPath,
|
|
140
|
+
...files.map((file) => path.resolve(root, file)),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseShardReceipt(output) {
|
|
145
|
+
if (typeof output !== 'string') return null;
|
|
146
|
+
const root = output.match(/^\s*(?:<\?xml\b[^?]*\?>\s*)?<testsuites\b([^>]*)>[\s\S]*<\/testsuites\s*>\s*$/);
|
|
147
|
+
const openingTags = output.match(/<testsuites\b/g) || [];
|
|
148
|
+
const closingTags = output.match(/<\/testsuites\s*>/g) || [];
|
|
149
|
+
if (!root || openingTags.length !== 1 || closingTags.length !== 1) return null;
|
|
150
|
+
|
|
151
|
+
const readAttribute = (name) => root[1].match(new RegExp('\\b' + name + '="(\\d+)"'));
|
|
152
|
+
const requiredAttributes = ['tests', 'assertions', 'failures', 'skipped'];
|
|
153
|
+
const values = requiredAttributes.map(readAttribute);
|
|
154
|
+
const hasInvalidRequiredAttribute = requiredAttributes.some((name, index) => {
|
|
155
|
+
const occurrences = root[1].match(new RegExp('\\b' + name + '\\s*=', 'g')) || [];
|
|
156
|
+
return occurrences.length !== 1 || !values[index];
|
|
157
|
+
});
|
|
158
|
+
const errorsOccurrences = root[1].match(/\berrors\s*=/g) || [];
|
|
159
|
+
const errorsAttribute = readAttribute('errors');
|
|
160
|
+
if (hasInvalidRequiredAttribute
|
|
161
|
+
|| errorsOccurrences.length > 1
|
|
162
|
+
|| (errorsOccurrences.length === 1 && !errorsAttribute)) return null;
|
|
163
|
+
|
|
164
|
+
const tests = Number.parseInt(values[0][1], 10);
|
|
165
|
+
const assertions = Number.parseInt(values[1][1], 10);
|
|
166
|
+
const failed = Number.parseInt(values[2][1], 10);
|
|
167
|
+
const errors = Number.parseInt(errorsAttribute?.[1] || '0', 10);
|
|
168
|
+
const skipped = Number.parseInt(values[3][1], 10);
|
|
169
|
+
const passed = tests - failed - errors - skipped;
|
|
170
|
+
if (tests === 0 || passed < 0) return null;
|
|
171
|
+
return { assertions, errors, failed, passed, skipped, tests };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function aggregateShardReceipts(receipts, expectedCount) {
|
|
175
|
+
const totals = {
|
|
176
|
+
assertions: 0,
|
|
177
|
+
errors: 0,
|
|
178
|
+
failed: 0,
|
|
179
|
+
passed: 0,
|
|
180
|
+
skipped: 0,
|
|
181
|
+
tests: 0,
|
|
182
|
+
};
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
let incomplete = receipts.length !== expectedCount;
|
|
185
|
+
let failedProcess = false;
|
|
186
|
+
|
|
187
|
+
for (const receipt of receipts) {
|
|
188
|
+
if (receipt === null || typeof receipt !== 'object'
|
|
189
|
+
|| !Number.isInteger(receipt.index)
|
|
190
|
+
|| receipt.index < 0
|
|
191
|
+
|| receipt.index >= expectedCount
|
|
192
|
+
|| seen.has(receipt.index)) {
|
|
193
|
+
incomplete = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
seen.add(receipt.index);
|
|
197
|
+
if (!Number.isInteger(receipt.code)) {
|
|
198
|
+
incomplete = true;
|
|
199
|
+
} else {
|
|
200
|
+
failedProcess ||= receipt.code !== 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const parsed = parseShardReceipt(receipt.output);
|
|
204
|
+
if (!parsed) {
|
|
205
|
+
incomplete = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const key of Object.keys(totals)) totals[key] += parsed[key];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
incomplete ||= seen.size !== expectedCount;
|
|
212
|
+
const status = incomplete
|
|
213
|
+
? 'INCOMPLETE'
|
|
214
|
+
: (failedProcess || totals.failed > 0 || totals.errors > 0 ? 'FAIL' : 'PASS');
|
|
215
|
+
return { ...totals, exitCode: status === 'PASS' ? 0 : 1, status };
|
|
216
|
+
}
|
|
217
|
+
|
|
106
218
|
function spawnShard(shard, options = {}) {
|
|
107
219
|
const spawn = options.spawn || defaultSpawn;
|
|
108
220
|
const env = options.env || process.env;
|
|
109
221
|
const bunCommand = options.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
|
|
110
222
|
const labelPrefix = options.labelPrefix || 'local-full';
|
|
111
|
-
|
|
112
|
-
const
|
|
223
|
+
const targetReportDir = options.reportDirectory || reportDir;
|
|
224
|
+
const platform = options.platform || process.platform;
|
|
225
|
+
const processTree = options.processTree || createProcessTree({ env, platform });
|
|
226
|
+
const resolvedReportDir = path.resolve(targetReportDir);
|
|
227
|
+
const junitPath = path.resolve(resolvedReportDir, `${labelPrefix}-shard-${shard.index}.xml`);
|
|
228
|
+
if (path.dirname(junitPath) !== resolvedReportDir) {
|
|
229
|
+
return Promise.reject(new Error('label prefix must produce a receipt directly inside test-results'));
|
|
230
|
+
}
|
|
231
|
+
fs.mkdirSync(resolvedReportDir, { recursive: true });
|
|
113
232
|
|
|
114
233
|
return new Promise((resolve, reject) => {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
'
|
|
118
|
-
|
|
119
|
-
'--reporter=junit',
|
|
120
|
-
'--reporter-outfile',
|
|
121
|
-
junitPath,
|
|
122
|
-
...shard.files,
|
|
123
|
-
], {
|
|
124
|
-
cwd: rootDir,
|
|
125
|
-
env,
|
|
126
|
-
shell: false,
|
|
127
|
-
stdio: 'inherit',
|
|
234
|
+
const reservation = processTree.reserveChild({
|
|
235
|
+
command: bunCommand,
|
|
236
|
+
kind: 'test-shard',
|
|
237
|
+
label: `${labelPrefix}-shard-${shard.index}`,
|
|
128
238
|
});
|
|
239
|
+
if (!reservation) {
|
|
240
|
+
reject(new Error('test shard ownership manifest is unavailable'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let child;
|
|
245
|
+
let settled = false;
|
|
246
|
+
const finish = (code, output) => {
|
|
247
|
+
if (settled) return;
|
|
248
|
+
settled = true;
|
|
249
|
+
processTree.unregisterChild(reservation);
|
|
250
|
+
resolve({ code, index: shard.index, output });
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
fs.rmSync(junitPath, { force: true });
|
|
254
|
+
child = spawn(bunCommand, buildShardTestArgs({
|
|
255
|
+
junitPath,
|
|
256
|
+
files: shard.files,
|
|
257
|
+
}), {
|
|
258
|
+
cwd: rootDir,
|
|
259
|
+
env,
|
|
260
|
+
shell: false,
|
|
261
|
+
stdio: 'inherit',
|
|
262
|
+
detached: platform !== 'win32',
|
|
263
|
+
windowsHide: true,
|
|
264
|
+
});
|
|
265
|
+
child.on('error', (error) => {
|
|
266
|
+
if (settled) return;
|
|
267
|
+
settled = true;
|
|
268
|
+
processTree.unregisterChild(reservation);
|
|
269
|
+
reject(error);
|
|
270
|
+
});
|
|
271
|
+
if (!processTree.registerChild(reservation, child)) {
|
|
272
|
+
settled = true;
|
|
273
|
+
if (typeof processTree.abortChild === 'function') {
|
|
274
|
+
processTree.abortChild(reservation, child);
|
|
275
|
+
} else {
|
|
276
|
+
try {
|
|
277
|
+
child.kill?.('SIGKILL');
|
|
278
|
+
} finally {
|
|
279
|
+
processTree.cleanup?.('SIGKILL');
|
|
280
|
+
processTree.unregisterChild(reservation);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
reject(new Error('test shard process could not be registered'));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!settled) processTree.unregisterChild(reservation);
|
|
288
|
+
reject(error);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
129
291
|
|
|
130
|
-
child.on('error', reject);
|
|
131
292
|
child.on('close', (code) => {
|
|
132
|
-
|
|
293
|
+
let output = null;
|
|
294
|
+
try {
|
|
295
|
+
output = fs.readFileSync(junitPath, 'utf8');
|
|
296
|
+
} catch {}
|
|
297
|
+
finish(code ?? 1, output);
|
|
133
298
|
});
|
|
134
299
|
});
|
|
135
300
|
}
|
|
136
301
|
|
|
137
302
|
async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
303
|
+
const env = deps.env || process.env;
|
|
304
|
+
const platform = deps.platform || process.platform;
|
|
305
|
+
const processTree = deps.processTree || createProcessTree({ env, platform });
|
|
306
|
+
let signal = null;
|
|
307
|
+
let completed = false;
|
|
308
|
+
const removeSignalHandlers = processTree.installSignalHandlers((received) => {
|
|
309
|
+
signal = received;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const allTests = deps.allTests || listAllFullSuiteTests();
|
|
314
|
+
const shardTotal = Number.isInteger(args.shards) && args.shards > 0
|
|
315
|
+
? args.shards
|
|
316
|
+
: getDefaultShardCount(deps.cpuCount);
|
|
317
|
+
const profile = deps.profile || readNewestProfile(reportDir);
|
|
318
|
+
const durationMap = deps.durationMap || createDurationMap(profile);
|
|
319
|
+
const shardSpecs = buildShardSpecs(allTests, shardTotal, durationMap);
|
|
150
320
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
321
|
+
if (shardSpecs.length === 0) {
|
|
322
|
+
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
323
|
+
console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
|
|
324
|
+
console.log('Full suite exit: ' + exitCode);
|
|
325
|
+
completed = true;
|
|
326
|
+
return exitCode;
|
|
327
|
+
}
|
|
158
328
|
|
|
159
|
-
|
|
329
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
330
|
+
const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
|
|
331
|
+
|
|
332
|
+
console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
|
|
333
|
+
const childEnv = stripGitHookEnv(
|
|
334
|
+
typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
|
|
335
|
+
);
|
|
336
|
+
let results;
|
|
337
|
+
try {
|
|
338
|
+
results = await Promise.all(shardSpecs.map((shard) => spawnShard(shard, {
|
|
339
|
+
bunCommand: deps.bunCommand,
|
|
340
|
+
env: childEnv,
|
|
341
|
+
labelPrefix: args.labelPrefix,
|
|
342
|
+
reportDirectory: runReportDir,
|
|
343
|
+
spawn: deps.spawn,
|
|
344
|
+
platform,
|
|
345
|
+
processTree,
|
|
346
|
+
})));
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error('Full suite shard execution failed:', error);
|
|
349
|
+
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
350
|
+
console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
|
|
351
|
+
console.log('Full suite exit: ' + exitCode);
|
|
352
|
+
return exitCode;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const aggregate = aggregateShardReceipts(results, shardSpecs.length);
|
|
356
|
+
const exitCode = signal ? signalExitCode(signal) : aggregate.exitCode;
|
|
357
|
+
if (signal) aggregate.status = 'INCOMPLETE';
|
|
358
|
+
if (aggregate.status === 'PASS' && exitCode === 0) {
|
|
359
|
+
fs.rmSync(runReportDir, { force: true, recursive: true });
|
|
360
|
+
}
|
|
361
|
+
console.log(`Full suite aggregate: status=${aggregate.status} tests=${aggregate.tests} assertions=${aggregate.assertions} passed=${aggregate.passed} failed=${aggregate.failed} errors=${aggregate.errors} skipped=${aggregate.skipped}`);
|
|
362
|
+
console.log(`Full suite exit: ${exitCode}`);
|
|
363
|
+
completed = true;
|
|
364
|
+
return exitCode;
|
|
365
|
+
} finally {
|
|
366
|
+
removeSignalHandlers();
|
|
367
|
+
processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
|
|
368
|
+
}
|
|
160
369
|
}
|
|
161
370
|
|
|
162
371
|
async function main(argv = process.argv.slice(2), deps = {}) {
|
|
@@ -175,6 +384,9 @@ if (require.main === module) {
|
|
|
175
384
|
}
|
|
176
385
|
|
|
177
386
|
module.exports = {
|
|
387
|
+
aggregateShardReceipts,
|
|
388
|
+
assertExactShardAssignment,
|
|
389
|
+
buildShardTestArgs,
|
|
178
390
|
buildShardSpecs,
|
|
179
391
|
getDefaultShardCount,
|
|
180
392
|
listAllFullSuiteTests,
|