@gr8ful/spf 0.5.1 → 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.
- package/README.md +61 -17
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +32 -6
- package/assets/prompts/refiner/user.md +37 -5
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +153 -4
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +6 -0
- 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 +27 -9
- package/dist/chains/steps.js +82 -13
- package/dist/cli/commands/doctor.js +108 -1
- 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 +19 -0
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.js +40 -26
- package/dist/cli/index.js +12 -0
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +232 -0
- package/dist/core/data_types.js +135 -0
- 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 +24 -1
- 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 +7 -1
- package/dist/core/issues/github_provider.js +33 -2
- package/dist/core/issues/jira_provider.d.ts +5 -1
- package/dist/core/issues/jira_provider.js +26 -3
- package/dist/core/issues/provider.d.ts +53 -2
- 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/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 +89 -9
- package/dist/core/watch.js +193 -19
- 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
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { test } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { isKnownToolName, CcToolCallTracker } from "../core/agent_cc.js";
|
|
4
|
-
test("isKnownToolName: SPF's lowercase vocabulary maps onto Claude Code's own tool names, ls is known-but-dropped, typos are unknown", () => {
|
|
5
|
-
for (const name of ["read", "write", "edit", "bash", "grep", "glob"]) {
|
|
6
|
-
assert.equal(isKnownToolName(name), true, name);
|
|
7
|
-
}
|
|
8
|
-
assert.equal(isKnownToolName("find"), true, "find aliases to glob");
|
|
9
|
-
assert.equal(isKnownToolName("ls"), true, "ls is a known name, just dropped");
|
|
10
|
-
assert.equal(isKnownToolName("harness_engineering_only"), false);
|
|
11
|
-
assert.equal(isKnownToolName("bogus"), false);
|
|
12
|
-
});
|
|
13
|
-
// Every message shape below is a real captured line from `claude -p
|
|
14
|
-
// --output-format stream-json --verbose` (v2.1.237), not a guess — see
|
|
15
|
-
// agent_cc.ts's module doc comment for the exact commands run.
|
|
16
|
-
test("CcToolCallTracker: folds a real tool_use + tool_result pair into one record", () => {
|
|
17
|
-
const tracker = new CcToolCallTracker();
|
|
18
|
-
assert.equal(tracker.observe({
|
|
19
|
-
type: "assistant",
|
|
20
|
-
message: {
|
|
21
|
-
content: [{ type: "tool_use", id: "toolu_01ABC", name: "Read", input: { file_path: "/private/tmp/cc-probe/test.txt" } }],
|
|
22
|
-
},
|
|
23
|
-
timestamp: "2026-08-20T11:40:00.000Z",
|
|
24
|
-
}), null, "the assistant's tool_use alone produces no record");
|
|
25
|
-
const record = tracker.observe({
|
|
26
|
-
type: "user",
|
|
27
|
-
message: {
|
|
28
|
-
content: [{ type: "tool_result", tool_use_id: "toolu_01ABC", content: "1\thello world\n2\t" }],
|
|
29
|
-
},
|
|
30
|
-
timestamp: "2026-08-20T11:40:00.500Z",
|
|
31
|
-
});
|
|
32
|
-
assert.ok(record);
|
|
33
|
-
assert.equal(record.tool, "Read");
|
|
34
|
-
assert.equal(record.tool_call_id, "toolu_01ABC");
|
|
35
|
-
assert.deepEqual(record.args, { file_path: "/private/tmp/cc-probe/test.txt" });
|
|
36
|
-
assert.equal(record.ok, true);
|
|
37
|
-
assert.equal(record.label, "Read: /private/tmp/cc-probe/test.txt");
|
|
38
|
-
assert.equal(record.result_snippet, "1\thello world\n2\t");
|
|
39
|
-
assert.equal(record.duration_ms, 500);
|
|
40
|
-
});
|
|
41
|
-
test("CcToolCallTracker: a real StructuredOutput call folds like any other tool call, deliberately unfiltered", () => {
|
|
42
|
-
const tracker = new CcToolCallTracker();
|
|
43
|
-
tracker.observe({
|
|
44
|
-
type: "assistant",
|
|
45
|
-
message: {
|
|
46
|
-
content: [
|
|
47
|
-
{
|
|
48
|
-
type: "tool_use",
|
|
49
|
-
id: "toolu_01MPFgPqyPeEzWfqRE25JLzF",
|
|
50
|
-
name: "StructuredOutput",
|
|
51
|
-
input: { status: "success", summary: "hello from claude code" },
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
const record = tracker.observe({
|
|
57
|
-
type: "user",
|
|
58
|
-
message: {
|
|
59
|
-
content: [{ type: "tool_result", tool_use_id: "toolu_01MPFgPqyPeEzWfqRE25JLzF", content: "Structured output provided successfully" }],
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
assert.ok(record);
|
|
63
|
-
assert.equal(record.tool, "StructuredOutput");
|
|
64
|
-
assert.equal(record.ok, true);
|
|
65
|
-
assert.equal(record.result_snippet, "Structured output provided successfully");
|
|
66
|
-
});
|
|
67
|
-
test("CcToolCallTracker: is_error:true on the tool_result folds to ok:false", () => {
|
|
68
|
-
const tracker = new CcToolCallTracker();
|
|
69
|
-
tracker.observe({
|
|
70
|
-
type: "assistant",
|
|
71
|
-
message: { content: [{ type: "tool_use", id: "toolu_err", name: "Bash", input: { command: "false" } }] },
|
|
72
|
-
});
|
|
73
|
-
const record = tracker.observe({
|
|
74
|
-
type: "user",
|
|
75
|
-
message: { content: [{ type: "tool_result", tool_use_id: "toolu_err", content: "command failed", is_error: true }] },
|
|
76
|
-
});
|
|
77
|
-
assert.ok(record);
|
|
78
|
-
assert.equal(record.ok, false);
|
|
79
|
-
});
|
|
80
|
-
test("CcToolCallTracker: non-tool message types (system, thinking-only assistant, result) produce no record", () => {
|
|
81
|
-
const tracker = new CcToolCallTracker();
|
|
82
|
-
assert.equal(tracker.observe({ type: "system", subtype: "init", cwd: "/tmp", session_id: "s1", tools: [] }), null);
|
|
83
|
-
assert.equal(tracker.observe({ type: "assistant", message: { content: [{ type: "thinking", thinking: "..." }] } }), null);
|
|
84
|
-
assert.equal(tracker.observe({ type: "result", subtype: "success", session_id: "s1" }), null);
|
|
85
|
-
});
|
|
86
|
-
test("CcToolCallTracker: a tool_result with no matching tool_use still produces a record (defensive)", () => {
|
|
87
|
-
const tracker = new CcToolCallTracker();
|
|
88
|
-
const record = tracker.observe({
|
|
89
|
-
type: "user",
|
|
90
|
-
message: { content: [{ type: "tool_result", tool_use_id: "orphan", content: "ok" }] },
|
|
91
|
-
});
|
|
92
|
-
assert.ok(record);
|
|
93
|
-
assert.equal(record.tool, "tool");
|
|
94
|
-
assert.deepEqual(record.args, {});
|
|
95
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { test } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { isKnownToolName, resolveModel, ToolCallTracker } from "../core/agent_flue.js";
|
|
4
|
-
test("resolveModel: accepts provider/model-id, rejects anything else", () => {
|
|
5
|
-
assert.deepEqual(resolveModel("openrouter/google/gemini-3.6-flash"), ["openrouter", "google/gemini-3.6-flash"]);
|
|
6
|
-
assert.deepEqual(resolveModel("openai/gpt-5.6-terra"), ["openai", "gpt-5.6-terra"]);
|
|
7
|
-
assert.throws(() => resolveModel("gpt-5.6-terra"), /must be written as provider\/model-id/);
|
|
8
|
-
assert.throws(() => resolveModel("/gpt-5.6-terra"), /must be written as provider\/model-id/);
|
|
9
|
-
assert.throws(() => resolveModel("openai/"), /must be written as provider\/model-id/);
|
|
10
|
-
});
|
|
11
|
-
test("isKnownToolName: pi vocabulary maps onto Flue's six builtins, ls is known-but-dropped, typos are unknown", () => {
|
|
12
|
-
for (const name of ["read", "write", "edit", "bash", "grep", "glob"]) {
|
|
13
|
-
assert.equal(isKnownToolName(name), true, name);
|
|
14
|
-
}
|
|
15
|
-
assert.equal(isKnownToolName("find"), true, "find aliases to glob");
|
|
16
|
-
assert.equal(isKnownToolName("ls"), true, "ls is a known name, just dropped");
|
|
17
|
-
assert.equal(isKnownToolName("subagent_create"), false, "harness_engineering-only names are unknown here");
|
|
18
|
-
assert.equal(isKnownToolName("bogus"), false);
|
|
19
|
-
});
|
|
20
|
-
test("ToolCallTracker: folds tool-input + tool-output into one record", () => {
|
|
21
|
-
const tracker = new ToolCallTracker();
|
|
22
|
-
assert.equal(tracker.observe({
|
|
23
|
-
type: "tool-input",
|
|
24
|
-
conversationId: "c1",
|
|
25
|
-
messageId: "m1",
|
|
26
|
-
toolCallId: "call1",
|
|
27
|
-
toolName: "bash",
|
|
28
|
-
input: { command: "ls -la src" },
|
|
29
|
-
position: { batch: 0, index: 0 },
|
|
30
|
-
}), null, "tool-input alone produces no record");
|
|
31
|
-
const record = tracker.observe({
|
|
32
|
-
type: "tool-output",
|
|
33
|
-
conversationId: "c1",
|
|
34
|
-
toolCallId: "call1",
|
|
35
|
-
output: "src/index.ts\nsrc/core",
|
|
36
|
-
durationMs: 12,
|
|
37
|
-
position: { batch: 0, index: 1 },
|
|
38
|
-
});
|
|
39
|
-
assert.ok(record);
|
|
40
|
-
assert.equal(record.tool, "bash");
|
|
41
|
-
assert.equal(record.tool_call_id, "call1");
|
|
42
|
-
assert.deepEqual(record.args, { command: "ls -la src" });
|
|
43
|
-
assert.equal(record.ok, true);
|
|
44
|
-
assert.equal(record.label, "bash: ls -la src");
|
|
45
|
-
assert.equal(record.result_snippet, "src/index.ts\nsrc/core");
|
|
46
|
-
assert.equal(record.duration_ms, 12);
|
|
47
|
-
});
|
|
48
|
-
test("ToolCallTracker: tool-output-error folds to ok:false with the error text as the snippet", () => {
|
|
49
|
-
const tracker = new ToolCallTracker();
|
|
50
|
-
tracker.observe({
|
|
51
|
-
type: "tool-input",
|
|
52
|
-
conversationId: "c1",
|
|
53
|
-
messageId: "m1",
|
|
54
|
-
toolCallId: "call2",
|
|
55
|
-
toolName: "read",
|
|
56
|
-
input: { path: "missing.txt" },
|
|
57
|
-
position: { batch: 0, index: 0 },
|
|
58
|
-
});
|
|
59
|
-
const record = tracker.observe({
|
|
60
|
-
type: "tool-output-error",
|
|
61
|
-
conversationId: "c1",
|
|
62
|
-
toolCallId: "call2",
|
|
63
|
-
errorText: "ENOENT: no such file",
|
|
64
|
-
position: { batch: 0, index: 1 },
|
|
65
|
-
});
|
|
66
|
-
assert.ok(record);
|
|
67
|
-
assert.equal(record.ok, false);
|
|
68
|
-
assert.equal(record.result_snippet, "ENOENT: no such file");
|
|
69
|
-
assert.equal(record.label, "read: missing.txt");
|
|
70
|
-
});
|
|
71
|
-
test("ToolCallTracker: a tool-output with no matching tool-input still produces a record (defensive)", () => {
|
|
72
|
-
const tracker = new ToolCallTracker();
|
|
73
|
-
const record = tracker.observe({
|
|
74
|
-
type: "tool-output",
|
|
75
|
-
conversationId: "c1",
|
|
76
|
-
toolCallId: "orphan",
|
|
77
|
-
output: "ok",
|
|
78
|
-
position: { batch: 0, index: 0 },
|
|
79
|
-
});
|
|
80
|
-
assert.ok(record);
|
|
81
|
-
assert.equal(record.tool, "tool");
|
|
82
|
-
assert.deepEqual(record.args, {});
|
|
83
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression net for the chain registry. Chains used to each be a hand-written
|
|
3
|
-
* module; now every one but `simple-sdlc` is a `steps` list whose
|
|
4
|
-
* `phases`/`requiredAgents`/`requiredSuites` are DERIVED (see
|
|
5
|
-
* `chains/steps.ts`'s `derivePhases`/`deriveRequiredAgents`/
|
|
6
|
-
* `deriveRequiredSuites`). These tests pin what that derivation produces
|
|
7
|
-
* today, so a step-factory change that silently alters a chain's shape (an
|
|
8
|
-
* agent dropped from `requiredAgents`, a suite no longer required, a phase
|
|
9
|
-
* missing from the display string) fails here instead of only showing up as
|
|
10
|
-
* a `spf list` diff nobody happened to read.
|
|
11
|
-
*/
|
|
12
|
-
export {};
|
package/dist/test/chains.test.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression net for the chain registry. Chains used to each be a hand-written
|
|
3
|
-
* module; now every one but `simple-sdlc` is a `steps` list whose
|
|
4
|
-
* `phases`/`requiredAgents`/`requiredSuites` are DERIVED (see
|
|
5
|
-
* `chains/steps.ts`'s `derivePhases`/`deriveRequiredAgents`/
|
|
6
|
-
* `deriveRequiredSuites`). These tests pin what that derivation produces
|
|
7
|
-
* today, so a step-factory change that silently alters a chain's shape (an
|
|
8
|
-
* agent dropped from `requiredAgents`, a suite no longer required, a phase
|
|
9
|
-
* missing from the display string) fails here instead of only showing up as
|
|
10
|
-
* a `spf list` diff nobody happened to read.
|
|
11
|
-
*/
|
|
12
|
-
import { test } from "node:test";
|
|
13
|
-
import assert from "node:assert/strict";
|
|
14
|
-
import { CHAINS, findChain, resolveRequiredAgents, resolveRequiredSuites } from "../chains/index.js";
|
|
15
|
-
// name -> [phases, requiredAgents (with no options), requiredSuites]
|
|
16
|
-
const EXPECTED = {
|
|
17
|
-
prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
|
|
18
|
-
scout: { phases: "engineer(request) -> scout", agents: ["scout"], suites: [] },
|
|
19
|
-
plan: { phases: "engineer(request) -> planner", agents: ["planner"], suites: [] },
|
|
20
|
-
build: { phases: "engineer(request) -> builder", agents: ["builder"], suites: [] },
|
|
21
|
-
"plan-build": { phases: "engineer(request) -> planner -> builder -> git(commit)", agents: ["planner", "builder"], suites: [] },
|
|
22
|
-
"build-test": {
|
|
23
|
-
phases: "engineer(request) -> builder -> code(test) [-> builder(fix) -> code(test) ...] bounded",
|
|
24
|
-
agents: ["builder"],
|
|
25
|
-
suites: ["test"],
|
|
26
|
-
},
|
|
27
|
-
"plan-build-test": {
|
|
28
|
-
phases: "engineer(request) -> planner -> builder -> code(test) [-> builder(fix) -> code(test) ...] bounded -> git(commit)",
|
|
29
|
-
agents: ["planner", "builder"],
|
|
30
|
-
suites: ["test"],
|
|
31
|
-
},
|
|
32
|
-
"plan-build-test-quality": {
|
|
33
|
-
phases: "engineer(request) -> planner -> builder -> code(verify) [-> builder(fix) -> code(verify) ...] bounded -> git(commit)",
|
|
34
|
-
agents: ["planner", "builder"],
|
|
35
|
-
suites: ["all"],
|
|
36
|
-
},
|
|
37
|
-
"build-review": {
|
|
38
|
-
phases: "engineer(request) -> builder -> reviewer [-> builder(revise) -> reviewer ...] bounded",
|
|
39
|
-
agents: ["builder", "reviewer"],
|
|
40
|
-
suites: [],
|
|
41
|
-
},
|
|
42
|
-
quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
|
|
43
|
-
document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
|
|
44
|
-
refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
|
|
45
|
-
"simple-sdlc": {
|
|
46
|
-
phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
|
|
47
|
-
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
|
|
48
|
-
"-> code(changes) -> documenter -> git(commit_docs)",
|
|
49
|
-
agents: ["planner", "builder", "reviewer", "documenter"],
|
|
50
|
-
suites: ["test"],
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
test("every chain in the registry has an expectation pinned here", () => {
|
|
54
|
-
const names = CHAINS.map((c) => c.name).sort();
|
|
55
|
-
assert.deepEqual(names, Object.keys(EXPECTED).sort(), "a chain was added/removed/renamed without updating this test");
|
|
56
|
-
});
|
|
57
|
-
for (const chain of CHAINS) {
|
|
58
|
-
const expected = EXPECTED[chain.name];
|
|
59
|
-
test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
|
|
60
|
-
assert.equal(chain.phases, expected.phases);
|
|
61
|
-
assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
|
|
62
|
-
assert.deepEqual(resolveRequiredSuites(chain, {}), expected.suites);
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
|
|
66
|
-
const chain = findChain("prompt");
|
|
67
|
-
assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
|
|
68
|
-
assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
|
|
69
|
-
});
|
|
70
|
-
test("plan-build-test: requiredSuites depends on --suite, the same way prompt's agent does", () => {
|
|
71
|
-
const chain = findChain("plan-build-test");
|
|
72
|
-
assert.deepEqual(resolveRequiredSuites(chain, {}), ["test"], "no --suite -> falls back to the compiled-in default");
|
|
73
|
-
assert.deepEqual(resolveRequiredSuites(chain, { suite: "custom" }), ["custom"], "--suite overrides the default");
|
|
74
|
-
});
|
|
75
|
-
test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
|
|
76
|
-
for (const chain of CHAINS) {
|
|
77
|
-
if (chain.name === "simple-sdlc") {
|
|
78
|
-
assert.ok(chain.run, "simple-sdlc should still be the one hand-written chain");
|
|
79
|
-
assert.equal(chain.steps, undefined);
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
assert.ok(chain.steps && chain.steps.length > 0, `${chain.name} should be a steps list`);
|
|
83
|
-
assert.equal(chain.run, undefined, `${chain.name} should not also define run()`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
test("findChain resolves every registered name and nothing else", () => {
|
|
88
|
-
for (const chain of CHAINS) {
|
|
89
|
-
assert.equal(findChain(chain.name)?.name, chain.name);
|
|
90
|
-
}
|
|
91
|
-
assert.equal(findChain("not-a-real-chain"), undefined);
|
|
92
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression tests for the zod -> Valibot conversion. Not exhaustive schema
|
|
3
|
-
* tests — these guard the three specific things that conversion could get
|
|
4
|
-
* wrong: the three-state writes/tools semantics that hard rule 9 depends on,
|
|
5
|
-
* default-value reference freshness (verified NOT to be a live bug in
|
|
6
|
-
* valibot 1.4.2 — see the comment below — kept as a tripwire against a
|
|
7
|
-
* future version regressing it), and toJsonSchema staying convertible now
|
|
8
|
-
* that it backs the sf_report tool wiring landing in the Flue phase.
|
|
9
|
-
*/
|
|
10
|
-
export {};
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression tests for the zod -> Valibot conversion. Not exhaustive schema
|
|
3
|
-
* tests — these guard the three specific things that conversion could get
|
|
4
|
-
* wrong: the three-state writes/tools semantics that hard rule 9 depends on,
|
|
5
|
-
* default-value reference freshness (verified NOT to be a live bug in
|
|
6
|
-
* valibot 1.4.2 — see the comment below — kept as a tripwire against a
|
|
7
|
-
* future version regressing it), and toJsonSchema staying convertible now
|
|
8
|
-
* that it backs the sf_report tool wiring landing in the Flue phase.
|
|
9
|
-
*/
|
|
10
|
-
import { test } from "node:test";
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import * as v from "valibot";
|
|
16
|
-
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
17
|
-
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, EventRecordTypeSchema, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewConfigSchema, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
-
import { agentEnv, loadConfig } from "../core/agents.js";
|
|
19
|
-
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
20
|
-
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
21
|
-
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
22
|
-
assert.equal(unrestricted.writes, undefined, "key absent -> undefined (unrestricted)");
|
|
23
|
-
const explicitNull = v.parse(AgentConfigSchema, { ...base, writes: null });
|
|
24
|
-
assert.equal(explicitNull.writes, null, "explicit null -> null (also unrestricted)");
|
|
25
|
-
const readOnly = v.parse(AgentConfigSchema, { ...base, writes: [] });
|
|
26
|
-
assert.deepEqual(readOnly.writes, [], "explicit [] -> read-only, must stay an empty array, not undefined/null");
|
|
27
|
-
const allowlisted = v.parse(AgentConfigSchema, { ...base, writes: ["specs/"] });
|
|
28
|
-
assert.deepEqual(allowlisted.writes, ["specs/"]);
|
|
29
|
-
assert.throws(() => v.parse(AgentConfigSchema, { ...base, writes: "specs/" }), "a bare string must not be silently accepted as an allowlist");
|
|
30
|
-
});
|
|
31
|
-
test("array defaults never alias across parses (tripwire, not a fix — see module comment)", () => {
|
|
32
|
-
const first = v.parse(BuildOutput.schema, { status: "success" });
|
|
33
|
-
const second = v.parse(BuildOutput.schema, { status: "success" });
|
|
34
|
-
first.artifacts.push("should-not-leak");
|
|
35
|
-
first.changed_files.push("should-not-leak");
|
|
36
|
-
assert.deepEqual(second.artifacts, [], "a later parse must not see an earlier parse's push");
|
|
37
|
-
assert.deepEqual(second.changed_files, []);
|
|
38
|
-
assert.notEqual(first.artifacts, second.artifacts, "distinct array references");
|
|
39
|
-
});
|
|
40
|
-
test("PhaseParamsSchema rejects a blank description and an echo of the name, with the exact messages", () => {
|
|
41
|
-
assert.throws(() => makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: " " }), /description is required/);
|
|
42
|
-
assert.throws(() => makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: "Commit Plan" }), /only restates the phase name/);
|
|
43
|
-
const ok = makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: " Commit the plan file to the repo " });
|
|
44
|
-
assert.equal(ok.description, "Commit the plan file to the repo", "whitespace is collapsed, not just trimmed");
|
|
45
|
-
});
|
|
46
|
-
test("every envelope type still converts to JSON Schema (the sf_report tool wiring depends on this)", () => {
|
|
47
|
-
for (const envelope of [GenericOutput, PlanOutput, BuildOutput, ScoutOutput, ReviewOutput, DocumentOutput, ChangesOutput, VerifyOutput]) {
|
|
48
|
-
assert.doesNotThrow(() => toJsonSchema(envelope.schema), `${envelope.name} must stay convertible — no rawTransform allowed on an envelope schema`);
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
test("PhaseParamsSchema itself (the one schema WITH a rawTransform) correctly refuses JSON Schema conversion", () => {
|
|
52
|
-
assert.throws(() => toJsonSchema(PhaseParamsSchema), /raw_transform/);
|
|
53
|
-
});
|
|
54
|
-
test("NotificationsConfigSchema defaults to off, no channels", () => {
|
|
55
|
-
const parsed = v.parse(NotificationsConfigSchema, {});
|
|
56
|
-
assert.equal(parsed.events, "off");
|
|
57
|
-
assert.equal(parsed.timeout_ms, 5_000);
|
|
58
|
-
assert.deepEqual(parsed.channels, []);
|
|
59
|
-
});
|
|
60
|
-
test("a channel's own `events` overrides the top-level scope; unset inherits it", () => {
|
|
61
|
-
const parsed = v.parse(NotificationsConfigSchema, {
|
|
62
|
-
events: "errors",
|
|
63
|
-
channels: [{ kind: "slack" }, { kind: "teams", events: "all" }],
|
|
64
|
-
});
|
|
65
|
-
assert.equal(parsed.channels[0].events, undefined, "unset per-channel scope stays undefined, not defaulted to the top-level value — the caller inherits at read time");
|
|
66
|
-
assert.equal(parsed.channels[1].events, "all");
|
|
67
|
-
});
|
|
68
|
-
test("notifications survives loadConfig's merge — key-by-key like observability/quality, channels replaced wholesale on override", () => {
|
|
69
|
-
const dir = mkdtempSync(join(tmpdir(), "spf-notify-merge-test-"));
|
|
70
|
-
try {
|
|
71
|
-
const base = join(dir, "base.yaml");
|
|
72
|
-
const override = join(dir, "override.yaml");
|
|
73
|
-
writeFileSync(base, "notifications:\n events: off\n channels:\n - {kind: webhook, webhook_url_env: BASE_HOOK}\n");
|
|
74
|
-
writeFileSync(override, "notifications:\n events: all\n channels:\n - {kind: slack, webhook_url_env: SLACK_WEBHOOK_URL}\n");
|
|
75
|
-
const cfg = loadConfig([base, override]);
|
|
76
|
-
assert.equal(cfg.notifications.events, "all", "events: key-by-key, override wins");
|
|
77
|
-
assert.deepEqual(cfg.notifications.channels.map((c) => c.kind), ["slack"], "channels: a whole-array replace, not an append — same semantics as quality.checks");
|
|
78
|
-
}
|
|
79
|
-
finally {
|
|
80
|
-
rmSync(dir, { recursive: true, force: true });
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
test("ReviewConfigSchema: defaults to require_human_signoff=false, signoff_timeout_seconds=300", () => {
|
|
84
|
-
const parsed = v.parse(ReviewConfigSchema, {});
|
|
85
|
-
assert.equal(parsed.require_human_signoff, false, "this release fails OPEN by default — see the schema's own doc comment");
|
|
86
|
-
assert.equal(parsed.signoff_timeout_seconds, 300);
|
|
87
|
-
});
|
|
88
|
-
// The silent-drop trap: mergeRawConfig (core/agents.ts) is a FIXED-SHAPE
|
|
89
|
-
// object literal, so a `review:` key in a real config file that isn't named
|
|
90
|
-
// on both sides of that literal is dropped before SFConfigSchema ever sees
|
|
91
|
-
// it — parsing would then succeed anyway, quietly, on the schema default.
|
|
92
|
-
// This is adversarial history on this branch, not a hypothetical: it is
|
|
93
|
-
// exactly the bug `observability`/`notifications` already guard against, and
|
|
94
|
-
// `review` gets the same guard the day it is added.
|
|
95
|
-
test("review survives loadConfig's merge — the silent-drop trap mergeRawConfig's fixed-shape literal sets, for a single config file", () => {
|
|
96
|
-
const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-test-"));
|
|
97
|
-
try {
|
|
98
|
-
const configPath = join(dir, "spf.config.yaml");
|
|
99
|
-
writeFileSync(configPath, "review:\n require_human_signoff: true\n signoff_timeout_seconds: 45\n");
|
|
100
|
-
const cfg = loadConfig([configPath]);
|
|
101
|
-
assert.equal(cfg.review.require_human_signoff, true, "a review: value from a real config file must reach SFConfig, not be dropped by mergeRawConfig's object literal");
|
|
102
|
-
assert.equal(cfg.review.signoff_timeout_seconds, 45);
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
rmSync(dir, { recursive: true, force: true });
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
test("review merges key-by-key across two layered config files, like observability/notifications", () => {
|
|
109
|
-
const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-layered-test-"));
|
|
110
|
-
try {
|
|
111
|
-
const base = join(dir, "base.yaml");
|
|
112
|
-
const override = join(dir, "override.yaml");
|
|
113
|
-
writeFileSync(base, "review:\n require_human_signoff: false\n signoff_timeout_seconds: 120\n");
|
|
114
|
-
writeFileSync(override, "review:\n require_human_signoff: true\n");
|
|
115
|
-
const cfg = loadConfig([base, override]);
|
|
116
|
-
assert.equal(cfg.review.require_human_signoff, true, "override wins for the key it names");
|
|
117
|
-
assert.equal(cfg.review.signoff_timeout_seconds, 120, "unset in the override -> the base's value survives, key-by-key, not a whole-block replace");
|
|
118
|
-
}
|
|
119
|
-
finally {
|
|
120
|
-
rmSync(dir, { recursive: true, force: true });
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
// observability.otel is the one nested OBJECT under a top-level key that
|
|
124
|
-
// mergeRawConfig spreads field-by-field, so its merge semantics differ from its
|
|
125
|
-
// siblings' and are worth pinning: `otel:` is replaced as a WHOLE OBJECT by an
|
|
126
|
-
// override that names it (you never want a half-merged endpoint/headers pair —
|
|
127
|
-
// that is how an auth token gets POSTed to the wrong collector), while
|
|
128
|
-
// `observability`'s other keys still merge key-by-key around it. Also the
|
|
129
|
-
// activation contract: absent by default, so no repo starts exporting because
|
|
130
|
-
// it upgraded.
|
|
131
|
-
test("observability.otel survives loadConfig's merge — absent by default, whole-object replace on override", () => {
|
|
132
|
-
const dir = mkdtempSync(join(tmpdir(), "spf-otel-merge-test-"));
|
|
133
|
-
try {
|
|
134
|
-
const bare = join(dir, "bare.yaml");
|
|
135
|
-
writeFileSync(bare, "observability:\n poll_ms: 250\n");
|
|
136
|
-
assert.equal(loadConfig([bare]).observability.otel, undefined, "no otel: block -> export stays off, the default for every repo");
|
|
137
|
-
const base = join(dir, "base.yaml");
|
|
138
|
-
const override = join(dir, "override.yaml");
|
|
139
|
-
writeFileSync(base, "observability:\n poll_ms: 250\n otel:\n endpoint: http://base-collector:4318/v1/traces\n headers: {authorization: base-token}\n service_name: base\n");
|
|
140
|
-
writeFileSync(override, "observability:\n otel:\n endpoint: http://override-collector:4318/v1/traces\n");
|
|
141
|
-
const cfg = loadConfig([base, override]);
|
|
142
|
-
assert.equal(cfg.observability.poll_ms, 250, "observability's other keys still merge key-by-key around otel");
|
|
143
|
-
assert.equal(cfg.observability.otel?.endpoint, "http://override-collector:4318/v1/traces");
|
|
144
|
-
assert.equal(cfg.observability.otel?.headers, undefined, "whole-object replace: the base's auth header does NOT follow the override's endpoint");
|
|
145
|
-
assert.equal(cfg.observability.otel?.service_name, "spf", "and the base's service_name doesn't either — the schema default applies");
|
|
146
|
-
// A single config file must reach SFConfig at all — the silent-drop trap
|
|
147
|
-
// mergeRawConfig's fixed-shape object literal sets for any new key.
|
|
148
|
-
const single = join(dir, "single.yaml");
|
|
149
|
-
writeFileSync(single, "observability:\n otel:\n endpoint: https://collector.example.com/v1/traces\n");
|
|
150
|
-
assert.equal(loadConfig([single]).observability.otel?.endpoint, "https://collector.example.com/v1/traces");
|
|
151
|
-
// A typo fails at config load, not as a silent per-run export failure.
|
|
152
|
-
const bad = join(dir, "bad.yaml");
|
|
153
|
-
writeFileSync(bad, "observability:\n otel:\n endpoint: not-a-url\n");
|
|
154
|
-
assert.throws(() => loadConfig([bad]), /invalid config/, "endpoint is URL-validated");
|
|
155
|
-
}
|
|
156
|
-
finally {
|
|
157
|
-
rmSync(dir, { recursive: true, force: true });
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
test("EventRecordTypeSchema accepts every observed event type and rejects an unknown one", () => {
|
|
161
|
-
for (const type of ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"]) {
|
|
162
|
-
assert.equal(v.parse(EventRecordTypeSchema, type), type);
|
|
163
|
-
}
|
|
164
|
-
assert.throws(() => v.parse(EventRecordTypeSchema, "bogus_type"), "an event type outside the observed set must be rejected, not silently stored");
|
|
165
|
-
});
|
|
166
|
-
test("AgentConfigSchema: env_allowlist is optional and defaults to undefined (byte-identical to before this field existed)", () => {
|
|
167
|
-
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
168
|
-
const unset = v.parse(AgentConfigSchema, base);
|
|
169
|
-
assert.equal(unset.env_allowlist, undefined);
|
|
170
|
-
const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
|
|
171
|
-
assert.deepEqual(allowlisted.env_allowlist, ["MY_API_KEY"]);
|
|
172
|
-
const nulled = v.parse(AgentConfigSchema, { ...base, env_allowlist: null });
|
|
173
|
-
assert.equal(nulled.env_allowlist, null, "null must parse (the 'unrestricted' spelling config.md teaches for writes)");
|
|
174
|
-
});
|
|
175
|
-
// This is the load-bearing half of the field: the schema merely declares
|
|
176
|
-
// the shape, agentEnv() is what actually filters the operator's own
|
|
177
|
-
// environment down to the allowlist. A test that only exercised the schema
|
|
178
|
-
// (as the previous version of this test did) would never notice agentEnv
|
|
179
|
-
// silently failing to filter anything.
|
|
180
|
-
test("agentEnv: filters the operator environment down to the allowlist plus the baseline keys", () => {
|
|
181
|
-
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
182
|
-
const savedApiKey = process.env["MY_API_KEY"];
|
|
183
|
-
const savedSecret = process.env["SECRET_TOKEN"];
|
|
184
|
-
try {
|
|
185
|
-
process.env["MY_API_KEY"] = "abc123";
|
|
186
|
-
process.env["SECRET_TOKEN"] = "should-never-appear";
|
|
187
|
-
// unset -> undefined: both backends fall back to `request.env ?? operatorEnv()`,
|
|
188
|
-
// so this is byte-identical to the unfiltered behavior that predates the field.
|
|
189
|
-
const unset = v.parse(AgentConfigSchema, base);
|
|
190
|
-
assert.equal(agentEnv(unset), undefined);
|
|
191
|
-
// [] -> baseline-only: every ENV_BASELINE_KEYS entry actually present in
|
|
192
|
-
// process.env, and nothing else — SECRET_TOKEN must not leak through.
|
|
193
|
-
const empty = v.parse(AgentConfigSchema, { ...base, env_allowlist: [] });
|
|
194
|
-
const baselineOnly = agentEnv(empty);
|
|
195
|
-
const baselineKeys = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
|
|
196
|
-
for (const key of Object.keys(baselineOnly)) {
|
|
197
|
-
assert.ok(baselineKeys.includes(key), `${key} is not a baseline key — [] must yield baseline-only`);
|
|
198
|
-
}
|
|
199
|
-
assert.equal(baselineOnly["SECRET_TOKEN"], undefined);
|
|
200
|
-
assert.equal(baselineOnly["MY_API_KEY"], undefined);
|
|
201
|
-
// ["MY_API_KEY"] -> baseline + MY_API_KEY, with SECRET_TOKEN provably absent.
|
|
202
|
-
const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
|
|
203
|
-
const filtered = agentEnv(allowlisted);
|
|
204
|
-
assert.equal(filtered["MY_API_KEY"], "abc123");
|
|
205
|
-
assert.equal(filtered["SECRET_TOKEN"], undefined, "an unrelated secret must not survive the filter");
|
|
206
|
-
for (const key of Object.keys(filtered)) {
|
|
207
|
-
assert.ok(baselineKeys.includes(key) || key === "MY_API_KEY", `${key} leaked through the allowlist unexpectedly`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
finally {
|
|
211
|
-
if (savedApiKey === undefined)
|
|
212
|
-
delete process.env["MY_API_KEY"];
|
|
213
|
-
else
|
|
214
|
-
process.env["MY_API_KEY"] = savedApiKey;
|
|
215
|
-
if (savedSecret === undefined)
|
|
216
|
-
delete process.env["SECRET_TOKEN"];
|
|
217
|
-
else
|
|
218
|
-
process.env["SECRET_TOKEN"] = savedSecret;
|
|
219
|
-
}
|
|
220
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `.env` upsert (`cli/env_file.ts`) — the write-side counterpart of
|
|
3
|
-
* `process.loadEnvFile()` in `cli/index.ts`, which every command already
|
|
4
|
-
* uses to load `<repo_root>/.env`. Mirrors `ensureGitignore`'s idempotent
|
|
5
|
-
* shape: preserve everything unrelated, touch only the keys asked for.
|
|
6
|
-
*/
|
|
7
|
-
import { test, beforeEach, afterEach } from "node:test";
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { maskSecret, readEnvFile, upsertEnvFile, writeEnvExample } from "../cli/env_file.js";
|
|
13
|
-
let dir;
|
|
14
|
-
// Each test gets its own scratch directory with no .env yet — cheap, and
|
|
15
|
-
// avoids one test's leftover .env leaking into the next.
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
dir = mkdtempSync(join(tmpdir(), "spf-env-file-test-"));
|
|
18
|
-
});
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
rmSync(dir, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
test("upsertEnvFile creates a fresh .env with the given keys", () => {
|
|
23
|
-
const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
24
|
-
assert.deepEqual(result, { added: ["GITHUB_TOKEN"], updated: [], unchanged: [] });
|
|
25
|
-
assert.equal(readEnvFile(join(dir, ".env")).get("GITHUB_TOKEN"), "ghp_abc");
|
|
26
|
-
});
|
|
27
|
-
test("upsertEnvFile preserves unrelated lines and comments verbatim, appends new keys under its own header", () => {
|
|
28
|
-
writeFileSync(join(dir, ".env"), "# a hand-written comment\nFOO=bar\n\nBAZ=qux\n");
|
|
29
|
-
upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
30
|
-
const raw = readFileSync(join(dir, ".env"), "utf-8");
|
|
31
|
-
assert.match(raw, /# a hand-written comment/);
|
|
32
|
-
assert.match(raw, /FOO=bar/);
|
|
33
|
-
assert.match(raw, /BAZ=qux/);
|
|
34
|
-
assert.match(raw, /GITHUB_TOKEN=ghp_abc/);
|
|
35
|
-
});
|
|
36
|
-
test("upsertEnvFile rewrites an existing key in place — never a duplicate line", () => {
|
|
37
|
-
writeFileSync(join(dir, ".env"), "GITHUB_TOKEN=ghp_old\nOTHER=1\n");
|
|
38
|
-
const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_new" });
|
|
39
|
-
assert.deepEqual(result, { added: [], updated: ["GITHUB_TOKEN"], unchanged: [] });
|
|
40
|
-
const raw = readFileSync(join(dir, ".env"), "utf-8");
|
|
41
|
-
assert.equal((raw.match(/GITHUB_TOKEN=/g) || []).length, 1);
|
|
42
|
-
assert.match(raw, /GITHUB_TOKEN=ghp_new/);
|
|
43
|
-
assert.match(raw, /OTHER=1/);
|
|
44
|
-
});
|
|
45
|
-
test("upsertEnvFile is a no-op the second time with the same values", () => {
|
|
46
|
-
upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
47
|
-
const before = readFileSync(join(dir, ".env"), "utf-8");
|
|
48
|
-
const result = upsertEnvFile(dir, { GITHUB_TOKEN: "ghp_abc" });
|
|
49
|
-
assert.deepEqual(result, { added: [], updated: [], unchanged: ["GITHUB_TOKEN"] });
|
|
50
|
-
assert.equal(readFileSync(join(dir, ".env"), "utf-8"), before);
|
|
51
|
-
});
|
|
52
|
-
test("readEnvFile ignores blank lines and comments, and returns an empty map for a missing file", () => {
|
|
53
|
-
writeFileSync(join(dir, ".env"), "# comment\n\nA=1\n # indented comment\nB=2\n");
|
|
54
|
-
const values = readEnvFile(join(dir, ".env"));
|
|
55
|
-
assert.deepEqual([...values.entries()], [["A", "1"], ["B", "2"]]);
|
|
56
|
-
assert.equal(readEnvFile(join(dir, "nope.env")).size, 0);
|
|
57
|
-
});
|
|
58
|
-
test("maskSecret keeps only the last 4 characters; short values are fully masked", () => {
|
|
59
|
-
const value = "ghp_1234567890abcdef"; // 20 chars, last 4 = "cdef"
|
|
60
|
-
const masked = maskSecret(value);
|
|
61
|
-
assert.equal(masked.length, value.length);
|
|
62
|
-
assert.match(masked, /^•+cdef$/);
|
|
63
|
-
assert.equal(maskSecret("abcd"), "••••");
|
|
64
|
-
assert.equal(maskSecret("ab"), "••");
|
|
65
|
-
});
|
|
66
|
-
test("writeEnvExample writes key names with empty values, sorted and de-duplicated", () => {
|
|
67
|
-
writeEnvExample(dir, ["GITHUB_TOKEN", "JIRA_EMAIL", "GITHUB_TOKEN"]);
|
|
68
|
-
const raw = readFileSync(join(dir, ".env.example"), "utf-8");
|
|
69
|
-
assert.equal(raw, "# spf — keys required by this repo's spf.config.yaml; fill in real values in .env (gitignored)\nGITHUB_TOKEN=\nJIRA_EMAIL=\n");
|
|
70
|
-
});
|
|
71
|
-
test("writeEnvExample writes nothing when there are no keys", () => {
|
|
72
|
-
writeEnvExample(dir, []);
|
|
73
|
-
assert.equal(existsSync(join(dir, ".env.example")), false);
|
|
74
|
-
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared test double for `cli/ask.ts`'s `Asker` interface — the seam
|
|
3
|
-
* `runInterview` (`cli/interview.ts`) exists to be driven through, the same
|
|
4
|
-
* way `FakeProvider`/`FakeCodeHost` (`watch.test.ts`) stand in for
|
|
5
|
-
* `IssueProvider`/`CodeHostProvider`.
|
|
6
|
-
*
|
|
7
|
-
* Answers are keyed by a SUBSTRING of the question's label, not by call
|
|
8
|
-
* order — `runInterview` asks a different number of questions depending on
|
|
9
|
-
* earlier answers (skip a quality check, skip watch entirely, ...), so a
|
|
10
|
-
* positional queue would silently misalign the moment a test changes one
|
|
11
|
-
* answer. Substring matching keeps each scenario's script self-documenting
|
|
12
|
-
* and immune to that.
|
|
13
|
-
*/
|
|
14
|
-
import type { Asker } from "../cli/ask.js";
|
|
15
|
-
export interface AskerScript {
|
|
16
|
-
text?: Record<string, string>;
|
|
17
|
-
select?: Record<string, string>;
|
|
18
|
-
confirm?: Record<string, boolean>;
|
|
19
|
-
secret?: Record<string, string>;
|
|
20
|
-
/** Used when a confirm's label matches nothing in `confirm` above. Defaults to the caller's own default. */
|
|
21
|
-
defaultConfirm?: boolean;
|
|
22
|
-
}
|
|
23
|
-
export declare function createFakeAsker(script: AskerScript): Asker;
|