@gr8ful/spf 0.4.0 → 0.5.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 +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 +147 -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/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/refine.test.js +64 -1
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +124 -1
- package/package.json +5 -5
package/dist/core/providers.js
CHANGED
|
@@ -21,4 +21,8 @@ export const PROVIDER_ENV_KEYS = {
|
|
|
21
21
|
deepseek: ["DEEPSEEK_API_KEY"],
|
|
22
22
|
together: ["TOGETHER_API_KEY"],
|
|
23
23
|
cerebras: ["CEREBRAS_API_KEY"],
|
|
24
|
+
// Keyless: a local server, not a hosted API — nothing to check for or
|
|
25
|
+
// prompt for. An empty array here means "known provider, needs no key",
|
|
26
|
+
// never "unknown provider" (that's a missing table entry, not `[]`).
|
|
27
|
+
ollama: [],
|
|
24
28
|
};
|
package/dist/core/refine.js
CHANGED
|
@@ -27,14 +27,22 @@ export function resolveAuthoringProvider(cfg) {
|
|
|
27
27
|
throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — ` +
|
|
28
28
|
`the refine lane needs "github" (see jira_provider.ts's module comment on why Jira isn't wired up yet)`);
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Issue authoring always targets the ISSUE tracker's repo — `issue_repo`
|
|
31
|
+
// if set, falling back to plain `repo` (the common case: issue_provider
|
|
32
|
+
// and code_host are both github, so they're the same repo). Reading
|
|
33
|
+
// `repo` alone would be wrong for a github-issues + bitbucket-code setup,
|
|
34
|
+
// where `repo` names the BITBUCKET repo (see WatchConfigSchema's doc
|
|
35
|
+
// comment) — this would try to create GitHub issues against a Bitbucket
|
|
36
|
+
// identifier.
|
|
37
|
+
const repo = cfg.watch.issue_repo.trim() || cfg.watch.repo.trim();
|
|
38
|
+
if (!repo) {
|
|
39
|
+
throw new Error(`watch.repo (or watch.issue_repo, if code_host names a different repo) is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
|
|
32
40
|
}
|
|
33
41
|
const token = process.env["GITHUB_TOKEN"];
|
|
34
42
|
if (!token) {
|
|
35
43
|
throw new Error('GITHUB_TOKEN is not set — the refine lane needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo)');
|
|
36
44
|
}
|
|
37
|
-
return new GitHubProvider(
|
|
45
|
+
return new GitHubProvider(repo, cfg.watch.label_prefix, token);
|
|
38
46
|
}
|
|
39
47
|
function typeLabel(labelPrefix, kind) {
|
|
40
48
|
return `${labelPrefix}:type:${kind}`;
|
package/dist/core/session.js
CHANGED
|
@@ -11,6 +11,15 @@ import { Run } from "./runner.js";
|
|
|
11
11
|
import { Tracer } from "./tracer.js";
|
|
12
12
|
import { engineerName, newId } from "./utils.js";
|
|
13
13
|
import { resolveNotifier } from "./notify/notifier.js";
|
|
14
|
+
import * as otel from "./otel.js";
|
|
15
|
+
/**
|
|
16
|
+
* How long a signalled run may spend pushing spans before it exits anyway.
|
|
17
|
+
* Short on purpose: someone who just pressed ^C is waiting, and an
|
|
18
|
+
* observability projection is never worth making a kill feel broken. The
|
|
19
|
+
* budget is enforced inside `otel.flushAll()` (a raced, unref'd deadline), so
|
|
20
|
+
* an unreachable collector costs exactly this and not one tick more.
|
|
21
|
+
*/
|
|
22
|
+
const SIGNAL_DRAIN_MS = 750;
|
|
14
23
|
/**
|
|
15
24
|
* A killed run still closes its own trace.
|
|
16
25
|
*
|
|
@@ -20,11 +29,31 @@ import { resolveNotifier } from "./notify/notifier.js";
|
|
|
20
29
|
* flight that is already dead. Handling the signal both finalizes here and
|
|
21
30
|
* lets the phase's try/catch record the phase as failed on the way out
|
|
22
31
|
* (best-effort: a signal can still land mid-write).
|
|
32
|
+
*
|
|
33
|
+
* SQLite is written FIRST and synchronously, exactly as before — the otel
|
|
34
|
+
* drain is appended after it and can only ever cost time, never correctness.
|
|
35
|
+
* (`notify` has no equivalent drain on this path: its in-flight webhooks are
|
|
36
|
+
* dropped on a signal today. Fixing that means touching the notifier's
|
|
37
|
+
* lifecycle, which is outside this change; only the otel path is drained here.)
|
|
38
|
+
* A second signal during the drain exits immediately — someone pressing ^C
|
|
39
|
+
* twice means "now", and a shutdown path that ignores that is a hang.
|
|
23
40
|
*/
|
|
24
41
|
function finalizeWhenKilled(run) {
|
|
42
|
+
let draining = false;
|
|
25
43
|
const handler = (signal) => {
|
|
44
|
+
const code = 128 + (signal === "SIGINT" ? 2 : 15);
|
|
45
|
+
if (draining)
|
|
46
|
+
process.exit(code);
|
|
47
|
+
draining = true;
|
|
26
48
|
run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
|
|
27
|
-
|
|
49
|
+
// Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it did before
|
|
50
|
+
// otel existed — no extra tick between the signal and the exit for the
|
|
51
|
+
// repos that never opted in.
|
|
52
|
+
if (!run.tracer.otel)
|
|
53
|
+
process.exit(code);
|
|
54
|
+
// Bounded and never-throwing: flushAll() swallows its own failures and
|
|
55
|
+
// resolves on its own deadline, so this always reaches process.exit().
|
|
56
|
+
void otel.flushAll(SIGNAL_DRAIN_MS).then(() => process.exit(code), () => process.exit(code));
|
|
28
57
|
};
|
|
29
58
|
process.on("SIGTERM", handler);
|
|
30
59
|
process.on("SIGINT", handler);
|
|
@@ -44,7 +73,15 @@ export function ensure(cfg, adwId, cwd, chainName) {
|
|
|
44
73
|
const id = adwId || newId(8);
|
|
45
74
|
const anchor = paths.resolveAnchor(cwd);
|
|
46
75
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
47
|
-
|
|
76
|
+
// `null` unless `observability.otel` is configured — no environment variable
|
|
77
|
+
// can turn this on (see core/otel.ts's EXPLICIT CONFIG ONLY). Constructed
|
|
78
|
+
// BEFORE the Tracer because the Tracer's write methods are the fan-out
|
|
79
|
+
// seams: SQLite stays the source of truth, otel is a projection off it, and
|
|
80
|
+
// registering here (module-level LIVE, exactly like resolveNotifier) is what
|
|
81
|
+
// lets the CLI's finally block and the signal handler above drain it without
|
|
82
|
+
// threading a handle through every call site.
|
|
83
|
+
const otelExporter = otel.resolveOtelExporter(cfg, { adwId: id, chainName: chainName || "adw" });
|
|
84
|
+
const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"), otelExporter);
|
|
48
85
|
const run = new Run({
|
|
49
86
|
cfg,
|
|
50
87
|
adwId: id,
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -2,18 +2,47 @@
|
|
|
2
2
|
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
4
|
* Files are the raw record; spf.db is the queryable mirror the UI polls.
|
|
5
|
-
* No push transport — the flow is always: agents -> sqlite -> web ui.
|
|
6
5
|
* WAL mode so the UI can read while ADW processes write.
|
|
6
|
+
*
|
|
7
|
+
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
+
* agents -> sqlite -> web ui. SQLite is the source of truth; nothing
|
|
9
|
+
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
|
+
*
|
|
11
|
+
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
|
+
* each write method below ends with a single fan-out line to an optional
|
|
13
|
+
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
14
|
+
* pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
|
|
15
|
+
* record: it never blocks, never throws into a caller (see `fanOut`), drops
|
|
16
|
+
* spans under backpressure, and carries only the allowlisted subset of fields
|
|
17
|
+
* (never `EventRecord.payload`, never the request text, never envelope
|
|
18
|
+
* contents — `core/otel.ts`'s header has the full list and the reasons).
|
|
19
|
+
* Methods whose data is entirely outside that allowlist —
|
|
20
|
+
* `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
|
|
21
|
+
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
|
+
* add one.
|
|
7
23
|
*/
|
|
8
24
|
import { Database } from "./sqlite.ts";
|
|
9
25
|
import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
26
|
+
import type { OtelExporter } from "./otel.ts";
|
|
10
27
|
export declare class Tracer {
|
|
11
28
|
db: Database;
|
|
12
29
|
dbPath: string;
|
|
13
30
|
eventsJsonl: string;
|
|
14
|
-
|
|
31
|
+
/** `null` unless `observability.otel` is configured — see the header. */
|
|
32
|
+
otel: OtelExporter | null;
|
|
33
|
+
constructor(dbPath: string, eventsJsonl: string, otel?: OtelExporter | null);
|
|
15
34
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
16
35
|
private migrate;
|
|
36
|
+
/**
|
|
37
|
+
* The ONE door to the optional otel projection, and the only reason a fan-out
|
|
38
|
+
* line is safe to put at the end of a synchronous write method: it is a
|
|
39
|
+
* no-op when unconfigured, and it swallows everything. An exporter bug, a
|
|
40
|
+
* malformed span, an exhausted queue — none of it may ever surface as a
|
|
41
|
+
* failed phase, because export is not allowed to dispose of anything. The
|
|
42
|
+
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
43
|
+
* on an unref'd timer.
|
|
44
|
+
*/
|
|
45
|
+
private fanOut;
|
|
17
46
|
event(record: EventRecord): string;
|
|
18
47
|
sessionStart(adwId: string, engineer: string, adwName?: string | null): void;
|
|
19
48
|
sessionRequest(adwId: string, request: string): void;
|
package/dist/core/tracer.js
CHANGED
|
@@ -2,8 +2,24 @@
|
|
|
2
2
|
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
4
|
* Files are the raw record; spf.db is the queryable mirror the UI polls.
|
|
5
|
-
* No push transport — the flow is always: agents -> sqlite -> web ui.
|
|
6
5
|
* WAL mode so the UI can read while ADW processes write.
|
|
6
|
+
*
|
|
7
|
+
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
+
* agents -> sqlite -> web ui. SQLite is the source of truth; nothing
|
|
9
|
+
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
|
+
*
|
|
11
|
+
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
|
+
* each write method below ends with a single fan-out line to an optional
|
|
13
|
+
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
14
|
+
* pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
|
|
15
|
+
* record: it never blocks, never throws into a caller (see `fanOut`), drops
|
|
16
|
+
* spans under backpressure, and carries only the allowlisted subset of fields
|
|
17
|
+
* (never `EventRecord.payload`, never the request text, never envelope
|
|
18
|
+
* contents — `core/otel.ts`'s header has the full list and the reasons).
|
|
19
|
+
* Methods whose data is entirely outside that allowlist —
|
|
20
|
+
* `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
|
|
21
|
+
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
|
+
* add one.
|
|
7
23
|
*/
|
|
8
24
|
import { Database } from "./sqlite.js";
|
|
9
25
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
@@ -97,7 +113,10 @@ export class Tracer {
|
|
|
97
113
|
db;
|
|
98
114
|
dbPath;
|
|
99
115
|
eventsJsonl;
|
|
100
|
-
|
|
116
|
+
/** `null` unless `observability.otel` is configured — see the header. */
|
|
117
|
+
otel;
|
|
118
|
+
constructor(dbPath, eventsJsonl, otel) {
|
|
119
|
+
this.otel = otel ?? null;
|
|
101
120
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
102
121
|
this.dbPath = dbPath;
|
|
103
122
|
this.eventsJsonl = eventsJsonl;
|
|
@@ -118,6 +137,27 @@ export class Tracer {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The ONE door to the optional otel projection, and the only reason a fan-out
|
|
142
|
+
* line is safe to put at the end of a synchronous write method: it is a
|
|
143
|
+
* no-op when unconfigured, and it swallows everything. An exporter bug, a
|
|
144
|
+
* malformed span, an exhausted queue — none of it may ever surface as a
|
|
145
|
+
* failed phase, because export is not allowed to dispose of anything. The
|
|
146
|
+
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
147
|
+
* on an unref'd timer.
|
|
148
|
+
*/
|
|
149
|
+
fanOut(action) {
|
|
150
|
+
if (!this.otel)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
action(this.otel);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Deliberately silent: a logged line per event on a hot path would be
|
|
157
|
+
// its own failure mode, and otel.ts already logs its own send failures
|
|
158
|
+
// exactly once.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
121
161
|
// ── events ──────────────────────────────────────────────────────────────
|
|
122
162
|
event(record) {
|
|
123
163
|
const eventId = `evt_${newId(12)}`;
|
|
@@ -128,23 +168,28 @@ export class Tracer {
|
|
|
128
168
|
.query(`INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,
|
|
129
169
|
payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
130
170
|
.run(eventId, record.adw_id, record.phase_id, record.parent_id, record.type, record.name, JSON.stringify(record.payload), record.tokens ?? null, record.started_at || ts, record.ended_at ?? null);
|
|
171
|
+
this.fanOut((otel) => otel.recordEvent(record, eventId, ts)); // otel projection — see fanOut
|
|
131
172
|
return eventId;
|
|
132
173
|
}
|
|
133
174
|
// ── sessions ────────────────────────────────────────────────────────────
|
|
134
175
|
sessionStart(adwId, engineer, adwName) {
|
|
176
|
+
const startedAt = nowIso();
|
|
135
177
|
this.db
|
|
136
178
|
.query(`INSERT INTO sessions (adw_id, status, engineer, started_at) VALUES (?,?,?,?)
|
|
137
179
|
ON CONFLICT(adw_id) DO UPDATE SET status='running'`)
|
|
138
|
-
.run(adwId, "running", engineer,
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
180
|
+
.run(adwId, "running", engineer, startedAt);
|
|
181
|
+
if (adwName) {
|
|
182
|
+
// A joined session chains ADWs — record each distinct one, in run order.
|
|
183
|
+
const row = this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId);
|
|
184
|
+
const names = row?.adw_name ? row.adw_name.split(" + ") : [];
|
|
185
|
+
if (!names.includes(adwName)) {
|
|
186
|
+
names.push(adwName);
|
|
187
|
+
this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
|
|
188
|
+
}
|
|
147
189
|
}
|
|
190
|
+
// otel projection: the run's clock only. `engineer` is a person's name —
|
|
191
|
+
// outside the allowlist, and not a measure of anything.
|
|
192
|
+
this.fanOut((otel) => otel.recordSessionStart(startedAt));
|
|
148
193
|
}
|
|
149
194
|
sessionRequest(adwId, request) {
|
|
150
195
|
this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
|
|
@@ -154,6 +199,7 @@ export class Tracer {
|
|
|
154
199
|
.query("UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?")
|
|
155
200
|
.run(ok ? "success" : "fail", nowIso(), adwId);
|
|
156
201
|
this.processesEndAll(adwId); // nothing of this run is alive any more
|
|
202
|
+
this.fanOut((otel) => otel.recordSessionFinish(ok)); // otel projection: emits the root run span, once
|
|
157
203
|
}
|
|
158
204
|
sessionAddUsage(adwId, tokens, cost) {
|
|
159
205
|
this.db
|
|
@@ -208,6 +254,9 @@ export class Tracer {
|
|
|
208
254
|
ON CONFLICT(phase_id) DO UPDATE SET status=excluded.status,
|
|
209
255
|
attempt=excluded.attempt, error=excluded.error, ended_at=excluded.ended_at`)
|
|
210
256
|
.run(phase.phase_id, phase.adw_id, phase.seq, p.name, p.kind, p.owner, p.description, phase.status, phase.attempt, p.retries, phase.error ?? null, phase.started_at ?? null, phase.ended_at ?? null);
|
|
257
|
+
// otel projection: a no-op on the start-of-phase upsert (no ended_at yet) —
|
|
258
|
+
// phase spans are emitted at phase END only. `phase.error` never crosses.
|
|
259
|
+
this.fanOut((otel) => otel.recordPhase(phase));
|
|
211
260
|
}
|
|
212
261
|
// ── envelopes / gates / agent sessions ──────────────────────────────────
|
|
213
262
|
envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
|
|
@@ -222,6 +271,10 @@ export class Tracer {
|
|
|
222
271
|
.query(`INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,
|
|
223
272
|
violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)`)
|
|
224
273
|
.run(phase.adw_id, phase.phase_id, attempt, gate, report.passed ? 1 : 0, JSON.stringify(report.violations), JSON.stringify(report.checks), nowIso());
|
|
274
|
+
// otel projection: gate name + verdict + violation COUNT as a span event on
|
|
275
|
+
// the phase span. The violation and check TEXT stays here in SQLite — it
|
|
276
|
+
// quotes the agent's claim and the repo's files.
|
|
277
|
+
this.fanOut((otel) => otel.recordGate(phase, gate, report, attempt));
|
|
225
278
|
}
|
|
226
279
|
/**
|
|
227
280
|
* The agent's config row is the source of truth for its label and color.
|
|
@@ -242,5 +295,10 @@ export class Tracer {
|
|
|
242
295
|
context_window=excluded.context_window,
|
|
243
296
|
last_used_at=excluded.last_used_at`)
|
|
244
297
|
.run(adwId, agent.name, agent.coding_agent, agent.model, agent.color, sessionId, contextTokens, contextWindow, ts, ts);
|
|
298
|
+
// otel projection: the TYPED source of an agent's model + backend for its
|
|
299
|
+
// span (agents.ts writes this row before the agent_end event, which is what
|
|
300
|
+
// lets otel.ts avoid reading the agent_start payload at all). `sessionId`
|
|
301
|
+
// is not exported — it is a coding-agent handle, not a measure.
|
|
302
|
+
this.fanOut((otel) => otel.recordAgentSession(agent));
|
|
245
303
|
}
|
|
246
304
|
}
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -6,6 +6,17 @@ export interface ChainRunResult {
|
|
|
6
6
|
adwId: string;
|
|
7
7
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
8
8
|
detail: string;
|
|
9
|
+
/**
|
|
10
|
+
* A short, already-sanitized/truncated digest of the reviewer's verdict
|
|
11
|
+
* (approved/blocking/findings), read back best-effort from the sessions DB
|
|
12
|
+
* — see `cli/commands/watch.ts`'s `runChain`. `undefined` when the chain
|
|
13
|
+
* that ran has no reviewer step, or the DB read/parse failed; `runIssue`
|
|
14
|
+
* below falls back to `reviewRequired` to tell those two apart in the PR
|
|
15
|
+
* body and `pr_opened` notification.
|
|
16
|
+
*/
|
|
17
|
+
reviewSummary?: string;
|
|
18
|
+
/** Whether the chain that ran declares a "reviewer" in its `requiredAgents` — distinguishes "reviewer approved" from "nothing reviewed this change" when `reviewSummary` is absent. */
|
|
19
|
+
reviewRequired?: boolean;
|
|
9
20
|
}
|
|
10
21
|
/** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
|
|
11
22
|
export interface RefinedIssueRef {
|
package/dist/core/watch.js
CHANGED
|
@@ -284,6 +284,15 @@ async function runIssue(deps, issue) {
|
|
|
284
284
|
return;
|
|
285
285
|
}
|
|
286
286
|
wtGit.push("origin", branch);
|
|
287
|
+
// The human merging this PR sees whatever the reviewer found — or, if
|
|
288
|
+
// nothing reviewed this change at all, is told that plainly rather than
|
|
289
|
+
// left to assume a silent approval. `reviewSummary` is already
|
|
290
|
+
// sanitized/truncated by the caller (see runChain's own doc comment).
|
|
291
|
+
const reviewLine = result.reviewSummary
|
|
292
|
+
? result.reviewSummary
|
|
293
|
+
: result.reviewRequired
|
|
294
|
+
? "Reviewer ran, but no verdict could be read back from the session data."
|
|
295
|
+
: `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
|
|
287
296
|
// No cross-linking magic keyword here on purpose (a code host paired
|
|
288
297
|
// with a different tracker has no "Closes #n" convention to hook into
|
|
289
298
|
// — see provider.ts) — the issue id in the title/body is plain text
|
|
@@ -292,7 +301,7 @@ async function runIssue(deps, issue) {
|
|
|
292
301
|
const pr = await deps.codeHost.openPr({
|
|
293
302
|
branch,
|
|
294
303
|
title: `${issue.title} (${issue.id})`,
|
|
295
|
-
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}
|
|
304
|
+
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.\n\n${reviewLine}`,
|
|
296
305
|
base: deps.baseBranch,
|
|
297
306
|
});
|
|
298
307
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
|
|
@@ -302,7 +311,13 @@ async function runIssue(deps, issue) {
|
|
|
302
311
|
kind: "pr_opened",
|
|
303
312
|
level: "info",
|
|
304
313
|
title: `PR #${pr.number} opened`,
|
|
305
|
-
|
|
314
|
+
detail: reviewLine,
|
|
315
|
+
fields: [
|
|
316
|
+
["issue", issue.id],
|
|
317
|
+
["title", issue.title],
|
|
318
|
+
["chain", deps.chain],
|
|
319
|
+
["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
|
|
320
|
+
],
|
|
306
321
|
url: pr.url || undefined,
|
|
307
322
|
});
|
|
308
323
|
}
|
package/dist/test/chains.test.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
|
|
14
|
+
import { CHAINS, findChain, resolveRequiredAgents, resolveRequiredSuites } from "../chains/index.js";
|
|
15
15
|
// name -> [phases, requiredAgents (with no options), requiredSuites]
|
|
16
16
|
const EXPECTED = {
|
|
17
17
|
prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
|
|
@@ -44,7 +44,7 @@ const EXPECTED = {
|
|
|
44
44
|
refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
|
|
45
45
|
"simple-sdlc": {
|
|
46
46
|
phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
|
|
47
|
-
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
|
|
47
|
+
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
|
|
48
48
|
"-> code(changes) -> documenter -> git(commit_docs)",
|
|
49
49
|
agents: ["planner", "builder", "reviewer", "documenter"],
|
|
50
50
|
suites: ["test"],
|
|
@@ -59,7 +59,7 @@ for (const chain of CHAINS) {
|
|
|
59
59
|
test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
|
|
60
60
|
assert.equal(chain.phases, expected.phases);
|
|
61
61
|
assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
|
|
62
|
-
assert.deepEqual(chain
|
|
62
|
+
assert.deepEqual(resolveRequiredSuites(chain, {}), expected.suites);
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
|
|
@@ -67,6 +67,11 @@ test("prompt: requiredAgents depends on --agent, not a fixed list — the one dy
|
|
|
67
67
|
assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
|
|
68
68
|
assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
|
|
69
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
|
+
});
|
|
70
75
|
test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
|
|
71
76
|
for (const chain of CHAINS) {
|
|
72
77
|
if (chain.name === "simple-sdlc") {
|
|
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import * as v from "valibot";
|
|
16
16
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
17
|
-
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
-
import { loadConfig } from "../core/agents.js";
|
|
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
19
|
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
20
20
|
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
21
21
|
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
@@ -80,3 +80,141 @@ test("notifications survives loadConfig's merge — key-by-key like observabilit
|
|
|
80
80
|
rmSync(dir, { recursive: true, force: true });
|
|
81
81
|
}
|
|
82
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./hermetic_git.ts";
|
|
@@ -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 {};
|