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/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,13 +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
|
-
|
|
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;
|
|
19
21
|
// REQ-IDX-007 the rest of the line after Covers:, and each REQ ID in it with word boundaries (042/D4)
|
|
20
22
|
const COVER_TAG = /Covers:(.*)$/s;
|
|
21
23
|
const REQ_ID = /\bREQ-[A-Z]{2,6}-\d{3}\b/g;
|
|
22
24
|
const SENTENCE_END = new RegExp(`(?<=[.!?])${S}`);
|
|
23
25
|
const COMMA = new RegExp(`${S}*,${S}*`);
|
|
24
|
-
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
26
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".ctx"]);
|
|
25
27
|
export function scanner(root, cfg) {
|
|
26
28
|
const s = cfg.scan;
|
|
27
29
|
const all = [...s.specs, ...s.changes, ...s.decisions, ...s.code, ...s.tests];
|
|
@@ -295,15 +297,23 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
295
297
|
const reqs = [];
|
|
296
298
|
for (const g of body.matchAll(TASK_REQ))
|
|
297
299
|
reqs.push(...g[1].split(COMMA));
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
}
|
|
302
307
|
// the full task body is the title, so query output shows the Verify clause
|
|
303
|
-
|
|
308
|
+
const t = entry({
|
|
304
309
|
id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
|
|
305
310
|
status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
|
|
306
|
-
})
|
|
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);
|
|
307
317
|
}
|
|
308
318
|
}
|
|
309
319
|
// REQ-IDX-002 text holds the lower-cased body of each block
|
|
@@ -325,6 +335,20 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
325
335
|
}
|
|
326
336
|
}
|
|
327
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
|
+
}
|
|
328
352
|
export function isTestPath(sc, rpath) {
|
|
329
353
|
const name = rpath.slice(rpath.lastIndexOf("/") + 1);
|
|
330
354
|
return anyMatch(sc.tests, rpath) || rpath.includes("/tests/") || rpath.startsWith("tests/")
|
|
@@ -360,7 +384,10 @@ export function scanSource(sc, rpath, entries, scnNamed) {
|
|
|
360
384
|
}
|
|
361
385
|
const m = CODE_TAG.exec(line);
|
|
362
386
|
if (m && !line.includes("Covers:")) {
|
|
363
|
-
|
|
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
|
+
}
|
|
364
391
|
}
|
|
365
392
|
}
|
|
366
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
|
@@ -6,6 +6,7 @@ import { ASSETS } from "../assets.generated.js";
|
|
|
6
6
|
import { UsageExit } from "../cli/errors.js";
|
|
7
7
|
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
|
|
8
8
|
import { loadConfigOrExit } from "../cli/config.js";
|
|
9
|
+
import { VERSION } from "../version.js";
|
|
9
10
|
export const SCHEMA = "spec-driven-ids";
|
|
10
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). */
|
|
11
12
|
export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
|
|
@@ -41,7 +42,7 @@ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
|
|
|
41
42
|
* exists at the root. `src/**` becomes `lib/**` when only `lib/` exists. The comments stay.
|
|
42
43
|
* `openspec` replaces the default command on the `openspec = ` line (022/D2).
|
|
43
44
|
*/
|
|
44
|
-
export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
45
|
+
export function configText(root, openspec = DEFAULT_OPENSPEC, run = null) {
|
|
45
46
|
const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
|
|
46
47
|
let text = INIT_TEXT;
|
|
47
48
|
if (!isDir("tests") && isDir("test"))
|
|
@@ -50,8 +51,18 @@ export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
|
50
51
|
text = text.replace('\ncode = ["src/**"]\n', '\ncode = ["lib/**"]\n');
|
|
51
52
|
if (openspec !== DEFAULT_OPENSPEC)
|
|
52
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`;
|
|
53
57
|
return text;
|
|
54
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
|
+
}
|
|
55
66
|
/**
|
|
56
67
|
* REQ-SCH-006 wrap the ID after a requirement or scenario header prefix in square brackets (032/D1).
|
|
57
68
|
* An ID in brackets already stays. Task citations, Refs lines, and tags have no header prefix, so they stay bare.
|
|
@@ -74,8 +85,8 @@ export function bracketsOf(root) {
|
|
|
74
85
|
return existsSync(join(root, CONFIG_FILE)) ? loadConfigOrExit(root).ids.brackets : false;
|
|
75
86
|
}
|
|
76
87
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
77
|
-
export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
|
|
78
|
-
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))]];
|
|
79
90
|
for (const rel of Object.keys(ASSETS).sort()) {
|
|
80
91
|
// REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
|
|
81
92
|
if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
|
|
@@ -416,7 +427,7 @@ export function runInit(root, o) {
|
|
|
416
427
|
const brackets = bracketsOf(root);
|
|
417
428
|
const fragment = fragmentText(brackets);
|
|
418
429
|
const otherFragment = fragmentText(!brackets);
|
|
419
|
-
let files = plan(o, root, openspec, brackets);
|
|
430
|
+
let files = plan(o, root, openspec, brackets, runPrefixFor(process.env));
|
|
420
431
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
421
432
|
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
422
433
|
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// REQ-JRN-001 REQ-JRN-003 the journal write. One call, one line, no output, no throw (057/D2, 057/D3).
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { COMMANDS } from "../cli/args.js";
|
|
5
|
+
import { dumps } from "../util/json.js";
|
|
6
|
+
import { SIGNALS } from "./note.js";
|
|
7
|
+
export const JOURNAL_DIR = ".ctx/local";
|
|
8
|
+
export const JOURNAL_FILE = "runs.jsonl";
|
|
9
|
+
export const ROTATED_FILE = "runs.1.jsonl";
|
|
10
|
+
export const MAX_BYTES = 256 * 1024;
|
|
11
|
+
export const MAX_LINE = 4096;
|
|
12
|
+
/** REQ-JRN-001 the line, with sorted keys and no newline inside. It stays under 4 KB. */
|
|
13
|
+
export function journalLine(sink, run) {
|
|
14
|
+
const signals = new Set(sink.signals);
|
|
15
|
+
if (run.exit === 2)
|
|
16
|
+
signals.add("usage-error");
|
|
17
|
+
if (run.exit === 3)
|
|
18
|
+
signals.add("internal-error");
|
|
19
|
+
const rec = {
|
|
20
|
+
at: run.at ?? new Date().toISOString(),
|
|
21
|
+
cmd: sink.cmd !== undefined && COMMANDS.includes(sink.cmd) ? sink.cmd : "?",
|
|
22
|
+
exit: run.exit,
|
|
23
|
+
ms: Math.max(0, Math.round(run.ms)),
|
|
24
|
+
signals: SIGNALS.filter((s) => signals.has(s)),
|
|
25
|
+
v: 1,
|
|
26
|
+
version: run.version,
|
|
27
|
+
};
|
|
28
|
+
if (sink.err && run.exit === 3)
|
|
29
|
+
rec.err = { code: sink.err.code, frames: sink.err.frames, name: sink.err.name };
|
|
30
|
+
let line = JSON.stringify(JSON.parse(dumps(rec)));
|
|
31
|
+
if (line.length > MAX_LINE && sink.err) {
|
|
32
|
+
rec.err = { code: sink.err.code, frames: sink.err.frames.slice(0, 5), name: sink.err.name };
|
|
33
|
+
line = JSON.stringify(JSON.parse(dumps(rec)));
|
|
34
|
+
}
|
|
35
|
+
if (line.length > MAX_LINE) {
|
|
36
|
+
delete rec.err;
|
|
37
|
+
line = JSON.stringify(JSON.parse(dumps(rec)));
|
|
38
|
+
}
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
/** REQ-JRN-003 append one line. Any failure stays silent. */
|
|
42
|
+
export function appendRun(sink, run) {
|
|
43
|
+
try {
|
|
44
|
+
if (process.env.SPECCTX_JOURNAL === "0")
|
|
45
|
+
return;
|
|
46
|
+
const root = sink.root;
|
|
47
|
+
if (!root)
|
|
48
|
+
return;
|
|
49
|
+
const os = join(root, "openspec");
|
|
50
|
+
if (!existsSync(os) || !statSync(os).isDirectory())
|
|
51
|
+
return;
|
|
52
|
+
const dir = join(root, JOURNAL_DIR);
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
const ignore = join(dir, ".gitignore");
|
|
55
|
+
if (!existsSync(ignore))
|
|
56
|
+
writeFileSync(ignore, "*\n");
|
|
57
|
+
const file = join(dir, JOURNAL_FILE);
|
|
58
|
+
if (existsSync(file) && statSync(file).size > MAX_BYTES)
|
|
59
|
+
renameSync(file, join(dir, ROTATED_FILE));
|
|
60
|
+
appendFileSync(file, journalLine(sink, run) + "\n");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// the journal never changes the exit code or the output
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// REQ-JRN-001 REQ-JRN-002 the sink that main() fills. It has no fs import, so a --version run loads no write code (057/D1).
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** REQ-JRN-002 the closed set of signals. The line version `v` versions it. */
|
|
5
|
+
export const SIGNALS = ["init-ok", "context-has-code-or-test", "empty-context", "truncated-context", "usage-error", "internal-error"];
|
|
6
|
+
/** REQ-JRN-001 fill the sink. It does nothing when the caller passed no sink. */
|
|
7
|
+
export function note(io, patch) {
|
|
8
|
+
const s = io.journal;
|
|
9
|
+
if (!s)
|
|
10
|
+
return;
|
|
11
|
+
if (patch.root !== undefined)
|
|
12
|
+
s.root = patch.root;
|
|
13
|
+
if (patch.cmd !== undefined)
|
|
14
|
+
s.cmd = patch.cmd;
|
|
15
|
+
if (patch.run !== undefined)
|
|
16
|
+
s.run = patch.run;
|
|
17
|
+
if (patch.signal !== undefined && SIGNALS.includes(patch.signal))
|
|
18
|
+
s.signals.add(patch.signal);
|
|
19
|
+
if (patch.err !== undefined)
|
|
20
|
+
s.err = patch.err;
|
|
21
|
+
}
|
|
22
|
+
/** The package root: two folders above src/journal or dist/journal. */
|
|
23
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
24
|
+
/** REQ-JRN-002 name, code, and package frames of an error. Never the message (057/D4). */
|
|
25
|
+
export function errInfo(e, pkgRoot = PACKAGE_ROOT) {
|
|
26
|
+
if (!(e instanceof Error))
|
|
27
|
+
return { name: "NonError", code: null, frames: [] };
|
|
28
|
+
const raw = e.code;
|
|
29
|
+
const code = typeof raw === "string" && /^[A-Z][A-Z0-9_]*$/.test(raw) ? raw : null;
|
|
30
|
+
const frames = [];
|
|
31
|
+
// stack line 1 holds the message, so it never counts
|
|
32
|
+
for (const line of (e.stack ?? "").split("\n").slice(1)) {
|
|
33
|
+
if (!/^\s+at /.test(line))
|
|
34
|
+
continue;
|
|
35
|
+
const m = /\(?((?:file:\/\/)?[^()\s]+):(\d+):\d+\)?$/.exec(line);
|
|
36
|
+
if (!m)
|
|
37
|
+
continue;
|
|
38
|
+
let file = m[1];
|
|
39
|
+
try {
|
|
40
|
+
if (file.startsWith("file://"))
|
|
41
|
+
file = fileURLToPath(file);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const rel = relative(pkgRoot, file);
|
|
47
|
+
if (rel === "" || rel.startsWith("..") || rel.split(/[\\/]/).includes("node_modules") || resolve(rel) === rel)
|
|
48
|
+
continue;
|
|
49
|
+
frames.push(`${rel.split("\\").join("/")}:${m[2]}`);
|
|
50
|
+
if (frames.length === 10)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return { name: e.name, code, frames };
|
|
54
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// REQ-JRN-004 REQ-JRN-005 the bug report. The only reader of the journal. It never runs gh and never uses the network (057/D6).
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "smol-toml";
|
|
5
|
+
import { JOURNAL_DIR, JOURNAL_FILE, ROTATED_FILE } from "./journal.js";
|
|
6
|
+
import { SIGNALS } from "./note.js";
|
|
7
|
+
export const NO_JOURNAL = `no journal found under ${JOURNAL_DIR}\n`;
|
|
8
|
+
function readLines(path) {
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return [];
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
13
|
+
if (!raw.trim())
|
|
14
|
+
continue;
|
|
15
|
+
try {
|
|
16
|
+
const v = JSON.parse(raw);
|
|
17
|
+
if (v === null || typeof v !== "object" || v.v !== 1)
|
|
18
|
+
continue;
|
|
19
|
+
if (typeof v.at !== "string" || Number.isNaN(Date.parse(v.at)) || typeof v.cmd !== "string")
|
|
20
|
+
continue;
|
|
21
|
+
if (typeof v.exit !== "number" || typeof v.ms !== "number" || !Array.isArray(v.signals))
|
|
22
|
+
continue;
|
|
23
|
+
const line = { at: v.at, cmd: v.cmd, exit: v.exit, ms: v.ms, signals: v.signals.filter((s) => typeof s === "string") };
|
|
24
|
+
const e = v.err;
|
|
25
|
+
if (e && typeof e === "object") {
|
|
26
|
+
line.err = {
|
|
27
|
+
name: typeof e.name === "string" && /^\w+$/.test(e.name) ? e.name : "?",
|
|
28
|
+
code: typeof e.code === "string" && /^[A-Z][A-Z0-9_]*$/.test(e.code) ? e.code : null,
|
|
29
|
+
frames: Array.isArray(e.frames) ? e.frames.filter((f) => typeof f === "string" && /^[\w./-]+:\d+$/.test(f)) : [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
out.push(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// a line that is not JSON is skipped
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** REQ-JRN-004 section and key names of spec-ctx.toml, with no value. */
|
|
41
|
+
export function configShape(root) {
|
|
42
|
+
const path = join(root, "spec-ctx.toml");
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
return ["none (no spec-ctx.toml)"];
|
|
45
|
+
let doc;
|
|
46
|
+
try {
|
|
47
|
+
doc = parse(readFileSync(path, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return ["none (spec-ctx.toml does not parse)"];
|
|
51
|
+
}
|
|
52
|
+
const rows = [];
|
|
53
|
+
const top = Object.keys(doc).filter((k) => !isTable(doc[k]));
|
|
54
|
+
if (top.length)
|
|
55
|
+
rows.push(`(top level): ${top.join(", ")}`);
|
|
56
|
+
const walk = (name, t) => {
|
|
57
|
+
const keys = Object.keys(t).filter((k) => !isTable(t[k]));
|
|
58
|
+
rows.push(`[${name}]: ${keys.length ? keys.join(", ") : "no keys"}`);
|
|
59
|
+
for (const k of Object.keys(t))
|
|
60
|
+
if (isTable(t[k]))
|
|
61
|
+
walk(`${name}.${k}`, t[k]);
|
|
62
|
+
};
|
|
63
|
+
for (const k of Object.keys(doc))
|
|
64
|
+
if (isTable(doc[k]))
|
|
65
|
+
walk(k, doc[k]);
|
|
66
|
+
return rows.length ? rows : ["none (empty file)"];
|
|
67
|
+
}
|
|
68
|
+
function isTable(v) {
|
|
69
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
70
|
+
}
|
|
71
|
+
/** Nearest rank. */
|
|
72
|
+
export function p95(values) {
|
|
73
|
+
const s = [...values].sort((a, b) => a - b);
|
|
74
|
+
return s[Math.max(0, Math.ceil(0.95 * s.length) - 1)];
|
|
75
|
+
}
|
|
76
|
+
/** REQ-JRN-004 the Markdown report. It is a pure function of the journal files and the config. */
|
|
77
|
+
export function buildReport(root, now, sys) {
|
|
78
|
+
const dir = join(root, JOURNAL_DIR);
|
|
79
|
+
if (!existsSync(join(dir, JOURNAL_FILE)) && !existsSync(join(dir, ROTATED_FILE)))
|
|
80
|
+
return { found: false, text: NO_JOURNAL };
|
|
81
|
+
const lines = [...readLines(join(dir, ROTATED_FILE)), ...readLines(join(dir, JOURNAL_FILE))];
|
|
82
|
+
const md = ["# spec-ctx report", "", `Generated: ${now.toISOString()}`, ""];
|
|
83
|
+
md.push("## Versions", "", `- spec-ctx: ${sys.version}`, `- Node: ${sys.node}`, `- OS: ${sys.platform} ${sys.arch}`, "");
|
|
84
|
+
md.push("## Config shape", "", ...configShape(root).map((r) => `- ${r}`), "");
|
|
85
|
+
const i = lines.findIndex((l) => l.signals.includes("init-ok"));
|
|
86
|
+
const j = i < 0 ? -1 : lines.findIndex((l, k) => k > i && l.signals.includes("context-has-code-or-test"));
|
|
87
|
+
md.push("## North star", "");
|
|
88
|
+
if (j < 0)
|
|
89
|
+
md.push("- Minutes from the first `init-ok` to the first `context-has-code-or-test`: n/a", "- Runs between them: n/a", "");
|
|
90
|
+
else {
|
|
91
|
+
const mins = (Date.parse(lines[j].at) - Date.parse(lines[i].at)) / 60000;
|
|
92
|
+
md.push(`- Minutes from the first \`init-ok\` to the first \`context-has-code-or-test\`: ${mins.toFixed(1)}`, `- Runs between them: ${j - i}`, "");
|
|
93
|
+
}
|
|
94
|
+
md.push(`## Runs`, "", `- Total: ${lines.length}`, "");
|
|
95
|
+
const byCmd = new Map();
|
|
96
|
+
for (const l of lines)
|
|
97
|
+
byCmd.set(l.cmd, [...(byCmd.get(l.cmd) ?? []), l.ms]);
|
|
98
|
+
md.push("## Time", "", "| cmd | runs | p95 ms |", "| --- | --- | --- |");
|
|
99
|
+
for (const c of [...byCmd.keys()].sort())
|
|
100
|
+
md.push(`| ${c.replace(/[^\w ?-]/g, "?")} | ${byCmd.get(c).length} | ${p95(byCmd.get(c))} |`);
|
|
101
|
+
md.push("", "## Signals", "");
|
|
102
|
+
for (const s of SIGNALS)
|
|
103
|
+
md.push(`- ${s}: ${lines.filter((l) => l.signals.includes(s)).length}`);
|
|
104
|
+
md.push("", "## Last internal error", "");
|
|
105
|
+
const last = [...lines].reverse().find((l) => l.err !== undefined);
|
|
106
|
+
if (!last?.err)
|
|
107
|
+
md.push("- none");
|
|
108
|
+
else {
|
|
109
|
+
md.push(`- name: ${last.err.name}`, `- code: ${last.err.code ?? "none"}`, "- frames:", ...last.err.frames.map((f) => ` - ${f}`));
|
|
110
|
+
if (!last.err.frames.length)
|
|
111
|
+
md.push(" - none");
|
|
112
|
+
}
|
|
113
|
+
md.push("", "Review this text, then file it. Run: `gh issue create -R sonqh/spec-ctx --body-file <file>`", "");
|
|
114
|
+
return { found: true, text: md.join("\n") };
|
|
115
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// REQ-JRN-002 the signals of one context run. They read the provenance lines of the printed text (057/D3).
|
|
2
|
+
import { parseProvenance } from "../graph/bundle.js";
|
|
3
|
+
import { canon } from "../index/canonical.js";
|
|
4
|
+
/** Only `context` calls this. No provenance line gives empty-context. A kept code or test block gives context-has-code-or-test. */
|
|
5
|
+
export function contextSignals(entries, b) {
|
|
6
|
+
const ids = canon(entries);
|
|
7
|
+
const out = [];
|
|
8
|
+
let lines = 0;
|
|
9
|
+
let line = false;
|
|
10
|
+
for (const l of b.text.split("\n")) {
|
|
11
|
+
const id = parseProvenance(l);
|
|
12
|
+
if (id === null)
|
|
13
|
+
continue;
|
|
14
|
+
lines++;
|
|
15
|
+
const k = ids.get(id)?.kind;
|
|
16
|
+
if (k === "code" || k === "test")
|
|
17
|
+
line = true;
|
|
18
|
+
}
|
|
19
|
+
if (lines === 0)
|
|
20
|
+
out.push("empty-context");
|
|
21
|
+
if (line)
|
|
22
|
+
out.push("context-has-code-or-test");
|
|
23
|
+
if (!b.complete)
|
|
24
|
+
out.push("truncated-context");
|
|
25
|
+
return out;
|
|
26
|
+
}
|