spec-ctx 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +298 -2
- package/action.yml +33 -0
- package/dist/adopt/config-write.js +59 -0
- package/dist/adopt/plan.js +276 -0
- package/dist/adopt/write.js +126 -0
- package/dist/assets.generated.js +14 -0
- package/dist/baseline/baseline.js +65 -0
- package/dist/check/check.js +126 -0
- package/dist/check/evidence.js +70 -0
- package/dist/check/hints.js +26 -0
- package/dist/cli/args.js +90 -0
- package/dist/cli/color.js +23 -0
- package/dist/cli/commands.js +278 -0
- package/dist/cli/errors.js +3 -0
- package/dist/cli/gate.js +187 -0
- package/dist/cli/help.js +230 -0
- package/dist/cli/root.js +32 -0
- package/dist/cli/status.js +116 -0
- package/dist/cli/workflow.js +285 -0
- package/dist/cli.js +14 -0
- package/dist/config.js +119 -0
- package/dist/graph/bundle.js +94 -0
- package/dist/graph/query.js +31 -0
- package/dist/graph/related.js +76 -0
- package/dist/handoff/handoff.js +148 -0
- package/dist/ids/ids.js +115 -0
- package/dist/index/build.js +57 -0
- package/dist/index/canonical.js +44 -0
- package/dist/index/scan.js +357 -0
- package/dist/index/types.js +2 -0
- package/dist/init/init.js +169 -0
- package/dist/lint/cli.js +253 -0
- package/dist/lint/py.js +152 -0
- package/dist/lint/selftest.js +246 -0
- package/dist/lint/ste.js +395 -0
- package/dist/lint/tells.js +116 -0
- package/dist/main.js +74 -0
- package/dist/stats/stats.js +147 -0
- package/dist/stats/write.js +26 -0
- package/dist/tasks/evidence.js +102 -0
- package/dist/tasks/tasks.js +105 -0
- package/dist/util/glob.js +43 -0
- package/dist/util/hash.js +14 -0
- package/dist/util/json.js +16 -0
- package/dist/util/pystr.js +67 -0
- package/dist/util/time.js +11 -0
- package/dist/verify/parse.js +58 -0
- package/dist/verify/run.js +42 -0
- package/dist/version.js +2 -0
- package/package.json +50 -4
- package/schemas/spec-driven-ids/README.md +31 -0
- package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
- package/schemas/spec-driven-ids/schema.yaml +301 -0
- package/schemas/spec-driven-ids/templates/design.md +22 -0
- package/schemas/spec-driven-ids/templates/proposal.md +34 -0
- package/schemas/spec-driven-ids/templates/spec.md +15 -0
- package/schemas/spec-driven-ids/templates/tasks.md +10 -0
- package/skills/specctx-bootstrap/SKILL.md +27 -0
- package/skills/specctx-done/SKILL.md +25 -0
- package/skills/specctx-next/SKILL.md +23 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 write commands.
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { dumps } from "../util/json.js";
|
|
5
|
+
import { paint } from "./color.js";
|
|
6
|
+
import { UsageExit } from "./errors.js";
|
|
7
|
+
const arg = (c, n, what) => {
|
|
8
|
+
const v = c.p.positional[n];
|
|
9
|
+
if (v === undefined)
|
|
10
|
+
throw new UsageExit(`${c.p.command} needs ${what}`);
|
|
11
|
+
return v;
|
|
12
|
+
};
|
|
13
|
+
const noMore = (c, n) => {
|
|
14
|
+
if (c.p.positional.length > n)
|
|
15
|
+
throw new UsageExit(`${c.p.command} takes ${n} argument(s)`);
|
|
16
|
+
};
|
|
17
|
+
const print = (c, v) => c.io.out(dumps(v) + "\n");
|
|
18
|
+
export async function runWorkflow(c) {
|
|
19
|
+
const entries = c.data.entries;
|
|
20
|
+
const f = c.p.flags;
|
|
21
|
+
switch (c.p.command) {
|
|
22
|
+
case "id new": {
|
|
23
|
+
const { newIds } = await import("../ids/ids.js");
|
|
24
|
+
const kind = arg(c, 0, "a kind: REQ, SCN, ADR, or DEC");
|
|
25
|
+
noMore(c, 2);
|
|
26
|
+
const ids = newIds(c.root, c.cfg, entries, {
|
|
27
|
+
kind, cap: c.p.positional[1], forReq: f.for, count: f.count ?? 1,
|
|
28
|
+
});
|
|
29
|
+
if (c.json)
|
|
30
|
+
print(c, ids);
|
|
31
|
+
else
|
|
32
|
+
c.io.out(ids.map((i) => i + "\n").join(""));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
case "id retire": {
|
|
36
|
+
const { retire } = await import("../ids/ids.js");
|
|
37
|
+
const id = arg(c, 0, "an ID");
|
|
38
|
+
const reason = c.p.positional.slice(1).join(" ");
|
|
39
|
+
const row = retire(c.root, entries, id, reason);
|
|
40
|
+
if (c.json)
|
|
41
|
+
print(c, { retired: id, row });
|
|
42
|
+
else
|
|
43
|
+
c.io.out(`retired ${id} in decisions/retired-ids.md\n`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
case "task next": {
|
|
47
|
+
noMore(c, 0);
|
|
48
|
+
const { nextTask } = await import("../tasks/tasks.js");
|
|
49
|
+
const t = nextTask(c.root, entries, f.change);
|
|
50
|
+
if (!t) {
|
|
51
|
+
if (c.json)
|
|
52
|
+
print(c, null);
|
|
53
|
+
else {
|
|
54
|
+
// REQ-TASK-001 name the next step. The exit code stays 1.
|
|
55
|
+
const { activeChanges } = await import("../tasks/tasks.js");
|
|
56
|
+
const { matchesChange } = await import("../index/scan.js");
|
|
57
|
+
const want = f.change;
|
|
58
|
+
const ready = activeChanges(entries).filter((x) => !want || matchesChange(x, want));
|
|
59
|
+
c.io.out(ready.length
|
|
60
|
+
? `no open task. Every task in the active changes is done. Next: archive ${ready.join(", ")}.\n`
|
|
61
|
+
: "no open task. No active change has a task.\n");
|
|
62
|
+
}
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const bundleCommand = `specctx bundle ${t.reqs.join(" ")} --budget ${c.cfg.bundle.budget}`;
|
|
66
|
+
if (c.json) {
|
|
67
|
+
print(c, { bundle_command: bundleCommand, change: t.change, id: t.id, reqs: t.reqs, text: t.text, verify: t.verify.command });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
c.io.out(`${t.id} ${t.text}\nreqs: ${t.reqs.join(", ") || "none"}\nverify: ${t.verify.command ?? "manual"}\n`);
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
case "task start": {
|
|
75
|
+
const { findTask } = await import("../tasks/tasks.js");
|
|
76
|
+
const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
|
|
77
|
+
noMore(c, 1);
|
|
78
|
+
if (t.done)
|
|
79
|
+
throw new UsageExit(`task ${t.id} is done`);
|
|
80
|
+
if (t.archived)
|
|
81
|
+
throw new UsageExit(`task ${t.id} is in an archived change`);
|
|
82
|
+
const { canon } = await import("../index/canonical.js");
|
|
83
|
+
const { bundle } = await import("../graph/bundle.js");
|
|
84
|
+
const ids = canon(entries);
|
|
85
|
+
const known = t.reqs.filter((r) => ids.has(r));
|
|
86
|
+
for (const r of t.reqs.filter((x) => !ids.has(x)))
|
|
87
|
+
c.io.err(`warning: task ${t.id} cites ${r}, which the index does not hold\n`);
|
|
88
|
+
const b = bundle(c.root, entries, known, c.cfg.bundle.budget, c.cfg.bundle.depth);
|
|
89
|
+
const { isoNow } = await import("../util/time.js");
|
|
90
|
+
const { CURRENT_PATH } = await import("../handoff/handoff.js");
|
|
91
|
+
mkdirSync(join(c.root, ".ctx"), { recursive: true });
|
|
92
|
+
const { cpLen } = await import("../util/pystr.js");
|
|
93
|
+
// REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
|
|
94
|
+
writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id }) + "\n");
|
|
95
|
+
if (c.json)
|
|
96
|
+
print(c, { bundle: b, task: t.id });
|
|
97
|
+
else
|
|
98
|
+
c.io.out(b.text + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
case "task verify":
|
|
102
|
+
case "task done": {
|
|
103
|
+
const { findTask, setBox, hasEvidence } = await import("../tasks/tasks.js");
|
|
104
|
+
const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
|
|
105
|
+
noMore(c, 1);
|
|
106
|
+
const dry = c.p.command === "task verify";
|
|
107
|
+
const manual = f.manual;
|
|
108
|
+
if (!dry && manual !== undefined) {
|
|
109
|
+
if (!manual.trim())
|
|
110
|
+
throw new UsageExit("--manual needs a note");
|
|
111
|
+
if (t.done && hasEvidence(c.root, t))
|
|
112
|
+
throw new UsageExit(`task ${t.id} is done`);
|
|
113
|
+
// a manual note records evidence without a run. On a task done by hand it backfills the evidence.
|
|
114
|
+
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
115
|
+
const rel = writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", manual.trim());
|
|
116
|
+
if (!t.done)
|
|
117
|
+
setBox(c.root, t, true);
|
|
118
|
+
clearCurrent(c.root, t.id);
|
|
119
|
+
if (c.json)
|
|
120
|
+
print(c, { evidence: rel, kind: "manual", task: t.id });
|
|
121
|
+
else
|
|
122
|
+
c.io.out(paint("ok", `done ${t.id} (manual). Evidence: ${rel}\n`, c.io.tty));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
if (!dry && t.done)
|
|
126
|
+
throw new UsageExit(`task ${t.id} is done`);
|
|
127
|
+
if (t.verify.command === null) {
|
|
128
|
+
throw new UsageExit(`task ${t.id} has no Verify command. ${dry ? "Nothing to run" : 'A --manual "<note>" is required'}`);
|
|
129
|
+
}
|
|
130
|
+
const { runVerify, tail } = await import("../verify/run.js");
|
|
131
|
+
const { resolve: resolvePath, relative, isAbsolute } = await import("node:path");
|
|
132
|
+
let dir = c.root;
|
|
133
|
+
if (t.verify.cwd !== undefined) {
|
|
134
|
+
// REQ-VER-004 the cwd prefix must stay inside the project root
|
|
135
|
+
dir = resolvePath(c.root, t.verify.cwd);
|
|
136
|
+
const rel = relative(c.root, dir);
|
|
137
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
138
|
+
throw new UsageExit(`the Verify cwd ${t.verify.cwd} is outside the project root`);
|
|
139
|
+
const { existsSync: exists } = await import("node:fs");
|
|
140
|
+
if (!exists(dir))
|
|
141
|
+
throw new UsageExit(`the Verify cwd ${t.verify.cwd} does not exist`);
|
|
142
|
+
}
|
|
143
|
+
const r = await runVerify(t.verify.command, dir, c.cfg.verify.timeout_s);
|
|
144
|
+
const outTail = tail(r.output);
|
|
145
|
+
if (dry) {
|
|
146
|
+
if (c.json)
|
|
147
|
+
print(c, { command: t.verify.command, exit: r.exit, tail: outTail, timed_out: r.timedOut });
|
|
148
|
+
else
|
|
149
|
+
c.io.out(`${outTail ? outTail + "\n" : ""}exit ${r.timedOut ? "timeout" : r.exit}\n`);
|
|
150
|
+
return r.exit === 0 ? 0 : 1;
|
|
151
|
+
}
|
|
152
|
+
if (r.timedOut || r.exit !== 0) {
|
|
153
|
+
const msg = r.timedOut ? `verify timed out after ${c.cfg.verify.timeout_s}s` : `verify failed with exit ${r.exit}`;
|
|
154
|
+
if (c.json)
|
|
155
|
+
print(c, { command: t.verify.command, error: msg, exit: r.exit, tail: outTail, task: t.id });
|
|
156
|
+
else
|
|
157
|
+
c.io.out(outTail ? outTail + "\n" : "");
|
|
158
|
+
// REQ-VER-003 a timeout keeps its own line on stderr
|
|
159
|
+
if (r.timedOut)
|
|
160
|
+
c.io.err(`specctx: ${msg}. Task ${t.id} stays open.\n`);
|
|
161
|
+
// REQ-TASK-003 the refusal is the last line of the output. REQ-CLI-008 it is red on a TTY.
|
|
162
|
+
else if (!c.json)
|
|
163
|
+
c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
const { sha256 } = await import("../util/hash.js");
|
|
167
|
+
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
168
|
+
// the Verify command can change tagged files, so the digests come from the tree after the run
|
|
169
|
+
const { loadIndex } = await import("../index/build.js");
|
|
170
|
+
const after = loadIndex(c.root, c.cfg).data.entries;
|
|
171
|
+
const rel = writeEvidence(c.root, t, after, {
|
|
172
|
+
command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: tail(r.stderr.toString("utf8")),
|
|
173
|
+
}, "command", undefined, t.verify.cwd);
|
|
174
|
+
setBox(c.root, t, true);
|
|
175
|
+
clearCurrent(c.root, t.id);
|
|
176
|
+
if (c.json)
|
|
177
|
+
print(c, { evidence: rel, kind: "command", task: t.id });
|
|
178
|
+
else
|
|
179
|
+
c.io.out(paint("ok", `done ${t.id}. Evidence: ${rel}\n`, c.io.tty)); // REQ-CLI-008
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
case "task backfill": {
|
|
183
|
+
// REQ-TASK-006 manual evidence for every ticked task that has none, through the task done --manual path
|
|
184
|
+
noMore(c, 0);
|
|
185
|
+
const note = f.manual?.trim();
|
|
186
|
+
if (!note)
|
|
187
|
+
throw new UsageExit('task backfill needs --manual "<note>"');
|
|
188
|
+
const { tasksOf, toTask, hasEvidence } = await import("../tasks/tasks.js");
|
|
189
|
+
const { matchesChange } = await import("../index/scan.js");
|
|
190
|
+
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
191
|
+
const want = f.change;
|
|
192
|
+
const counts = new Map();
|
|
193
|
+
for (const e of tasksOf(entries)) {
|
|
194
|
+
if (e.status !== "done")
|
|
195
|
+
continue;
|
|
196
|
+
if (want && !matchesChange(e.change, want))
|
|
197
|
+
continue;
|
|
198
|
+
if (hasEvidence(c.root, e))
|
|
199
|
+
continue;
|
|
200
|
+
const t = toTask(c.root, e);
|
|
201
|
+
writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", note);
|
|
202
|
+
counts.set(e.change, (counts.get(e.change) ?? 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
const { cmpStr } = await import("../util/pystr.js");
|
|
205
|
+
const rows = [...counts.keys()].sort(cmpStr).map((k) => ({ change: k, count: counts.get(k) }));
|
|
206
|
+
const total = rows.reduce((n, r) => n + r.count, 0);
|
|
207
|
+
if (c.json)
|
|
208
|
+
print(c, { changes: rows, total });
|
|
209
|
+
else
|
|
210
|
+
c.io.out(rows.map((r) => `${r.change}: ${r.count}\n`).join("") + `backfilled ${total} task(s)\n`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
case "task reopen": {
|
|
214
|
+
const { findTask, setBox } = await import("../tasks/tasks.js");
|
|
215
|
+
const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
|
|
216
|
+
const reason = c.p.positional.slice(1).join(" ").trim();
|
|
217
|
+
if (!reason)
|
|
218
|
+
throw new UsageExit("task reopen needs a reason");
|
|
219
|
+
if (!t.done)
|
|
220
|
+
throw new UsageExit(`task ${t.id} is open`);
|
|
221
|
+
if (t.archived)
|
|
222
|
+
throw new UsageExit(`task ${t.id} is in an archived change`);
|
|
223
|
+
const { retireEvidence } = await import("../tasks/evidence.js");
|
|
224
|
+
const { addReopened } = await import("../handoff/handoff.js");
|
|
225
|
+
setBox(c.root, t, false);
|
|
226
|
+
const moved = retireEvidence(c.root, t, reason);
|
|
227
|
+
addReopened(c.root, entries, t.id, reason);
|
|
228
|
+
if (c.json)
|
|
229
|
+
print(c, { evidence: moved, task: t.id });
|
|
230
|
+
else
|
|
231
|
+
c.io.out(`reopened ${t.id}.${moved ? ` Old evidence: ${moved}` : ""}\n`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
case "handoff write": {
|
|
235
|
+
noMore(c, 0);
|
|
236
|
+
const { writeHandoff, HANDOFF_PATH } = await import("../handoff/handoff.js");
|
|
237
|
+
const h = writeHandoff(c.root, entries);
|
|
238
|
+
if (c.json)
|
|
239
|
+
print(c, h);
|
|
240
|
+
else
|
|
241
|
+
c.io.out(`wrote ${HANDOFF_PATH}\n`);
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
case "handoff ask": {
|
|
245
|
+
const { ask } = await import("../handoff/handoff.js");
|
|
246
|
+
const h = ask(c.root, entries, c.p.positional.join(" "));
|
|
247
|
+
if (c.json)
|
|
248
|
+
print(c, { open_questions: h.open_questions });
|
|
249
|
+
else
|
|
250
|
+
c.io.out(`open question ${h.open_questions.length} added\n`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
case "handoff resolve": {
|
|
254
|
+
const { resolve } = await import("../handoff/handoff.js");
|
|
255
|
+
const n = arg(c, 0, "a question number");
|
|
256
|
+
if (!/^\d+$/.test(n))
|
|
257
|
+
throw new UsageExit("handoff resolve needs a question number");
|
|
258
|
+
const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "));
|
|
259
|
+
if (c.json)
|
|
260
|
+
print(c, { open_questions: h.open_questions, resolved: h.resolved });
|
|
261
|
+
else
|
|
262
|
+
c.io.out(`resolved question ${n}\n`);
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw new UsageExit(`unknown command: ${c.p.command}`);
|
|
267
|
+
}
|
|
268
|
+
function clearCurrent(root, task) {
|
|
269
|
+
const p = join(root, ".ctx/current.json");
|
|
270
|
+
if (!existsSync(p))
|
|
271
|
+
return;
|
|
272
|
+
try {
|
|
273
|
+
const cur = JSON.parse(readFileSync(p, "utf8"));
|
|
274
|
+
if (cur.task === task)
|
|
275
|
+
rmSync(p);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// a damaged current.json stays for the user to read
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// REQ-TASK-002 the footer of task start: the bundle size and its share of the spec text. bundle never prints it.
|
|
282
|
+
export function footer(n, m) {
|
|
283
|
+
const p = m > 0 ? Math.round((n / m) * 1000) / 10 : 0;
|
|
284
|
+
return `-- bundle ${n} chars, ${p}% of ${m} chars of spec text\n`;
|
|
285
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// REQ-CLI-001 bin entry. tests call main() from src/main.ts in process.
|
|
3
|
+
import { main } from "./main.js";
|
|
4
|
+
process.stdout.on("error", (e) => {
|
|
5
|
+
if (e.code === "EPIPE")
|
|
6
|
+
process.exit(0);
|
|
7
|
+
});
|
|
8
|
+
const code = await main(process.argv.slice(2), {
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
out: (s) => process.stdout.write(s),
|
|
11
|
+
err: (s) => process.stderr.write(s),
|
|
12
|
+
tty: process.stdout.isTTY === true,
|
|
13
|
+
});
|
|
14
|
+
process.exitCode = code;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// REQ-DIST-002 spec-ctx.toml with defaults from REQ-IDX-001 and REQ-QRY-003.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "smol-toml";
|
|
5
|
+
export const CONFIG_FILE = "spec-ctx.toml";
|
|
6
|
+
export function defaults() {
|
|
7
|
+
return {
|
|
8
|
+
scan: {
|
|
9
|
+
specs: ["openspec/specs/**/*.md"],
|
|
10
|
+
changes: ["openspec/changes/**/*.md"],
|
|
11
|
+
decisions: ["decisions/*.md"],
|
|
12
|
+
code: ["src/**"],
|
|
13
|
+
tests: ["tests/**"],
|
|
14
|
+
ignore: [],
|
|
15
|
+
},
|
|
16
|
+
ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {} },
|
|
17
|
+
bundle: { budget: 8000, depth: 1 },
|
|
18
|
+
verify: { timeout_s: 600 },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export class ConfigError extends Error {
|
|
22
|
+
}
|
|
23
|
+
function strList(v, key) {
|
|
24
|
+
if (typeof v === "string")
|
|
25
|
+
return [v];
|
|
26
|
+
if (Array.isArray(v) && v.every((x) => typeof x === "string"))
|
|
27
|
+
return v;
|
|
28
|
+
throw new ConfigError(`${CONFIG_FILE}: ${key} must be a string or a list of strings.`);
|
|
29
|
+
}
|
|
30
|
+
/** Read the config of a project root. A missing file gives all defaults. */
|
|
31
|
+
export function loadConfig(root) {
|
|
32
|
+
const cfg = defaults();
|
|
33
|
+
const p = join(root, CONFIG_FILE);
|
|
34
|
+
if (!existsSync(p))
|
|
35
|
+
return cfg;
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = parse(readFileSync(p, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
throw new ConfigError(`${CONFIG_FILE}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
|
|
42
|
+
}
|
|
43
|
+
const sec = (name) => {
|
|
44
|
+
const v = raw[name];
|
|
45
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
46
|
+
};
|
|
47
|
+
const scan = sec("scan");
|
|
48
|
+
for (const k of Object.keys(cfg.scan)) {
|
|
49
|
+
if (k in scan)
|
|
50
|
+
cfg.scan[k] = strList(scan[k], `scan.${k}`);
|
|
51
|
+
}
|
|
52
|
+
const ids = sec("ids");
|
|
53
|
+
if ("caps" in ids)
|
|
54
|
+
cfg.ids.caps = strList(ids.caps, "ids.caps");
|
|
55
|
+
if ("task_pattern" in ids) {
|
|
56
|
+
if (typeof ids.task_pattern !== "string")
|
|
57
|
+
throw new ConfigError(`${CONFIG_FILE}: ids.task_pattern must be a string.`);
|
|
58
|
+
cfg.ids.task_pattern = ids.task_pattern;
|
|
59
|
+
}
|
|
60
|
+
if ("paths" in ids) {
|
|
61
|
+
const v = ids.paths;
|
|
62
|
+
if (v === null || typeof v !== "object" || Array.isArray(v) || !Object.values(v).every((x) => typeof x === "string")) {
|
|
63
|
+
throw new ConfigError(`${CONFIG_FILE}: ids.paths must be a table of code = "capability path".`);
|
|
64
|
+
}
|
|
65
|
+
cfg.ids.paths = { ...v };
|
|
66
|
+
}
|
|
67
|
+
const bundle = sec("bundle");
|
|
68
|
+
for (const k of ["budget", "depth"]) {
|
|
69
|
+
if (k in bundle) {
|
|
70
|
+
const v = bundle[k];
|
|
71
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
72
|
+
throw new ConfigError(`${CONFIG_FILE}: bundle.${k} must be an integer of 0 or more.`);
|
|
73
|
+
}
|
|
74
|
+
cfg.bundle[k] = v;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const verify = sec("verify");
|
|
78
|
+
if ("timeout_s" in verify) {
|
|
79
|
+
const v = verify.timeout_s;
|
|
80
|
+
if (typeof v !== "number" || !(v > 0))
|
|
81
|
+
throw new ConfigError(`${CONFIG_FILE}: verify.timeout_s must be a number above 0.`);
|
|
82
|
+
cfg.verify.timeout_s = v;
|
|
83
|
+
}
|
|
84
|
+
return cfg;
|
|
85
|
+
}
|
|
86
|
+
/** The file that `specctx init` writes. Each key has a comment above it. */
|
|
87
|
+
export const INIT_TEXT = `# spec-ctx configuration. Every key is optional. A missing key takes its default.
|
|
88
|
+
|
|
89
|
+
[scan]
|
|
90
|
+
# Main specs. Markdown files with requirement and scenario headers.
|
|
91
|
+
specs = ["openspec/specs/**/*.md"]
|
|
92
|
+
# Active and archived OpenSpec changes.
|
|
93
|
+
changes = ["openspec/changes/**/*.md"]
|
|
94
|
+
# Decision records.
|
|
95
|
+
decisions = ["decisions/*.md"]
|
|
96
|
+
# Source files that carry code tags. A code tag is a comment with a REQ ID.
|
|
97
|
+
code = ["src/**"]
|
|
98
|
+
# Test files that carry Covers tags and scenario IDs.
|
|
99
|
+
tests = ["tests/**"]
|
|
100
|
+
# Paths that the scan skips.
|
|
101
|
+
ignore = []
|
|
102
|
+
|
|
103
|
+
[ids]
|
|
104
|
+
# Capability codes. check reports a REQ whose code is not in this list. id new accepts only these codes.
|
|
105
|
+
# An empty list turns the rule off, and id new accepts any code of 2 to 6 upper case letters.
|
|
106
|
+
caps = []
|
|
107
|
+
# Pattern of the task number in a tasks.md checkbox.
|
|
108
|
+
task_pattern = '\\d+\\.\\d+'
|
|
109
|
+
|
|
110
|
+
[bundle]
|
|
111
|
+
# Default character budget of a bundle.
|
|
112
|
+
budget = 8000
|
|
113
|
+
# Default depth of related entries in a bundle.
|
|
114
|
+
depth = 1
|
|
115
|
+
|
|
116
|
+
[verify]
|
|
117
|
+
# Seconds before task done stops a Verify command.
|
|
118
|
+
timeout_s = 600
|
|
119
|
+
`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// REQ-QRY-003 canonical text with one provenance line per block, in a fixed order, within a budget.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { canon } from "../index/canonical.js";
|
|
4
|
+
import { blockRange, readText } from "../index/scan.js";
|
|
5
|
+
import { cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
|
|
6
|
+
import { KIND_RANK, related } from "./related.js";
|
|
7
|
+
export class Lines {
|
|
8
|
+
cache = new Map();
|
|
9
|
+
root;
|
|
10
|
+
constructor(root) {
|
|
11
|
+
this.root = root;
|
|
12
|
+
}
|
|
13
|
+
get(rpath) {
|
|
14
|
+
let l = this.cache.get(rpath);
|
|
15
|
+
if (!l)
|
|
16
|
+
this.cache.set(rpath, (l = splitLines(readText(join(this.root, rpath)))));
|
|
17
|
+
return l;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Markdown text of an entry. Code and test entries print no body, only their provenance line. */
|
|
21
|
+
export function blockText(e, files) {
|
|
22
|
+
if (e.kind === "code" || e.kind === "test")
|
|
23
|
+
return "";
|
|
24
|
+
const lines = files.get(e.file);
|
|
25
|
+
const start = e.line - 1;
|
|
26
|
+
if (e.kind === "task")
|
|
27
|
+
return pyStrip(lines[start] ?? "") + "\n";
|
|
28
|
+
if (e.kind === "decision") {
|
|
29
|
+
const out = [lines[start] ?? ""];
|
|
30
|
+
for (const line of lines.slice(start + 1)) {
|
|
31
|
+
if (!pyStrip(line) || line.startsWith("- ") || line.startsWith("#"))
|
|
32
|
+
break;
|
|
33
|
+
out.push(line);
|
|
34
|
+
}
|
|
35
|
+
return rstrip(out.join("\n")) + "\n";
|
|
36
|
+
}
|
|
37
|
+
const [s, end] = blockRange(lines, start);
|
|
38
|
+
return rstrip(lines.slice(s, end).join("\n")) + "\n";
|
|
39
|
+
}
|
|
40
|
+
function rstrip(s) {
|
|
41
|
+
const t = pyStrip("x" + s);
|
|
42
|
+
return t.slice(1);
|
|
43
|
+
}
|
|
44
|
+
export function provenance(e, reason) {
|
|
45
|
+
return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
|
|
46
|
+
}
|
|
47
|
+
export function bundle(root, entries, wanted, budget, depth, all = false) {
|
|
48
|
+
const ids = canon(entries);
|
|
49
|
+
const blocks = [];
|
|
50
|
+
for (const i of wanted) {
|
|
51
|
+
const copies = [ids.get(i)];
|
|
52
|
+
if (all)
|
|
53
|
+
copies.push(...entries.filter((e) => e.id === i && !e.canonical));
|
|
54
|
+
for (const e of copies)
|
|
55
|
+
blocks.push([e, "requested"]);
|
|
56
|
+
}
|
|
57
|
+
const found = new Map();
|
|
58
|
+
for (const i of wanted) {
|
|
59
|
+
// decisions that reference a requested ID are included at every depth
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.kind === "decision" && e.refs.includes(i) && !found.has(e.id))
|
|
62
|
+
found.set(e.id, `decision for ${i}`);
|
|
63
|
+
}
|
|
64
|
+
for (const [r, reason] of related(entries, i, depth, all)) {
|
|
65
|
+
if (!wanted.includes(r) && !found.has(r))
|
|
66
|
+
found.set(r, reason);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// scenarios print inside their requirement
|
|
70
|
+
const tail = [...found.keys()].filter((r) => ids.has(r) && ids.get(r).kind !== "scn");
|
|
71
|
+
tail.sort((a, b) => cmpTuple([KIND_RANK[ids.get(a).kind] ?? 9, a], [KIND_RANK[ids.get(b).kind] ?? 9, b]));
|
|
72
|
+
for (const r of tail)
|
|
73
|
+
blocks.push([ids.get(r), found.get(r)]);
|
|
74
|
+
const files = new Lines(root);
|
|
75
|
+
const out = [];
|
|
76
|
+
const omitted = [];
|
|
77
|
+
let used = 0;
|
|
78
|
+
for (const [e, reason] of blocks) {
|
|
79
|
+
const body = blockText(e, files);
|
|
80
|
+
// a blank line closes a block with a body. Code and test paths stay one line each.
|
|
81
|
+
const t = provenance(e, reason) + body + (body ? "\n" : "");
|
|
82
|
+
const n = cpLen(t);
|
|
83
|
+
if (used + n > budget) {
|
|
84
|
+
omitted.push(e.id);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out.push(t);
|
|
88
|
+
used += n;
|
|
89
|
+
}
|
|
90
|
+
let text = out.join("");
|
|
91
|
+
if (omitted.length)
|
|
92
|
+
text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
|
|
93
|
+
return { text, omitted };
|
|
94
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// REQ-QRY-001 token AND over id, title, text. Score 3 for id, 2 for title, 1 for text, per term.
|
|
2
|
+
import { poolOf } from "../index/canonical.js";
|
|
3
|
+
import { matchesChange } from "../index/scan.js";
|
|
4
|
+
import { cmpTuple, pySplit } from "../util/pystr.js";
|
|
5
|
+
export function query(entries, args, o = {}) {
|
|
6
|
+
const terms = args.flatMap((a) => pySplit(a.toLowerCase()));
|
|
7
|
+
const hits = [];
|
|
8
|
+
for (const e of poolOf(entries, !!o.all)) {
|
|
9
|
+
if ((o.kind && e.kind !== o.kind) || (o.change && !matchesChange(e.change, o.change)))
|
|
10
|
+
continue;
|
|
11
|
+
// REQ-IDX-005 --change takes a key or a full folder name
|
|
12
|
+
const idl = e.id.toLowerCase();
|
|
13
|
+
const tl = e.title.toLowerCase();
|
|
14
|
+
const xl = e.text ?? "";
|
|
15
|
+
if (!terms.every((t) => idl.includes(t) || tl.includes(t) || xl.includes(t)))
|
|
16
|
+
continue;
|
|
17
|
+
let score = 0;
|
|
18
|
+
for (const t of terms)
|
|
19
|
+
score += 3 * +idl.includes(t) + 2 * +tl.includes(t) + +xl.includes(t);
|
|
20
|
+
hits.push([-score, e.id, e.file, e.line, e]);
|
|
21
|
+
}
|
|
22
|
+
hits.sort((a, b) => cmpTuple(a.slice(0, 4), b.slice(0, 4)));
|
|
23
|
+
let out = hits.map((h) => h[4]);
|
|
24
|
+
const limit = o.limit ?? 20;
|
|
25
|
+
if (limit > 0)
|
|
26
|
+
out = out.slice(0, limit);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function queryLine(e) {
|
|
30
|
+
return `${e.id} ${e.kind} ${e.status} ${e.file}:${e.line} ${e.title}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// REQ-QRY-002 IDs reachable from a start ID, with the reason each one was included.
|
|
2
|
+
import { canon, poolOf } from "../index/canonical.js";
|
|
3
|
+
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
4
|
+
// REQ-QRY-003 fixed block order after the requested IDs
|
|
5
|
+
export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5 };
|
|
6
|
+
export const REASONS = ["scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers"];
|
|
7
|
+
export function related(entries, start, depth, all = false) {
|
|
8
|
+
const pool = poolOf(entries, all);
|
|
9
|
+
const ids = canon(entries);
|
|
10
|
+
for (const e of pool)
|
|
11
|
+
if (!ids.has(e.id))
|
|
12
|
+
ids.set(e.id, e);
|
|
13
|
+
const back = new Map();
|
|
14
|
+
for (const e of pool) {
|
|
15
|
+
for (const r of e.refs) {
|
|
16
|
+
let s = back.get(r);
|
|
17
|
+
if (!s)
|
|
18
|
+
back.set(r, (s = new Set()));
|
|
19
|
+
s.add(e.id);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const found = new Map();
|
|
23
|
+
const add = (i, reason) => {
|
|
24
|
+
if (i !== start && !found.has(i)) {
|
|
25
|
+
found.set(i, reason);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
const s = ids.get(start);
|
|
31
|
+
if (s && s.kind === "req")
|
|
32
|
+
for (const sid of s.scenarios ?? [])
|
|
33
|
+
add(sid, `scenario of ${start}`);
|
|
34
|
+
let frontier = [start];
|
|
35
|
+
for (let d = 0; d < depth; d++) {
|
|
36
|
+
const nxt = [];
|
|
37
|
+
for (const x of frontier) {
|
|
38
|
+
const ex = ids.get(x);
|
|
39
|
+
if (ex) {
|
|
40
|
+
for (const r of ex.refs)
|
|
41
|
+
if (add(r, `ref from ${x}`))
|
|
42
|
+
nxt.push(r);
|
|
43
|
+
if (ex.kind === "req") {
|
|
44
|
+
for (const sid of ex.scenarios ?? [])
|
|
45
|
+
if (add(sid, `scenario of ${x}`))
|
|
46
|
+
nxt.push(sid);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const y of [...(back.get(x) ?? [])].sort(cmpStr)) {
|
|
50
|
+
const ey = ids.get(y);
|
|
51
|
+
const k = ey ? ey.kind : "?";
|
|
52
|
+
let reason;
|
|
53
|
+
if (k === "scn" && ex && (ex.scenarios ?? []).includes(y))
|
|
54
|
+
reason = `scenario of ${x}`;
|
|
55
|
+
else if (k === "decision")
|
|
56
|
+
reason = `decision for ${x}`;
|
|
57
|
+
else if (k === "task")
|
|
58
|
+
reason = `task cites ${x}`;
|
|
59
|
+
else if (k === "code")
|
|
60
|
+
reason = `implements ${x}`;
|
|
61
|
+
else if (k === "test")
|
|
62
|
+
reason = `covers ${x}`;
|
|
63
|
+
else
|
|
64
|
+
reason = `ref to ${x}`;
|
|
65
|
+
if (add(y, reason))
|
|
66
|
+
nxt.push(y);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
frontier = nxt;
|
|
70
|
+
}
|
|
71
|
+
const rank = (i) => {
|
|
72
|
+
const e = ids.get(i);
|
|
73
|
+
return [e ? (KIND_RANK[e.kind] ?? 9) : 9, i];
|
|
74
|
+
};
|
|
75
|
+
return [...found.keys()].sort((a, b) => cmpTuple(rank(a), rank(b))).map((i) => [i, found.get(i)]);
|
|
76
|
+
}
|