@gr8ful/spf 0.5.0 → 0.6.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.
Files changed (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +51 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/paths.d.ts +3 -0
  46. package/dist/core/paths.js +48 -1
  47. package/dist/core/runner.d.ts +8 -0
  48. package/dist/core/runner.js +7 -0
  49. package/dist/core/session.d.ts +25 -0
  50. package/dist/core/session.js +97 -28
  51. package/dist/core/tiering.d.ts +145 -0
  52. package/dist/core/tiering.js +235 -0
  53. package/dist/core/tracer.d.ts +10 -0
  54. package/dist/core/tracer.js +12 -0
  55. package/dist/core/watch.d.ts +89 -9
  56. package/dist/core/watch.js +193 -19
  57. package/dist/ui/server/db.d.ts +39 -0
  58. package/dist/ui/server/db.js +61 -0
  59. package/package.json +2 -1
  60. package/dist/test/agent_cc.test.d.ts +0 -1
  61. package/dist/test/agent_cc.test.js +0 -95
  62. package/dist/test/agent_flue.test.d.ts +0 -1
  63. package/dist/test/agent_flue.test.js +0 -83
  64. package/dist/test/chains.test.d.ts +0 -12
  65. package/dist/test/chains.test.js +0 -92
  66. package/dist/test/data_types.test.d.ts +0 -10
  67. package/dist/test/data_types.test.js +0 -220
  68. package/dist/test/env_file.test.d.ts +0 -1
  69. package/dist/test/env_file.test.js +0 -74
  70. package/dist/test/fake_asker.d.ts +0 -23
  71. package/dist/test/fake_asker.js +0 -30
  72. package/dist/test/git_helper.test.d.ts +0 -1
  73. package/dist/test/git_helper.test.js +0 -59
  74. package/dist/test/hermetic_git.d.ts +0 -1
  75. package/dist/test/hermetic_git.js +0 -22
  76. package/dist/test/init_command.test.d.ts +0 -14
  77. package/dist/test/init_command.test.js +0 -136
  78. package/dist/test/interview.test.d.ts +0 -15
  79. package/dist/test/interview.test.js +0 -425
  80. package/dist/test/notify.test.d.ts +0 -1
  81. package/dist/test/notify.test.js +0 -174
  82. package/dist/test/ollama_provider.test.d.ts +0 -1
  83. package/dist/test/ollama_provider.test.js +0 -103
  84. package/dist/test/otel.test.d.ts +0 -26
  85. package/dist/test/otel.test.js +0 -512
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -1,189 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { publish, resolveAuthoringProvider } from "../core/refine.js";
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
- }
24
- /** In-memory fake — exactly the seam `IssueAuthoringProvider` exists for. */
25
- class FakeTracker {
26
- created = [];
27
- links = [];
28
- nextId = 1;
29
- async createIssue(input) {
30
- this.created.push(input);
31
- const id = String(this.nextId++);
32
- return { id, internal_id: `db-${id}`, title: input.title, body: input.body, labels: input.labels };
33
- }
34
- async linkChild(parent, child) {
35
- this.links.push({ parent: parent.id, child: child.id });
36
- }
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
- });
82
- function node(overrides) {
83
- return { body: "", parent: "", blocked_by: [], ...overrides };
84
- }
85
- // ── publish() ────────────────────────────────────────────────────────────
86
- test("publish: creates a container before its children, and links them via linkChild", async () => {
87
- const tracker = new FakeTracker();
88
- const issues = [
89
- node({ key: "S1", kind: "story", title: "Owner invites by email", parent: "F1" }),
90
- node({ key: "F1", kind: "feature", title: "Team invitations" }),
91
- ];
92
- const created = await publish(tracker, issues, { labelPrefix: "spf" });
93
- assert.deepEqual(tracker.created.map((c) => c.title), ["Team invitations", "Owner invites by email"], "the parent must be created before its child");
94
- assert.deepEqual(tracker.links, [{ parent: "1", child: "2" }]);
95
- assert.deepEqual(created.map((c) => ({ key: c.key, isLeaf: c.isLeaf })), [
96
- { key: "F1", isLeaf: false },
97
- { key: "S1", isLeaf: true },
98
- ]);
99
- });
100
- test("publish: labels a leaf with its type AND spf:refined; a container gets only its type label", async () => {
101
- const tracker = new FakeTracker();
102
- const issues = [node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })];
103
- await publish(tracker, issues, { labelPrefix: "spf" });
104
- const feature = tracker.created.find((c) => c.title === "A feature");
105
- const leaf = tracker.created.find((c) => c.title === "A leaf");
106
- assert.deepEqual(feature.labels, ["spf:type:feature"]);
107
- assert.deepEqual(leaf.labels, ["spf:type:story", "spf:refined"]);
108
- });
109
- test("publish: creates a blocker before what it blocks, and renders a real #n reference in the body", async () => {
110
- const tracker = new FakeTracker();
111
- const issues = [
112
- node({ key: "S2", kind: "bug", title: "Fix the expiry check", blocked_by: ["S1"] }),
113
- node({ key: "S1", kind: "story", title: "Owner invites by email" }),
114
- ];
115
- const created = await publish(tracker, issues, { labelPrefix: "spf" });
116
- const s1Id = created.find((c) => c.key === "S1").issue.id;
117
- const s2 = tracker.created.find((c) => c.title === "Fix the expiry check");
118
- assert.equal(created[0].key, "S1", "the blocker publishes first");
119
- assert.match(s2.body, new RegExp(`## Blocked by\\n\\n- #${s1Id}`));
120
- });
121
- test("publish: a leaf with no blockers gets the 'None (can start immediately)' text", async () => {
122
- const tracker = new FakeTracker();
123
- const created = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
124
- assert.match(created[0].issue.body, /## Blocked by\n\nNone \(can start immediately\)\./);
125
- });
126
- test("publish: renders '## Parent: #<id>' when a spec issue id is given, and omits it otherwise", async () => {
127
- const tracker = new FakeTracker();
128
- const withSpec = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf", specIssueId: "42" });
129
- assert.match(withSpec[0].issue.body, /## Parent\n\nDecomposed from #42\./);
130
- const tracker2 = new FakeTracker();
131
- const withoutSpec = await publish(tracker2, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
132
- assert.doesNotMatch(withoutSpec[0].issue.body, /## Parent/);
133
- });
134
- // ── gates.refinementWellFormed ───────────────────────────────────────────
135
- function envelope(issues) {
136
- return { status: "success", summary: "", artifacts: [], notes_for_next_agent: "", issues };
137
- }
138
- test("refinementWellFormed: an empty issues list fails", () => {
139
- const report = refinementWellFormed(envelope([]), { repo_root: "/repo" });
140
- assert.equal(report.passed, false);
141
- });
142
- test("refinementWellFormed: a well-formed feature/story tree with no blockers passes clean", () => {
143
- const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
144
- assert.equal(report.passed, true);
145
- });
146
- test("refinementWellFormed: a container mislabeled as a leaf kind fails", () => {
147
- const report = refinementWellFormed(envelope([node({ key: "F1", kind: "story", title: "Should be a feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
148
- assert.equal(report.passed, false);
149
- assert.ok(report.violations.some((v) => v.includes("F1.kind")));
150
- });
151
- test("refinementWellFormed: a two-node parent cycle has no leaves AND fails the dependency-graph check", () => {
152
- // A finite, ACYCLIC parent forest always has at least one leaf by
153
- // construction (some node's children set is empty) — the only way to
154
- // drive leafCount to zero is a cycle, which the dependency-graph check
155
- // already independently rejects. This test exercises that overlap
156
- // directly rather than asserting an "all containers, no leaves" shape
157
- // that isn't otherwise reachable.
158
- const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A", parent: "F2" }), node({ key: "F2", kind: "feature", title: "B", parent: "F1" })]), { repo_root: "/repo" });
159
- assert.equal(report.passed, false);
160
- assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
161
- assert.ok(report.violations.some((v) => v.toLowerCase().includes("leaves")));
162
- });
163
- test("refinementWellFormed: an unresolved parent key fails", () => {
164
- const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "Orphaned", parent: "nonexistent" })]), {
165
- repo_root: "/repo",
166
- });
167
- assert.equal(report.passed, false);
168
- assert.ok(report.violations.some((v) => v.includes("parent")));
169
- });
170
- test("refinementWellFormed: an unresolved blocked_by key fails", () => {
171
- const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A leaf", blocked_by: ["nonexistent"] })]), {
172
- repo_root: "/repo",
173
- });
174
- assert.equal(report.passed, false);
175
- assert.ok(report.violations.some((v) => v.includes("blocked_by")));
176
- });
177
- test("refinementWellFormed: a blocked_by cycle fails", () => {
178
- const report = refinementWellFormed(envelope([
179
- node({ key: "S1", kind: "story", title: "A", blocked_by: ["S2"] }),
180
- node({ key: "S2", kind: "story", title: "B", blocked_by: ["S1"] }),
181
- ]), { repo_root: "/repo" });
182
- assert.equal(report.passed, false);
183
- assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
184
- });
185
- test("refinementWellFormed: duplicate keys fail", () => {
186
- const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A" }), node({ key: "S1", kind: "bug", title: "B" })]), { repo_root: "/repo" });
187
- assert.equal(report.passed, false);
188
- assert.ok(report.violations.some((v) => v.includes("duplicate")));
189
- });
@@ -1,21 +0,0 @@
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 {};
@@ -1,416 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- import "./hermetic_git.ts";