spec-ctx 0.3.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 +119 -16
- package/action.yml +8 -0
- package/dist/adopt/write.js +9 -4
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +23 -0
- package/dist/check/hints.js +16 -11
- package/dist/cli/args.js +5 -4
- package/dist/cli/commands.js +89 -29
- package/dist/cli/config.js +14 -0
- package/dist/cli/gate.js +133 -4
- package/dist/cli/help.js +25 -7
- package/dist/cli/workflow.js +28 -11
- package/dist/config.js +9 -1
- package/dist/gate/rerun.js +169 -0
- package/dist/graph/bundle.js +145 -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 +11 -7
- package/dist/handoff/questions.js +9 -1
- package/dist/ids/ids.js +4 -0
- package/dist/index/build.js +33 -4
- package/dist/index/clock.js +55 -0
- package/dist/index/scan.js +42 -11
- package/dist/index/structure.js +97 -0
- package/dist/init/init.js +28 -12
- package/dist/loop/fails.js +15 -8
- package/dist/loop/next.js +89 -37
- package/dist/runcmd.js +5 -0
- package/dist/util/time.js +1 -0
- package/dist/verify/run.js +3 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +9 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// REQ-VER-013 REQ-VER-005 REQ-VER-006 REQ-VER-007 the rerun step: the tasks a range closed, the compare, the groups (036/D1).
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { changeKey, changeOf } from "../index/scan.js";
|
|
4
|
+
import { cmpStr } from "../util/pystr.js";
|
|
5
|
+
function git(root, args) {
|
|
6
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
7
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim() };
|
|
8
|
+
}
|
|
9
|
+
/** REQ-VER-005 the one line of a failed merge base. A shallow clone gets a second sentence. */
|
|
10
|
+
export function noMergeBaseLine(root, base) {
|
|
11
|
+
const shallow = git(root, ["rev-parse", "--is-shallow-repository"]);
|
|
12
|
+
const tail = shallow.ok && shallow.out === "true" ? ". This clone is shallow" : "";
|
|
13
|
+
return `rerun: no merge base with ${base}, fetch full history (fetch-depth: 0)${tail}`;
|
|
14
|
+
}
|
|
15
|
+
/** REQ-VER-005 the task ID order: the change key, then the group and the number as integers. */
|
|
16
|
+
export function cmpTaskId(a, b) {
|
|
17
|
+
const [ka, na = ""] = a.split("/");
|
|
18
|
+
const [kb, nb = ""] = b.split("/");
|
|
19
|
+
const k = cmpStr(ka, kb);
|
|
20
|
+
if (k !== 0)
|
|
21
|
+
return k;
|
|
22
|
+
const pa = na.split(".").map(Number);
|
|
23
|
+
const pb = nb.split(".").map(Number);
|
|
24
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
25
|
+
const d = (pa[i] ?? -1) - (pb[i] ?? -1);
|
|
26
|
+
if (d !== 0)
|
|
27
|
+
return d < 0 ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
/** REQ-VER-005 the task lines of one tasks.md text, with the key that the index would give them. */
|
|
32
|
+
export function parseTasksFile(path, text, taskRe) {
|
|
33
|
+
const key = changeKey(changeOf(path));
|
|
34
|
+
const archived = path.includes("/archive/");
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const line of text.split(/\r?\n/)) {
|
|
37
|
+
const m = taskRe.exec(line);
|
|
38
|
+
if (m)
|
|
39
|
+
out.push({ id: `${key}/${m[2]}`, done: m[1].toLowerCase() === "x", archived });
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** REQ-VER-005 one state per ID. An active copy wins over an archived copy, as findTask does. */
|
|
44
|
+
export function mergeStates(states) {
|
|
45
|
+
const out = new Map();
|
|
46
|
+
for (const s of states) {
|
|
47
|
+
const had = out.get(s.id);
|
|
48
|
+
if (!had || (had.archived && !s.archived))
|
|
49
|
+
out.set(s.id, s);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** REQ-VER-005 the state of every task in the tasks.md files at a commit. */
|
|
54
|
+
export function tasksAt(root, sha, taskRe) {
|
|
55
|
+
const ls = git(root, ["ls-tree", "-z", "-r", "--name-only", sha, "--", "openspec/changes"]);
|
|
56
|
+
const all = [];
|
|
57
|
+
if (!ls.ok)
|
|
58
|
+
return new Map();
|
|
59
|
+
for (const p of ls.out.split("\0").filter((x) => x.endsWith("/tasks.md")).sort(cmpStr)) {
|
|
60
|
+
const show = spawnSync("git", ["show", `${sha}:${p}`], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
61
|
+
if (show.status === 0)
|
|
62
|
+
all.push(...parseTasksFile(p, show.stdout, taskRe));
|
|
63
|
+
}
|
|
64
|
+
return mergeStates(all);
|
|
65
|
+
}
|
|
66
|
+
/** REQ-VER-005 the index entries of tasks, one per ID. The active copy wins. */
|
|
67
|
+
export function headStates(entries) {
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (e.kind !== "task")
|
|
71
|
+
continue;
|
|
72
|
+
const archived = e.file.includes("/archive/");
|
|
73
|
+
const had = out.get(e.id);
|
|
74
|
+
if (!had || (had.state.archived && !archived))
|
|
75
|
+
out.set(e.id, { state: { id: e.id, done: e.status === "done", archived }, entry: e });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** REQ-VER-010 the commit that HEAD names, or "" when git fails. */
|
|
80
|
+
export function headSha(root) {
|
|
81
|
+
const h = git(root, ["rev-parse", "HEAD"]);
|
|
82
|
+
return h.ok ? h.out : "";
|
|
83
|
+
}
|
|
84
|
+
/** REQ-VER-010 the line for a merge base equal to HEAD. */
|
|
85
|
+
export const HEAD_BASE_LINE = "rerun: merge base is HEAD, nothing to compare. On push, pass --base <the commit before the push>";
|
|
86
|
+
/** REQ-VER-013 the line for a base of forty zeros, the `before` of a push that creates a branch. */
|
|
87
|
+
export const ZERO_BASE_LINE = "rerun: base is the zero commit (a new branch), nothing to compare";
|
|
88
|
+
/** REQ-VER-013 a base that ends the rerun before the merge base: the zero commit passes, an unknown commit fails. */
|
|
89
|
+
export function baseProblem(root, base) {
|
|
90
|
+
if (/^0{40}$/.test(base))
|
|
91
|
+
return { ok: true, line: ZERO_BASE_LINE };
|
|
92
|
+
const known = !base.startsWith("-") && git(root, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).ok;
|
|
93
|
+
if (known)
|
|
94
|
+
return null;
|
|
95
|
+
return { ok: false, line: `rerun: base ${base} is not in this clone. Fetch it or use fetch-depth: 0` };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* REQ-VER-005 the tasks that are done at HEAD and open or absent at the merge base, in ID order.
|
|
99
|
+
* `entries` is the live index. `taskRe` is the task regex of the scanner.
|
|
100
|
+
*/
|
|
101
|
+
export function closedTasks(root, base, entries, taskRe) {
|
|
102
|
+
const mb = git(root, ["merge-base", "HEAD", base]);
|
|
103
|
+
if (!mb.ok || !mb.out)
|
|
104
|
+
return { ok: false, line: noMergeBaseLine(root, base) };
|
|
105
|
+
const before = tasksAt(root, mb.out, taskRe);
|
|
106
|
+
const tasks = [];
|
|
107
|
+
for (const [id, { state, entry }] of headStates(entries)) {
|
|
108
|
+
if (state.done && !before.get(id)?.done)
|
|
109
|
+
tasks.push(entry);
|
|
110
|
+
}
|
|
111
|
+
tasks.sort((a, b) => cmpTaskId(a.id, b.id));
|
|
112
|
+
return { ok: true, mergeBase: mb.out, tasks };
|
|
113
|
+
}
|
|
114
|
+
/** REQ-VER-006 compare one closed task with its evidence, in the fixed order of 036/D4. */
|
|
115
|
+
export function compare(verify, evidence, strict, weak) {
|
|
116
|
+
if (evidence !== null && evidence.kind === "manual")
|
|
117
|
+
return strict ? { act: "fail", kind: "manual" } : { act: "warn", why: "manual" };
|
|
118
|
+
if (evidence === null)
|
|
119
|
+
return { act: "fail", kind: "no-evidence" };
|
|
120
|
+
const cwd = evidence.cwd ?? ".";
|
|
121
|
+
if (verify.command !== evidence.command || (verify.cwd ?? ".") !== cwd) {
|
|
122
|
+
return { act: "fail", kind: "verify-drift", detail: `tasks.md has ${verify.command ?? "no command"}${verify.cwd ? ` (cwd ${verify.cwd})` : ""}, evidence has ${evidence.command ?? "no command"}${evidence.cwd ? ` (cwd ${evidence.cwd})` : ""}` };
|
|
123
|
+
}
|
|
124
|
+
if (evidence.command === null)
|
|
125
|
+
return { act: "fail", kind: "no-evidence" };
|
|
126
|
+
if (strict && weak(evidence.command))
|
|
127
|
+
return { act: "fail", kind: "weak-verify" };
|
|
128
|
+
return { act: "run", command: evidence.command, cwd, expected: evidence.exit };
|
|
129
|
+
}
|
|
130
|
+
/** REQ-VER-007 the key that makes two runs the same run. */
|
|
131
|
+
export const runKey = (v) => `${v.cwd}\u0000${v.command}`;
|
|
132
|
+
/** REQ-VER-007 one group per frozen command and cwd, in the ID order of the first task. */
|
|
133
|
+
export function groupRuns(items) {
|
|
134
|
+
const sorted = [...items].sort((a, b) => cmpTaskId(a.id, b.id));
|
|
135
|
+
const groups = new Map();
|
|
136
|
+
for (const it of sorted) {
|
|
137
|
+
const key = runKey(it);
|
|
138
|
+
const g = groups.get(key);
|
|
139
|
+
if (g)
|
|
140
|
+
g.members.push(it);
|
|
141
|
+
else
|
|
142
|
+
groups.set(key, { key, command: it.command, cwd: it.cwd, members: [it] });
|
|
143
|
+
}
|
|
144
|
+
return [...groups.values()];
|
|
145
|
+
}
|
|
146
|
+
/** REQ-VER-006 ok when the exit code equals the evidence. A timeout or a different code is a mismatch. */
|
|
147
|
+
export function judge(expected, o) {
|
|
148
|
+
return !o.timedOut && o.exit !== null && o.exit === expected ? "ok" : "mismatch";
|
|
149
|
+
}
|
|
150
|
+
const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
151
|
+
/** REQ-VER-007 the line that runs the frozen command on a developer machine as the rerun runs it. */
|
|
152
|
+
export function reproduceLine(command, cwd) {
|
|
153
|
+
return `reproduce: cd ${shq(cwd)} && env CI=1 SPECCTX_RERUN=1 sh -c ${shq(command)}`;
|
|
154
|
+
}
|
|
155
|
+
/** REQ-VER-007 the report of a mismatch. The output tail is the last 20 lines. */
|
|
156
|
+
export function formatMismatch(m) {
|
|
157
|
+
const tailLines = m.outcome.output.replace(/\n$/, "") === "" ? [] : m.outcome.output.replace(/\n$/, "").split("\n").slice(-20);
|
|
158
|
+
const actual = m.outcome.timedOut ? `timed out after ${m.timeoutS}s` : `exit ${m.outcome.exit}`;
|
|
159
|
+
return [
|
|
160
|
+
`${m.id}: mismatch`,
|
|
161
|
+
` at ${m.file}:${m.line}`,
|
|
162
|
+
` evidence ${m.evidence}`,
|
|
163
|
+
` expected exit ${m.expected}`,
|
|
164
|
+
` actual ${actual}`,
|
|
165
|
+
" output (last 20 lines):",
|
|
166
|
+
...tailLines.map((l) => ` ${l}`),
|
|
167
|
+
` ${reproduceLine(m.command, m.cwd)}`,
|
|
168
|
+
].join("\n") + "\n";
|
|
169
|
+
}
|
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,50 +45,168 @@ 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
|
}
|
|
47
|
-
|
|
48
|
+
/** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
|
|
49
|
+
export function reqBlocks(root, reqs) {
|
|
50
|
+
const files = new Lines(root);
|
|
51
|
+
return reqs.map((e) => blockText(e, files)).join("\n");
|
|
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;
|
|
48
118
|
const ids = canon(entries);
|
|
119
|
+
// block, reason, class. The array is in print order.
|
|
49
120
|
const blocks = [];
|
|
50
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;
|
|
51
125
|
const copies = [ids.get(i)];
|
|
52
126
|
if (all)
|
|
53
127
|
copies.push(...entries.filter((e) => e.id === i && !e.canonical));
|
|
54
128
|
for (const e of copies)
|
|
55
|
-
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]);
|
|
56
134
|
}
|
|
57
135
|
const found = new Map();
|
|
58
136
|
for (const i of wanted) {
|
|
59
|
-
// 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).
|
|
60
139
|
for (const e of entries) {
|
|
61
|
-
if (e.kind
|
|
62
|
-
|
|
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 });
|
|
63
145
|
}
|
|
64
|
-
for (const [r,
|
|
146
|
+
for (const [r, hit] of walkFound(entries, i, depth, all)) {
|
|
65
147
|
if (!wanted.includes(r) && !found.has(r))
|
|
66
|
-
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 });
|
|
67
169
|
}
|
|
68
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
|
+
}
|
|
69
176
|
// scenarios print inside their requirement
|
|
70
|
-
const tail = [...found.keys()].filter((
|
|
177
|
+
const tail = [...found.keys()].filter((x) => ids.has(x) && ids.get(x).kind !== "scn");
|
|
71
178
|
tail.sort((a, b) => cmpTuple([KIND_RANK[ids.get(a).kind] ?? 9, a], [KIND_RANK[ids.get(b).kind] ?? 9, b]));
|
|
72
|
-
for (const
|
|
73
|
-
|
|
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
|
+
}
|
|
74
188
|
const files = new Lines(root);
|
|
75
|
-
const
|
|
76
|
-
const omitted = [];
|
|
77
|
-
let used = 0;
|
|
78
|
-
for (const [e, reason] of blocks) {
|
|
189
|
+
const texts = blocks.map(([e, reason]) => {
|
|
79
190
|
const body = blockText(e, files);
|
|
80
191
|
// a blank line closes a block with a body. Code and test paths stay one line each.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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;
|
|
86
206
|
}
|
|
87
|
-
out.push(t);
|
|
88
|
-
used += n;
|
|
89
207
|
}
|
|
90
|
-
|
|
208
|
+
const omitted = blocks.filter((_, k) => !keep[k]).map(([e]) => e.id);
|
|
209
|
+
let text = texts.filter((_, k) => keep[k]).join("");
|
|
91
210
|
if (omitted.length)
|
|
92
211
|
text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
|
|
93
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
|