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
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
|
@@ -149,7 +149,7 @@ function refresh(root, entries) {
|
|
|
149
149
|
* REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6).
|
|
150
150
|
* REQ-LOOP-005 with a change, the question carries the active change folder, so next blocks the change (029/D1).
|
|
151
151
|
*/
|
|
152
|
-
export function ask(root, entries, text, change) {
|
|
152
|
+
export function ask(root, entries, text, change, plan = false) {
|
|
153
153
|
let task = currentTask(root);
|
|
154
154
|
let stage;
|
|
155
155
|
if (change !== undefined) {
|
|
@@ -157,21 +157,25 @@ export function ask(root, entries, text, change) {
|
|
|
157
157
|
if (folder === undefined)
|
|
158
158
|
throw new UsageExit(`no active change ${change}`);
|
|
159
159
|
task = folder;
|
|
160
|
-
stage = stageOf(entries, folder);
|
|
160
|
+
stage = stageOf(entries, folder, plan);
|
|
161
161
|
}
|
|
162
162
|
const q = addQuestion(root, task, text, stage);
|
|
163
163
|
const h = refresh(root, entries);
|
|
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]
|
|
168
|
-
export function stageOf(entries, folder) {
|
|
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
|
+
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"))
|
|
173
|
+
return "archive";
|
|
174
|
+
return "design";
|
|
171
175
|
}
|
|
172
176
|
/** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
|
|
173
|
-
export function resolve(root, entries, n, answer) {
|
|
174
|
-
resolveQuestion(root, n, answer);
|
|
177
|
+
export function resolve(root, entries, n, answer, verdict) {
|
|
178
|
+
resolveQuestion(root, n, answer, verdict);
|
|
175
179
|
return refresh(root, entries);
|
|
176
180
|
}
|
|
177
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();
|
package/dist/ids/ids.js
CHANGED
|
@@ -39,6 +39,10 @@ export function newIds(root, cfg, entries, o) {
|
|
|
39
39
|
const checkCap = (cap) => {
|
|
40
40
|
if (!cap)
|
|
41
41
|
throw new UsageExit(`id new ${kind} needs a capability code`);
|
|
42
|
+
// REQ-IDS-005 a code with a number is a scenario number typed in the code slot (038/D4)
|
|
43
|
+
const num = /^([A-Z]{2,6})-\d+$/.exec(cap);
|
|
44
|
+
if (num)
|
|
45
|
+
throw new UsageExit(`the capability code ${cap} holds a number. ${kind === "SCN" ? `Use id new SCN ${num[1]} --for REQ-${num[1]}-<NNN>.` : `Use id new ${kind} ${num[1]}.`}`);
|
|
42
46
|
if (!/^[A-Z]{2,6}$/.test(cap))
|
|
43
47
|
throw new UsageExit(`the capability code ${cap} must be 2 to 6 upper case letters`);
|
|
44
48
|
// REQ-IDS-001 an empty caps list accepts any code. A non-empty list must hold the code.
|
package/dist/index/build.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
|
-
// REQ-IDX-001 REQ-IDX-002 REQ-IDX-004 build, write, and load .ctx/index.json.
|
|
1
|
+
// REQ-IDX-001 REQ-IDX-002 REQ-IDX-004 REQ-IDX-014 build, write, and load .ctx/index.json.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { fsNowMs, mtimeMsOf, pause, setMtimeMs } from "./clock.js";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { dumps } from "../util/json.js";
|
|
5
6
|
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
6
7
|
import { markCanonical } from "./canonical.js";
|
|
8
|
+
import { addStructure } from "./structure.js";
|
|
7
9
|
import { scanMarkdown, scannedOf, scanner, scanSource, walk } from "./scan.js";
|
|
8
10
|
export const INDEX_PATH = ".ctx/index.json";
|
|
9
|
-
|
|
11
|
+
/** REQ-IDX-014 the most that a file mtime may pass the clock reading of a build and still count as a write in its tick. */
|
|
12
|
+
const TICK_MS = 50;
|
|
13
|
+
/** REQ-IDX-014 the most waits of 2 ms for the file clock to pass the newest mtime: 100 ms in all. */
|
|
14
|
+
const MAX_PAUSES = 50;
|
|
15
|
+
/**
|
|
16
|
+
* REQ-IDX-004 build and write the index. `t0` is the clock reading taken before the walk of `files`. The index file gets
|
|
17
|
+
* `t0` as its mtime, so a write in the same tick as the build, or during it, leaves a file at or after that mtime (049/D2).
|
|
18
|
+
* A newest file 0 to 50 ms after `t0` makes the build wait until the file clock passes it, then take `t0` again (049/D4).
|
|
19
|
+
* A file far after `t0`, such as one dated in the future, gets no wait. Only file clock readings enter this logic.
|
|
20
|
+
*/
|
|
21
|
+
export function buildIndex(root, cfg, files, sc, t0) {
|
|
10
22
|
const s = sc ?? scanner(root, cfg);
|
|
23
|
+
let at = t0 ?? fsNowMs(root);
|
|
11
24
|
const list = files ?? walk(s);
|
|
25
|
+
const newest = scannedOf(root, list).newest_mtime_ms;
|
|
26
|
+
if (newest >= at && newest - at <= TICK_MS) {
|
|
27
|
+
for (let i = 0; i < MAX_PAUSES; i++) {
|
|
28
|
+
at = fsNowMs(root);
|
|
29
|
+
if (at > newest)
|
|
30
|
+
break;
|
|
31
|
+
pause(2);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return buildOnce(root, cfg, list, s, at);
|
|
35
|
+
}
|
|
36
|
+
function buildOnce(root, cfg, list, s, t0) {
|
|
12
37
|
const entries = [];
|
|
13
38
|
const problems = [];
|
|
14
39
|
const scnNamed = new Set();
|
|
@@ -18,6 +43,7 @@ export function buildIndex(root, cfg, files, sc) {
|
|
|
18
43
|
else
|
|
19
44
|
scanSource(s, f.path, entries, scnNamed);
|
|
20
45
|
}
|
|
46
|
+
addStructure(entries, list);
|
|
21
47
|
markCanonical(entries);
|
|
22
48
|
entries.sort((a, b) => cmpTuple([a.id, a.file, a.line], [b.id, b.file, b.line]));
|
|
23
49
|
const uniq = new Map();
|
|
@@ -32,10 +58,12 @@ export function buildIndex(root, cfg, files, sc) {
|
|
|
32
58
|
};
|
|
33
59
|
mkdirSync(join(root, ".ctx"), { recursive: true });
|
|
34
60
|
writeFileSync(join(root, INDEX_PATH), dumps(data) + "\n");
|
|
61
|
+
setMtimeMs(join(root, INDEX_PATH), t0);
|
|
35
62
|
return data;
|
|
36
63
|
}
|
|
37
64
|
/** REQ-IDX-004 compare the stored scan record with a fresh stat walk. Rebuild on a mismatch. */
|
|
38
65
|
export function loadIndex(root, cfg) {
|
|
66
|
+
const t0 = fsNowMs(root);
|
|
39
67
|
const sc = scanner(root, cfg);
|
|
40
68
|
const files = walk(sc);
|
|
41
69
|
const p = join(root, INDEX_PATH);
|
|
@@ -50,8 +78,9 @@ export function loadIndex(root, cfg) {
|
|
|
50
78
|
}
|
|
51
79
|
const fresh = scannedOf(root, files);
|
|
52
80
|
if (data === null || !data.scanned || data.scanned.count !== fresh.count
|
|
53
|
-
|| data.scanned.newest_mtime_ms !== fresh.newest_mtime_ms || Object.keys(data.scanned).length !== 2
|
|
54
|
-
|
|
81
|
+
|| data.scanned.newest_mtime_ms !== fresh.newest_mtime_ms || Object.keys(data.scanned).length !== 2
|
|
82
|
+
|| fresh.newest_mtime_ms >= (mtimeMsOf(p) ?? 0)) {
|
|
83
|
+
return { data: buildIndex(root, cfg, files, sc, t0), rebuilt: true };
|
|
55
84
|
}
|
|
56
85
|
return { data, rebuilt: false };
|
|
57
86
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// REQ-IDX-014 the file system clock. A write gets its mtime from a coarse kernel clock, so two writes in one tick
|
|
2
|
+
// share an mtime. The index keeps the clock reading of the start of its build as its own mtime (049/D2).
|
|
3
|
+
import { closeSync, mkdirSync, openSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/** Whole milliseconds of an mtime in nanoseconds. */
|
|
6
|
+
export function msOf(ns) {
|
|
7
|
+
return Number(ns / 1000000n);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The time of the file system clock in whole milliseconds: create one file in `.ctx/`, stat it, delete it. When the
|
|
11
|
+
* clock cannot be read, the value is one second before `Date.now()`. An earlier value can only cause an extra rebuild.
|
|
12
|
+
*/
|
|
13
|
+
export function fsNowMs(root) {
|
|
14
|
+
const dir = join(root, ".ctx");
|
|
15
|
+
const f = join(dir, `.clock-${process.pid}`);
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
try {
|
|
19
|
+
unlinkSync(f);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// no old file
|
|
23
|
+
}
|
|
24
|
+
closeSync(openSync(f, "wx"));
|
|
25
|
+
const ms = msOf(statSync(f, { bigint: true }).mtimeNs);
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(f);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// the reading stays
|
|
31
|
+
}
|
|
32
|
+
return ms;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return Date.now() - 1000;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Set the mtime of a file to a whole number of milliseconds. Half a millisecond is added so that rounding keeps the value. */
|
|
39
|
+
export function setMtimeMs(path, ms) {
|
|
40
|
+
const t = (ms + 0.5) / 1000;
|
|
41
|
+
utimesSync(path, t, t);
|
|
42
|
+
}
|
|
43
|
+
/** The mtime of a file in whole milliseconds, or null when it is absent. */
|
|
44
|
+
export function mtimeMsOf(path) {
|
|
45
|
+
try {
|
|
46
|
+
return msOf(statSync(path, { bigint: true }).mtimeNs);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Sleep for a few milliseconds. The engine has no timer, so this blocks on a wait cell. */
|
|
53
|
+
export function pause(ms) {
|
|
54
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
55
|
+
}
|
package/dist/index/scan.js
CHANGED
|
@@ -15,11 +15,15 @@ const SCN_HDR = new RegExp(`^#### Scenario:${S}*(?:(SCN-[A-Z]{2,6}-\\d{3}-\\d+)|
|
|
|
15
15
|
const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
|
|
16
16
|
const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
|
|
17
17
|
const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
|
|
18
|
-
|
|
19
|
-
const
|
|
18
|
+
// REQ-IDX-008 the first marker and ID start the tag. Every REQ ID to the end of the line counts, with word boundaries (045/D5).
|
|
19
|
+
const CODE_TAG = new RegExp(`(?://|#|/\\*|\\*)${S}*(REQ-[A-Z]{2,6}-\\d{3})\\b`);
|
|
20
|
+
const CODE_SPAN = /`([^`]+)`/g;
|
|
21
|
+
// REQ-IDX-007 the rest of the line after Covers:, and each REQ ID in it with word boundaries (042/D4)
|
|
22
|
+
const COVER_TAG = /Covers:(.*)$/s;
|
|
23
|
+
const REQ_ID = /\bREQ-[A-Z]{2,6}-\d{3}\b/g;
|
|
20
24
|
const SENTENCE_END = new RegExp(`(?<=[.!?])${S}`);
|
|
21
25
|
const COMMA = new RegExp(`${S}*,${S}*`);
|
|
22
|
-
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
26
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".ctx"]);
|
|
23
27
|
export function scanner(root, cfg) {
|
|
24
28
|
const s = cfg.scan;
|
|
25
29
|
const all = [...s.specs, ...s.changes, ...s.decisions, ...s.code, ...s.tests];
|
|
@@ -293,15 +297,23 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
293
297
|
const reqs = [];
|
|
294
298
|
for (const g of body.matchAll(TASK_REQ))
|
|
295
299
|
reqs.push(...g[1].split(COMMA));
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
+
// REQ-CHK-001 an archived task is history: no citation finding
|
|
301
|
+
if (status !== "archived") {
|
|
302
|
+
if (reqs.length === 0)
|
|
303
|
+
problems.push([rpath, i, "task-without-req", tid]);
|
|
304
|
+
if (!body.includes("Verify:"))
|
|
305
|
+
problems.push([rpath, i, "task-without-verify", tid]);
|
|
306
|
+
}
|
|
300
307
|
// the full task body is the title, so query output shows the Verify clause
|
|
301
|
-
|
|
308
|
+
const t = entry({
|
|
302
309
|
id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
|
|
303
310
|
status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
|
|
304
|
-
})
|
|
311
|
+
});
|
|
312
|
+
// REQ-IDX-009 an active task records the paths that its line names in backticks
|
|
313
|
+
const paths = status === "archived" ? [] : taskPaths(body);
|
|
314
|
+
if (paths.length)
|
|
315
|
+
t.paths = paths;
|
|
316
|
+
entries.push(t);
|
|
305
317
|
}
|
|
306
318
|
}
|
|
307
319
|
// REQ-IDX-002 text holds the lower-cased body of each block
|
|
@@ -323,6 +335,20 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
323
335
|
}
|
|
324
336
|
}
|
|
325
337
|
}
|
|
338
|
+
/** REQ-IDX-009 code spans with no space and no `*` that hold a `/` or a `.`. A leading `./` is dropped. */
|
|
339
|
+
export function taskPaths(body) {
|
|
340
|
+
const out = new Set();
|
|
341
|
+
for (const m of body.matchAll(CODE_SPAN)) {
|
|
342
|
+
let t = m[1];
|
|
343
|
+
if (/[ \t\n\r\f\v*]/.test(t) || !(t.includes("/") || t.includes(".")))
|
|
344
|
+
continue;
|
|
345
|
+
while (t.startsWith("./"))
|
|
346
|
+
t = t.slice(2);
|
|
347
|
+
if (t)
|
|
348
|
+
out.add(t);
|
|
349
|
+
}
|
|
350
|
+
return [...out].sort(cmpStr);
|
|
351
|
+
}
|
|
326
352
|
export function isTestPath(sc, rpath) {
|
|
327
353
|
const name = rpath.slice(rpath.lastIndexOf("/") + 1);
|
|
328
354
|
return anyMatch(sc.tests, rpath) || rpath.includes("/tests/") || rpath.startsWith("tests/")
|
|
@@ -348,7 +374,9 @@ export function scanSource(sc, rpath, entries, scnNamed) {
|
|
|
348
374
|
if (isTest) {
|
|
349
375
|
const m = COVER_TAG.exec(line);
|
|
350
376
|
if (m) {
|
|
351
|
-
|
|
377
|
+
// a Covers line never counts as a code tag, with or without a REQ ID
|
|
378
|
+
const ids = [...new Set(m[1].match(REQ_ID) ?? [])];
|
|
379
|
+
for (const rid of ids) {
|
|
352
380
|
entries.push(entry({ id: `${rpath}:${i}`, kind: "test", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [rid] }));
|
|
353
381
|
}
|
|
354
382
|
continue;
|
|
@@ -356,7 +384,10 @@ export function scanSource(sc, rpath, entries, scnNamed) {
|
|
|
356
384
|
}
|
|
357
385
|
const m = CODE_TAG.exec(line);
|
|
358
386
|
if (m && !line.includes("Covers:")) {
|
|
359
|
-
|
|
387
|
+
const ids = [...new Set(line.slice(m.index).match(REQ_ID) ?? [])];
|
|
388
|
+
for (const rid of ids) {
|
|
389
|
+
entries.push(entry({ id: `${rpath}:${i}`, kind: "code", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [rid] }));
|
|
390
|
+
}
|
|
360
391
|
}
|
|
361
392
|
}
|
|
362
393
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// REQ-IDX-010 REQ-IDX-012 REQ-IDX-013 change entries, capability entries, and the design links of tasks.
|
|
2
|
+
import { cmpStr } from "../util/pystr.js";
|
|
3
|
+
import { changeKey, changeOf, resolveChange, taskPaths } from "./scan.js";
|
|
4
|
+
const CHANGE_DIR = /^(openspec\/changes\/(?:archive\/)?[^/]+)\//;
|
|
5
|
+
const ARCHIVE_DATE = /^openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2})-/;
|
|
6
|
+
const CAP_SPEC = /(?:^|\/)specs\/([^/]+)\/spec\.md$/;
|
|
7
|
+
// REQ-IDX-012 a full name `<key>/D<n>` or a bare `D<n>`. A bare D follows no letter, digit, slash, or hyphen.
|
|
8
|
+
const FULL_DESIGN = /(?<![\w/-])([\w-]+)\/D(\d+)(?!\w)/g;
|
|
9
|
+
const BARE_DESIGN = /(?<![\w/-])D(\d+)(?!\w)/g;
|
|
10
|
+
function entryOf(e) {
|
|
11
|
+
return { ...e, line: 1, text: "", canonical: true, refs: [] };
|
|
12
|
+
}
|
|
13
|
+
/** REQ-IDX-013 the winner among folders with one key: active first, then the smaller name. Archived: newest date, then the smaller name. */
|
|
14
|
+
function better(a, b) {
|
|
15
|
+
if (a.archived !== b.archived)
|
|
16
|
+
return !a.archived;
|
|
17
|
+
if (a.archived && a.date !== b.date)
|
|
18
|
+
return a.date > b.date;
|
|
19
|
+
return cmpStr(a.change, b.change) < 0;
|
|
20
|
+
}
|
|
21
|
+
/** REQ-IDX-010 one change entry per key and one cap entry per capability name. */
|
|
22
|
+
function structureEntries(files) {
|
|
23
|
+
const folders = new Map();
|
|
24
|
+
const caps = new Map();
|
|
25
|
+
for (const f of files) {
|
|
26
|
+
const m = CHANGE_DIR.exec(f.path);
|
|
27
|
+
if (m) {
|
|
28
|
+
let fo = folders.get(m[1]);
|
|
29
|
+
if (!fo) {
|
|
30
|
+
const date = ARCHIVE_DATE.exec(f.path)?.[1] ?? "";
|
|
31
|
+
folders.set(m[1], (fo = { dir: m[1], change: changeOf(f.path), archived: f.path.includes("/archive/"), date, files: [] }));
|
|
32
|
+
}
|
|
33
|
+
fo.files.push(f.path);
|
|
34
|
+
}
|
|
35
|
+
const c = CAP_SPEC.exec(f.path);
|
|
36
|
+
if (c) {
|
|
37
|
+
let l = caps.get(c[1]);
|
|
38
|
+
if (!l)
|
|
39
|
+
caps.set(c[1], (l = []));
|
|
40
|
+
l.push(f.path);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const win = new Map();
|
|
44
|
+
for (const fo of folders.values()) {
|
|
45
|
+
const key = changeKey(fo.change);
|
|
46
|
+
const cur = win.get(key);
|
|
47
|
+
if (!cur || better(fo, cur))
|
|
48
|
+
win.set(key, fo);
|
|
49
|
+
}
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const [key, fo] of win) {
|
|
52
|
+
const proposal = `${fo.dir}/proposal.md`;
|
|
53
|
+
const file = fo.files.includes(proposal) ? proposal : [...fo.files].sort(cmpStr)[0];
|
|
54
|
+
out.push(entryOf({ id: `change/${key}`, kind: "change", file, title: fo.change, change: fo.change, status: fo.archived ? "archived" : "active" }));
|
|
55
|
+
}
|
|
56
|
+
for (const [name, list] of caps) {
|
|
57
|
+
// the main spec, else the first active delta, else the newest archived delta
|
|
58
|
+
const sorted = [...list].sort(cmpStr);
|
|
59
|
+
const file = sorted.find((p) => p.startsWith("openspec/specs/"))
|
|
60
|
+
?? sorted.find((p) => !p.includes("/archive/"))
|
|
61
|
+
?? sorted[sorted.length - 1];
|
|
62
|
+
out.push(entryOf({ id: `cap/${name}`, kind: "cap", file, title: name, change: "main", status: "active" }));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** REQ-IDX-012 the `design:` links of a task. The scan reads the text before `Verify:` with the path spans removed. */
|
|
67
|
+
function designLinks(entries) {
|
|
68
|
+
const decisions = new Set(entries.filter((e) => e.kind === "decision").map((e) => e.id));
|
|
69
|
+
for (const t of entries) {
|
|
70
|
+
if (t.kind !== "task")
|
|
71
|
+
continue;
|
|
72
|
+
let body = t.title;
|
|
73
|
+
const v = body.indexOf("Verify:");
|
|
74
|
+
if (v >= 0)
|
|
75
|
+
body = body.slice(0, v);
|
|
76
|
+
for (const p of taskPaths(body))
|
|
77
|
+
body = body.split("`" + p + "`").join(" ");
|
|
78
|
+
const found = new Set();
|
|
79
|
+
for (const m of body.matchAll(FULL_DESIGN)) {
|
|
80
|
+
const folder = resolveChange(entries, m[1]);
|
|
81
|
+
const id = `${folder === undefined ? m[1] : changeKey(folder)}/D${m[2]}`;
|
|
82
|
+
if (decisions.has(id))
|
|
83
|
+
found.add(id);
|
|
84
|
+
}
|
|
85
|
+
const own = `${changeKey(t.change)}/D`;
|
|
86
|
+
for (const m of body.matchAll(BARE_DESIGN))
|
|
87
|
+
if (decisions.has(own + m[1]))
|
|
88
|
+
found.add(own + m[1]);
|
|
89
|
+
if (found.size)
|
|
90
|
+
t.links = [...found].sort(cmpStr).map((id) => `design:${id}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Add the structural entries and the design links. It runs after the scan and before the canonical mark. */
|
|
94
|
+
export function addStructure(entries, files) {
|
|
95
|
+
designLinks(entries);
|
|
96
|
+
entries.push(...structureEntries(files));
|
|
97
|
+
}
|
package/dist/init/init.js
CHANGED
|
@@ -4,7 +4,9 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { ASSETS } from "../assets.generated.js";
|
|
6
6
|
import { UsageExit } from "../cli/errors.js";
|
|
7
|
-
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT
|
|
7
|
+
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
|
|
8
|
+
import { loadConfigOrExit } from "../cli/config.js";
|
|
9
|
+
import { VERSION } from "../version.js";
|
|
8
10
|
export const SCHEMA = "spec-driven-ids";
|
|
9
11
|
/** REQ-SCH-003 where the templates land in a project. Upstream reads no project override for the stock templates, so this is a reference path (025/D4). */
|
|
10
12
|
export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
|
|
@@ -40,7 +42,7 @@ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
|
|
|
40
42
|
* exists at the root. `src/**` becomes `lib/**` when only `lib/` exists. The comments stay.
|
|
41
43
|
* `openspec` replaces the default command on the `openspec = ` line (022/D2).
|
|
42
44
|
*/
|
|
43
|
-
export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
45
|
+
export function configText(root, openspec = DEFAULT_OPENSPEC, run = null) {
|
|
44
46
|
const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
|
|
45
47
|
let text = INIT_TEXT;
|
|
46
48
|
if (!isDir("tests") && isDir("test"))
|
|
@@ -49,8 +51,18 @@ export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
|
49
51
|
text = text.replace('\ncode = ["src/**"]\n', '\ncode = ["lib/**"]\n');
|
|
50
52
|
if (openspec !== DEFAULT_OPENSPEC)
|
|
51
53
|
text = text.replace(`\nopenspec = "${DEFAULT_OPENSPEC}"\n`, `\nopenspec = ${JSON.stringify(openspec)}\n`);
|
|
54
|
+
// REQ-DIST-015 a run prefix adds the [cli] section at the end (043/D3)
|
|
55
|
+
if (run !== null)
|
|
56
|
+
text += `\n[cli]\n# The prefix of every command that spec-ctx prints for you to run. init wrote it because it ran under npx.\nrun = ${JSON.stringify(run)}\n`;
|
|
52
57
|
return text;
|
|
53
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* REQ-DIST-015 the run prefix that init writes. npm sets npm_command to exec for npm exec and npx.
|
|
61
|
+
* Any other value, or none, gives null and the default applies (043/D3).
|
|
62
|
+
*/
|
|
63
|
+
export function runPrefixFor(env) {
|
|
64
|
+
return env.npm_command === "exec" ? `npx -y spec-ctx@${VERSION}` : null;
|
|
65
|
+
}
|
|
54
66
|
/**
|
|
55
67
|
* REQ-SCH-006 wrap the ID after a requirement or scenario header prefix in square brackets (032/D1).
|
|
56
68
|
* An ID in brackets already stays. Task citations, Refs lines, and tags have no header prefix, so they stay bare.
|
|
@@ -70,11 +82,11 @@ export function fragmentText(brackets) {
|
|
|
70
82
|
}
|
|
71
83
|
/** REQ-SCH-006 the value of [ids] brackets, false when spec-ctx.toml is absent (032/D2). */
|
|
72
84
|
export function bracketsOf(root) {
|
|
73
|
-
return existsSync(join(root, CONFIG_FILE)) ?
|
|
85
|
+
return existsSync(join(root, CONFIG_FILE)) ? loadConfigOrExit(root).ids.brackets : false;
|
|
74
86
|
}
|
|
75
87
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
76
|
-
export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
|
|
77
|
-
const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
|
|
88
|
+
export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false, run = null) {
|
|
89
|
+
const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec, run))]];
|
|
78
90
|
for (const rel of Object.keys(ASSETS).sort()) {
|
|
79
91
|
// REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
|
|
80
92
|
if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
|
|
@@ -415,11 +427,13 @@ export function runInit(root, o) {
|
|
|
415
427
|
const brackets = bracketsOf(root);
|
|
416
428
|
const fragment = fragmentText(brackets);
|
|
417
429
|
const otherFragment = fragmentText(!brackets);
|
|
418
|
-
let files = plan(o, root, openspec, brackets);
|
|
430
|
+
let files = plan(o, root, openspec, brackets, runPrefixFor(process.env));
|
|
419
431
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
420
432
|
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
421
433
|
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
422
|
-
|
|
434
|
+
// REQ-DIST-013 a skills refresh in a project with spec-ctx.toml touches only the skill folders (038/D3)
|
|
435
|
+
const skillsOnly = o.skills && !o.schema && existsSync(join(root, CONFIG_FILE));
|
|
436
|
+
const ignoreDone = skillsOnly || existsSync(join(root, GITIGNORE)) && GITIGNORE_LINES.every((l) => ignoreLines(readFileSync(join(root, GITIGNORE), "utf8")).has(l));
|
|
423
437
|
if (same && ignoreDone && !o.force) {
|
|
424
438
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
425
439
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
@@ -478,11 +492,13 @@ export function runInit(root, o) {
|
|
|
478
492
|
res.notes.push(LEGACY_SCHEMA_NOTE);
|
|
479
493
|
}
|
|
480
494
|
// REQ-DIST-002 the four .gitignore lines (017/D2, 022/D5)
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
495
|
+
if (!skillsOnly) {
|
|
496
|
+
const added = writeGitignore(root);
|
|
497
|
+
if (added)
|
|
498
|
+
res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
|
|
499
|
+
else
|
|
500
|
+
res.kept.push(GITIGNORE);
|
|
501
|
+
}
|
|
486
502
|
// REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
|
|
487
503
|
if (o.skills && ignoresClaude(root))
|
|
488
504
|
res.notes.push(CLAUDE_NOTE);
|