@gr8ful/spf 0.4.0 → 0.5.1
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/README.md +122 -4
- package/assets/defaults/spf.config.yaml +6 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +69 -4
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +5 -0
- package/dist/chains/context.d.ts +30 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +70 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +215 -20
- package/dist/chains/steps.js +429 -61
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +319 -11
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +78 -1
- package/dist/cli/commands/list.js +42 -5
- package/dist/cli/commands/run.js +25 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +158 -10
- package/dist/cli/index.js +60 -3
- package/dist/cli/interview.js +65 -10
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +104 -4
- package/dist/core/data_types.js +99 -2
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.js +11 -3
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +11 -0
- package/dist/core/watch.js +17 -2
- package/dist/test/chains.test.js +8 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +54 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +127 -0
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/dist/test/refine.test.js +64 -1
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +124 -1
- package/package.json +5 -5
package/dist/test/refine.test.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { publish } from "../core/refine.js";
|
|
3
|
+
import { publish, resolveAuthoringProvider } from "../core/refine.js";
|
|
4
4
|
import { refinementWellFormed } from "../core/gates.js";
|
|
5
|
+
/** Only the `watch:` fields resolveAuthoringProvider actually reads — the rest of SFConfig is irrelevant to it. */
|
|
6
|
+
function makeCfg(watch) {
|
|
7
|
+
return {
|
|
8
|
+
watch: {
|
|
9
|
+
issue_provider: "github",
|
|
10
|
+
code_host: "github",
|
|
11
|
+
repo: "",
|
|
12
|
+
issue_repo: "",
|
|
13
|
+
label_prefix: "spf",
|
|
14
|
+
chain: "plan-build-test",
|
|
15
|
+
base_branch: "main",
|
|
16
|
+
poll_ms: 60_000,
|
|
17
|
+
concurrency: 2,
|
|
18
|
+
jira: { base_url: "", project_key: "" },
|
|
19
|
+
refine: { enabled: false, chain: "refine", concurrency: 1 },
|
|
20
|
+
...watch,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
5
24
|
/** In-memory fake — exactly the seam `IssueAuthoringProvider` exists for. */
|
|
6
25
|
class FakeTracker {
|
|
7
26
|
created = [];
|
|
@@ -16,6 +35,50 @@ class FakeTracker {
|
|
|
16
35
|
this.links.push({ parent: parent.id, child: child.id });
|
|
17
36
|
}
|
|
18
37
|
}
|
|
38
|
+
// ── resolveAuthoringProvider ─────────────────────────────────────────────
|
|
39
|
+
test("resolveAuthoringProvider: rejects a non-github issue_provider", () => {
|
|
40
|
+
assert.throws(() => resolveAuthoringProvider(makeCfg({ issue_provider: "jira" })), /does not support issue authoring/);
|
|
41
|
+
});
|
|
42
|
+
test("resolveAuthoringProvider: rejects when neither issue_repo nor repo is set", () => {
|
|
43
|
+
assert.throws(() => resolveAuthoringProvider(makeCfg({ issue_provider: "github", repo: "", issue_repo: "" })), /watch\.repo/);
|
|
44
|
+
});
|
|
45
|
+
test("resolveAuthoringProvider: rejects when GITHUB_TOKEN is unset", () => {
|
|
46
|
+
const before = process.env["GITHUB_TOKEN"];
|
|
47
|
+
delete process.env["GITHUB_TOKEN"];
|
|
48
|
+
try {
|
|
49
|
+
assert.throws(() => resolveAuthoringProvider(makeCfg({ issue_provider: "github", repo: "acme/widgets" })), /GITHUB_TOKEN/);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (before !== undefined)
|
|
53
|
+
process.env["GITHUB_TOKEN"] = before;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
test("resolveAuthoringProvider: issue_repo overrides repo — the github-issues-against-bitbucket-code case", async () => {
|
|
57
|
+
const before = process.env["GITHUB_TOKEN"];
|
|
58
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
59
|
+
const originalFetch = globalThis.fetch;
|
|
60
|
+
let capturedUrl = "";
|
|
61
|
+
globalThis.fetch = async (url) => {
|
|
62
|
+
capturedUrl = String(url);
|
|
63
|
+
return { ok: true, status: 201, json: async () => ({ id: 1, number: 1, title: "x", body: "", labels: [] }) };
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
// repo is the BITBUCKET code repo here — issue authoring must use
|
|
67
|
+
// issue_repo (the GitHub repo), never fall back to repo, or it would
|
|
68
|
+
// try to create a GitHub issue against a Bitbucket-shaped identifier.
|
|
69
|
+
const provider = resolveAuthoringProvider(makeCfg({ issue_provider: "github", repo: "acme-workspace/widgets-code", issue_repo: "acme/widgets-issues" }));
|
|
70
|
+
await provider.createIssue({ title: "x", body: "", labels: [] });
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
if (before !== undefined)
|
|
75
|
+
process.env["GITHUB_TOKEN"] = before;
|
|
76
|
+
else
|
|
77
|
+
delete process.env["GITHUB_TOKEN"];
|
|
78
|
+
}
|
|
79
|
+
assert.match(capturedUrl, /acme\/widgets-issues/);
|
|
80
|
+
assert.doesNotMatch(capturedUrl, /widgets-code/);
|
|
81
|
+
});
|
|
19
82
|
function node(overrides) {
|
|
20
83
|
return { body: "", parent: "", blocked_by: [], ...overrides };
|
|
21
84
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-local chains (`.spf/chains/*.yaml`) — the loader's contract.
|
|
3
|
+
*
|
|
4
|
+
* Two things are under test here, and they pull in opposite directions:
|
|
5
|
+
*
|
|
6
|
+
* 1. A yaml chain must be INDISTINGUISHABLE from a hand-written one. It is
|
|
7
|
+
* built with the same `stepChain()` from the same step factories, so its
|
|
8
|
+
* derived `phases`/`requiredAgents`/`requiredSuites` must come out
|
|
9
|
+
* byte-identical to the equivalent built-in composition. If that ever
|
|
10
|
+
* stops being true, a repo chain has become a second run path — the
|
|
11
|
+
* thing `repo_chains.ts` exists to avoid.
|
|
12
|
+
* 2. A BAD yaml file must never throw. `loadRepoChains()` runs at CLI
|
|
13
|
+
* startup for every command; one malformed file in a repo cannot be
|
|
14
|
+
* allowed to break `spf sessions` or `spf doctor`. Every failure mode
|
|
15
|
+
* below asserts `problems`, and asserts the loader RETURNED.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here shells out to git: `RepoAnchor` is a plain data object, so
|
|
18
|
+
* the tests construct one pointing at a temp directory rather than
|
|
19
|
+
* initializing a repo to be discovered.
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-local chains (`.spf/chains/*.yaml`) — the loader's contract.
|
|
3
|
+
*
|
|
4
|
+
* Two things are under test here, and they pull in opposite directions:
|
|
5
|
+
*
|
|
6
|
+
* 1. A yaml chain must be INDISTINGUISHABLE from a hand-written one. It is
|
|
7
|
+
* built with the same `stepChain()` from the same step factories, so its
|
|
8
|
+
* derived `phases`/`requiredAgents`/`requiredSuites` must come out
|
|
9
|
+
* byte-identical to the equivalent built-in composition. If that ever
|
|
10
|
+
* stops being true, a repo chain has become a second run path — the
|
|
11
|
+
* thing `repo_chains.ts` exists to avoid.
|
|
12
|
+
* 2. A BAD yaml file must never throw. `loadRepoChains()` runs at CLI
|
|
13
|
+
* startup for every command; one malformed file in a repo cannot be
|
|
14
|
+
* allowed to break `spf sessions` or `spf doctor`. Every failure mode
|
|
15
|
+
* below asserts `problems`, and asserts the loader RETURNED.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here shells out to git: `RepoAnchor` is a plain data object, so
|
|
18
|
+
* the tests construct one pointing at a temp directory rather than
|
|
19
|
+
* initializing a repo to be discovered.
|
|
20
|
+
*/
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { loadRepoChains } from "../chains/repo_chains.js";
|
|
27
|
+
import { CHAINS, allChains, findChain, registerRepoChains, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites, stepChain, } from "../chains/index.js";
|
|
28
|
+
import * as steps from "../chains/steps.js";
|
|
29
|
+
import * as gates from "../core/gates.js";
|
|
30
|
+
/** A temp `.spf/chains/` holding the given files, plus the anchor pointing at it. */
|
|
31
|
+
function withChains(files) {
|
|
32
|
+
const root = mkdtempSync(path.join(tmpdir(), "spf-repo-chains-"));
|
|
33
|
+
const spfDir = path.join(root, ".spf");
|
|
34
|
+
const dir = path.join(spfDir, "chains");
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
for (const [name, content] of Object.entries(files)) {
|
|
37
|
+
writeFileSync(path.join(dir, name), content);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
anchor: { cwd: root, repo_root: root, spf_dir: spfDir },
|
|
41
|
+
dir,
|
|
42
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Load one file's worth of yaml and hand back whatever came out. */
|
|
46
|
+
function load(yaml, filename = "custom.yaml") {
|
|
47
|
+
const { anchor, dir, cleanup } = withChains({ [filename]: yaml });
|
|
48
|
+
try {
|
|
49
|
+
return { ...loadRepoChains(anchor), file: path.join(dir, filename) };
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
cleanup();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ── the happy path ───────────────────────────────────────────────────────
|
|
56
|
+
test("a two-step yaml chain derives exactly what the equivalent hand-written stepChain does", () => {
|
|
57
|
+
const { chains, problems, file } = load([
|
|
58
|
+
"name: ship-it",
|
|
59
|
+
"describe: our own build-test chain",
|
|
60
|
+
"steps:",
|
|
61
|
+
" - step: request",
|
|
62
|
+
" - step: fixLoop",
|
|
63
|
+
" suite: test",
|
|
64
|
+
"",
|
|
65
|
+
].join("\n"));
|
|
66
|
+
assert.deepEqual(problems, [], "a valid file must produce no problems");
|
|
67
|
+
assert.equal(chains.length, 1);
|
|
68
|
+
const loaded = chains[0];
|
|
69
|
+
const handWritten = stepChain("ship-it", "our own build-test chain", [steps.request(), steps.fixLoop({ suite: "test" })]);
|
|
70
|
+
assert.equal(loaded.name, handWritten.name);
|
|
71
|
+
assert.equal(loaded.describe, handWritten.describe);
|
|
72
|
+
assert.equal(loaded.phases, handWritten.phases, "derived phases must be byte-identical to the hand-written composition");
|
|
73
|
+
assert.deepEqual(resolveRequiredAgents(loaded, {}), resolveRequiredAgents(handWritten, {}));
|
|
74
|
+
assert.deepEqual(resolveRequiredSuites(loaded, {}), resolveRequiredSuites(handWritten, {}));
|
|
75
|
+
assert.deepEqual(resolveRequiredSuites(loaded, { suite: "smoke" }), ["smoke"], "--suite still overrides, exactly as in a built-in");
|
|
76
|
+
assert.equal(loaded.steps?.length, 2, "a repo chain is a plain steps list on the same runSteps driver");
|
|
77
|
+
assert.equal(loaded.run, undefined, "a repo chain never gets the imperative escape hatch");
|
|
78
|
+
assert.equal(loaded.source, file, "source is the absolute yaml path, for ChainContext.chain_source");
|
|
79
|
+
});
|
|
80
|
+
test("owner overrides are visible in the derived requiredAgents and phases — no second sync point", () => {
|
|
81
|
+
const { chains, problems } = load(["name: ours", "describe: plan with our own architect", "steps:", " - step: request", " - step: plan", " owner: architect", ""].join("\n"));
|
|
82
|
+
assert.deepEqual(problems, []);
|
|
83
|
+
const chain = chains[0];
|
|
84
|
+
assert.deepEqual(resolveRequiredAgents(chain, {}), ["architect"], "the owner param must reach deriveRequiredAgents");
|
|
85
|
+
assert.equal(chain.phases, "engineer(request) -> architect", "and the derived display string too");
|
|
86
|
+
});
|
|
87
|
+
test("extraGates adds a gate by name; the built-in gates stay in place (additive only)", () => {
|
|
88
|
+
const { chains, problems } = load(["name: strict-build", "describe: build with an extra gate", "steps:", " - step: build", " extraGates: [jsonParses, filesNonEmpty]", ""].join("\n"));
|
|
89
|
+
assert.deepEqual(problems, []);
|
|
90
|
+
assert.equal(chains.length, 1, "a known gate name resolves through the allowlist");
|
|
91
|
+
// There is no `gates:` param to assert the absence of a built-in gate with —
|
|
92
|
+
// that is the point of the policy. The type-level guarantee is in
|
|
93
|
+
// steps.ts (withExtraGates always prepends the built-ins).
|
|
94
|
+
});
|
|
95
|
+
// ── gate composition, driven directly (Amendment 1's actual invariant) ────
|
|
96
|
+
//
|
|
97
|
+
// The test above only proves the chain LOADS with a known gate name; its own
|
|
98
|
+
// comment concedes there is no `gates:` param to assert a built-in's absence
|
|
99
|
+
// with, and defers to "the type-level guarantee in steps.ts" — there isn't
|
|
100
|
+
// one. `withExtraGates` is a plain function; a future `gates: opts.gates ??
|
|
101
|
+
// builtIn` would pass every test above while silently making a built-in gate
|
|
102
|
+
// removable. These drive the step directly and inspect the actual
|
|
103
|
+
// `AgentCall` handed to `ph.call(...)`, which is where the built-in and any
|
|
104
|
+
// `extraGates` are actually composed.
|
|
105
|
+
function stateStub() {
|
|
106
|
+
return {
|
|
107
|
+
prompt: "do it",
|
|
108
|
+
options: {},
|
|
109
|
+
previous: null,
|
|
110
|
+
quality: null,
|
|
111
|
+
review: null,
|
|
112
|
+
changeset: null,
|
|
113
|
+
baseline: "",
|
|
114
|
+
issue_id: null,
|
|
115
|
+
accepted: true,
|
|
116
|
+
reason: "",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** A stub `Run` whose `phase()` just runs the callback and records every `ph.call(...)`'s gates. */
|
|
120
|
+
function captureCalls() {
|
|
121
|
+
const calls = [];
|
|
122
|
+
const run = {
|
|
123
|
+
phase: async (params, fn) => {
|
|
124
|
+
const ph = {
|
|
125
|
+
log: () => { },
|
|
126
|
+
call: async (call) => {
|
|
127
|
+
calls.push({ phase: params.name, gates: call.gates ?? [] });
|
|
128
|
+
return { status: "success", summary: "", artifacts: [], approved: false, blocking: [], findings: [] };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
return fn(ph);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
return { run, calls };
|
|
135
|
+
}
|
|
136
|
+
test("build()'s built-in gate is always first, and extraGates is additive — driven directly, not just loaded", async () => {
|
|
137
|
+
const { run, calls } = captureCalls();
|
|
138
|
+
await steps.build({ extraGates: ["jsonParses"] })(run, stateStub());
|
|
139
|
+
assert.equal(calls.length, 1);
|
|
140
|
+
assert.deepEqual(calls[0].gates, [gates.diffMatchesClaims, gates.jsonParses], "diffMatchesClaims must stay first and present; jsonParses is appended, not substituted");
|
|
141
|
+
});
|
|
142
|
+
test("plan()'s built-in gates stay in front of an added one", async () => {
|
|
143
|
+
const { run, calls } = captureCalls();
|
|
144
|
+
await steps.plan({ extraGates: ["jsonParses"] })(run, stateStub());
|
|
145
|
+
assert.equal(calls.length, 1);
|
|
146
|
+
assert.deepEqual(calls[0].gates, [gates.artifactsExist, gates.filesNonEmpty, gates.jsonParses]);
|
|
147
|
+
});
|
|
148
|
+
test("reviseLoop()'s review phase keeps verdictConsistent as a built-in", async () => {
|
|
149
|
+
const { run, calls } = captureCalls();
|
|
150
|
+
await steps.reviseLoop({ max: 1 })(run, stateStub()); // max: 1 -> exactly one review, no revise
|
|
151
|
+
assert.equal(calls.length, 1);
|
|
152
|
+
assert.deepEqual(calls[0].gates, [gates.artifactsExist, gates.verdictConsistent]);
|
|
153
|
+
});
|
|
154
|
+
test("reviseLoop()'s revise phase keeps diffMatchesClaims as a built-in — a different envelope, a different built-in", async () => {
|
|
155
|
+
const { run, calls } = captureCalls();
|
|
156
|
+
await steps.reviseLoop({ max: 2 })(run, stateStub()); // review never approves in this stub, so a revise must run
|
|
157
|
+
const revise = calls.find((c) => c.phase.startsWith("revise_"));
|
|
158
|
+
assert.ok(revise, "expected a revise phase to have run");
|
|
159
|
+
assert.deepEqual(revise.gates, [gates.diffMatchesClaims]);
|
|
160
|
+
});
|
|
161
|
+
test("fixLoop()'s fix phase keeps diffMatchesClaims as a built-in", async () => {
|
|
162
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "spf-fixloop-gates-"));
|
|
163
|
+
try {
|
|
164
|
+
const calls = [];
|
|
165
|
+
const phases = [];
|
|
166
|
+
const run = {
|
|
167
|
+
cfg: {
|
|
168
|
+
quality: {
|
|
169
|
+
// Always fails (`node -e process.exit(1)`) so the loop is forced
|
|
170
|
+
// into its fix phase — this test is about that phase's gates, not
|
|
171
|
+
// about quality.ts's own command-running, which has its own tests.
|
|
172
|
+
checks: [{ name: "test", area: "backend", operation: "typecheck", argv: ["node", "-e", "process.exit(1)"], timeout_seconds: 5 }],
|
|
173
|
+
suites: { test: ["test"] },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
phases,
|
|
177
|
+
context_handoff_dir: scratch,
|
|
178
|
+
repo_root: scratch,
|
|
179
|
+
console: { note: () => { } },
|
|
180
|
+
tracer: { event: () => "evt" },
|
|
181
|
+
adw_id: "test-adw",
|
|
182
|
+
phase: async (params, fn) => {
|
|
183
|
+
const phase = { phase_id: `p_${phases.length}`, seq: phases.length };
|
|
184
|
+
phases.push(phase);
|
|
185
|
+
const ph = {
|
|
186
|
+
log: () => { },
|
|
187
|
+
call: async (call) => {
|
|
188
|
+
calls.push({ phase: params.name, gates: call.gates ?? [] });
|
|
189
|
+
return { status: "success", summary: "", artifacts: [], changed_files: [] };
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
return fn(ph);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
await steps.fixLoop({ suite: "test", max: 2 })(run, stateStub());
|
|
196
|
+
const fix = calls.find((c) => c.phase === "fix_1");
|
|
197
|
+
assert.ok(fix, "expected a fix_1 phase to have run — the stubbed suite always fails");
|
|
198
|
+
assert.deepEqual(fix.gates, [gates.diffMatchesClaims]);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("no .spf/ and no chains/ directory are both quietly empty, not errors", () => {
|
|
205
|
+
const noSpf = { cwd: "/nonexistent", repo_root: "/nonexistent", spf_dir: null };
|
|
206
|
+
assert.deepEqual(loadRepoChains(noSpf), { chains: [], problems: [] });
|
|
207
|
+
const root = mkdtempSync(path.join(tmpdir(), "spf-repo-chains-"));
|
|
208
|
+
try {
|
|
209
|
+
mkdirSync(path.join(root, ".spf"));
|
|
210
|
+
const anchor = { cwd: root, repo_root: root, spf_dir: path.join(root, ".spf") };
|
|
211
|
+
assert.deepEqual(loadRepoChains(anchor), { chains: [], problems: [] }, "a .spf/ with no chains/ is the normal case");
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
rmSync(root, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
// ── every failure is a problem, never a throw ────────────────────────────
|
|
218
|
+
test("an unknown step name is a problem naming the steps that do exist", () => {
|
|
219
|
+
const { chains, problems, file } = load(["name: bad-step", "describe: nope", "steps:", " - step: deploy", ""].join("\n"));
|
|
220
|
+
assert.deepEqual(chains, []);
|
|
221
|
+
assert.equal(problems.length, 1);
|
|
222
|
+
assert.equal(problems[0].file, file);
|
|
223
|
+
assert.match(problems[0].message, /unknown step "deploy"/);
|
|
224
|
+
assert.match(problems[0].message, /available steps: .*fixLoop/, "the message must list what can be used instead");
|
|
225
|
+
});
|
|
226
|
+
test("a param of the wrong type is a problem, not a runtime surprise", () => {
|
|
227
|
+
const { chains, problems } = load(["name: bad-param", "describe: nope", "steps:", " - step: build", " retries: two", ""].join("\n"));
|
|
228
|
+
assert.deepEqual(chains, []);
|
|
229
|
+
assert.equal(problems.length, 1);
|
|
230
|
+
assert.match(problems[0].message, /steps\[0\] \(build\)/, "the message must point at which step in the list");
|
|
231
|
+
assert.match(problems[0].message, /retries/);
|
|
232
|
+
});
|
|
233
|
+
test("an unknown param is rejected, never silently ignored", () => {
|
|
234
|
+
const { chains, problems } = load(["name: typo", "describe: nope", "steps:", " - step: build", " retires: 2", ""].join("\n"));
|
|
235
|
+
assert.deepEqual(chains, []);
|
|
236
|
+
assert.equal(problems.length, 1);
|
|
237
|
+
assert.match(problems[0].message, /unknown param\(s\) "retires"/);
|
|
238
|
+
assert.match(problems[0].message, /build accepts: .*retries/);
|
|
239
|
+
});
|
|
240
|
+
test("a description that only echoes the phase name is a problem — makePhaseParams' rule, enforced at load time", () => {
|
|
241
|
+
const { chains, problems } = load(["name: echo", "describe: nope", "steps:", " - step: plan", ' description: "plan"', ""].join("\n"));
|
|
242
|
+
assert.deepEqual(chains, [], "the chain must not load");
|
|
243
|
+
assert.equal(problems.length, 1, "and the throw from the factory must have become a problem");
|
|
244
|
+
assert.match(problems[0].message, /steps\[0\] \(plan\)/);
|
|
245
|
+
assert.match(problems[0].message, /restates the phase name/);
|
|
246
|
+
});
|
|
247
|
+
test("an unknown gate name is a problem listing the allowlist — testsPass is deliberately not on it", () => {
|
|
248
|
+
const { chains, problems } = load(["name: sneaky", "describe: nope", "steps:", " - step: build", " extraGates: [testsPass]", ""].join("\n"));
|
|
249
|
+
assert.deepEqual(chains, []);
|
|
250
|
+
assert.equal(problems.length, 1);
|
|
251
|
+
assert.match(problems[0].message, /unknown gate/);
|
|
252
|
+
assert.match(problems[0].message, /diffMatchesClaims/, "the message names the gates that ARE allowed");
|
|
253
|
+
});
|
|
254
|
+
test("verdictConsistent on build is rejected — that gate only means something on a review envelope", () => {
|
|
255
|
+
const { chains, problems } = load(["name: build-with-review-gate", "describe: nope", "steps:", " - step: build", " extraGates: [verdictConsistent]", ""].join("\n"));
|
|
256
|
+
assert.deepEqual(chains, [], "a per-step envelope mismatch must be a load problem, not a chain that is guaranteed to fail its gate on every run");
|
|
257
|
+
assert.equal(problems.length, 1);
|
|
258
|
+
assert.match(problems[0].message, /unknown gate/);
|
|
259
|
+
assert.match(problems[0].message, /diffMatchesClaims/, "build's allowlist should still name the gate that DOES apply");
|
|
260
|
+
assert.doesNotMatch(problems[0].message, /verdictConsistent/, "verdictConsistent must not be offered as an allowed alternative for build");
|
|
261
|
+
});
|
|
262
|
+
test("diffMatchesClaims on reviseLoop's review phase (extraGates) is rejected — verdictConsistent is the one that fits a ReviewOutput", () => {
|
|
263
|
+
const { chains, problems } = load(["name: review-with-build-gate", "describe: nope", "steps:", " - step: reviseLoop", " extraGates: [diffMatchesClaims]", ""].join("\n"));
|
|
264
|
+
assert.deepEqual(chains, []);
|
|
265
|
+
assert.equal(problems.length, 1);
|
|
266
|
+
assert.match(problems[0].message, /unknown gate/);
|
|
267
|
+
assert.match(problems[0].message, /verdictConsistent/);
|
|
268
|
+
});
|
|
269
|
+
test("retries above the bound is a problem — an unattended chain must not be able to loop unboundedly from a typo", () => {
|
|
270
|
+
const { chains, problems } = load(["name: too-many-retries", "describe: nope", "steps:", " - step: build", " retries: 9999", ""].join("\n"));
|
|
271
|
+
assert.deepEqual(chains, []);
|
|
272
|
+
assert.equal(problems.length, 1);
|
|
273
|
+
assert.match(problems[0].message, /retries/);
|
|
274
|
+
assert.match(problems[0].message, /raise this bound deliberately/);
|
|
275
|
+
});
|
|
276
|
+
test("max above the bound is a problem, for the same reason", () => {
|
|
277
|
+
const { chains, problems } = load(["name: too-many-loops", "describe: nope", "steps:", " - step: fixLoop", " max: 100000", ""].join("\n"));
|
|
278
|
+
assert.deepEqual(chains, []);
|
|
279
|
+
assert.equal(problems.length, 1);
|
|
280
|
+
assert.match(problems[0].message, /max/);
|
|
281
|
+
assert.match(problems[0].message, /raise this bound deliberately/);
|
|
282
|
+
});
|
|
283
|
+
test("a whitespace-only owner is rejected, not loaded as an invisible agent name", () => {
|
|
284
|
+
const { chains, problems } = load(["name: blank-owner", "describe: nope", "steps:", " - step: plan", ' owner: " "', ""].join("\n"));
|
|
285
|
+
assert.deepEqual(chains, []);
|
|
286
|
+
assert.equal(problems.length, 1);
|
|
287
|
+
assert.match(problems[0].message, /owner/);
|
|
288
|
+
});
|
|
289
|
+
test("malformed YAML is a problem and the loader still returns", () => {
|
|
290
|
+
const { chains, problems, file } = load("name: broken\ndescribe: [unclosed\nsteps:\n - step: request\n");
|
|
291
|
+
assert.deepEqual(chains, []);
|
|
292
|
+
assert.equal(problems.length, 1);
|
|
293
|
+
assert.equal(problems[0].file, file);
|
|
294
|
+
assert.match(problems[0].message, /not valid YAML/);
|
|
295
|
+
});
|
|
296
|
+
test("a missing/empty required field is a problem naming the field", () => {
|
|
297
|
+
const noSteps = load(["name: thin", "describe: has no steps", "steps: []", ""].join("\n"));
|
|
298
|
+
assert.deepEqual(noSteps.chains, []);
|
|
299
|
+
assert.match(noSteps.problems[0].message, /at least one step/);
|
|
300
|
+
const noName = load(["describe: nameless", "steps:", " - step: request", ""].join("\n"));
|
|
301
|
+
assert.deepEqual(noName.chains, []);
|
|
302
|
+
assert.match(noName.problems[0].message, /name/);
|
|
303
|
+
// A bare scalar/list document declared SOMETHING, just not a chain — "is
|
|
304
|
+
// empty" still fits. An actually-empty document (below) does not: see the
|
|
305
|
+
// next test.
|
|
306
|
+
const notAnObject = load("just a string\n");
|
|
307
|
+
assert.deepEqual(notAnObject.chains, []);
|
|
308
|
+
assert.match(notAnObject.problems[0].message, /is empty/);
|
|
309
|
+
});
|
|
310
|
+
test("a document that parses to null (blank, or fully commented out) declares no chain — skipped silently, not a problem", () => {
|
|
311
|
+
// This is exactly what `spf init`'s scaffolded, not-yet-uncommented
|
|
312
|
+
// example.yaml looks like: every line a `#` comment, so `parseYaml`
|
|
313
|
+
// returns `undefined`. It must never be reported as a broken chain file —
|
|
314
|
+
// that would put a red `spf doctor` line and a `spf list` warning into
|
|
315
|
+
// every freshly-initialized repo, caused solely by spf's own scaffold.
|
|
316
|
+
const blank = load("\n");
|
|
317
|
+
assert.deepEqual(blank.chains, [], "a blank file is the absence of a chain, not a malformed one");
|
|
318
|
+
assert.deepEqual(blank.problems, []);
|
|
319
|
+
const allComments = load(["# name: example", "# describe: not registered", "# steps:", "# - step: request", ""].join("\n"));
|
|
320
|
+
assert.deepEqual(allComments.chains, []);
|
|
321
|
+
assert.deepEqual(allComments.problems, []);
|
|
322
|
+
});
|
|
323
|
+
test("a name that is not command-line safe is a problem", () => {
|
|
324
|
+
const { chains, problems } = load(["name: My Chain", "describe: spaces", "steps:", " - step: request", ""].join("\n"));
|
|
325
|
+
assert.deepEqual(chains, []);
|
|
326
|
+
assert.match(problems[0].message, /name/);
|
|
327
|
+
});
|
|
328
|
+
// ── collisions are problems, never shadows ───────────────────────────────
|
|
329
|
+
test("a repo chain may not take a built-in's name", () => {
|
|
330
|
+
const { chains, problems } = load(["name: plan-build", "describe: our version", "steps:", " - step: request", ""].join("\n"));
|
|
331
|
+
assert.deepEqual(chains, [], "shadowing a built-in must not load");
|
|
332
|
+
assert.equal(problems.length, 1);
|
|
333
|
+
assert.match(problems[0].message, /built-in chain/);
|
|
334
|
+
});
|
|
335
|
+
test("a repo chain may not take an spf subcommand's name", () => {
|
|
336
|
+
const { chains, problems } = load(["name: watch", "describe: not reachable as `spf watch`", "steps:", " - step: request", ""].join("\n"));
|
|
337
|
+
assert.deepEqual(chains, []);
|
|
338
|
+
assert.match(problems[0].message, /subcommand/);
|
|
339
|
+
});
|
|
340
|
+
test("two files claiming one name: the first (sorted) wins and the second is a problem", () => {
|
|
341
|
+
const yaml = (describe) => ["name: dup", `describe: ${describe}`, "steps:", " - step: request", ""].join("\n");
|
|
342
|
+
const { anchor, cleanup } = withChains({ "a-first.yaml": yaml("first"), "b-second.yaml": yaml("second") });
|
|
343
|
+
try {
|
|
344
|
+
const { chains, problems } = loadRepoChains(anchor);
|
|
345
|
+
assert.equal(chains.length, 1, "exactly one definition may answer to a name");
|
|
346
|
+
assert.equal(chains[0].describe, "first", "load order is sorted by filename, so a collision reproduces identically everywhere");
|
|
347
|
+
assert.equal(problems.length, 1);
|
|
348
|
+
assert.match(problems[0].message, /already defined by a-first\.yaml/);
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
cleanup();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
test("one broken file does not stop the others from loading", () => {
|
|
355
|
+
const { anchor, cleanup } = withChains({
|
|
356
|
+
"good.yaml": ["name: good-one", "describe: fine", "steps:", " - step: request", ""].join("\n"),
|
|
357
|
+
"bad.yaml": ["name: bad-one", "describe: fine", "steps:", " - step: nope", ""].join("\n"),
|
|
358
|
+
});
|
|
359
|
+
try {
|
|
360
|
+
const { chains, problems } = loadRepoChains(anchor);
|
|
361
|
+
assert.deepEqual(chains.map((c) => c.name), ["good-one"]);
|
|
362
|
+
assert.equal(problems.length, 1);
|
|
363
|
+
assert.match(problems[0].file, /bad\.yaml$/);
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
cleanup();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
// ── the registry: two tiers, built-ins untouchable ───────────────────────
|
|
370
|
+
test("registerRepoChains never mutates CHAINS — built-ins stay built-in-only", () => {
|
|
371
|
+
const builtInNames = CHAINS.map((c) => c.name);
|
|
372
|
+
const extra = stepChain("registry-probe", "a repo chain", [steps.request()]);
|
|
373
|
+
registerRepoChains([extra], [{ file: "/tmp/x.yaml", message: "boom" }]);
|
|
374
|
+
try {
|
|
375
|
+
assert.deepEqual(CHAINS.map((c) => c.name), builtInNames, "CHAINS is the immutable built-in-only const");
|
|
376
|
+
assert.equal(allChains().length, CHAINS.length + 1, "allChains() is the merged view: built-ins first, then repo chains");
|
|
377
|
+
assert.deepEqual(allChains().slice(0, CHAINS.length).map((c) => c.name), builtInNames, "built-ins come first");
|
|
378
|
+
assert.equal(allChains()[CHAINS.length].name, "registry-probe");
|
|
379
|
+
assert.equal(findChain("registry-probe")?.name, "registry-probe", "findChain consults repo chains after built-ins");
|
|
380
|
+
assert.deepEqual(repoChainProblems(), [{ file: "/tmp/x.yaml", message: "boom" }]);
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
registerRepoChains([], []);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
test("registerRepoChains is idempotent — a second call replaces, it never duplicates", () => {
|
|
387
|
+
const extra = stepChain("registry-probe", "a repo chain", [steps.request()]);
|
|
388
|
+
const problem = { file: "/tmp/x.yaml", message: "boom" };
|
|
389
|
+
registerRepoChains([extra], [problem]);
|
|
390
|
+
registerRepoChains([extra], [problem]);
|
|
391
|
+
try {
|
|
392
|
+
assert.equal(allChains().filter((c) => c.name === "registry-probe").length, 1, "re-registering the same chain must not double it");
|
|
393
|
+
assert.equal(repoChainProblems().length, 1, "nor its problems");
|
|
394
|
+
// A second, different registration REPLACES: a stale definition left
|
|
395
|
+
// behind would keep running after the yaml that defined it was deleted.
|
|
396
|
+
registerRepoChains([stepChain("other-probe", "another", [steps.request()])], []);
|
|
397
|
+
assert.equal(findChain("registry-probe"), undefined, "the previous registration is gone, not merged");
|
|
398
|
+
assert.equal(findChain("other-probe")?.name, "other-probe");
|
|
399
|
+
assert.deepEqual(repoChainProblems(), []);
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
registerRepoChains([], []);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
test("a built-in name always resolves to the built-in, even if a repo chain is registered under it", () => {
|
|
406
|
+
// loadRepoChains() refuses this file, so it takes a hand-built definition
|
|
407
|
+
// to reach findChain's own ordering — the second line of defence.
|
|
408
|
+
const impostor = stepChain("plan-build", "not the real one", [steps.request()]);
|
|
409
|
+
registerRepoChains([impostor], []);
|
|
410
|
+
try {
|
|
411
|
+
assert.equal(findChain("plan-build")?.describe, CHAINS.find((c) => c.name === "plan-build").describe);
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
registerRepoChains([], []);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./hermetic_git.ts";
|