spec-ctx 0.4.0 → 0.4.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 +88 -16
- package/dist/adopt/write.js +6 -2
- package/dist/assets.generated.js +1 -1
- package/dist/check/hints.js +10 -9
- package/dist/cli/args.js +2 -1
- package/dist/cli/commands.js +66 -20
- package/dist/cli/help.js +17 -5
- package/dist/cli/workflow.js +24 -10
- package/dist/config.js +8 -0
- package/dist/graph/bundle.js +140 -26
- package/dist/graph/files.js +31 -0
- package/dist/graph/input.js +37 -0
- package/dist/graph/links.js +96 -0
- package/dist/graph/path.js +54 -0
- package/dist/graph/query.js +3 -0
- package/dist/graph/related.js +58 -11
- package/dist/handoff/handoff.js +6 -4
- package/dist/handoff/questions.js +9 -1
- package/dist/index/build.js +33 -4
- package/dist/index/clock.js +55 -0
- package/dist/index/scan.js +36 -9
- package/dist/index/structure.js +97 -0
- package/dist/init/init.js +15 -4
- package/dist/loop/fails.js +10 -2
- package/dist/loop/next.js +38 -17
- package/dist/runcmd.js +5 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +3 -3
package/dist/cli/workflow.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 REQ-LOOP-002 write commands.
|
|
1
|
+
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-007 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 REQ-LOOP-002 write commands.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { dumps } from "../util/json.js";
|
|
5
5
|
import { paint } from "./color.js";
|
|
6
6
|
import { UsageExit } from "./errors.js";
|
|
7
|
+
import { runCommand } from "../runcmd.js";
|
|
8
|
+
/** REQ-CLI-010 a command to print, with the run prefix of the config (043/D1). */
|
|
9
|
+
const rc = (c, args) => runCommand(c.cfg.cli.run, args);
|
|
7
10
|
const arg = (c, n, what) => {
|
|
8
11
|
const v = c.p.positional[n];
|
|
9
12
|
if (v === undefined)
|
|
@@ -62,7 +65,8 @@ export async function runWorkflow(c) {
|
|
|
62
65
|
}
|
|
63
66
|
return 1;
|
|
64
67
|
}
|
|
65
|
-
|
|
68
|
+
// REQ-TASK-007 the command names the answer that task start prints
|
|
69
|
+
const bundleCommand = rc(c, `context ${t.id} --budget ${c.cfg.bundle.budget}`);
|
|
66
70
|
if (c.json) {
|
|
67
71
|
print(c, { bundle_command: bundleCommand, change: t.change, id: t.id, reqs: t.reqs, text: t.text, verify: t.verify.command });
|
|
68
72
|
}
|
|
@@ -82,10 +86,10 @@ export async function runWorkflow(c) {
|
|
|
82
86
|
const { canon } = await import("../index/canonical.js");
|
|
83
87
|
const { bundle } = await import("../graph/bundle.js");
|
|
84
88
|
const ids = canon(entries);
|
|
85
|
-
const known = t.reqs.filter((r) => ids.has(r));
|
|
86
89
|
for (const r of t.reqs.filter((x) => !ids.has(x)))
|
|
87
90
|
c.io.err(`warning: task ${t.id} cites ${r}, which the index does not hold\n`);
|
|
88
|
-
|
|
91
|
+
// 047/D5 the entry that findTask found goes in, so an active task wins over an archived one with its number
|
|
92
|
+
const b = bundle(c.root, entries, [t.id], c.cfg.bundle.budget, c.cfg.bundle.depth, { task: t.entry });
|
|
89
93
|
const { isoNow } = await import("../util/time.js");
|
|
90
94
|
const { CURRENT_PATH } = await import("../handoff/handoff.js");
|
|
91
95
|
mkdirSync(join(c.root, ".ctx"), { recursive: true });
|
|
@@ -94,7 +98,7 @@ export async function runWorkflow(c) {
|
|
|
94
98
|
// REQ-TASK-002 the Verify command is frozen here, so task done can refuse a changed one (019/D3)
|
|
95
99
|
writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id, verify: t.verify.command }) + "\n");
|
|
96
100
|
// REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
|
|
97
|
-
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run
|
|
101
|
+
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run ${rc(c, `query --change ${t.change} --kind req`)} to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
|
|
98
102
|
if (c.json)
|
|
99
103
|
print(c, { bundle: b, hint, task: t.id });
|
|
100
104
|
else
|
|
@@ -113,7 +117,7 @@ export async function runWorkflow(c) {
|
|
|
113
117
|
throw new UsageExit("--manual needs a note");
|
|
114
118
|
// REQ-TASK-003 a task with a Verify command gets no manual note (019/D1, ADR-004)
|
|
115
119
|
if (t.verify.command !== null)
|
|
116
|
-
throw new UsageExit(`task ${t.id} has a Verify command. Run it with
|
|
120
|
+
throw new UsageExit(`task ${t.id} has a Verify command. Run it with ${rc(c, `task done ${t.id}`)}.`);
|
|
117
121
|
if (t.done && hasEvidence(c.root, t))
|
|
118
122
|
throw new UsageExit(`task ${t.id} is done`);
|
|
119
123
|
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
@@ -137,7 +141,7 @@ export async function runWorkflow(c) {
|
|
|
137
141
|
if (!dry) {
|
|
138
142
|
const frozen = readCurrent(c.root);
|
|
139
143
|
if (frozen && frozen.task === t.id && typeof frozen.verify === "string" && frozen.verify !== t.verify.command) {
|
|
140
|
-
throw new UsageExit(`task ${t.id}: the Verify command changed since task start. Run
|
|
144
|
+
throw new UsageExit(`task ${t.id}: the Verify command changed since task start. Run ${rc(c, `task start ${t.id}`)} again.`);
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
const { runVerify, tail } = await import("../verify/run.js");
|
|
@@ -170,7 +174,7 @@ export async function runWorkflow(c) {
|
|
|
170
174
|
const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, outTail);
|
|
171
175
|
let question = openQuestionFor(c.root, t.id)?.n ?? null;
|
|
172
176
|
if (question === null && fail.file.attempt >= c.cfg.loop.max_attempts) {
|
|
173
|
-
question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail)).n;
|
|
177
|
+
question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail), "cap").n;
|
|
174
178
|
}
|
|
175
179
|
if (c.json)
|
|
176
180
|
print(c, { attempt: fail.file.attempt, command: t.verify.command, error: msg, exit: r.exit, fail: fail.rel, question, tail: outTail, task: t.id });
|
|
@@ -184,7 +188,7 @@ export async function runWorkflow(c) {
|
|
|
184
188
|
c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
|
|
185
189
|
// REQ-LOOP-002 the blocked line comes after the failure text, so the last line names the way out
|
|
186
190
|
if (!c.json && question !== null)
|
|
187
|
-
c.io.out(blockedLine(question) + "\n");
|
|
191
|
+
c.io.out(blockedLine(question, c.cfg.cli.run) + "\n");
|
|
188
192
|
return 1;
|
|
189
193
|
}
|
|
190
194
|
const { sha256 } = await import("../util/hash.js");
|
|
@@ -197,6 +201,13 @@ export async function runWorkflow(c) {
|
|
|
197
201
|
}, "command", undefined, t.verify.cwd);
|
|
198
202
|
setBox(c.root, t, true);
|
|
199
203
|
clearCurrent(c.root, t.id);
|
|
204
|
+
// REQ-LOOP-014 a pass closes the attempt-cap question of the task. Plan, design, and archive questions stay (044/D3).
|
|
205
|
+
const { isCapQuestion } = await import("../loop/fails.js");
|
|
206
|
+
const { readQuestions, resolveQuestion } = await import("../handoff/questions.js");
|
|
207
|
+
for (const q of readQuestions(c.root)) {
|
|
208
|
+
if (isCapQuestion(q, t.id))
|
|
209
|
+
resolveQuestion(c.root, q.n, "Verify passed. Resolved by task done.");
|
|
210
|
+
}
|
|
200
211
|
if (c.json)
|
|
201
212
|
print(c, { evidence: rel, kind: "command", task: t.id });
|
|
202
213
|
else
|
|
@@ -255,7 +266,10 @@ export async function runWorkflow(c) {
|
|
|
255
266
|
const n = arg(c, 0, "a question number");
|
|
256
267
|
if (!/^\d+$/.test(n))
|
|
257
268
|
throw new UsageExit("handoff resolve needs a question number");
|
|
258
|
-
const
|
|
269
|
+
const approve = c.p.flags.approve === true;
|
|
270
|
+
const reject = c.p.flags.reject === true;
|
|
271
|
+
const verdict = approve && reject ? "both" : approve ? "approved" : reject ? "rejected" : undefined;
|
|
272
|
+
const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "), verdict);
|
|
259
273
|
if (c.json)
|
|
260
274
|
print(c, { open_questions: h.open_questions, resolved: h.resolved });
|
|
261
275
|
else
|
package/dist/config.js
CHANGED
|
@@ -21,6 +21,7 @@ export function defaults() {
|
|
|
21
21
|
// REQ-DIST-002 the gate lints nothing unless the project opts in (022/D1, ADR-006 item 9)
|
|
22
22
|
gate: { markdown: [], openspec: DEFAULT_OPENSPEC, base: "origin/main" },
|
|
23
23
|
loop: { max_attempts: 3, archive: "ask" },
|
|
24
|
+
cli: { run: "specctx" },
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
export class ConfigError extends Error {
|
|
@@ -122,6 +123,13 @@ export function loadConfig(root) {
|
|
|
122
123
|
throw new ConfigError(`${CONFIG_FILE}: loop.archive must be "ask" or "auto".`);
|
|
123
124
|
cfg.loop.archive = v;
|
|
124
125
|
}
|
|
126
|
+
// REQ-CLI-010 the run prefix of printed commands (043/D1)
|
|
127
|
+
const cli = sec("cli");
|
|
128
|
+
if ("run" in cli) {
|
|
129
|
+
if (typeof cli.run !== "string" || !cli.run.trim())
|
|
130
|
+
throw new ConfigError(`${CONFIG_FILE}: cli.run must be a string that is not empty.`);
|
|
131
|
+
cfg.cli.run = cli.run.trim();
|
|
132
|
+
}
|
|
125
133
|
return cfg;
|
|
126
134
|
}
|
|
127
135
|
/** The file that `specctx init` writes. Each key has a comment above it. */
|
package/dist/graph/bundle.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// REQ-QRY-003 canonical text with one provenance line per block, in a fixed order, within a budget.
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { canon } from "../index/canonical.js";
|
|
3
|
+
import { canon, poolOf } from "../index/canonical.js";
|
|
4
4
|
import { blockRange, readText } from "../index/scan.js";
|
|
5
|
-
import { cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
|
|
6
|
-
import { KIND_RANK,
|
|
5
|
+
import { cmpStr, cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
|
|
6
|
+
import { KIND_RANK, mainVersion, walkFound } from "./related.js";
|
|
7
7
|
export class Lines {
|
|
8
8
|
cache = new Map();
|
|
9
9
|
root;
|
|
@@ -19,7 +19,8 @@ export class Lines {
|
|
|
19
19
|
}
|
|
20
20
|
/** Markdown text of an entry. Code and test entries print no body, only their provenance line. */
|
|
21
21
|
export function blockText(e, files) {
|
|
22
|
-
|
|
22
|
+
// REQ-QRY-009 a capability or a change prints its provenance line only
|
|
23
|
+
if (e.kind === "code" || e.kind === "test" || e.kind === "cap" || e.kind === "change")
|
|
23
24
|
return "";
|
|
24
25
|
const lines = files.get(e.file);
|
|
25
26
|
const start = e.line - 1;
|
|
@@ -49,50 +50,163 @@ export function reqBlocks(root, reqs) {
|
|
|
49
50
|
const files = new Lines(root);
|
|
50
51
|
return reqs.map((e) => blockText(e, files)).join("\n");
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/** REQ-QRY-012 the budget class of a block (046/D7). A lower class keeps its place first. */
|
|
54
|
+
function budgetClass(e, requested, hit) {
|
|
55
|
+
if (requested)
|
|
56
|
+
return 0;
|
|
57
|
+
if (!hit || hit.depth >= 2)
|
|
58
|
+
return 3;
|
|
59
|
+
if (e.kind === "code" || e.kind === "test")
|
|
60
|
+
return 1;
|
|
61
|
+
if (e.kind === "decision" && e.status === "archived")
|
|
62
|
+
return 3;
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* REQ-QRY-016 REQ-QRY-017 the requirements that the task cites or that the file tags, and the code and test
|
|
67
|
+
* entries of each, from one pass over the pool (047/D3, 047/D7). The entries get depth 1.
|
|
68
|
+
*/
|
|
69
|
+
function reach(entries, ids, wanted, all) {
|
|
70
|
+
const cited = [];
|
|
71
|
+
const tagged = [];
|
|
72
|
+
const add = (list, r) => {
|
|
73
|
+
if (ids.get(r)?.kind === "req" && !cited.includes(r) && !tagged.includes(r))
|
|
74
|
+
list.push(r);
|
|
75
|
+
};
|
|
76
|
+
const pool = poolOf(entries, all);
|
|
77
|
+
for (const i of wanted) {
|
|
78
|
+
const e = ids.get(i);
|
|
79
|
+
if (e?.kind === "task")
|
|
80
|
+
for (const r of e.refs)
|
|
81
|
+
add(cited, r);
|
|
82
|
+
// a requested input that is no ID is a path. Only the tags of the file count, not a mention in a task line.
|
|
83
|
+
else if (!e)
|
|
84
|
+
for (const x of pool)
|
|
85
|
+
if (x.file === i && (x.kind === "code" || x.kind === "test"))
|
|
86
|
+
for (const r of x.refs)
|
|
87
|
+
add(tagged, r);
|
|
88
|
+
}
|
|
89
|
+
const lines = new Map();
|
|
90
|
+
if (!cited.length && !tagged.length)
|
|
91
|
+
return { cited, lines };
|
|
92
|
+
const want = new Set([...cited, ...tagged]);
|
|
93
|
+
const back = new Map();
|
|
94
|
+
for (const x of pool) {
|
|
95
|
+
if (x.kind !== "code" && x.kind !== "test")
|
|
96
|
+
continue;
|
|
97
|
+
for (const r of x.refs) {
|
|
98
|
+
if (!want.has(r))
|
|
99
|
+
continue;
|
|
100
|
+
let l = back.get(r);
|
|
101
|
+
if (!l)
|
|
102
|
+
back.set(r, (l = []));
|
|
103
|
+
l.push(x);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const r of [...cited, ...tagged]) {
|
|
107
|
+
for (const x of (back.get(r) ?? []).sort((a, b) => cmpStr(a.id, b.id))) {
|
|
108
|
+
if (!lines.has(x.id))
|
|
109
|
+
lines.set(x.id, { reason: `${x.kind === "code" ? "implements" : "covers"} ${r}`, depth: 1 });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { cited, lines };
|
|
113
|
+
}
|
|
114
|
+
export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
|
|
115
|
+
const all = !!opts.all;
|
|
116
|
+
// 047/D5 the entry that the caller found stands for its ID. It goes last, so canon() holds it.
|
|
117
|
+
const entries = opts.task && canon(entries0).get(opts.task.id) !== opts.task ? [...entries0.filter((e) => e !== opts.task), opts.task] : entries0;
|
|
53
118
|
const ids = canon(entries);
|
|
119
|
+
// block, reason, class. The array is in print order.
|
|
54
120
|
const blocks = [];
|
|
55
121
|
for (const i of wanted) {
|
|
122
|
+
// REQ-QRY-005 a path has no block of its own. Its links come from related below.
|
|
123
|
+
if (!ids.has(i))
|
|
124
|
+
continue;
|
|
56
125
|
const copies = [ids.get(i)];
|
|
57
126
|
if (all)
|
|
58
127
|
copies.push(...entries.filter((e) => e.id === i && !e.canonical));
|
|
59
128
|
for (const e of copies)
|
|
60
|
-
blocks.push([e, "requested"]);
|
|
129
|
+
blocks.push([e, "requested", 0]);
|
|
130
|
+
// REQ-QRY-010 the main version of a modified requirement, also for a requested ID
|
|
131
|
+
const m = all ? undefined : mainVersion(entries, i);
|
|
132
|
+
if (m)
|
|
133
|
+
blocks.push([m, `main version of ${i}`, 0]);
|
|
61
134
|
}
|
|
62
135
|
const found = new Map();
|
|
63
136
|
for (const i of wanted) {
|
|
64
|
-
// decisions that reference a requested ID are included at every depth
|
|
137
|
+
// decisions that reference a requested ID are included at every depth, as depth 1.
|
|
138
|
+
// REQ-QRY-016 They replace a deeper hit, which the walk for an earlier requested ID can leave (048/D1).
|
|
65
139
|
for (const e of entries) {
|
|
66
|
-
if (e.kind
|
|
67
|
-
|
|
140
|
+
if (e.kind !== "decision" || !e.refs.includes(i))
|
|
141
|
+
continue;
|
|
142
|
+
const cur = found.get(e.id);
|
|
143
|
+
if (!cur || cur.depth > 1)
|
|
144
|
+
found.set(e.id, { reason: `decision for ${i}`, depth: 1 });
|
|
68
145
|
}
|
|
69
|
-
for (const [r,
|
|
146
|
+
for (const [r, hit] of walkFound(entries, i, depth, all)) {
|
|
70
147
|
if (!wanted.includes(r) && !found.has(r))
|
|
71
|
-
found.set(r,
|
|
148
|
+
found.set(r, hit);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// REQ-QRY-016 REQ-QRY-017 the cited requirements, their decisions, and the lines of the requirements of a task or a file
|
|
152
|
+
const r = depth >= 1 ? reach(entries, ids, wanted, all) : { cited: [], lines: new Map() };
|
|
153
|
+
const citedSet = new Set(r.cited);
|
|
154
|
+
for (const t of wanted) {
|
|
155
|
+
const te = ids.get(t);
|
|
156
|
+
if (te?.kind !== "task")
|
|
157
|
+
continue;
|
|
158
|
+
for (const c of te.refs)
|
|
159
|
+
if (citedSet.has(c) && !wanted.includes(c) && !found.has(c))
|
|
160
|
+
found.set(c, { reason: `ref from ${t}`, depth: 1 });
|
|
161
|
+
}
|
|
162
|
+
for (const c of r.cited) {
|
|
163
|
+
for (const e of entries) {
|
|
164
|
+
if (e.kind !== "decision" || !e.refs.includes(c))
|
|
165
|
+
continue;
|
|
166
|
+
const cur = found.get(e.id);
|
|
167
|
+
if (!cur || cur.depth > 1)
|
|
168
|
+
found.set(e.id, { reason: `decision for ${c}`, depth: 1 });
|
|
72
169
|
}
|
|
73
170
|
}
|
|
171
|
+
for (const [id, hit] of r.lines) {
|
|
172
|
+
const cur = found.get(id);
|
|
173
|
+
if (!wanted.includes(id) && (!cur || cur.depth > 1))
|
|
174
|
+
found.set(id, hit);
|
|
175
|
+
}
|
|
74
176
|
// scenarios print inside their requirement
|
|
75
|
-
const tail = [...found.keys()].filter((
|
|
177
|
+
const tail = [...found.keys()].filter((x) => ids.has(x) && ids.get(x).kind !== "scn");
|
|
76
178
|
tail.sort((a, b) => cmpTuple([KIND_RANK[ids.get(a).kind] ?? 9, a], [KIND_RANK[ids.get(b).kind] ?? 9, b]));
|
|
77
|
-
for (const
|
|
78
|
-
|
|
179
|
+
for (const r2 of tail) {
|
|
180
|
+
const hit = found.get(r2);
|
|
181
|
+
const e = ids.get(r2);
|
|
182
|
+
const cls = citedSet.has(r2) ? 0 : budgetClass(e, false, hit);
|
|
183
|
+
blocks.push([e, hit.reason, cls]);
|
|
184
|
+
const m = all ? undefined : mainVersion(entries, r2);
|
|
185
|
+
if (m)
|
|
186
|
+
blocks.push([m, `main version of ${r2}`, cls]);
|
|
187
|
+
}
|
|
79
188
|
const files = new Lines(root);
|
|
80
|
-
const
|
|
81
|
-
const omitted = [];
|
|
82
|
-
let used = 0;
|
|
83
|
-
for (const [e, reason] of blocks) {
|
|
189
|
+
const texts = blocks.map(([e, reason]) => {
|
|
84
190
|
const body = blockText(e, files);
|
|
85
191
|
// a blank line closes a block with a body. Code and test paths stay one line each.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
192
|
+
return provenance(e, reason) + body + (body ? "\n" : "");
|
|
193
|
+
});
|
|
194
|
+
// fill the budget class by class, in print order inside a class, and skip a block that does not fit
|
|
195
|
+
const keep = blocks.map(() => false);
|
|
196
|
+
let used = 0;
|
|
197
|
+
for (let cls = 0; cls <= 3; cls++) {
|
|
198
|
+
for (let k = 0; k < blocks.length; k++) {
|
|
199
|
+
if (blocks[k][2] !== cls)
|
|
200
|
+
continue;
|
|
201
|
+
const n = cpLen(texts[k]);
|
|
202
|
+
if (used + n > budget)
|
|
203
|
+
continue;
|
|
204
|
+
keep[k] = true;
|
|
205
|
+
used += n;
|
|
91
206
|
}
|
|
92
|
-
out.push(t);
|
|
93
|
-
used += n;
|
|
94
207
|
}
|
|
95
|
-
|
|
208
|
+
const omitted = blocks.filter((_, k) => !keep[k]).map(([e]) => e.id);
|
|
209
|
+
let text = texts.filter((_, k) => keep[k]).join("");
|
|
96
210
|
if (omitted.length)
|
|
97
211
|
text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
|
|
98
212
|
return { text, omitted };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// REQ-QRY-006 the links of one file: code tags, Covers tags, and paths that active task lines name.
|
|
2
|
+
import { canon } from "../index/canonical.js";
|
|
3
|
+
import { cmpStr } from "../util/pystr.js";
|
|
4
|
+
/**
|
|
5
|
+
* The direct links of a repository path as [ID, reason] pairs. The order is `implements`,
|
|
6
|
+
* `covers`, `mentions`, then by ID. An ID keeps its first reason. Only canonical IDs count.
|
|
7
|
+
*/
|
|
8
|
+
export function fileEdges(entries, path) {
|
|
9
|
+
const ids = canon(entries);
|
|
10
|
+
const groups = [[], [], []];
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const push = (g, id, reason) => {
|
|
13
|
+
if (!ids.has(id) || seen.has(id))
|
|
14
|
+
return;
|
|
15
|
+
seen.add(id);
|
|
16
|
+
groups[g].push([id, reason]);
|
|
17
|
+
};
|
|
18
|
+
const tagged = entries
|
|
19
|
+
.filter((e) => e.file === path && (e.kind === "code" || e.kind === "test"))
|
|
20
|
+
.sort((a, b) => a.line - b.line);
|
|
21
|
+
for (const e of tagged) {
|
|
22
|
+
const g = e.kind === "code" ? 0 : 1;
|
|
23
|
+
const verb = e.kind === "code" ? "implements" : "covers";
|
|
24
|
+
for (const r of e.refs)
|
|
25
|
+
push(g, r, `${verb}, tag at ${e.file}:${e.line}`);
|
|
26
|
+
}
|
|
27
|
+
const mentions = entries.filter((e) => e.kind === "task" && e.canonical && (e.paths ?? []).includes(path));
|
|
28
|
+
for (const e of mentions)
|
|
29
|
+
push(2, e.id, "mentions, in task line");
|
|
30
|
+
return groups.flatMap((g) => g.sort((a, b) => cmpStr(a[0], b[0])));
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// REQ-QRY-005 REQ-QRY-011 REQ-QRY-015 turn the inputs of bundle or context into IDs, changes, and paths (047/D2).
|
|
2
|
+
import { canon } from "../index/canonical.js";
|
|
3
|
+
import { changeInput, resolveFileInput } from "./path.js";
|
|
4
|
+
/** An ID wins over a change, and a change wins over a path (045/D1, 046/D8). */
|
|
5
|
+
export function resolveInputs(root, cwd, entries, inputs) {
|
|
6
|
+
const ids = canon(entries);
|
|
7
|
+
const wanted = [];
|
|
8
|
+
const paths = new Set();
|
|
9
|
+
const unknown = [];
|
|
10
|
+
const errors = [];
|
|
11
|
+
let taskId;
|
|
12
|
+
for (const i of inputs) {
|
|
13
|
+
if (ids.has(i)) {
|
|
14
|
+
wanted.push(i);
|
|
15
|
+
if (taskId === undefined && ids.get(i).kind === "task")
|
|
16
|
+
taskId = i;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const ch = changeInput(entries, i);
|
|
20
|
+
if (ch) {
|
|
21
|
+
wanted.push(ch);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const r = resolveFileInput(root, cwd, i);
|
|
25
|
+
if ("rel" in r) {
|
|
26
|
+
wanted.push(r.rel);
|
|
27
|
+
paths.add(r.rel);
|
|
28
|
+
}
|
|
29
|
+
else if (r.error.startsWith("unknown id "))
|
|
30
|
+
unknown.push(i);
|
|
31
|
+
else
|
|
32
|
+
errors.push(r.error);
|
|
33
|
+
}
|
|
34
|
+
if (unknown.length)
|
|
35
|
+
errors.unshift(`unknown id(s): ${unknown.join(", ")}`);
|
|
36
|
+
return { wanted, paths, errors, taskId };
|
|
37
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { cmpStr } from "../util/pystr.js";
|
|
2
|
+
const CHANGE_DIR = /^(openspec\/changes\/(?:archive\/)?[^/]+)\//;
|
|
3
|
+
const CAP_SPEC = /(?:^|\/)specs\/([^/]+)\/spec\.md$/;
|
|
4
|
+
/** The change folder that holds a file, or undefined. */
|
|
5
|
+
export const folderOf = (file) => CHANGE_DIR.exec(file)?.[1];
|
|
6
|
+
/** The capability name of a `specs/<cap>/spec.md` path, or undefined. */
|
|
7
|
+
export const capNameOf = (file) => CAP_SPEC.exec(file)?.[1];
|
|
8
|
+
function push(m, k, v) {
|
|
9
|
+
const l = m.get(k);
|
|
10
|
+
if (!l)
|
|
11
|
+
m.set(k, [v]);
|
|
12
|
+
else if (!l.includes(v))
|
|
13
|
+
l.push(v);
|
|
14
|
+
}
|
|
15
|
+
const cache = new WeakMap();
|
|
16
|
+
export function structure(entries) {
|
|
17
|
+
let s = cache.get(entries);
|
|
18
|
+
if (s)
|
|
19
|
+
return s;
|
|
20
|
+
s = { deltas: new Map(), caps: new Map(), reqsOfCap: new Map(), designs: new Map(), tasksOf: new Map(), main: new Map() };
|
|
21
|
+
const modified = new Set();
|
|
22
|
+
for (const e of entries) {
|
|
23
|
+
if (e.kind === "req" || e.kind === "scn") {
|
|
24
|
+
const dir = folderOf(e.file);
|
|
25
|
+
const cap = capNameOf(e.file);
|
|
26
|
+
if (dir && cap) {
|
|
27
|
+
push(s.deltas, dir, e.id);
|
|
28
|
+
push(s.caps, dir, cap);
|
|
29
|
+
}
|
|
30
|
+
if (e.kind === "req" && e.canonical && cap)
|
|
31
|
+
push(s.reqsOfCap, cap, e.id);
|
|
32
|
+
if (e.kind === "req" && e.canonical && e.status === "modified")
|
|
33
|
+
modified.add(e.id);
|
|
34
|
+
}
|
|
35
|
+
else if (e.kind === "task" && e.links) {
|
|
36
|
+
for (const l of e.links) {
|
|
37
|
+
if (!l.startsWith("design:"))
|
|
38
|
+
continue;
|
|
39
|
+
const d = l.slice(7);
|
|
40
|
+
push(s.designs, e.id, d);
|
|
41
|
+
push(s.tasksOf, d, e.id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (e.kind === "req" && !e.canonical && e.status === "active" && e.change === "main" && modified.has(e.id) && !s.main.has(e.id)) {
|
|
47
|
+
s.main.set(e.id, e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const l of [s.caps, s.reqsOfCap, s.tasksOf])
|
|
51
|
+
for (const v of l.values())
|
|
52
|
+
v.sort(cmpStr);
|
|
53
|
+
cache.set(entries, s);
|
|
54
|
+
return s;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The structural edges that leave one entry, as [ID, reason] pairs. A task reaches its deltas and a
|
|
58
|
+
* capability reaches its requirements only when each is the start of the walk (046/D5).
|
|
59
|
+
*/
|
|
60
|
+
export function structuralEdges(s, ex, x, isStart, has) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const put = (id, reason) => {
|
|
63
|
+
if (has(id))
|
|
64
|
+
out.push([id, reason]);
|
|
65
|
+
};
|
|
66
|
+
if (ex.kind === "task") {
|
|
67
|
+
// like a capability, a task reaches its deltas only as the start, so depth 2 adds no sibling (046/D5)
|
|
68
|
+
const dir = folderOf(ex.file);
|
|
69
|
+
if (isStart)
|
|
70
|
+
for (const id of (dir && s.deltas.get(dir)) || [])
|
|
71
|
+
put(id, `delta in change of ${x}`);
|
|
72
|
+
for (const id of s.designs.get(x) ?? [])
|
|
73
|
+
put(id, `design named in ${x}`);
|
|
74
|
+
}
|
|
75
|
+
else if (ex.kind === "decision") {
|
|
76
|
+
for (const id of s.tasksOf.get(x) ?? [])
|
|
77
|
+
put(id, `task names ${x}`);
|
|
78
|
+
}
|
|
79
|
+
else if (ex.kind === "req") {
|
|
80
|
+
const cap = capNameOf(ex.file);
|
|
81
|
+
if (cap)
|
|
82
|
+
put(`cap/${cap}`, `capability of ${x}`);
|
|
83
|
+
}
|
|
84
|
+
else if (ex.kind === "cap" && isStart) {
|
|
85
|
+
for (const id of s.reqsOfCap.get(ex.title) ?? [])
|
|
86
|
+
put(id, `requirement in ${x}`);
|
|
87
|
+
}
|
|
88
|
+
else if (ex.kind === "change") {
|
|
89
|
+
const dir = folderOf(ex.file);
|
|
90
|
+
for (const id of (dir && s.deltas.get(dir)) || [])
|
|
91
|
+
put(id, `delta in ${x}`);
|
|
92
|
+
for (const c of (dir && s.caps.get(dir)) || [])
|
|
93
|
+
put(`cap/${c}`, `capability touched by ${x}`);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// REQ-QRY-005 REQ-QRY-008 REQ-QRY-011 turn the input of related or bundle into a change or a repository path.
|
|
2
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { changeKey, resolveChange } from "../index/scan.js";
|
|
5
|
+
const ID_SHAPE = /^(?:(?:REQ|SCN)-[A-Z]{2,6}-\d{3}(?:-\d+)?|ADR-\d{3}|[^/\s]+\/(?:\d+\.\d+|D\d+)|(?:change|cap)\/[^/\s]+)$/;
|
|
6
|
+
/**
|
|
7
|
+
* REQ-QRY-011 a change key or a folder name that is no ID resolves to `change/<key>`.
|
|
8
|
+
* The caller runs this after the ID check and before the path rule (046/D8).
|
|
9
|
+
*/
|
|
10
|
+
export function changeInput(entries, input) {
|
|
11
|
+
const folder = resolveChange(entries, input);
|
|
12
|
+
if (folder === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
const id = `change/${changeKey(folder)}`;
|
|
15
|
+
return entries.some((e) => e.id === id) ? id : undefined;
|
|
16
|
+
}
|
|
17
|
+
/** An input that looks like an ID. When it is no file, the error is `unknown id`. */
|
|
18
|
+
export const idShaped = (input) => ID_SHAPE.test(input);
|
|
19
|
+
function real(p) {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(p);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** REQ-QRY-008 the path against the root, then against the working directory. The result is a file inside the root. */
|
|
28
|
+
export function resolveFileInput(root, cwd, input) {
|
|
29
|
+
const realRoot = real(root) ?? root;
|
|
30
|
+
const tries = isAbsolute(input) ? [input] : [resolve(root, input), resolve(cwd, input)];
|
|
31
|
+
let found = null;
|
|
32
|
+
for (const t of tries) {
|
|
33
|
+
const r = real(t);
|
|
34
|
+
if (r !== null) {
|
|
35
|
+
found = r;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (found === null)
|
|
40
|
+
return idShaped(input) ? { error: `unknown id ${input}` } : { error: `no such file: ${input}` };
|
|
41
|
+
const rel = relative(realRoot, found);
|
|
42
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
|
|
43
|
+
return { error: `outside the repository: ${input}` };
|
|
44
|
+
let isFile = false;
|
|
45
|
+
try {
|
|
46
|
+
isFile = statSync(found).isFile();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
isFile = false;
|
|
50
|
+
}
|
|
51
|
+
if (!isFile)
|
|
52
|
+
return idShaped(input) ? { error: `unknown id ${input}` } : { error: `not a file: ${input}` };
|
|
53
|
+
return { rel: rel.split(sep).join("/") };
|
|
54
|
+
}
|
package/dist/graph/query.js
CHANGED
|
@@ -9,6 +9,9 @@ export function query(entries, args, o = {}) {
|
|
|
9
9
|
const kind = o.kind?.toLowerCase();
|
|
10
10
|
const hits = [];
|
|
11
11
|
for (const e of poolOf(entries, !!o.all)) {
|
|
12
|
+
// REQ-QRY-013 change and cap entries show only for an explicit --kind
|
|
13
|
+
if (!kind && (e.kind === "cap" || e.kind === "change"))
|
|
14
|
+
continue;
|
|
12
15
|
if ((kind && e.kind !== kind) || (o.change && !matchesChange(e.change, o.change)))
|
|
13
16
|
continue;
|
|
14
17
|
// REQ-IDX-005 --change takes a key or a full folder name
|