@izkac/forgekit 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/forge.mjs +2 -0
- package/package.json +1 -1
- package/src/e2e.mjs +172 -0
- package/src/init.mjs +13 -6
- package/src/init.test.mjs +32 -1
- package/src/integrity-check.mjs +5 -3
- package/src/integrity.mjs +310 -20
- package/src/integrity.test.mjs +169 -17
- package/src/set-phase.test.mjs +9 -1
- package/vendor/skills/forge/SKILL.md +1 -1
- package/vendor/skills/forge/docs/forge.md +53 -31
- package/vendor/skills/forge/phases/finish.md +1 -1
- package/vendor/skills/forge/phases/implement.md +1 -0
- package/vendor/skills/forge/phases/plan-openspec.md +8 -2
- package/vendor/skills/forge/phases/plan-specs.md +8 -2
- package/vendor/skills/forge/phases/verify.md +7 -3
- package/vendor/skills/forge/references/runtime-integrity.md +28 -10
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +9 -6
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +1 -0
- package/vendor/templates/project/claude/commands/forge.md +1 -1
- package/vendor/templates/project/claude/rules/forge.md +20 -16
- package/vendor/templates/project/codex/rules/forge.md +12 -10
- package/vendor/templates/project/cursor/rules/forge.mdc +25 -21
package/bin/forge.mjs
CHANGED
|
@@ -31,6 +31,7 @@ const COMMANDS = {
|
|
|
31
31
|
triage: { script: 'triage-prompt.mjs' },
|
|
32
32
|
change: { script: 'change.mjs' },
|
|
33
33
|
spine: { script: 'spine.mjs' },
|
|
34
|
+
e2e: { script: 'e2e.mjs' },
|
|
34
35
|
defer: { script: 'defer.mjs' },
|
|
35
36
|
'integrity-check': { script: 'integrity-check.mjs', aliases: ['integrity'] },
|
|
36
37
|
score: { script: 'score-cli.mjs', aliases: ['scorecard'] },
|
|
@@ -59,6 +60,7 @@ Commands:
|
|
|
59
60
|
triage Classify whether a prompt needs Forge triage
|
|
60
61
|
change new|archive Specs-engine change scaffold / archive
|
|
61
62
|
spine init|check Capability→runtime spine matrix (spine.json)
|
|
63
|
+
e2e init|run|check Executable product-loop acceptance (e2e.json)
|
|
62
64
|
defer add|resolve|list Deferral registry (deferred wiring = tracked debt)
|
|
63
65
|
integrity-check Mechanical integrity gate (runs at phase done)
|
|
64
66
|
score [--write] L2 session scorecard (auto-written at phase done)
|
package/package.json
CHANGED
package/src/e2e.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forge E2E acceptance — the closed product loop as an executable step list.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forge e2e # show status (file, validity, results freshness)
|
|
7
|
+
* forge e2e init [--force] # scaffold e2e.json for the active change
|
|
8
|
+
* forge e2e run # execute steps, write e2e-results.json (session dir)
|
|
9
|
+
* forge e2e check # gate check: green + current results; exit 1 with problems
|
|
10
|
+
* [--session <id>]
|
|
11
|
+
*
|
|
12
|
+
* e2e.json lives next to spine.json (change dir, falling back to the session
|
|
13
|
+
* dir). Results carry a hash of the steps, so editing e2e.json after a green
|
|
14
|
+
* run invalidates the results. `forge integrity-check` / `forge phase done`
|
|
15
|
+
* run the same gate when the spine has real rows.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import { loadSession, readActive, readJson } from './lib.mjs';
|
|
20
|
+
import {
|
|
21
|
+
checkE2eGate,
|
|
22
|
+
e2ePath,
|
|
23
|
+
e2eResultsPath,
|
|
24
|
+
e2eStepsHash,
|
|
25
|
+
initE2e,
|
|
26
|
+
loadE2eResults,
|
|
27
|
+
runE2eSteps,
|
|
28
|
+
validateE2e,
|
|
29
|
+
writeE2eResults,
|
|
30
|
+
} from './integrity.mjs';
|
|
31
|
+
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
|
|
34
|
+
|
|
35
|
+
if (args[0] === '--help' || sub === 'help') {
|
|
36
|
+
process.stdout.write(
|
|
37
|
+
'Usage: forge e2e [init [--force] | run | check | status] [--session <id>]\n',
|
|
38
|
+
);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let sessionId = null;
|
|
43
|
+
let force = false;
|
|
44
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
45
|
+
if (args[i] === '--session' && args[i + 1]) {
|
|
46
|
+
sessionId = args[i + 1];
|
|
47
|
+
i += 1;
|
|
48
|
+
} else if (args[i] === '--force') {
|
|
49
|
+
force = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!sessionId) {
|
|
54
|
+
const active = readActive();
|
|
55
|
+
sessionId = active?.sessionId;
|
|
56
|
+
}
|
|
57
|
+
if (!sessionId) {
|
|
58
|
+
process.stderr.write('No active session. Run forge new first.\n');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { dir, session } = loadSession(sessionId);
|
|
63
|
+
const file = e2ePath({ session, sessionDir: dir });
|
|
64
|
+
|
|
65
|
+
if (sub === 'init') {
|
|
66
|
+
try {
|
|
67
|
+
initE2e({ file, change: session.openspecChange ?? null, force });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(
|
|
73
|
+
`Scaffolded ${file}\nAuthor the product-loop steps (produce → consume → assert domain side effects).\nSteps that would pass against a stubbed handler are invalid.\nRun with: forge e2e run\n`,
|
|
74
|
+
);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sub === 'run') {
|
|
79
|
+
if (!fs.existsSync(file)) {
|
|
80
|
+
process.stderr.write(`e2e.json not found at ${file} — run forge e2e init\n`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
let doc;
|
|
84
|
+
try {
|
|
85
|
+
doc = readJson(file);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
process.stderr.write(`e2e.json unreadable: ${err instanceof Error ? err.message : err}\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const valid = validateE2e(doc);
|
|
91
|
+
if (!valid.ok) {
|
|
92
|
+
process.stderr.write(`e2e.json invalid:\n${valid.problems.map((p) => ` - ${p}`).join('\n')}\n`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
if (typeof doc.notApplicable === 'string' && doc.notApplicable.trim()) {
|
|
96
|
+
process.stdout.write(`e2e notApplicable: ${doc.notApplicable}\nNothing to run.\n`);
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
const results = runE2eSteps(doc, { cwd: process.cwd() });
|
|
100
|
+
const resultsFile = writeE2eResults(dir, results);
|
|
101
|
+
for (const step of results.steps) {
|
|
102
|
+
if (step.skipped) {
|
|
103
|
+
process.stdout.write(` - ${step.name}: skipped (earlier step failed)\n`);
|
|
104
|
+
} else {
|
|
105
|
+
process.stdout.write(
|
|
106
|
+
` ${step.ok ? '✓' : '✗'} ${step.name}: exit ${step.exitCode ?? 'n/a'}${
|
|
107
|
+
step.expectMatched === false ? ' (expect did not match)' : ''
|
|
108
|
+
}${step.error ? ` [${step.error}]` : ''} (${step.durationMs}ms)\n`,
|
|
109
|
+
);
|
|
110
|
+
if (!step.ok && step.outputTail) {
|
|
111
|
+
process.stdout.write(`${step.outputTail.replace(/^/gm, ' ')}\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
process.stdout.write(`${results.ok ? 'GREEN' : 'FAILED'} — results: ${resultsFile}\n`);
|
|
116
|
+
process.exit(results.ok ? 0 : 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (sub === 'check') {
|
|
120
|
+
const gate = checkE2eGate({ e2eFile: file, sessionDir: dir });
|
|
121
|
+
process.stdout.write(
|
|
122
|
+
JSON.stringify(
|
|
123
|
+
{ file, ok: gate.problems.length === 0, notApplicable: gate.notApplicable, problems: gate.problems },
|
|
124
|
+
null,
|
|
125
|
+
2,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
process.stdout.write('\n');
|
|
129
|
+
process.exit(gate.problems.length === 0 ? 0 : 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (sub === 'status') {
|
|
133
|
+
if (!fs.existsSync(file)) {
|
|
134
|
+
process.stdout.write(JSON.stringify({ file, exists: false }, null, 2));
|
|
135
|
+
process.stdout.write('\n');
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
let doc = null;
|
|
139
|
+
let valid = { ok: false, problems: ['unreadable'] };
|
|
140
|
+
try {
|
|
141
|
+
doc = readJson(file);
|
|
142
|
+
valid = validateE2e(doc);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
valid = { ok: false, problems: [`unreadable: ${err instanceof Error ? err.message : err}`] };
|
|
145
|
+
}
|
|
146
|
+
const results = loadE2eResults(dir);
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
file,
|
|
151
|
+
exists: true,
|
|
152
|
+
ok: valid.ok,
|
|
153
|
+
problems: valid.problems,
|
|
154
|
+
results: results
|
|
155
|
+
? {
|
|
156
|
+
file: e2eResultsPath(dir),
|
|
157
|
+
ok: results.ok === true,
|
|
158
|
+
ranAt: results.ranAt ?? null,
|
|
159
|
+
stale: doc ? results.stepsHash !== e2eStepsHash(doc.steps) : null,
|
|
160
|
+
}
|
|
161
|
+
: null,
|
|
162
|
+
},
|
|
163
|
+
null,
|
|
164
|
+
2,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
process.stdout.write('\n');
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process.stderr.write(`Unknown subcommand: ${sub}\n`);
|
|
172
|
+
process.exit(1);
|
package/src/init.mjs
CHANGED
|
@@ -100,7 +100,8 @@ Options:
|
|
|
100
100
|
--no-adr Disable ADRs for this project
|
|
101
101
|
--adr-dir <path> ADR directory (default: ${DEFAULT_ADR_DIR} or ~/.forgekit preference)
|
|
102
102
|
--overlay Also run \`forge overlay\` (OpenSpec vendor patches)
|
|
103
|
-
--force, -f
|
|
103
|
+
--force, -f Force re-scaffold of ADR/specs docs (managed command,
|
|
104
|
+
rule, and hook files always refresh to the latest template)
|
|
104
105
|
--cwd <path> Project root (default: cwd)
|
|
105
106
|
--help
|
|
106
107
|
|
|
@@ -124,16 +125,22 @@ export function resolveTemplatesRoot() {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/**
|
|
128
|
+
* Copy a forgekit-managed template file. These are regenerated pointers
|
|
129
|
+
* (forge-* commands/rules/hooks) with no user-owned content, so re-running
|
|
130
|
+
* `forge init` refreshes them in place — that's how template fixes propagate.
|
|
127
131
|
* @param {string} src
|
|
128
132
|
* @param {string} dest
|
|
129
|
-
* @param {{ force?: boolean }}
|
|
133
|
+
* @param {{ force?: boolean }} _opts
|
|
130
134
|
*/
|
|
131
|
-
function copyFile(src, dest,
|
|
135
|
+
function copyFile(src, dest, _opts) {
|
|
132
136
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
const next = fs.readFileSync(src);
|
|
138
|
+
if (fs.existsSync(dest)) {
|
|
139
|
+
if (fs.readFileSync(dest).equals(next)) return 'unchanged';
|
|
140
|
+
fs.writeFileSync(dest, next);
|
|
141
|
+
return 'updated';
|
|
135
142
|
}
|
|
136
|
-
fs.
|
|
143
|
+
fs.writeFileSync(dest, next);
|
|
137
144
|
return 'written';
|
|
138
145
|
}
|
|
139
146
|
|
package/src/init.test.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { parseArgs, initProject, rememberedAgents } from './init.mjs';
|
|
6
|
+
import { parseArgs, initProject, rememberedAgents, resolveTemplatesRoot } from './init.mjs';
|
|
7
7
|
import { installSkillsToAgents } from './install.mjs';
|
|
8
8
|
import { saveUserConfig } from './config.mjs';
|
|
9
9
|
|
|
@@ -35,6 +35,37 @@ test('rememberedAgents unions install config, installed skills, and project wiri
|
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test('re-running init refreshes stale managed rule files in place', () => {
|
|
39
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-refresh-'));
|
|
40
|
+
try {
|
|
41
|
+
initProject(['claude'], { cwd, adr: false, planEngine: null });
|
|
42
|
+
const rule = path.join(cwd, '.claude', 'rules', 'forge.md');
|
|
43
|
+
// Simulate an older install with a stale reference.
|
|
44
|
+
fs.writeFileSync(rule, 'Full workflow: forgekit `docs/forge.md`\n', 'utf8');
|
|
45
|
+
|
|
46
|
+
const report = initProject(['claude'], { cwd, adr: false, planEngine: null });
|
|
47
|
+
const updated = fs.readFileSync(rule, 'utf8');
|
|
48
|
+
assert.ok(!updated.includes('forgekit `docs/forge.md`'), 'stale ref replaced');
|
|
49
|
+
assert.ok(updated.includes('~/.claude/skills/forge/docs/forge.md'), 'points to global skill doc');
|
|
50
|
+
assert.ok(
|
|
51
|
+
report.files.some((f) => f.file.includes('forge.md') && f.status === 'updated'),
|
|
52
|
+
'reports the refresh as updated',
|
|
53
|
+
);
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('thin-rule templates are engine-neutral (no hardcoded OpenSpec-only flow)', () => {
|
|
60
|
+
const root = resolveTemplatesRoot();
|
|
61
|
+
for (const rel of ['claude/rules/forge.md', 'cursor/rules/forge.mdc', 'codex/rules/forge.md']) {
|
|
62
|
+
const body = fs.readFileSync(path.join(root, ...rel.split('/')), 'utf8');
|
|
63
|
+
assert.ok(!/Forge = OpenSpec/.test(body), `${rel} still says "Forge = OpenSpec"`);
|
|
64
|
+
assert.ok(body.includes('forge change new'), `${rel} missing built-in specs command`);
|
|
65
|
+
assert.ok(body.includes('/opsx:propose'), `${rel} missing OpenSpec command`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
38
69
|
test('initProject wires templated envs and marks the rest skill-only', () => {
|
|
39
70
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-init-'));
|
|
40
71
|
try {
|
package/src/integrity-check.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Fails (exit 1) when:
|
|
9
9
|
* - deferrals are unresolved
|
|
10
|
-
* - spine.json is
|
|
11
|
-
* - a spine with rows exists but
|
|
12
|
-
*
|
|
10
|
+
* - spine.json is missing or invalid
|
|
11
|
+
* - a spine with rows exists but the executable E2E acceptance is missing,
|
|
12
|
+
* failed, or stale (e2e.json + green, current e2e-results.json), or
|
|
13
|
+
* verify-evidence.md contains an explicit BLOCKED marker
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { loadSession, readActive } from './lib.mjs';
|
|
@@ -49,6 +50,7 @@ process.stdout.write(
|
|
|
49
50
|
problems: result.problems,
|
|
50
51
|
spineFile: result.spineFile,
|
|
51
52
|
spineExists: result.spineExists,
|
|
53
|
+
e2eFile: result.e2eFile,
|
|
52
54
|
},
|
|
53
55
|
null,
|
|
54
56
|
2,
|
package/src/integrity.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Forge runtime-integrity mechanics: spine matrix, deferral registry,
|
|
3
|
-
* and the integrity checks that gate
|
|
3
|
+
* executable E2E acceptance, and the integrity checks that gate
|
|
4
|
+
* `forge phase done|finish`.
|
|
4
5
|
*
|
|
5
6
|
* Spine matrix — `spine.json` in the change dir (or session dir when the
|
|
6
7
|
* session has no tracked change). One row per capability/REQ cluster:
|
|
@@ -8,13 +9,21 @@
|
|
|
8
9
|
* Library-only rows (missing runtime owner / writes / evidence) fail
|
|
9
10
|
* validation, so "wire later" cannot be checkboxed past `forge phase done`.
|
|
10
11
|
*
|
|
12
|
+
* E2E acceptance — `e2e.json` next to the spine: the closed product loop as
|
|
13
|
+
* an executable step list. `forge e2e run` executes it and records
|
|
14
|
+
* `e2e-results.json` (session dir) with a hash of the steps, so results go
|
|
15
|
+
* stale when steps change. When the spine has real rows, the gate requires a
|
|
16
|
+
* green, current run — prose in verify-evidence.md no longer satisfies it.
|
|
17
|
+
*
|
|
11
18
|
* Deferral registry — `deferrals.json` in the session dir. Reviewers may only
|
|
12
19
|
* accept "wiring deferred" when a registered deferral names the open task;
|
|
13
20
|
* unresolved deferrals block done/finish.
|
|
14
21
|
*/
|
|
15
22
|
|
|
23
|
+
import crypto from 'node:crypto';
|
|
16
24
|
import fs from 'node:fs';
|
|
17
25
|
import path from 'node:path';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
18
27
|
import { readJson, writeJson } from './lib.mjs';
|
|
19
28
|
import { DEFAULT_SPECS_DIR, resolveProjectPlanEngine } from './plan-engine.mjs';
|
|
20
29
|
|
|
@@ -169,6 +178,292 @@ export function validateSpine(doc) {
|
|
|
169
178
|
return { ok: problems.length === 0, problems };
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
/* E2E acceptance — executable product loop */
|
|
183
|
+
/* ------------------------------------------------------------------ */
|
|
184
|
+
|
|
185
|
+
const E2E_FILE = 'e2e.json';
|
|
186
|
+
const E2E_RESULTS_FILE = 'e2e-results.json';
|
|
187
|
+
export const E2E_DEFAULT_TIMEOUT_MS = 300_000;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Path to e2e.json: change dir when available, else session dir.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
|
|
193
|
+
*/
|
|
194
|
+
export function e2ePath(opts = {}) {
|
|
195
|
+
const changeDir = resolveChangeDir(opts);
|
|
196
|
+
if (changeDir) return path.join(changeDir, E2E_FILE);
|
|
197
|
+
if (opts.sessionDir) return path.join(opts.sessionDir, E2E_FILE);
|
|
198
|
+
throw new Error('Cannot resolve e2e.json location: no change and no session dir');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{ change?: string | null }} [opts]
|
|
203
|
+
*/
|
|
204
|
+
export function e2eTemplate(opts = {}) {
|
|
205
|
+
return {
|
|
206
|
+
change: opts.change ?? null,
|
|
207
|
+
notApplicable: null,
|
|
208
|
+
steps: [
|
|
209
|
+
{
|
|
210
|
+
name: '<boot>',
|
|
211
|
+
cmd: '<command that starts the system, e.g. docker compose up -d api worker>',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: '<produce>',
|
|
215
|
+
cmd: '<command that drives the real production entry point, e.g. node scripts/e2e/enqueue-analyze.mjs>',
|
|
216
|
+
expect: '<regex the combined output must match — delete this field if exit code 0 is enough>',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: '<consume-assert>',
|
|
220
|
+
cmd: '<command that proves the domain side effects exist, e.g. node scripts/e2e/assert-ratified.mjs>',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Scaffold e2e.json (refuses to overwrite unless force).
|
|
228
|
+
*
|
|
229
|
+
* @param {{ file: string, change?: string | null, force?: boolean }} opts
|
|
230
|
+
*/
|
|
231
|
+
export function initE2e(opts) {
|
|
232
|
+
if (fs.existsSync(opts.file) && !opts.force) {
|
|
233
|
+
throw new Error(`e2e.json already exists: ${opts.file} (use --force to overwrite)`);
|
|
234
|
+
}
|
|
235
|
+
writeJson(opts.file, e2eTemplate({ change: opts.change }));
|
|
236
|
+
return opts.file;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate an e2e document.
|
|
241
|
+
*
|
|
242
|
+
* Valid when either:
|
|
243
|
+
* - `notApplicable` is a non-empty string (loop cannot be driven by any
|
|
244
|
+
* command — reviewers police the reason), or
|
|
245
|
+
* - `steps` is a non-empty array where every step has a filled `name` and
|
|
246
|
+
* `cmd` (no scaffold placeholders), `expect` (optional) is a valid regex,
|
|
247
|
+
* and `timeoutMs` (optional) is a positive number.
|
|
248
|
+
*
|
|
249
|
+
* @param {unknown} doc
|
|
250
|
+
* @returns {{ ok: boolean, problems: string[] }}
|
|
251
|
+
*/
|
|
252
|
+
export function validateE2e(doc) {
|
|
253
|
+
/** @type {string[]} */
|
|
254
|
+
const problems = [];
|
|
255
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
256
|
+
return { ok: false, problems: ['e2e.json is not an object'] };
|
|
257
|
+
}
|
|
258
|
+
const e2e = /** @type {Record<string, unknown>} */ (doc);
|
|
259
|
+
|
|
260
|
+
if (isNonEmptyString(e2e.notApplicable)) {
|
|
261
|
+
return { ok: true, problems: [] };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const steps = e2e.steps;
|
|
265
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
problems: [
|
|
269
|
+
'e2e.steps is empty — add executable product-loop steps, or set notApplicable with a reason',
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
steps.forEach((step, i) => {
|
|
275
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
276
|
+
problems.push(`step ${i + 1}: not an object`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const s = /** @type {Record<string, unknown>} */ (step);
|
|
280
|
+
for (const field of ['name', 'cmd']) {
|
|
281
|
+
const value = s[field];
|
|
282
|
+
if (!isNonEmptyString(value)) {
|
|
283
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): missing ${field}`);
|
|
284
|
+
} else if (/^<.*>$/.test(value.trim())) {
|
|
285
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): ${field} still has scaffold placeholder`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (s.expect !== undefined && s.expect !== null) {
|
|
289
|
+
if (!isNonEmptyString(s.expect)) {
|
|
290
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect must be a non-empty regex string`);
|
|
291
|
+
} else if (/^<.*>$/.test(s.expect.trim())) {
|
|
292
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect still has scaffold placeholder`);
|
|
293
|
+
} else {
|
|
294
|
+
try {
|
|
295
|
+
new RegExp(s.expect);
|
|
296
|
+
} catch {
|
|
297
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect is not a valid regex`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (s.timeoutMs !== undefined && (typeof s.timeoutMs !== 'number' || s.timeoutMs <= 0)) {
|
|
302
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): timeoutMs must be a positive number`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return { ok: problems.length === 0, problems };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Hash of the step list — recorded in results so editing e2e.json after a
|
|
311
|
+
* green run invalidates the results.
|
|
312
|
+
*
|
|
313
|
+
* @param {unknown[]} steps
|
|
314
|
+
*/
|
|
315
|
+
export function e2eStepsHash(steps) {
|
|
316
|
+
return crypto.createHash('sha256').update(JSON.stringify(steps ?? [])).digest('hex');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {string} text
|
|
321
|
+
*/
|
|
322
|
+
function outputTail(text, lines = 30) {
|
|
323
|
+
if (!text) return '';
|
|
324
|
+
return text.split(/\r?\n/).slice(-lines).join('\n').trim();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Execute e2e steps sequentially (shell). Stops at the first failure —
|
|
329
|
+
* later steps depend on earlier ones. Exit code must be 0 and `expect`
|
|
330
|
+
* (when present) must match combined stdout+stderr.
|
|
331
|
+
*
|
|
332
|
+
* @param {{ steps?: unknown[] }} doc — a validated e2e document with steps
|
|
333
|
+
* @param {{ cwd?: string }} [opts]
|
|
334
|
+
*/
|
|
335
|
+
export function runE2eSteps(doc, opts = {}) {
|
|
336
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
337
|
+
const steps = Array.isArray(doc?.steps) ? doc.steps : [];
|
|
338
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
339
|
+
const results = [];
|
|
340
|
+
let ok = true;
|
|
341
|
+
|
|
342
|
+
for (const step of steps) {
|
|
343
|
+
const s = /** @type {Record<string, any>} */ (step);
|
|
344
|
+
if (!ok) {
|
|
345
|
+
results.push({ name: s.name, cmd: s.cmd, skipped: true });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const started = Date.now();
|
|
349
|
+
const r = spawnSync(s.cmd, {
|
|
350
|
+
shell: true,
|
|
351
|
+
cwd,
|
|
352
|
+
encoding: 'utf8',
|
|
353
|
+
timeout: typeof s.timeoutMs === 'number' ? s.timeoutMs : E2E_DEFAULT_TIMEOUT_MS,
|
|
354
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
355
|
+
});
|
|
356
|
+
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
|
357
|
+
const exitCode = typeof r.status === 'number' ? r.status : null;
|
|
358
|
+
let expectMatched = null;
|
|
359
|
+
let stepOk = exitCode === 0;
|
|
360
|
+
if (stepOk && isNonEmptyString(s.expect)) {
|
|
361
|
+
expectMatched = new RegExp(s.expect).test(output);
|
|
362
|
+
stepOk = expectMatched;
|
|
363
|
+
}
|
|
364
|
+
results.push({
|
|
365
|
+
name: s.name,
|
|
366
|
+
cmd: s.cmd,
|
|
367
|
+
exitCode,
|
|
368
|
+
expectMatched,
|
|
369
|
+
ok: stepOk,
|
|
370
|
+
durationMs: Date.now() - started,
|
|
371
|
+
outputTail: outputTail(output),
|
|
372
|
+
error: r.error ? String(r.error.message ?? r.error) : null,
|
|
373
|
+
});
|
|
374
|
+
if (!stepOk) ok = false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
ok,
|
|
379
|
+
ranAt: new Date().toISOString(),
|
|
380
|
+
stepsHash: e2eStepsHash(steps),
|
|
381
|
+
steps: results,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {string} sessionDir
|
|
387
|
+
*/
|
|
388
|
+
export function e2eResultsPath(sessionDir) {
|
|
389
|
+
return path.join(sessionDir, E2E_RESULTS_FILE);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param {string} sessionDir
|
|
394
|
+
* @param {ReturnType<typeof runE2eSteps>} results
|
|
395
|
+
*/
|
|
396
|
+
export function writeE2eResults(sessionDir, results) {
|
|
397
|
+
writeJson(e2eResultsPath(sessionDir), results);
|
|
398
|
+
return e2eResultsPath(sessionDir);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @param {string} sessionDir
|
|
403
|
+
* @returns {Record<string, any> | null}
|
|
404
|
+
*/
|
|
405
|
+
export function loadE2eResults(sessionDir) {
|
|
406
|
+
const file = e2eResultsPath(sessionDir);
|
|
407
|
+
if (!fs.existsSync(file)) return null;
|
|
408
|
+
try {
|
|
409
|
+
return readJson(file);
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Gate check for the executable E2E acceptance. Returns the problems that
|
|
417
|
+
* block `forge phase done` — empty when the loop was executed green (and the
|
|
418
|
+
* results are current), or when e2e.json honestly opts out via notApplicable.
|
|
419
|
+
*
|
|
420
|
+
* @param {{ e2eFile: string, sessionDir: string }} opts
|
|
421
|
+
* @returns {{ problems: string[], notApplicable: boolean }}
|
|
422
|
+
*/
|
|
423
|
+
export function checkE2eGate(opts) {
|
|
424
|
+
/** @type {string[]} */
|
|
425
|
+
const problems = [];
|
|
426
|
+
|
|
427
|
+
if (!fs.existsSync(opts.e2eFile)) {
|
|
428
|
+
problems.push(
|
|
429
|
+
`e2e.json required at ${opts.e2eFile} — run forge e2e init, author the product-loop steps, then forge e2e run. Spine rows mean an async loop exists; it must be executed, not described.`,
|
|
430
|
+
);
|
|
431
|
+
return { problems, notApplicable: false };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let doc;
|
|
435
|
+
try {
|
|
436
|
+
doc = readJson(opts.e2eFile);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
problems.push(`e2e.json unreadable: ${err instanceof Error ? err.message : err}`);
|
|
439
|
+
return { problems, notApplicable: false };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const valid = validateE2e(doc);
|
|
443
|
+
if (!valid.ok) {
|
|
444
|
+
problems.push(...valid.problems.map((p) => `e2e: ${p}`));
|
|
445
|
+
return { problems, notApplicable: false };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (isNonEmptyString(doc.notApplicable)) {
|
|
449
|
+
return { problems: [], notApplicable: true };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const results = loadE2eResults(opts.sessionDir);
|
|
453
|
+
if (!results) {
|
|
454
|
+
problems.push('e2e-results.json missing — run forge e2e run (a green run is required before done)');
|
|
455
|
+
} else if (results.stepsHash !== e2eStepsHash(doc.steps)) {
|
|
456
|
+
problems.push('e2e-results.json is stale — e2e.json changed since the last run; re-run forge e2e run');
|
|
457
|
+
} else if (!results.ok) {
|
|
458
|
+
const failed = Array.isArray(results.steps) ? results.steps.find((s) => s?.ok === false) : null;
|
|
459
|
+
problems.push(
|
|
460
|
+
`e2e run failed${failed ? ` at step "${failed.name}"` : ''} — fix and re-run forge e2e run`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { problems, notApplicable: false };
|
|
465
|
+
}
|
|
466
|
+
|
|
172
467
|
/**
|
|
173
468
|
* @param {string} sessionDir
|
|
174
469
|
*/
|
|
@@ -245,13 +540,15 @@ export function sessionJobsSignalText(session) {
|
|
|
245
540
|
* 2. spine.json — **always required** (filled rows, or `notApplicable` with a
|
|
246
541
|
* reason). Keyword sniffing is not enough to decide; missing spine is how
|
|
247
542
|
* library-only platforms checkbox past gaps.
|
|
248
|
-
* 3.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
543
|
+
* 3. E2E acceptance — when a spine has real rows (not notApplicable):
|
|
544
|
+
* e2e.json must exist with filled steps (or its own notApplicable reason)
|
|
545
|
+
* and e2e-results.json must record a green, current run (steps hash must
|
|
546
|
+
* match). Prose in verify-evidence.md does not satisfy this; an explicit
|
|
547
|
+
* BLOCKED marker there still means the change cannot be done. Sync-only
|
|
548
|
+
* work should prefer spine `notApplicable` over inventing a fake loop.
|
|
252
549
|
*
|
|
253
550
|
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
254
|
-
* @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean }}
|
|
551
|
+
* @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean, e2eFile: string | null }}
|
|
255
552
|
*/
|
|
256
553
|
export function runIntegrityChecks(opts) {
|
|
257
554
|
/** @type {string[]} */
|
|
@@ -295,23 +592,16 @@ export function runIntegrityChecks(opts) {
|
|
|
295
592
|
}
|
|
296
593
|
}
|
|
297
594
|
|
|
595
|
+
let e2eFile = null;
|
|
298
596
|
if (spineExists && spineHasRows) {
|
|
597
|
+
e2eFile = e2ePath({ cwd, session, sessionDir });
|
|
598
|
+
problems.push(...checkE2eGate({ e2eFile, sessionDir }).problems);
|
|
599
|
+
|
|
299
600
|
const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
|
|
300
|
-
if (
|
|
301
|
-
problems.push(
|
|
302
|
-
'verify-evidence.md missing — spine rows require product-loop evidence (or use notApplicable for sync-only work)',
|
|
303
|
-
);
|
|
304
|
-
} else {
|
|
305
|
-
const body = fs.readFileSync(evidenceFile, 'utf8');
|
|
306
|
-
if (/\bBLOCKED\b/.test(body)) {
|
|
307
|
-
problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
|
|
308
|
-
} else if (!/product[- ]loop/i.test(body)) {
|
|
309
|
-
problems.push(
|
|
310
|
-
'verify-evidence.md has no "Product loop" section — record the closed producer→consumer loop (or BLOCKED). Sync-only changes should use spine notApplicable instead.',
|
|
311
|
-
);
|
|
312
|
-
}
|
|
601
|
+
if (fs.existsSync(evidenceFile) && /\bBLOCKED\b/.test(fs.readFileSync(evidenceFile, 'utf8'))) {
|
|
602
|
+
problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
|
|
313
603
|
}
|
|
314
604
|
}
|
|
315
605
|
|
|
316
|
-
return { ok: problems.length === 0, problems, spineFile, spineExists };
|
|
606
|
+
return { ok: problems.length === 0, problems, spineFile, spineExists, e2eFile };
|
|
317
607
|
}
|