spec-ctx 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 +121 -199
- package/dist/adopt/write.js +6 -2
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +63 -23
- package/dist/check/hints.js +10 -9
- package/dist/cli/args.js +3 -1
- package/dist/cli/commands.js +96 -23
- package/dist/cli/help.js +23 -6
- package/dist/cli/workflow.js +31 -12
- package/dist/cli.js +45 -6
- package/dist/config.js +8 -0
- package/dist/graph/bundle.js +155 -27
- 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/journal/journal.js +65 -0
- package/dist/journal/note.js +54 -0
- package/dist/journal/report.js +115 -0
- package/dist/journal/signals.js +26 -0
- package/dist/loop/fails.js +71 -12
- package/dist/loop/next.js +38 -17
- package/dist/main.js +16 -1
- package/dist/runcmd.js +5 -0
- package/dist/tasks/evidence.js +3 -3
- package/dist/util/redact.js +57 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +3 -3
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;
|
|
@@ -44,56 +45,183 @@ function rstrip(s) {
|
|
|
44
45
|
export function provenance(e, reason) {
|
|
45
46
|
return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
|
|
46
47
|
}
|
|
48
|
+
/** REQ-JRN-002 the ID of a provenance line, or null. It anchors on the start of `provenance()` (057/D3). */
|
|
49
|
+
export function parseProvenance(line) {
|
|
50
|
+
const m = /^-- (\S+) \| /.exec(line);
|
|
51
|
+
return m ? m[1] : null;
|
|
52
|
+
}
|
|
47
53
|
/** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
|
|
48
54
|
export function reqBlocks(root, reqs) {
|
|
49
55
|
const files = new Lines(root);
|
|
50
56
|
return reqs.map((e) => blockText(e, files)).join("\n");
|
|
51
57
|
}
|
|
52
|
-
|
|
58
|
+
/** REQ-QRY-012 the budget class of a block (046/D7). A lower class keeps its place first. */
|
|
59
|
+
function budgetClass(e, requested, hit) {
|
|
60
|
+
if (requested)
|
|
61
|
+
return 0;
|
|
62
|
+
if (!hit || hit.depth >= 2)
|
|
63
|
+
return 3;
|
|
64
|
+
if (e.kind === "code" || e.kind === "test")
|
|
65
|
+
return 1;
|
|
66
|
+
if (e.kind === "decision" && e.status === "archived")
|
|
67
|
+
return 3;
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* REQ-QRY-016 REQ-QRY-017 the requirements that the task cites or that the file tags, and the code and test
|
|
72
|
+
* entries of each, from one pass over the pool (047/D3, 047/D7). The entries get depth 1.
|
|
73
|
+
*/
|
|
74
|
+
function reach(entries, ids, wanted, all) {
|
|
75
|
+
const cited = [];
|
|
76
|
+
const tagged = [];
|
|
77
|
+
const add = (list, r) => {
|
|
78
|
+
if (ids.get(r)?.kind === "req" && !cited.includes(r) && !tagged.includes(r))
|
|
79
|
+
list.push(r);
|
|
80
|
+
};
|
|
81
|
+
const pool = poolOf(entries, all);
|
|
82
|
+
for (const i of wanted) {
|
|
83
|
+
const e = ids.get(i);
|
|
84
|
+
if (e?.kind === "task")
|
|
85
|
+
for (const r of e.refs)
|
|
86
|
+
add(cited, r);
|
|
87
|
+
// a requested input that is no ID is a path. Only the tags of the file count, not a mention in a task line.
|
|
88
|
+
else if (!e)
|
|
89
|
+
for (const x of pool)
|
|
90
|
+
if (x.file === i && (x.kind === "code" || x.kind === "test"))
|
|
91
|
+
for (const r of x.refs)
|
|
92
|
+
add(tagged, r);
|
|
93
|
+
}
|
|
94
|
+
const lines = new Map();
|
|
95
|
+
if (!cited.length && !tagged.length)
|
|
96
|
+
return { cited, lines };
|
|
97
|
+
const want = new Set([...cited, ...tagged]);
|
|
98
|
+
const back = new Map();
|
|
99
|
+
for (const x of pool) {
|
|
100
|
+
if (x.kind !== "code" && x.kind !== "test")
|
|
101
|
+
continue;
|
|
102
|
+
for (const r of x.refs) {
|
|
103
|
+
if (!want.has(r))
|
|
104
|
+
continue;
|
|
105
|
+
let l = back.get(r);
|
|
106
|
+
if (!l)
|
|
107
|
+
back.set(r, (l = []));
|
|
108
|
+
l.push(x);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const r of [...cited, ...tagged]) {
|
|
112
|
+
for (const x of (back.get(r) ?? []).sort((a, b) => cmpStr(a.id, b.id))) {
|
|
113
|
+
if (!lines.has(x.id))
|
|
114
|
+
lines.set(x.id, { reason: `${x.kind === "code" ? "implements" : "covers"} ${r}`, depth: 1 });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { cited, lines };
|
|
118
|
+
}
|
|
119
|
+
export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
|
|
120
|
+
const all = !!opts.all;
|
|
121
|
+
// 047/D5 the entry that the caller found stands for its ID. It goes last, so canon() holds it.
|
|
122
|
+
const entries = opts.task && canon(entries0).get(opts.task.id) !== opts.task ? [...entries0.filter((e) => e !== opts.task), opts.task] : entries0;
|
|
53
123
|
const ids = canon(entries);
|
|
124
|
+
// block, reason, class. The array is in print order.
|
|
54
125
|
const blocks = [];
|
|
55
126
|
for (const i of wanted) {
|
|
127
|
+
// REQ-QRY-005 a path has no block of its own. Its links come from related below.
|
|
128
|
+
if (!ids.has(i))
|
|
129
|
+
continue;
|
|
56
130
|
const copies = [ids.get(i)];
|
|
57
131
|
if (all)
|
|
58
132
|
copies.push(...entries.filter((e) => e.id === i && !e.canonical));
|
|
59
133
|
for (const e of copies)
|
|
60
|
-
blocks.push([e, "requested"]);
|
|
134
|
+
blocks.push([e, "requested", 0]);
|
|
135
|
+
// REQ-QRY-010 the main version of a modified requirement, also for a requested ID
|
|
136
|
+
const m = all ? undefined : mainVersion(entries, i);
|
|
137
|
+
if (m)
|
|
138
|
+
blocks.push([m, `main version of ${i}`, 0]);
|
|
61
139
|
}
|
|
140
|
+
// REQ-QRY-018 the count of blocks that the loop above pushed. They print first and always stay.
|
|
141
|
+
const pinned = blocks.length;
|
|
62
142
|
const found = new Map();
|
|
63
143
|
for (const i of wanted) {
|
|
64
|
-
// decisions that reference a requested ID are included at every depth
|
|
144
|
+
// decisions that reference a requested ID are included at every depth, as depth 1.
|
|
145
|
+
// REQ-QRY-016 They replace a deeper hit, which the walk for an earlier requested ID can leave (048/D1).
|
|
65
146
|
for (const e of entries) {
|
|
66
|
-
|
|
67
|
-
|
|
147
|
+
// REQ-QRY-014 a requested decision prints once, as requested (050/D10)
|
|
148
|
+
if (e.kind !== "decision" || !e.refs.includes(i) || wanted.includes(e.id))
|
|
149
|
+
continue;
|
|
150
|
+
const cur = found.get(e.id);
|
|
151
|
+
if (!cur || cur.depth > 1)
|
|
152
|
+
found.set(e.id, { reason: `decision for ${i}`, depth: 1 });
|
|
68
153
|
}
|
|
69
|
-
for (const [r,
|
|
154
|
+
for (const [r, hit] of walkFound(entries, i, depth, all)) {
|
|
70
155
|
if (!wanted.includes(r) && !found.has(r))
|
|
71
|
-
found.set(r,
|
|
156
|
+
found.set(r, hit);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// REQ-QRY-016 REQ-QRY-017 the cited requirements, their decisions, and the lines of the requirements of a task or a file
|
|
160
|
+
const r = depth >= 1 ? reach(entries, ids, wanted, all) : { cited: [], lines: new Map() };
|
|
161
|
+
const citedSet = new Set(r.cited);
|
|
162
|
+
for (const t of wanted) {
|
|
163
|
+
const te = ids.get(t);
|
|
164
|
+
if (te?.kind !== "task")
|
|
165
|
+
continue;
|
|
166
|
+
for (const c of te.refs)
|
|
167
|
+
if (citedSet.has(c) && !wanted.includes(c) && !found.has(c))
|
|
168
|
+
found.set(c, { reason: `ref from ${t}`, depth: 1 });
|
|
169
|
+
}
|
|
170
|
+
for (const c of r.cited) {
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
if (e.kind !== "decision" || !e.refs.includes(c) || wanted.includes(e.id))
|
|
173
|
+
continue;
|
|
174
|
+
const cur = found.get(e.id);
|
|
175
|
+
if (!cur || cur.depth > 1)
|
|
176
|
+
found.set(e.id, { reason: `decision for ${c}`, depth: 1 });
|
|
72
177
|
}
|
|
73
178
|
}
|
|
179
|
+
for (const [id, hit] of r.lines) {
|
|
180
|
+
const cur = found.get(id);
|
|
181
|
+
if (!wanted.includes(id) && (!cur || cur.depth > 1))
|
|
182
|
+
found.set(id, hit);
|
|
183
|
+
}
|
|
74
184
|
// scenarios print inside their requirement
|
|
75
|
-
const tail = [...found.keys()].filter((
|
|
185
|
+
const tail = [...found.keys()].filter((x) => ids.has(x) && ids.get(x).kind !== "scn");
|
|
76
186
|
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
|
-
|
|
187
|
+
for (const r2 of tail) {
|
|
188
|
+
const hit = found.get(r2);
|
|
189
|
+
const e = ids.get(r2);
|
|
190
|
+
const cls = citedSet.has(r2) ? 0 : budgetClass(e, false, hit);
|
|
191
|
+
blocks.push([e, hit.reason, cls]);
|
|
192
|
+
const m = all ? undefined : mainVersion(entries, r2);
|
|
193
|
+
if (m)
|
|
194
|
+
blocks.push([m, `main version of ${r2}`, cls]);
|
|
195
|
+
}
|
|
79
196
|
const files = new Lines(root);
|
|
80
|
-
const
|
|
81
|
-
const omitted = [];
|
|
82
|
-
let used = 0;
|
|
83
|
-
for (const [e, reason] of blocks) {
|
|
197
|
+
const texts = blocks.map(([e, reason]) => {
|
|
84
198
|
const body = blockText(e, files);
|
|
85
199
|
// a blank line closes a block with a body. Code and test paths stay one line each.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
200
|
+
return provenance(e, reason) + body + (body ? "\n" : "");
|
|
201
|
+
});
|
|
202
|
+
// REQ-QRY-018 the blocks of the first loop always stay: each requested block and the main version of a requested ID (050/D2, 050/D3)
|
|
203
|
+
const keep = blocks.map((_, k) => k < pinned);
|
|
204
|
+
let used = 0;
|
|
205
|
+
for (let k = 0; k < pinned; k++)
|
|
206
|
+
used += cpLen(texts[k]);
|
|
207
|
+
// fill the budget class by class, in print order inside a class, and skip a block that does not fit
|
|
208
|
+
for (let cls = 0; cls <= 3; cls++) {
|
|
209
|
+
for (let k = pinned; k < blocks.length; k++) {
|
|
210
|
+
if (blocks[k][2] !== cls)
|
|
211
|
+
continue;
|
|
212
|
+
const n = cpLen(texts[k]);
|
|
213
|
+
if (used + n > budget)
|
|
214
|
+
continue;
|
|
215
|
+
keep[k] = true;
|
|
216
|
+
used += n;
|
|
91
217
|
}
|
|
92
|
-
out.push(t);
|
|
93
|
-
used += n;
|
|
94
218
|
}
|
|
95
|
-
|
|
219
|
+
const omitted = blocks.filter((_, k) => !keep[k]).map(([e]) => e.id);
|
|
220
|
+
let text = texts.filter((_, k) => keep[k]).join("");
|
|
96
221
|
if (omitted.length)
|
|
97
222
|
text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
|
|
98
|
-
|
|
223
|
+
// REQ-QRY-018 the over budget line goes last, so the 0.4.1 output stays a prefix (050/D4)
|
|
224
|
+
if (used > budget)
|
|
225
|
+
text += `over budget: ${used} of ${budget} characters\n`;
|
|
226
|
+
return { text, omitted, chars: cpLen(text), budget, complete: omitted.length === 0 };
|
|
99
227
|
}
|
|
@@ -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
|
package/dist/graph/related.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// REQ-QRY-002 IDs reachable from a start ID, with the reason each one was included.
|
|
2
2
|
import { canon, poolOf } from "../index/canonical.js";
|
|
3
3
|
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
4
|
+
import { fileEdges } from "./files.js";
|
|
5
|
+
import { structuralEdges, structure } from "./links.js";
|
|
4
6
|
// REQ-QRY-003 fixed block order after the requested IDs
|
|
5
|
-
|
|
6
|
-
export const
|
|
7
|
-
export
|
|
7
|
+
// REQ-QRY-012 capability and change lines print after the test lines (046/D7)
|
|
8
|
+
export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5, cap: 6, change: 7 };
|
|
9
|
+
export const REASONS = [
|
|
10
|
+
"scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers", "mentions",
|
|
11
|
+
"delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of",
|
|
12
|
+
];
|
|
13
|
+
/** The walk behind `related` and `bundle`. The map keeps the insertion order and the depth of each entry (046/D7). */
|
|
14
|
+
export function walkFound(entries, start, depth, all = false) {
|
|
8
15
|
const pool = poolOf(entries, all);
|
|
9
16
|
const ids = canon(entries);
|
|
10
17
|
for (const e of pool)
|
|
@@ -19,32 +26,48 @@ export function related(entries, start, depth, all = false) {
|
|
|
19
26
|
s.add(e.id);
|
|
20
27
|
}
|
|
21
28
|
}
|
|
29
|
+
const S = structure(entries);
|
|
22
30
|
const found = new Map();
|
|
23
|
-
const add = (i, reason) => {
|
|
31
|
+
const add = (i, reason, d) => {
|
|
32
|
+
if (seeds && ids.get(i) && (ids.get(i).kind === "code" || ids.get(i).kind === "test"))
|
|
33
|
+
return false;
|
|
24
34
|
if (i !== start && !found.has(i)) {
|
|
25
|
-
found.set(i, reason);
|
|
35
|
+
found.set(i, { reason, depth: d });
|
|
26
36
|
return true;
|
|
27
37
|
}
|
|
28
38
|
return false;
|
|
29
39
|
};
|
|
40
|
+
const isId = ids.has(start) || entries.some((e) => e.id === start);
|
|
41
|
+
const seeds = isId ? null : fileEdges(entries, start);
|
|
42
|
+
const frontier0 = [start];
|
|
43
|
+
if (seeds) {
|
|
44
|
+
frontier0.length = 0;
|
|
45
|
+
for (const [i, reason] of seeds)
|
|
46
|
+
if (add(i, reason, 1))
|
|
47
|
+
frontier0.push(i);
|
|
48
|
+
}
|
|
30
49
|
const s = ids.get(start);
|
|
31
50
|
if (s && s.kind === "req")
|
|
32
51
|
for (const sid of s.scenarios ?? [])
|
|
33
|
-
add(sid, `scenario of ${start}
|
|
34
|
-
let frontier =
|
|
52
|
+
add(sid, `scenario of ${start}`, 1);
|
|
53
|
+
let frontier = frontier0;
|
|
35
54
|
for (let d = 0; d < depth; d++) {
|
|
36
55
|
const nxt = [];
|
|
56
|
+
const dd = d + (seeds ? 2 : 1);
|
|
37
57
|
for (const x of frontier) {
|
|
38
58
|
const ex = ids.get(x);
|
|
39
59
|
if (ex) {
|
|
40
60
|
for (const r of ex.refs)
|
|
41
|
-
if (add(r, `ref from ${x}
|
|
61
|
+
if (add(r, `ref from ${x}`, dd))
|
|
42
62
|
nxt.push(r);
|
|
43
63
|
if (ex.kind === "req") {
|
|
44
64
|
for (const sid of ex.scenarios ?? [])
|
|
45
|
-
if (add(sid, `scenario of ${x}
|
|
65
|
+
if (add(sid, `scenario of ${x}`, dd))
|
|
46
66
|
nxt.push(sid);
|
|
47
67
|
}
|
|
68
|
+
for (const [t, reason] of structuralEdges(S, ex, x, x === start, (id) => ids.has(id)))
|
|
69
|
+
if (add(t, reason, dd))
|
|
70
|
+
nxt.push(t);
|
|
48
71
|
}
|
|
49
72
|
for (const y of [...(back.get(x) ?? [])].sort(cmpStr)) {
|
|
50
73
|
const ey = ids.get(y);
|
|
@@ -62,15 +85,39 @@ export function related(entries, start, depth, all = false) {
|
|
|
62
85
|
reason = `covers ${x}`;
|
|
63
86
|
else
|
|
64
87
|
reason = `ref to ${x}`;
|
|
65
|
-
if (add(y, reason))
|
|
88
|
+
if (add(y, reason, dd))
|
|
66
89
|
nxt.push(y);
|
|
67
90
|
}
|
|
68
91
|
}
|
|
69
92
|
frontier = nxt;
|
|
70
93
|
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
/** The main version of a modified requirement, or undefined. */
|
|
97
|
+
export function mainVersion(entries, id) {
|
|
98
|
+
return structure(entries).main.get(id);
|
|
99
|
+
}
|
|
100
|
+
/** REQ-QRY-005 a start that is no ID is a repository path. Its links seed the walk, and the walk adds no code or test row. */
|
|
101
|
+
export function related(entries, start, depth, all = false) {
|
|
102
|
+
const ids = canon(entries);
|
|
103
|
+
for (const e of poolOf(entries, all))
|
|
104
|
+
if (!ids.has(e.id))
|
|
105
|
+
ids.set(e.id, e);
|
|
106
|
+
const found = walkFound(entries, start, depth, all);
|
|
71
107
|
const rank = (i) => {
|
|
72
108
|
const e = ids.get(i);
|
|
73
109
|
return [e ? (KIND_RANK[e.kind] ?? 9) : 9, i];
|
|
74
110
|
};
|
|
75
|
-
|
|
111
|
+
const out = [];
|
|
112
|
+
// REQ-QRY-010 the main version of a modified requirement follows the pair of its delta. --all lists both copies already.
|
|
113
|
+
const withMain = (i, reason) => {
|
|
114
|
+
out.push([i, reason]);
|
|
115
|
+
if (!all && mainVersion(entries, i))
|
|
116
|
+
out.push([i, `main version of ${i}`]);
|
|
117
|
+
};
|
|
118
|
+
if (!all && mainVersion(entries, start))
|
|
119
|
+
out.push([start, `main version of ${start}`]);
|
|
120
|
+
for (const i of [...found.keys()].sort((a, b) => cmpTuple(rank(a), rank(b))))
|
|
121
|
+
withMain(i, found.get(i).reason);
|
|
122
|
+
return out;
|
|
76
123
|
}
|
package/dist/handoff/handoff.js
CHANGED
|
@@ -164,16 +164,18 @@ export function ask(root, entries, text, change, plan = false) {
|
|
|
164
164
|
h.asked = q.n;
|
|
165
165
|
return h;
|
|
166
166
|
}
|
|
167
|
-
/** REQ-LOOP-007 archive when the change has a task and every task is [x]. Else
|
|
167
|
+
/** REQ-LOOP-007 plan with the flag, whatever the task state. Else archive when the change has a task and every task is [x]. Else design. The text plays no part (041/D2). */
|
|
168
168
|
export function stageOf(entries, folder, plan = false) {
|
|
169
|
+
if (plan)
|
|
170
|
+
return "plan";
|
|
169
171
|
const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
|
|
170
172
|
if (tasks.length > 0 && tasks.every((t) => t.status === "done"))
|
|
171
173
|
return "archive";
|
|
172
|
-
return
|
|
174
|
+
return "design";
|
|
173
175
|
}
|
|
174
176
|
/** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
|
|
175
|
-
export function resolve(root, entries, n, answer) {
|
|
176
|
-
resolveQuestion(root, n, answer);
|
|
177
|
+
export function resolve(root, entries, n, answer, verdict) {
|
|
178
|
+
resolveQuestion(root, n, answer, verdict);
|
|
177
179
|
return refresh(root, entries);
|
|
178
180
|
}
|
|
179
181
|
export function addReopened(root, entries, task, reason) {
|
|
@@ -47,7 +47,7 @@ export function addQuestion(root, task, text, stage) {
|
|
|
47
47
|
return item;
|
|
48
48
|
}
|
|
49
49
|
/** REQ-HND-002 resolve the open question n with an answer. Exit 2 when n is not an open question. */
|
|
50
|
-
export function resolveQuestion(root, n, answer) {
|
|
50
|
+
export function resolveQuestion(root, n, answer, verdict) {
|
|
51
51
|
const qs = readQuestions(root);
|
|
52
52
|
const item = qs.find((x) => x.n === n && x.status === "open");
|
|
53
53
|
if (!Number.isInteger(n) || !item) {
|
|
@@ -57,6 +57,14 @@ export function resolveQuestion(root, n, answer) {
|
|
|
57
57
|
const a = oneLine(answer);
|
|
58
58
|
if (!a)
|
|
59
59
|
throw new UsageExit("handoff resolve needs an answer");
|
|
60
|
+
// REQ-HND-004 a plan or archive question needs one verdict. Other stages take none (048/D3).
|
|
61
|
+
const needs = item.stage === "plan" || item.stage === "archive";
|
|
62
|
+
if (needs && (verdict === undefined || verdict === "both"))
|
|
63
|
+
throw new UsageExit("handoff resolve needs --approve or --reject for a plan or archive question");
|
|
64
|
+
if (!needs && verdict !== undefined)
|
|
65
|
+
throw new UsageExit("--approve and --reject apply to plan and archive questions");
|
|
66
|
+
if (needs)
|
|
67
|
+
item.verdict = verdict;
|
|
60
68
|
item.status = "resolved";
|
|
61
69
|
item.answer = a;
|
|
62
70
|
item.resolved_at = isoNow();
|