@gr8ful/spf 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -4
- package/assets/defaults/spf.config.yaml +6 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +69 -4
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +5 -0
- package/dist/chains/context.d.ts +30 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +70 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +215 -20
- package/dist/chains/steps.js +429 -61
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +319 -11
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +78 -1
- package/dist/cli/commands/list.js +42 -5
- package/dist/cli/commands/run.js +25 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +158 -10
- package/dist/cli/index.js +60 -3
- package/dist/cli/interview.js +65 -10
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +104 -4
- package/dist/core/data_types.js +99 -2
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.js +11 -3
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +11 -0
- package/dist/core/watch.js +17 -2
- package/dist/test/chains.test.js +8 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +54 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +127 -0
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/dist/test/refine.test.js +64 -1
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +124 -1
- package/package.json +5 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import "./hermetic_git.js";
|
|
2
|
+
/**
|
|
3
|
+
* `findRepoRoot()`'s contract is "never throws, always returns some root."
|
|
4
|
+
* `isRepoAt()` only proves `git rev-parse --git-dir` succeeds, which is also
|
|
5
|
+
* true inside a bare repo and inside a `.git/` directory itself — neither
|
|
6
|
+
* has a work tree, so the follow-up `--show-toplevel` call fails there even
|
|
7
|
+
* though `isRepoAt()` said yes. Regression coverage for that gap: before the
|
|
8
|
+
* fix, `findRepoRoot()` let that failure propagate as an uncaught throw,
|
|
9
|
+
* which — because `cli/index.ts` calls `paths.resolveAnchor()` outside its
|
|
10
|
+
* top-level try/catch — crashed every command (including `spf --version`)
|
|
11
|
+
* when run from a bare repo or from inside `.git/`.
|
|
12
|
+
*/
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { findRepoRoot } from "../core/git_helper.js";
|
|
20
|
+
test("findRepoRoot falls back to cwd inside a bare repo (no work tree)", () => {
|
|
21
|
+
const dir = mkdtempSync(path.join(tmpdir(), "spf-bare-"));
|
|
22
|
+
try {
|
|
23
|
+
execFileSync("git", ["init", "--bare", dir], { stdio: "ignore" });
|
|
24
|
+
const root = findRepoRoot(dir);
|
|
25
|
+
assert.equal(root, path.resolve(dir));
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
test("findRepoRoot falls back to cwd inside a repo's .git directory", () => {
|
|
32
|
+
const dir = mkdtempSync(path.join(tmpdir(), "spf-dotgit-"));
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
|
|
35
|
+
const gitDir = path.join(dir, ".git");
|
|
36
|
+
const root = findRepoRoot(gitDir);
|
|
37
|
+
assert.equal(root, path.resolve(gitDir));
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test("findRepoRoot resolves the toplevel of an ordinary work tree", () => {
|
|
44
|
+
const dir = mkdtempSync(path.join(tmpdir(), "spf-worktree-"));
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
|
|
47
|
+
const sub = path.join(dir, "nested");
|
|
48
|
+
execFileSync("node", ["-e", `require("fs").mkdirSync(${JSON.stringify(sub)})`]);
|
|
49
|
+
const root = findRepoRoot(sub);
|
|
50
|
+
// git may resolve symlinked tmpdirs (e.g. macOS /tmp -> /private/tmp) —
|
|
51
|
+
// compare against what git itself reports as the raw toplevel is what
|
|
52
|
+
// resolveAnchor ultimately does too, so lean on findRepoRoot from the
|
|
53
|
+
// repo root itself for a stable assertion.
|
|
54
|
+
assert.equal(root, findRepoRoot(dir));
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import this FIRST in any test file that spawns `git`.
|
|
3
|
+
*
|
|
4
|
+
* When the suite runs inside a git hook (lefthook's `pre-push`), git exports
|
|
5
|
+
* GIT_DIR — and sometimes GIT_WORK_TREE/GIT_INDEX_FILE — into every child
|
|
6
|
+
* process, and those beat `cwd` for every git invocation. A test that does
|
|
7
|
+
* `git init` / `git remote add` / `git commit` in a scratch tmpdir then
|
|
8
|
+
* silently operates on the REAL repository being pushed. Observed damage
|
|
9
|
+
* before this guard existed: a stray empty "init" commit landed on the
|
|
10
|
+
* checked-out branch, and `git init` re-initialized the shared `.git`
|
|
11
|
+
* directory as BARE (git treats a target directory named `.git` as a bare
|
|
12
|
+
* repo), breaking `git status` in the main checkout until `core.bare` was
|
|
13
|
+
* flipped back.
|
|
14
|
+
*
|
|
15
|
+
* Deleting the variables at module load — each test file is its own
|
|
16
|
+
* `node --test` process — makes `cwd` authoritative again for the whole
|
|
17
|
+
* file, including git calls made by the code under test.
|
|
18
|
+
*/
|
|
19
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"]) {
|
|
20
|
+
delete process.env[key];
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for `spf init`'s non-interactive paths — `--yes`,
|
|
3
|
+
* `--template <name>`, and (implicitly, since the test runner's stdin/stdout
|
|
4
|
+
* are never a TTY) plain `spf init` with no flags. All three must keep
|
|
5
|
+
* writing the exact same content they always have, byte for byte, and must
|
|
6
|
+
* never touch stdin — a scripted `spf init` in CI must not hang.
|
|
7
|
+
*
|
|
8
|
+
* The interactive interview itself is covered directly against `Asker`
|
|
9
|
+
* in `interview.test.ts`; `initCommand`'s interactive branch always calls
|
|
10
|
+
* the real `createAsker()` (backed by `node:readline` on `process.stdin`),
|
|
11
|
+
* which has nothing to read in a test process — exercising it here would
|
|
12
|
+
* just hang, so it isn't.
|
|
13
|
+
*/
|
|
14
|
+
import "./hermetic_git.js";
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* which has nothing to read in a test process — exercising it here would
|
|
12
12
|
* just hang, so it isn't.
|
|
13
13
|
*/
|
|
14
|
+
import "./hermetic_git.js";
|
|
14
15
|
import { test, beforeEach, afterEach } from "node:test";
|
|
15
16
|
import assert from "node:assert/strict";
|
|
16
17
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
17
18
|
import { tmpdir } from "node:os";
|
|
18
19
|
import { join } from "node:path";
|
|
19
20
|
import { execFileSync } from "node:child_process";
|
|
20
|
-
import { initCommand } from "../cli/commands/init.js";
|
|
21
|
+
import { initCommand, EXAMPLE_CHAIN_YAML } from "../cli/commands/init.js";
|
|
22
|
+
import { loadRepoChains } from "../chains/repo_chains.js";
|
|
23
|
+
import { resolveAnchor } from "../core/paths.js";
|
|
21
24
|
let dir;
|
|
22
25
|
beforeEach(() => {
|
|
23
26
|
dir = mkdtempSync(join(tmpdir(), "spf-init-test-"));
|
|
@@ -74,6 +77,56 @@ test("--no-skills skips the skill install", async () => {
|
|
|
74
77
|
assert.equal(code, 0);
|
|
75
78
|
assert.equal(existsSync(join(dir, ".claude", "skills", "spf")), false);
|
|
76
79
|
});
|
|
80
|
+
test("scaffolds .spf/chains/example.yaml, fully commented out (registers nothing)", async () => {
|
|
81
|
+
const code = await initCommand(["--cwd", dir, "--yes"]);
|
|
82
|
+
assert.equal(code, 0);
|
|
83
|
+
const examplePath = join(dir, ".spf", "chains", "example.yaml");
|
|
84
|
+
assert.ok(existsSync(examplePath), "spf init should scaffold .spf/chains/example.yaml");
|
|
85
|
+
const content = readFileSync(examplePath, "utf-8");
|
|
86
|
+
for (const line of content.split("\n")) {
|
|
87
|
+
if (line.trim() === "")
|
|
88
|
+
continue;
|
|
89
|
+
assert.ok(line.startsWith("#"), `every non-blank line in the scaffolded example must be commented out: ${JSON.stringify(line)}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
test("never adds .spf/chains to .gitignore — chain files must stay tracked for protected_files to cover them", async () => {
|
|
93
|
+
await initCommand(["--cwd", dir, "--yes"]);
|
|
94
|
+
const gitignore = readFileSync(join(dir, ".gitignore"), "utf-8");
|
|
95
|
+
assert.doesNotMatch(gitignore, /\.spf\/chains/);
|
|
96
|
+
});
|
|
97
|
+
test("the scaffolded example.yaml loads clean — zero chains, zero problems (it must not report a problem against spf's own scaffold)", async () => {
|
|
98
|
+
const code = await initCommand(["--cwd", dir, "--yes"]);
|
|
99
|
+
assert.equal(code, 0);
|
|
100
|
+
const anchor = resolveAnchor(dir);
|
|
101
|
+
const { chains, problems } = loadRepoChains(anchor);
|
|
102
|
+
assert.deepEqual(chains, []);
|
|
103
|
+
assert.deepEqual(problems, []);
|
|
104
|
+
});
|
|
105
|
+
test("EXAMPLE_CHAIN_YAML's commented-out template, uncommented verbatim, is a chain the loader accepts", async () => {
|
|
106
|
+
const lines = EXAMPLE_CHAIN_YAML.split("\n");
|
|
107
|
+
const startIdx = lines.findIndex((l) => l.startsWith("# name: example"));
|
|
108
|
+
assert.ok(startIdx !== -1, "expected a `# name: example` line to anchor the template block");
|
|
109
|
+
const uncommented = lines
|
|
110
|
+
.slice(startIdx)
|
|
111
|
+
.map((l) => l.replace(/^#\s?/, ""))
|
|
112
|
+
.join("\n");
|
|
113
|
+
const chainsDir = join(dir, ".spf", "chains");
|
|
114
|
+
execFileSync("mkdir", ["-p", chainsDir]);
|
|
115
|
+
const { writeFileSync } = await import("node:fs");
|
|
116
|
+
writeFileSync(join(chainsDir, "uncommented.yaml"), uncommented);
|
|
117
|
+
const anchor = resolveAnchor(dir);
|
|
118
|
+
const { chains, problems } = loadRepoChains(anchor);
|
|
119
|
+
assert.deepEqual(problems, [], `expected zero problems, got: ${JSON.stringify(problems)}`);
|
|
120
|
+
assert.equal(chains.length, 1);
|
|
121
|
+
assert.equal(chains[0]?.name, "example");
|
|
122
|
+
});
|
|
123
|
+
test("re-running spf init doesn't overwrite an already-scaffolded example.yaml", async () => {
|
|
124
|
+
await initCommand(["--cwd", dir, "--yes"]);
|
|
125
|
+
const examplePath = join(dir, ".spf", "chains", "example.yaml");
|
|
126
|
+
const before = readFileSync(examplePath, "utf-8");
|
|
127
|
+
await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force
|
|
128
|
+
assert.equal(readFileSync(examplePath, "utf-8"), before);
|
|
129
|
+
});
|
|
77
130
|
test("re-running spf init doesn't re-copy an unchanged skill install (install-skill's own idempotency)", async () => {
|
|
78
131
|
await initCommand(["--cwd", dir, "--yes"]);
|
|
79
132
|
const manifestPath = join(dir, ".claude", "skills", "spf", ".spf-skill-version");
|
|
@@ -1 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `spf init`'s interactive interview (`cli/interview.ts`) — driven through
|
|
3
|
+
* the `Asker` seam with a scripted fake (`./fake_asker.ts`), never a real
|
|
4
|
+
* TTY. Covers the three things a wrong answer would actually break:
|
|
5
|
+
* 1. the pinned-roster trap (planner/reviewer/documenter overridden
|
|
6
|
+
* whenever the backend switches to claude_code — see agent_cc.ts/
|
|
7
|
+
* agents.ts's back-fill comment),
|
|
8
|
+
* 2. the generated config is a genuine override — validating it requires
|
|
9
|
+
* merging with the packaged built-in roster first, exactly like
|
|
10
|
+
* `spf doctor` does, never the raw document alone,
|
|
11
|
+
* 3. each `spf watch` combination collects exactly the credentials
|
|
12
|
+
* `cli/commands/watch.ts`'s resolveIssueProvider/resolveCodeHostProvider
|
|
13
|
+
* actually read — no more, no less.
|
|
14
|
+
*/
|
|
15
|
+
import "./hermetic_git.js";
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* `cli/commands/watch.ts`'s resolveIssueProvider/resolveCodeHostProvider
|
|
13
13
|
* actually read — no more, no less.
|
|
14
14
|
*/
|
|
15
|
+
import "./hermetic_git.js";
|
|
15
16
|
import { test, before, after } from "node:test";
|
|
16
17
|
import assert from "node:assert/strict";
|
|
17
18
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -107,6 +108,61 @@ test("flue + openrouter: no pinned-agent overrides needed, provider key collecte
|
|
|
107
108
|
assert.equal(result.env.OPENROUTER_API_KEY, "sk-or-v1-test");
|
|
108
109
|
assert.ok(!("watch" in config));
|
|
109
110
|
});
|
|
111
|
+
test("flue + ollama: keyless provider skips the API-key prompt, but still asks for OLLAMA_BASE_URL", async () => {
|
|
112
|
+
const ctx = gatherContext(dir, new Map());
|
|
113
|
+
const asker = createFakeAsker({
|
|
114
|
+
select: { "backend runs": "flue", Provider: "ollama" },
|
|
115
|
+
text: { "Model id": "llama3" }, // deliberately no "OLLAMA_BASE_URL" entry — the fake asker falls back to its default
|
|
116
|
+
confirm: {
|
|
117
|
+
'Add a "typecheck"': false,
|
|
118
|
+
'Add a "lint"': false,
|
|
119
|
+
'Add a "build"': false,
|
|
120
|
+
'Add a "test"': false,
|
|
121
|
+
"Enable spf watch": false,
|
|
122
|
+
"Configure advanced": false,
|
|
123
|
+
"Write .spf": true,
|
|
124
|
+
},
|
|
125
|
+
// Deliberately no `secret` entries: a keyless provider must never call
|
|
126
|
+
// asker.secret() at all — PROVIDER_ENV_KEYS.ollama is `[]`, so indexing
|
|
127
|
+
// [0] for a prompt label would be `undefined`, not skip the prompt.
|
|
128
|
+
});
|
|
129
|
+
const result = await runInterview(asker, ctx);
|
|
130
|
+
assert.ok(result);
|
|
131
|
+
const config = result.config;
|
|
132
|
+
assert.equal(config.defaults.coding_agent, "flue");
|
|
133
|
+
assert.equal(config.defaults.model, "ollama/llama3");
|
|
134
|
+
assert.deepEqual(result.env, { OLLAMA_BASE_URL: "http://localhost:11434/v1" }, "no API key, but the base URL prompt's default is written");
|
|
135
|
+
assert.deepEqual(result.envExampleKeys, ["OLLAMA_BASE_URL"], "OLLAMA_BASE_URL is the only key name to record for a keyless provider");
|
|
136
|
+
// Unlike the openrouter case above, ollama DOES need the pinned-roster
|
|
137
|
+
// fix: planner/reviewer/documenter pin their own fireworks/gemini/openai
|
|
138
|
+
// model strings in the packaged roster, which would otherwise always win
|
|
139
|
+
// over defaults.model and leave three agents needing keys this interview
|
|
140
|
+
// never asked for.
|
|
141
|
+
assert.deepEqual(config.agents, [
|
|
142
|
+
{ name: "planner", model: "ollama/llama3" },
|
|
143
|
+
{ name: "reviewer", model: "ollama/llama3" },
|
|
144
|
+
{ name: "documenter", model: "ollama/llama3" },
|
|
145
|
+
], "planner/reviewer/documenter are pinned to the chosen ollama model, same fix as the claude_code branch");
|
|
146
|
+
});
|
|
147
|
+
test("flue + ollama: a custom OLLAMA_BASE_URL answer is written verbatim", async () => {
|
|
148
|
+
const ctx = gatherContext(dir, new Map());
|
|
149
|
+
const asker = createFakeAsker({
|
|
150
|
+
select: { "backend runs": "flue", Provider: "ollama" },
|
|
151
|
+
text: { "Model id": "llama3", OLLAMA_BASE_URL: "http://gpu-box.local:11434/v1" },
|
|
152
|
+
confirm: {
|
|
153
|
+
'Add a "typecheck"': false,
|
|
154
|
+
'Add a "lint"': false,
|
|
155
|
+
'Add a "build"': false,
|
|
156
|
+
'Add a "test"': false,
|
|
157
|
+
"Enable spf watch": false,
|
|
158
|
+
"Configure advanced": false,
|
|
159
|
+
"Write .spf": true,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const result = await runInterview(asker, ctx);
|
|
163
|
+
assert.ok(result);
|
|
164
|
+
assert.equal(result.env.OLLAMA_BASE_URL, "http://gpu-box.local:11434/v1");
|
|
165
|
+
});
|
|
110
166
|
test("watch(jira/bitbucket): collects exactly JIRA_* + BITBUCKET_*, never GITHUB_TOKEN", async () => {
|
|
111
167
|
const ctx = gatherContext(dir, new Map());
|
|
112
168
|
const asker = createFakeAsker({
|
|
@@ -145,6 +201,77 @@ test("watch(jira/bitbucket): collects exactly JIRA_* + BITBUCKET_*, never GITHUB
|
|
|
145
201
|
assert.equal(result.env.BITBUCKET_API_TOKEN, "bb-token");
|
|
146
202
|
assert.equal(result.env.GITHUB_TOKEN, undefined, "neither provider needs GitHub — must not ask for or write GITHUB_TOKEN");
|
|
147
203
|
});
|
|
204
|
+
test("watch(github issues + bitbucket code): asks two repo questions and writes issue_repo separately from repo", async () => {
|
|
205
|
+
const ctx = gatherContext(dir, new Map());
|
|
206
|
+
const asker = createFakeAsker({
|
|
207
|
+
select: {
|
|
208
|
+
"backend runs": "claude_code",
|
|
209
|
+
"Model (Claude": "sonnet",
|
|
210
|
+
Authentication: "login",
|
|
211
|
+
"Issue tracker": "github",
|
|
212
|
+
"Code host": "bitbucket",
|
|
213
|
+
"Chain to run": "plan-build-test",
|
|
214
|
+
},
|
|
215
|
+
text: {
|
|
216
|
+
"Code repo, where PRs open (workspace/repo_slug)": "acme-workspace/widgets-code",
|
|
217
|
+
"Issue repo, where spf:ready issues live (owner/name)": "acme/widgets-issues",
|
|
218
|
+
},
|
|
219
|
+
confirm: {
|
|
220
|
+
'Add a "typecheck"': false,
|
|
221
|
+
'Add a "lint"': false,
|
|
222
|
+
'Add a "build"': false,
|
|
223
|
+
'Add a "test"': false,
|
|
224
|
+
"Enable spf watch": true,
|
|
225
|
+
"Also enable the refine lane": false,
|
|
226
|
+
"Configure advanced": false,
|
|
227
|
+
"Write .spf": true,
|
|
228
|
+
},
|
|
229
|
+
secret: { GITHUB_TOKEN: "ghp_x", BITBUCKET_API_TOKEN: "bb-token" },
|
|
230
|
+
});
|
|
231
|
+
const result = await runInterview(asker, ctx);
|
|
232
|
+
assert.ok(result);
|
|
233
|
+
const config = result.config;
|
|
234
|
+
assert.equal(config.watch.issue_provider, "github");
|
|
235
|
+
assert.equal(config.watch.code_host, "bitbucket");
|
|
236
|
+
assert.equal(config.watch.repo, "acme-workspace/widgets-code", "repo is the CODE HOST's repo");
|
|
237
|
+
assert.equal(config.watch.issue_repo, "acme/widgets-issues", "issue_repo overrides repo for the issue tracker side");
|
|
238
|
+
const configPath = mergedConfigPath();
|
|
239
|
+
const { stringify } = await import("yaml");
|
|
240
|
+
writeFileSync(configPath, stringify(config));
|
|
241
|
+
const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
|
|
242
|
+
assert.equal(cfg.watch.repo, "acme-workspace/widgets-code");
|
|
243
|
+
assert.equal(cfg.watch.issue_repo, "acme/widgets-issues");
|
|
244
|
+
});
|
|
245
|
+
test("watch(github issues + github code): a single repo answers both, issue_repo stays unset", async () => {
|
|
246
|
+
const ctx = gatherContext(dir, new Map());
|
|
247
|
+
const asker = createFakeAsker({
|
|
248
|
+
select: {
|
|
249
|
+
"backend runs": "claude_code",
|
|
250
|
+
"Model (Claude": "sonnet",
|
|
251
|
+
Authentication: "login",
|
|
252
|
+
"Issue tracker": "github",
|
|
253
|
+
"Code host": "github",
|
|
254
|
+
"Chain to run": "plan-build-test",
|
|
255
|
+
},
|
|
256
|
+
text: { "Repo (owner/name)": "acme/widgets" },
|
|
257
|
+
confirm: {
|
|
258
|
+
'Add a "typecheck"': false,
|
|
259
|
+
'Add a "lint"': false,
|
|
260
|
+
'Add a "build"': false,
|
|
261
|
+
'Add a "test"': false,
|
|
262
|
+
"Enable spf watch": true,
|
|
263
|
+
"Also enable the refine lane": false,
|
|
264
|
+
"Configure advanced": false,
|
|
265
|
+
"Write .spf": true,
|
|
266
|
+
},
|
|
267
|
+
secret: { GITHUB_TOKEN: "ghp_x" },
|
|
268
|
+
});
|
|
269
|
+
const result = await runInterview(asker, ctx);
|
|
270
|
+
assert.ok(result);
|
|
271
|
+
const config = result.config;
|
|
272
|
+
assert.equal(config.watch.repo, "acme/widgets");
|
|
273
|
+
assert.equal(config.watch.issue_repo, undefined, "the single-repo case never writes issue_repo at all");
|
|
274
|
+
});
|
|
148
275
|
test("declining the final confirm returns null — nothing to write", async () => {
|
|
149
276
|
const ctx = gatherContext(dir, new Map());
|
|
150
277
|
const asker = createFakeAsker({ defaultConfirm: false }); // every confirm, including the final one, says no
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic — never touches a live Ollama server. These exercise Flue's own
|
|
3
|
+
* in-process provider registry (via `@flue/runtime/internal`) plus this
|
|
4
|
+
* module's accumulation/idempotence logic, using synthetic model ids.
|
|
5
|
+
*/
|
|
6
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { hasProvider, resetModelsForTests, resolveModel as flueResolveModel } from "@flue/runtime/internal";
|
|
9
|
+
import { providerForTest, registerOllamaModel, resetOllamaRegistrationForTest, } from "../core/ollama_provider.js";
|
|
10
|
+
// A minimal stand-in for pi-ai's `AuthContext` — our resolver ignores it
|
|
11
|
+
// entirely (it has no ambient env/file lookups to do), but the `resolve()`
|
|
12
|
+
// call site still needs something shaped right to pass.
|
|
13
|
+
const fakeAuthContext = { env: async () => undefined, fileExists: async () => false };
|
|
14
|
+
const ORIGINAL_BASE_URL = process.env.OLLAMA_BASE_URL;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetOllamaRegistrationForTest();
|
|
17
|
+
resetModelsForTests();
|
|
18
|
+
delete process.env.OLLAMA_BASE_URL;
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (ORIGINAL_BASE_URL === undefined)
|
|
22
|
+
delete process.env.OLLAMA_BASE_URL;
|
|
23
|
+
else
|
|
24
|
+
process.env.OLLAMA_BASE_URL = ORIGINAL_BASE_URL;
|
|
25
|
+
});
|
|
26
|
+
test("registerOllamaModel: union accumulation — registering a second id doesn't orphan the first", async () => {
|
|
27
|
+
await registerOllamaModel("model-a");
|
|
28
|
+
await registerOllamaModel("model-b");
|
|
29
|
+
assert.equal(hasProvider("ollama"), true);
|
|
30
|
+
// `setProvider()` REPLACES the whole provider (models list included) on
|
|
31
|
+
// every call. If this module regressed from "re-register the full union"
|
|
32
|
+
// to "register just the newest id", model-a would throw "Unknown model
|
|
33
|
+
// ID … for provider \"ollama\"" here — exactly the failure the spike hit
|
|
34
|
+
// and the `registeredIds` Set exists to prevent.
|
|
35
|
+
const a = flueResolveModel("ollama/model-a");
|
|
36
|
+
const b = flueResolveModel("ollama/model-b");
|
|
37
|
+
assert.equal(a.id, "model-a");
|
|
38
|
+
assert.equal(b.id, "model-b");
|
|
39
|
+
const idsOnProvider = providerForTest()
|
|
40
|
+
.getModels()
|
|
41
|
+
.map((m) => m.id)
|
|
42
|
+
.sort();
|
|
43
|
+
assert.deepEqual(idsOnProvider, ["model-a", "model-b"], "the provider's own model list carries both ids, not just the latest");
|
|
44
|
+
});
|
|
45
|
+
test("registerOllamaModel: flue's registry resolves the SAME model object our provider registered — pins the pi-ai single-copy dedupe invariant", async () => {
|
|
46
|
+
// package.json pins @earendil-works/pi-ai to the exact version
|
|
47
|
+
// @flue/runtime depends on so npm dedupes to one physical copy — see that
|
|
48
|
+
// pin's own comment for why. If a future dependency bump ever re-splits
|
|
49
|
+
// that into two copies, `setProvider()`'s `Model`/`Provider` values would
|
|
50
|
+
// stop being instances flue's OWN copy of pi-ai recognizes, and
|
|
51
|
+
// `resolveModel()` would resolve to a DIFFERENT object than the one on
|
|
52
|
+
// our provider — same string id, wrong identity. Reference equality here
|
|
53
|
+
// is the one assertion that would actually catch that regression; a
|
|
54
|
+
// structural `deepEqual` would keep passing right through it.
|
|
55
|
+
await registerOllamaModel("model-a");
|
|
56
|
+
const resolved = flueResolveModel("ollama/model-a");
|
|
57
|
+
const onProvider = providerForTest().getModels().find((m) => m.id === "model-a");
|
|
58
|
+
assert.equal(resolved, onProvider, "flue's registry and our provider must hand back the identical object, not merely an equal one");
|
|
59
|
+
});
|
|
60
|
+
test("registerOllamaModel: idempotent for an already-registered id", async () => {
|
|
61
|
+
await registerOllamaModel("model-a");
|
|
62
|
+
const providerAfterFirst = providerForTest();
|
|
63
|
+
await registerOllamaModel("model-a");
|
|
64
|
+
assert.equal(providerForTest(), providerAfterFirst, "a repeat registration never calls setProvider again — same object identity");
|
|
65
|
+
});
|
|
66
|
+
test("registerOllamaModel: concurrent calls for the same new id both resolve to one registration, not a second no-op", async () => {
|
|
67
|
+
// Regression guard for committing to `registeredIds` before `setProvider`
|
|
68
|
+
// finishes: if the second call saw the id "already registered" before
|
|
69
|
+
// the first call's registration actually completed, it would resolve
|
|
70
|
+
// immediately with nothing registered yet, and a caller awaiting it could
|
|
71
|
+
// start dispatching before `setProvider()` ran.
|
|
72
|
+
const [a, b] = await Promise.all([registerOllamaModel("model-c"), registerOllamaModel("model-c")]);
|
|
73
|
+
assert.equal(a, undefined);
|
|
74
|
+
assert.equal(b, undefined);
|
|
75
|
+
const model = flueResolveModel("ollama/model-c");
|
|
76
|
+
assert.equal(model.id, "model-c");
|
|
77
|
+
});
|
|
78
|
+
test("registerOllamaModel: defaults to http://localhost:11434/v1 when OLLAMA_BASE_URL is unset", async () => {
|
|
79
|
+
await registerOllamaModel("model-a");
|
|
80
|
+
const model = flueResolveModel("ollama/model-a");
|
|
81
|
+
assert.equal(model.baseUrl, "http://localhost:11434/v1");
|
|
82
|
+
assert.equal(providerForTest().baseUrl, "http://localhost:11434/v1");
|
|
83
|
+
});
|
|
84
|
+
test("registerOllamaModel: OLLAMA_BASE_URL override is respected", async () => {
|
|
85
|
+
process.env.OLLAMA_BASE_URL = "http://example-ollama-host:9999/v1";
|
|
86
|
+
await registerOllamaModel("model-a");
|
|
87
|
+
const model = flueResolveModel("ollama/model-a");
|
|
88
|
+
assert.equal(model.baseUrl, "http://example-ollama-host:9999/v1");
|
|
89
|
+
assert.equal(providerForTest().baseUrl, "http://example-ollama-host:9999/v1");
|
|
90
|
+
});
|
|
91
|
+
test("registerOllamaModel: registered models carry contextWindow 0 (disables threshold compaction)", async () => {
|
|
92
|
+
await registerOllamaModel("model-a");
|
|
93
|
+
const model = flueResolveModel("ollama/model-a");
|
|
94
|
+
assert.equal(model.contextWindow, 0);
|
|
95
|
+
});
|
|
96
|
+
test("registerOllamaModel: auth.apiKey resolves a truthy dummy key, never the upstream 'No API key' failure", async () => {
|
|
97
|
+
await registerOllamaModel("model-a");
|
|
98
|
+
const apiKeyAuth = providerForTest().auth.apiKey;
|
|
99
|
+
assert.ok(apiKeyAuth, "auth.apiKey must be present — pi-ai requires at least one of apiKey/oauth even for a keyless provider");
|
|
100
|
+
const resolved = await apiKeyAuth.resolve({ ctx: fakeAuthContext, credential: undefined });
|
|
101
|
+
assert.ok(resolved, "resolve() must report the provider as configured, not 'unconfigured'");
|
|
102
|
+
assert.ok(typeof resolved.auth.apiKey === "string" && resolved.auth.apiKey.length > 0, "the resolved apiKey must be a non-empty string — a falsy one is exactly what pi-ai's getClientApiKey() throws on at dispatch");
|
|
103
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OTLP span exporter. Six things are pinned here, and every one of them is
|
|
3
|
+
* a thing a "simplification" would quietly break:
|
|
4
|
+
*
|
|
5
|
+
* 1. ID derivation — deterministic, right length, lowercase hex, never zero.
|
|
6
|
+
* 2. `traceparent` parsing — strict W3C, and GARBAGE IS SILENT (a malformed CI
|
|
7
|
+
* variable must degrade to "own root", never throw, never half-parse).
|
|
8
|
+
* 3. THE ALLOWLIST — a synthetic EventRecord whose payload carries a marker
|
|
9
|
+
* string, asserted absent from the literal JSON body the exporter would
|
|
10
|
+
* POST. This is the exfiltration test: `EventRecord.payload` holds tool
|
|
11
|
+
* args, result snippets, and the repo's own source. If someone adds a
|
|
12
|
+
* `stringValue` read from a payload, this test is what says no.
|
|
13
|
+
* 4. The queue bound — 3000 spans pushed synchronously stay <= the bound, with
|
|
14
|
+
* the overflow counted (drop-OLDEST, so the newest spans survive).
|
|
15
|
+
* 5. The wire shape — against an in-process `node:http` receiver, not a fetch
|
|
16
|
+
* mock, so the assertions are on real bytes: resourceSpans -> scopeSpans ->
|
|
17
|
+
* spans nesting, hex ids, AnyValue-wrapped attributes, and nanosecond
|
|
18
|
+
* timestamps AS STRINGS (a JSON number loses precision past 2^53).
|
|
19
|
+
* 6. Failure isolation — an endpoint that 500s or does not exist must resolve
|
|
20
|
+
* quietly, because export is never allowed to fail a run.
|
|
21
|
+
*
|
|
22
|
+
* Hermetic: every exporter is constructed with an explicit `env` (so the
|
|
23
|
+
* developer's own TRACEPARENT cannot leak in) and every network case talks to a
|
|
24
|
+
* loopback server on an ephemeral port.
|
|
25
|
+
*/
|
|
26
|
+
export {};
|