@gr8ful/spf 0.5.1 → 0.7.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/README.md +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
package/dist/test/paths.test.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression guard for `resolveDataPaths`'s self-heal: a `data_dir` that
|
|
3
|
-
* exists as a broken symlink (self-referential or dangling) must be removed
|
|
4
|
-
* before anything tries to `mkdirSync` through it — see `paths.ts`'s
|
|
5
|
-
* `healBrokenDataDir` for why this lives here and not at each `mkdirSync`
|
|
6
|
-
* call site individually. This is a live bug that shipped once already: a
|
|
7
|
-
* self-referential `.spf/data -> .spf/data` symlink was observed on a real
|
|
8
|
-
* checkout, and every subsequent command failed with `ELOOP` at its very
|
|
9
|
-
* first filesystem touch.
|
|
10
|
-
*/
|
|
11
|
-
import { test, beforeEach, afterEach } from "node:test";
|
|
12
|
-
import assert from "node:assert/strict";
|
|
13
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { resolveAnchor, resolveDataPaths } from "../core/paths.js";
|
|
17
|
-
let dir;
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
dir = mkdtempSync(join(tmpdir(), "spf-paths-test-"));
|
|
20
|
-
});
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
rmSync(dir, { recursive: true, force: true });
|
|
23
|
-
});
|
|
24
|
-
test("resolveDataPaths: a self-referential data_dir symlink is removed, not left to ELOOP later", () => {
|
|
25
|
-
const dataDir = join(dir, ".spf", "data");
|
|
26
|
-
mkdirSync(join(dir, ".spf"), { recursive: true });
|
|
27
|
-
symlinkSync(dataDir, dataDir, "dir"); // exactly the observed bug: points at itself
|
|
28
|
-
const anchor = resolveAnchor(dir);
|
|
29
|
-
const paths = resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
30
|
-
assert.equal(paths.data_dir, dataDir);
|
|
31
|
-
assert.equal(existsSync(dataDir), false, "the broken symlink must be gone, not just tolerated");
|
|
32
|
-
// Confirms the actual failure mode is fixed: this would throw ELOOP before the fix.
|
|
33
|
-
mkdirSync(dataDir, { recursive: true });
|
|
34
|
-
assert.ok(existsSync(dataDir));
|
|
35
|
-
});
|
|
36
|
-
test("resolveDataPaths: a dangling data_dir symlink (target removed) is also cleaned up", () => {
|
|
37
|
-
const realTarget = join(dir, "somewhere-else");
|
|
38
|
-
const dataDir = join(dir, ".spf", "data");
|
|
39
|
-
mkdirSync(realTarget, { recursive: true });
|
|
40
|
-
mkdirSync(join(dir, ".spf"), { recursive: true });
|
|
41
|
-
symlinkSync(realTarget, dataDir, "dir");
|
|
42
|
-
rmSync(realTarget, { recursive: true, force: true }); // now dataDir points at nothing
|
|
43
|
-
const anchor = resolveAnchor(dir);
|
|
44
|
-
resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
45
|
-
assert.equal(existsSync(dataDir), false);
|
|
46
|
-
});
|
|
47
|
-
test("resolveDataPaths: an ordinary real directory is left completely alone", () => {
|
|
48
|
-
const dataDir = join(dir, ".spf", "data");
|
|
49
|
-
mkdirSync(dataDir, { recursive: true });
|
|
50
|
-
writeFileSync(join(dataDir, "spf.db"), "not actually sqlite, just a marker");
|
|
51
|
-
const anchor = resolveAnchor(dir);
|
|
52
|
-
resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
53
|
-
assert.ok(existsSync(join(dataDir, "spf.db")), "a real, already-populated data_dir must never be touched");
|
|
54
|
-
});
|
|
55
|
-
test("resolveDataPaths: data_dir not existing yet (the ordinary first-run case) is a silent no-op", () => {
|
|
56
|
-
const anchor = resolveAnchor(dir);
|
|
57
|
-
assert.doesNotThrow(() => resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db"));
|
|
58
|
-
assert.equal(existsSync(join(dir, ".spf", "data")), false, "resolveDataPaths itself never creates the directory — only heals a broken one");
|
|
59
|
-
});
|
|
60
|
-
test("resolveDataPaths: a real FILE at data_dir's path (not a symlink) is left alone — not this bug", () => {
|
|
61
|
-
mkdirSync(join(dir, ".spf"), { recursive: true });
|
|
62
|
-
const dataDir = join(dir, ".spf", "data");
|
|
63
|
-
writeFileSync(dataDir, "oops, a file where a directory should be");
|
|
64
|
-
const anchor = resolveAnchor(dir);
|
|
65
|
-
resolveDataPaths(anchor, ".spf/data", ".spf/data/spf.db");
|
|
66
|
-
assert.ok(existsSync(dataDir), "only a broken SYMLINK is this function's concern, not any other kind of misconfiguration");
|
|
67
|
-
assert.equal(lstatSync(dataDir).isSymbolicLink(), false);
|
|
68
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/test/refine.test.js
DELETED
|
@@ -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 {};
|