spec-ctx 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +245 -2
- package/action.yml +33 -0
- package/dist/adopt/config-write.js +59 -0
- package/dist/adopt/plan.js +276 -0
- package/dist/adopt/write.js +126 -0
- package/dist/assets.generated.js +14 -0
- package/dist/baseline/baseline.js +65 -0
- package/dist/check/check.js +126 -0
- package/dist/check/evidence.js +70 -0
- package/dist/check/hints.js +26 -0
- package/dist/cli/args.js +90 -0
- package/dist/cli/commands.js +275 -0
- package/dist/cli/errors.js +3 -0
- package/dist/cli/gate.js +187 -0
- package/dist/cli/help.js +230 -0
- package/dist/cli/root.js +32 -0
- package/dist/cli/status.js +116 -0
- package/dist/cli/workflow.js +274 -0
- package/dist/cli.js +13 -0
- package/dist/config.js +119 -0
- package/dist/graph/bundle.js +94 -0
- package/dist/graph/query.js +31 -0
- package/dist/graph/related.js +76 -0
- package/dist/handoff/handoff.js +148 -0
- package/dist/ids/ids.js +115 -0
- package/dist/index/build.js +57 -0
- package/dist/index/canonical.js +44 -0
- package/dist/index/scan.js +357 -0
- package/dist/index/types.js +2 -0
- package/dist/init/init.js +141 -0
- package/dist/lint/cli.js +253 -0
- package/dist/lint/py.js +152 -0
- package/dist/lint/selftest.js +246 -0
- package/dist/lint/ste.js +395 -0
- package/dist/lint/tells.js +116 -0
- package/dist/main.js +74 -0
- package/dist/stats/stats.js +143 -0
- package/dist/stats/write.js +26 -0
- package/dist/tasks/evidence.js +102 -0
- package/dist/tasks/tasks.js +105 -0
- package/dist/util/glob.js +43 -0
- package/dist/util/hash.js +14 -0
- package/dist/util/json.js +16 -0
- package/dist/util/pystr.js +67 -0
- package/dist/util/time.js +11 -0
- package/dist/verify/parse.js +58 -0
- package/dist/verify/run.js +42 -0
- package/dist/version.js +2 -0
- package/package.json +50 -4
- package/schemas/spec-driven-ids/README.md +18 -0
- package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
- package/schemas/spec-driven-ids/schema.yaml +301 -0
- package/schemas/spec-driven-ids/templates/design.md +22 -0
- package/schemas/spec-driven-ids/templates/proposal.md +34 -0
- package/schemas/spec-driven-ids/templates/spec.md +15 -0
- package/schemas/spec-driven-ids/templates/tasks.md +10 -0
- package/skills/specctx-bootstrap/SKILL.md +27 -0
- package/skills/specctx-done/SKILL.md +25 -0
- package/skills/specctx-next/SKILL.md +23 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// REQ-STAT-001 REQ-STAT-002 REQ-STAT-003 REQ-STAT-004 REQ-STAT-005 five metrics from files that exist, plus the gate log.
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readBaseline } from "../baseline/baseline.js";
|
|
5
|
+
import { changeKey, matchesChange } from "../index/scan.js";
|
|
6
|
+
import { cmpStr, cpLen } from "../util/pystr.js";
|
|
7
|
+
export const DEBT_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence"];
|
|
8
|
+
const NA = (n = 0) => ({ value: null, n, text: "n/a" });
|
|
9
|
+
function readEvidence(root) {
|
|
10
|
+
const dir = join(root, ".ctx/evidence");
|
|
11
|
+
const live = [];
|
|
12
|
+
const reopened = [];
|
|
13
|
+
if (!existsSync(dir))
|
|
14
|
+
return { live, reopened };
|
|
15
|
+
for (const c of readdirSync(dir).sort(cmpStr)) {
|
|
16
|
+
let names;
|
|
17
|
+
try {
|
|
18
|
+
names = readdirSync(join(dir, c)).sort(cmpStr);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
for (const n of names) {
|
|
24
|
+
if (!n.endsWith(".json"))
|
|
25
|
+
continue;
|
|
26
|
+
try {
|
|
27
|
+
const ev = JSON.parse(readFileSync(join(dir, c, n), "utf8"));
|
|
28
|
+
if (typeof ev.task !== "string")
|
|
29
|
+
continue;
|
|
30
|
+
(n.includes(".reopened-") ? reopened : live).push(ev);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// a damaged file is a check finding
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { live, reopened };
|
|
38
|
+
}
|
|
39
|
+
export function median(xs) {
|
|
40
|
+
if (!xs.length)
|
|
41
|
+
return null;
|
|
42
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
43
|
+
const m = Math.floor(s.length / 2);
|
|
44
|
+
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
|
|
45
|
+
}
|
|
46
|
+
const fmt = (x, d = 2) => x.toFixed(d);
|
|
47
|
+
function metrics(change, live, reopened, specChars, debt) {
|
|
48
|
+
const withB = live.filter((e) => typeof e.bundle_chars === "number");
|
|
49
|
+
const ctx = withB.length && specChars > 0
|
|
50
|
+
? (() => {
|
|
51
|
+
const v = withB.reduce((a, e) => a + e.bundle_chars / specChars, 0) / withB.length;
|
|
52
|
+
return { value: v, n: withB.length, text: `${fmt(v, 4)} (n=${withB.length})` };
|
|
53
|
+
})()
|
|
54
|
+
: NA();
|
|
55
|
+
const cmd = live.filter((e) => e.kind === "command");
|
|
56
|
+
const ev = live.length
|
|
57
|
+
? { value: cmd.length / live.length, n: live.length, text: `${fmt(cmd.length / live.length)} (${cmd.length}/${live.length})`, tasks: cmd.map((e) => e.task).sort(cmpStr) }
|
|
58
|
+
: NA();
|
|
59
|
+
const stale = reopened.filter((e) => typeof e.reason === "string" && e.reason.includes("stale"));
|
|
60
|
+
const st = reopened.length
|
|
61
|
+
? { value: stale.length, n: reopened.length, text: `${stale.length} (of ${reopened.length} reopens)`, tasks: stale.map((e) => e.task).sort(cmpStr) }
|
|
62
|
+
: NA();
|
|
63
|
+
const cycles = live
|
|
64
|
+
.filter((e) => typeof e.started_at === "string" && typeof e.at === "string")
|
|
65
|
+
.map((e) => Math.round((Date.parse(e.at) - Date.parse(e.started_at)) / 1000))
|
|
66
|
+
.filter((x) => Number.isFinite(x) && x >= 0);
|
|
67
|
+
const med = median(cycles);
|
|
68
|
+
const cyc = med === null ? NA() : { value: med, n: cycles.length, text: `${med} (n=${cycles.length})` };
|
|
69
|
+
return { change, context_ratio: ctx, evidence_ratio: ev, stale_catches: st, cycle_time_median_s: cyc, debt };
|
|
70
|
+
}
|
|
71
|
+
/** REQ-STAT-005 gate metrics over the last 100 lines of the gate log. */
|
|
72
|
+
export function gateMetrics(root, last = 100) {
|
|
73
|
+
const p = join(root, ".ctx/gate-log.jsonl");
|
|
74
|
+
if (!existsSync(p))
|
|
75
|
+
return { gate_runs: NA(), gate_pass_rate: NA(), gate_failures_caught: NA() };
|
|
76
|
+
const lines = readFileSync(p, "utf8").split("\n").filter(Boolean).slice(-last);
|
|
77
|
+
const runs = lines.flatMap((l) => {
|
|
78
|
+
try {
|
|
79
|
+
return [JSON.parse(l)];
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
if (!runs.length)
|
|
86
|
+
return { gate_runs: NA(), gate_pass_rate: NA(), gate_failures_caught: NA() };
|
|
87
|
+
const ok = runs.filter((r) => r.ok === true).length;
|
|
88
|
+
const failed = runs.length - ok;
|
|
89
|
+
return {
|
|
90
|
+
gate_runs: { value: runs.length, n: runs.length, text: String(runs.length) },
|
|
91
|
+
gate_pass_rate: { value: ok / runs.length, n: runs.length, text: `${fmt(ok / runs.length)} (${ok}/${runs.length})` },
|
|
92
|
+
gate_failures_caught: { value: failed, n: runs.length, text: String(failed) },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function computeStats(root, entries, strictFindings, warnings, only) {
|
|
96
|
+
const specChars = entries
|
|
97
|
+
.filter((e) => e.canonical && (e.kind === "req" || e.kind === "scn" || e.kind === "decision"))
|
|
98
|
+
.reduce((a, e) => a + cpLen(e.text ?? ""), 0);
|
|
99
|
+
const { live, reopened } = readEvidence(root);
|
|
100
|
+
// REQ-IDX-005 the change name of each change key, from the task entries
|
|
101
|
+
const names = new Map();
|
|
102
|
+
for (const e of entries)
|
|
103
|
+
if (e.kind === "task")
|
|
104
|
+
names.set(changeKey(e.change), e.change);
|
|
105
|
+
const nameOf = (task) => {
|
|
106
|
+
const num = task.split("/")[0];
|
|
107
|
+
return names.get(num) ?? num;
|
|
108
|
+
};
|
|
109
|
+
// REQ-STAT-004 debt: baseline entries plus current debt findings and warnings outside the baseline
|
|
110
|
+
const base = readBaseline(root) ?? new Set();
|
|
111
|
+
const current = [
|
|
112
|
+
...strictFindings.filter((f) => DEBT_RULES.includes(f.rule)).map((f) => `${f.rule} ${f.id}`),
|
|
113
|
+
...warnings.filter((w) => DEBT_RULES.includes(w.split(" ")[0])),
|
|
114
|
+
];
|
|
115
|
+
const fresh = [...new Set(current)].filter((k) => !base.has(k));
|
|
116
|
+
const debtAll = { value: base.size + fresh.length, n: base.size + fresh.length, text: `${base.size + fresh.length} (${base.size} baselined + ${fresh.length} new)` };
|
|
117
|
+
// a change row counts the task scoped debt: tasks without evidence and tasks with manual evidence
|
|
118
|
+
const taskDebt = (change) => {
|
|
119
|
+
const keys = [...base, ...fresh].filter((k) => {
|
|
120
|
+
const [rule, id] = k.split(" ");
|
|
121
|
+
return (rule === "done-without-evidence" || rule === "manual-evidence") && nameOf(id ?? "") === change;
|
|
122
|
+
});
|
|
123
|
+
return { value: keys.length, n: keys.length, text: String(keys.length) };
|
|
124
|
+
};
|
|
125
|
+
const changes = [...new Set([...live, ...reopened].map((e) => nameOf(e.task)))].sort(cmpStr).filter((c) => !only || matchesChange(c, only));
|
|
126
|
+
const rows = changes.map((c) => metrics(c, live.filter((e) => nameOf(e.task) === c), reopened.filter((e) => nameOf(e.task) === c), specChars, taskDebt(c)));
|
|
127
|
+
const pick = (xs) => (only ? xs.filter((e) => changes.includes(nameOf(e.task))) : xs);
|
|
128
|
+
const total = metrics("total", pick(live), pick(reopened), specChars, debtAll);
|
|
129
|
+
return { spec_chars: specChars, rows, total, gate: gateMetrics(root) };
|
|
130
|
+
}
|
|
131
|
+
export const COLUMNS = ["context_ratio", "evidence_ratio", "stale_catches", "cycle_time_median_s", "debt"];
|
|
132
|
+
export const STATS_FOOTER = "context_ratio = bundle chars / chars of all specs. Lower is better.";
|
|
133
|
+
export function statsTable(s) {
|
|
134
|
+
const head = ["change", ...COLUMNS];
|
|
135
|
+
const body = [...s.rows, s.total].map((r) => [r.change, ...COLUMNS.map((c) => r[c].text)]);
|
|
136
|
+
const width = head.map((h, i) => Math.max(cpLen(h), ...body.map((b) => cpLen(b[i]))));
|
|
137
|
+
const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : c + " ".repeat(width[i] - cpLen(c)))).join(" ");
|
|
138
|
+
const out = [line(head), ...body.map(line)];
|
|
139
|
+
out.push(`gate_runs ${s.gate.gate_runs.text}`, `gate_pass_rate ${s.gate.gate_pass_rate.text}`, `gate_failures_caught ${s.gate.gate_failures_caught.text}`);
|
|
140
|
+
// REQ-STAT-005 the footer defines the context ratio
|
|
141
|
+
out.push(STATS_FOOTER);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// REQ-STAT-005 stats --write: docs/metrics/<YYYY-MM-DD>.md by UTC date, overwritten on the same day.
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { today } from "../util/time.js";
|
|
5
|
+
import { COLUMNS } from "./stats.js";
|
|
6
|
+
export function statsMarkdown(s, date) {
|
|
7
|
+
const out = [
|
|
8
|
+
`# Metrics ${date}`,
|
|
9
|
+
"",
|
|
10
|
+
"`specctx stats --write` wrote this file. Each value carries the count of its inputs. A value without inputs reads n/a.",
|
|
11
|
+
"",
|
|
12
|
+
`| change | ${COLUMNS.join(" | ")} |`,
|
|
13
|
+
`| --- | ${COLUMNS.map(() => "---").join(" | ")} |`,
|
|
14
|
+
];
|
|
15
|
+
for (const r of [...s.rows, s.total])
|
|
16
|
+
out.push(`| ${r.change} | ${COLUMNS.map((c) => r[c].text).join(" | ")} |`);
|
|
17
|
+
out.push("", "| gate metric | value |", "| --- | --- |", `| gate_runs | ${s.gate.gate_runs.text} |`, `| gate_pass_rate | ${s.gate.gate_pass_rate.text} |`, `| gate_failures_caught | ${s.gate.gate_failures_caught.text} |`, "", `The spec text in the index holds ${s.spec_chars} characters. The context ratio divides each bundle by that size.`);
|
|
18
|
+
return out.join("\n") + "\n";
|
|
19
|
+
}
|
|
20
|
+
export function writeStats(root, s) {
|
|
21
|
+
const date = today();
|
|
22
|
+
const rel = `docs/metrics/${date}.md`;
|
|
23
|
+
mkdirSync(join(root, "docs/metrics"), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, rel), statsMarkdown(s, date));
|
|
25
|
+
return rel;
|
|
26
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// REQ-TASK-004 evidence files under .ctx/evidence/<change key>/<task>.json.
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileSha256 } from "../util/hash.js";
|
|
5
|
+
import { dumps } from "../util/json.js";
|
|
6
|
+
import { isoNow } from "../util/time.js";
|
|
7
|
+
import { VERSION } from "../version.js";
|
|
8
|
+
import { evidenceRel } from "./tasks.js";
|
|
9
|
+
/** Digests of every code and test file tagged with one of the REQs. */
|
|
10
|
+
export function taggedFiles(root, entries, reqs) {
|
|
11
|
+
const want = new Set(reqs);
|
|
12
|
+
const paths = new Set();
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
if ((e.kind === "code" || e.kind === "test") && e.refs.some((r) => want.has(r)))
|
|
15
|
+
paths.add(e.file);
|
|
16
|
+
}
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const p of [...paths].sort()) {
|
|
19
|
+
const d = fileSha256(join(root, p));
|
|
20
|
+
if (d)
|
|
21
|
+
out[p] = d;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
export function writeEvidence(root, t, entries, verify, kind, note, cwd) {
|
|
26
|
+
const ev = {
|
|
27
|
+
task: t.id,
|
|
28
|
+
reqs: [...t.reqs],
|
|
29
|
+
kind,
|
|
30
|
+
verify,
|
|
31
|
+
files: taggedFiles(root, entries, t.reqs),
|
|
32
|
+
at: isoNow(),
|
|
33
|
+
tool_version: VERSION,
|
|
34
|
+
};
|
|
35
|
+
if (note !== undefined)
|
|
36
|
+
ev.note = note;
|
|
37
|
+
if (cwd !== undefined)
|
|
38
|
+
ev.cwd = cwd;
|
|
39
|
+
// REQ-STAT-002 copy the bundle size and the start time that task start wrote for this task
|
|
40
|
+
try {
|
|
41
|
+
const cur = JSON.parse(readFileSync(join(root, ".ctx/current.json"), "utf8"));
|
|
42
|
+
if (cur.task === t.id) {
|
|
43
|
+
if (typeof cur.bundle_chars === "number")
|
|
44
|
+
ev.bundle_chars = cur.bundle_chars;
|
|
45
|
+
if (typeof cur.started_at === "string")
|
|
46
|
+
ev.started_at = cur.started_at;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// no current task
|
|
51
|
+
}
|
|
52
|
+
const rel = evidenceRel(t);
|
|
53
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
54
|
+
writeFileSync(join(root, rel), dumps(ev) + "\n");
|
|
55
|
+
return rel;
|
|
56
|
+
}
|
|
57
|
+
/** Move the evidence of a reopened task to <task>.reopened-<n>.json. */
|
|
58
|
+
export function retireEvidence(root, t, reason) {
|
|
59
|
+
const rel = evidenceRel(t);
|
|
60
|
+
const abs = join(root, rel);
|
|
61
|
+
if (!existsSync(abs))
|
|
62
|
+
return null;
|
|
63
|
+
const base = rel.slice(0, -".json".length);
|
|
64
|
+
let n = 1;
|
|
65
|
+
while (existsSync(join(root, `${base}.reopened-${n}.json`)))
|
|
66
|
+
n++;
|
|
67
|
+
const target = `${base}.reopened-${n}.json`;
|
|
68
|
+
renameSync(abs, join(root, target));
|
|
69
|
+
if (reason !== undefined) {
|
|
70
|
+
// REQ-STAT-003 the reopen reason stays with the old evidence, so stats can count stale catches
|
|
71
|
+
try {
|
|
72
|
+
const ev = JSON.parse(readFileSync(join(root, target), "utf8"));
|
|
73
|
+
ev.reason = reason;
|
|
74
|
+
writeFileSync(join(root, target), dumps(ev) + "\n");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// a damaged file moves as it is
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
/** Every live evidence file, as a path relative to the root. Reopened copies are history and stay out. */
|
|
83
|
+
export function listEvidence(root) {
|
|
84
|
+
const dir = join(root, ".ctx/evidence");
|
|
85
|
+
if (!existsSync(dir))
|
|
86
|
+
return [];
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const c of readdirSync(dir).sort()) {
|
|
89
|
+
let names;
|
|
90
|
+
try {
|
|
91
|
+
names = readdirSync(join(dir, c));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const n of names.sort()) {
|
|
97
|
+
if (n.endsWith(".json") && !n.includes(".reopened-"))
|
|
98
|
+
out.push(`.ctx/evidence/${c}/${n}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 the task state machine over tasks.md.
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { UsageExit } from "../cli/errors.js";
|
|
5
|
+
import { matchesChange, resolveTaskId } from "../index/scan.js";
|
|
6
|
+
import { cmpStr, cmpTuple, splitLines } from "../util/pystr.js";
|
|
7
|
+
import { parseVerify } from "../verify/parse.js";
|
|
8
|
+
/** Raw lines of a file with their line ends, so an edit keeps every other byte. */
|
|
9
|
+
function rawLines(text) {
|
|
10
|
+
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
11
|
+
}
|
|
12
|
+
/** The task text without the REQ brackets and the Verify clause. */
|
|
13
|
+
export function taskText(title) {
|
|
14
|
+
let t = title;
|
|
15
|
+
const v = t.indexOf("Verify:");
|
|
16
|
+
if (v >= 0)
|
|
17
|
+
t = t.slice(0, v);
|
|
18
|
+
return t.replace(/\[REQ-[A-Z]{2,6}-\d{3}(?:\s*,\s*REQ-[A-Z]{2,6}-\d{3})*\]/g, "").replace(/\s+/g, " ").trim();
|
|
19
|
+
}
|
|
20
|
+
export function toTask(root, e) {
|
|
21
|
+
const lines = splitLines(readFileSync(join(root, e.file)).toString("utf8"));
|
|
22
|
+
const line = lines[e.line - 1] ?? e.title;
|
|
23
|
+
const num = e.id.slice(e.id.indexOf("/") + 1);
|
|
24
|
+
return {
|
|
25
|
+
entry: e,
|
|
26
|
+
id: e.id,
|
|
27
|
+
change: e.change,
|
|
28
|
+
group: num.split(".")[0],
|
|
29
|
+
num,
|
|
30
|
+
done: e.status === "done",
|
|
31
|
+
archived: e.file.includes("/archive/"),
|
|
32
|
+
reqs: [...e.refs],
|
|
33
|
+
line,
|
|
34
|
+
text: taskText(e.title),
|
|
35
|
+
verify: parseVerify(line),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function tasksOf(entries) {
|
|
39
|
+
return entries.filter((e) => e.kind === "task");
|
|
40
|
+
}
|
|
41
|
+
export function findTask(root, entries, id) {
|
|
42
|
+
// REQ-IDX-005 the full folder name works as an alias of the key
|
|
43
|
+
const want = resolveTaskId(entries, id);
|
|
44
|
+
const hits = tasksOf(entries).filter((e) => e.id === want);
|
|
45
|
+
if (hits.length === 0)
|
|
46
|
+
throw new UsageExit(`no task ${id}`);
|
|
47
|
+
// an active copy wins over an archived one with the same number
|
|
48
|
+
hits.sort((a, b) => Number(a.file.includes("/archive/")) - Number(b.file.includes("/archive/")));
|
|
49
|
+
return toTask(root, hits[0]);
|
|
50
|
+
}
|
|
51
|
+
export function activeChanges(entries) {
|
|
52
|
+
const s = new Set();
|
|
53
|
+
for (const e of tasksOf(entries))
|
|
54
|
+
if (!e.file.includes("/archive/") && e.change !== "main")
|
|
55
|
+
s.add(e.change);
|
|
56
|
+
return [...s].sort(cmpStr);
|
|
57
|
+
}
|
|
58
|
+
/** The first open task whose earlier tasks in the same group are done, in the lowest numbered active change. */
|
|
59
|
+
export function nextTask(root, entries, change) {
|
|
60
|
+
const changes = activeChanges(entries).filter((c) => !change || matchesChange(c, change));
|
|
61
|
+
for (const c of changes) {
|
|
62
|
+
const ts = tasksOf(entries)
|
|
63
|
+
.filter((e) => e.change === c && !e.file.includes("/archive/"))
|
|
64
|
+
.sort((a, b) => cmpTuple([a.file, a.line], [b.file, b.line]));
|
|
65
|
+
for (const e of ts) {
|
|
66
|
+
if (e.status !== "open")
|
|
67
|
+
continue;
|
|
68
|
+
const group = e.id.slice(e.id.indexOf("/") + 1).split(".")[0];
|
|
69
|
+
const earlier = ts.filter((x) => x.line < e.line && x.file === e.file && x.id.slice(x.id.indexOf("/") + 1).split(".")[0] === group);
|
|
70
|
+
if (earlier.every((x) => x.status === "done"))
|
|
71
|
+
return toTask(root, e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** Flip only the box characters on the task line. Every other byte of tasks.md stays. */
|
|
77
|
+
export function setBox(root, t, done) {
|
|
78
|
+
const p = join(root, t.entry.file);
|
|
79
|
+
const raw = rawLines(readFileSync(p).toString("utf8"));
|
|
80
|
+
const i = t.entry.line - 1;
|
|
81
|
+
const line = raw[i];
|
|
82
|
+
if (line === undefined)
|
|
83
|
+
throw new Error(`line ${t.entry.line} of ${t.entry.file} is missing`);
|
|
84
|
+
const m = /^(\s*- )\[([ xX]?)\]/.exec(line);
|
|
85
|
+
if (!m)
|
|
86
|
+
throw new Error(`${t.entry.file}:${t.entry.line} is not a task line`);
|
|
87
|
+
const box = done ? "[x]" : "[ ]";
|
|
88
|
+
raw[i] = m[1] + box + line.slice(m[0].length);
|
|
89
|
+
writeFileSync(p, raw.join(""));
|
|
90
|
+
invalidateIndex(root);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* REQ-IDX-004 a write by specctx itself drops the index, so the next command rebuilds it
|
|
94
|
+
* even when the edit lands in the same millisecond as the last build.
|
|
95
|
+
*/
|
|
96
|
+
export function invalidateIndex(root) {
|
|
97
|
+
rmSync(join(root, ".ctx/index.json"), { force: true });
|
|
98
|
+
}
|
|
99
|
+
export function evidenceRel(t) {
|
|
100
|
+
const [num, task] = t.id.split("/");
|
|
101
|
+
return `.ctx/evidence/${num}/${task}.json`;
|
|
102
|
+
}
|
|
103
|
+
export function hasEvidence(root, t) {
|
|
104
|
+
return existsSync(join(root, evidenceRel(t)));
|
|
105
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// REQ-IDX-001 glob matching for scan and ignore paths.
|
|
2
|
+
const SPECIAL = ".^$+{}()[]|\\";
|
|
3
|
+
/** `**\/` is zero or more directories, `**` is anything, `*` and `?` stay inside one segment. */
|
|
4
|
+
export function globRe(pattern) {
|
|
5
|
+
let out = "";
|
|
6
|
+
let i = 0;
|
|
7
|
+
while (i < pattern.length) {
|
|
8
|
+
if (pattern.startsWith("**/", i)) {
|
|
9
|
+
out += "(?:.*/)?";
|
|
10
|
+
i += 3;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (pattern.startsWith("**", i)) {
|
|
14
|
+
out += ".*";
|
|
15
|
+
i += 2;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const c = pattern[i];
|
|
19
|
+
if (c === "*")
|
|
20
|
+
out += "[^/]*";
|
|
21
|
+
else if (c === "?")
|
|
22
|
+
out += "[^/]";
|
|
23
|
+
else if (SPECIAL.includes(c))
|
|
24
|
+
out += "\\" + c;
|
|
25
|
+
else
|
|
26
|
+
out += c;
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
return new RegExp(`^(?:${out})$`, "s");
|
|
30
|
+
}
|
|
31
|
+
/** Directory part of a glob before the first wildcard segment. */
|
|
32
|
+
export function globBase(pattern) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
for (const seg of pattern.split("/").slice(0, -1)) {
|
|
35
|
+
if (seg.includes("*") || seg.includes("?"))
|
|
36
|
+
break;
|
|
37
|
+
parts.push(seg);
|
|
38
|
+
}
|
|
39
|
+
return parts.join("/");
|
|
40
|
+
}
|
|
41
|
+
export function anyMatch(globs, path) {
|
|
42
|
+
return globs.some((g) => g.test(path));
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// REQ-TASK-004 sha256 hex digests.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
export function sha256(data) {
|
|
5
|
+
return createHash("sha256").update(data).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function fileSha256(path) {
|
|
8
|
+
try {
|
|
9
|
+
return sha256(readFileSync(path));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// REQ-IDX-002 deterministic JSON: sorted keys, two-space indent, UTF-8 kept as is.
|
|
2
|
+
import { cmpStr } from "./pystr.js";
|
|
3
|
+
function sortKeys(v) {
|
|
4
|
+
if (Array.isArray(v))
|
|
5
|
+
return v.map(sortKeys);
|
|
6
|
+
if (v !== null && typeof v === "object") {
|
|
7
|
+
const out = {};
|
|
8
|
+
for (const k of Object.keys(v).sort(cmpStr))
|
|
9
|
+
out[k] = sortKeys(v[k]);
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
export function dumps(v) {
|
|
15
|
+
return JSON.stringify(sortKeys(v), null, 2);
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// REQ-IDX-002 string helpers with Python semantics. The goldens under tests/fixtures/parity-golden/ came from a Python engine.
|
|
2
|
+
/** Characters that Python `str.isspace()` accepts. JavaScript `\s` and `trim()` use a different set. */
|
|
3
|
+
const PY_WS = "\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
|
|
4
|
+
const WS_RUN = new RegExp(`[${PY_WS}]+`, "g");
|
|
5
|
+
const LEAD = new RegExp(`^[${PY_WS}]+`);
|
|
6
|
+
const TRAIL = new RegExp(`[${PY_WS}]+$`);
|
|
7
|
+
/** Python `str.strip()`. */
|
|
8
|
+
export function pyStrip(s) {
|
|
9
|
+
return s.replace(LEAD, "").replace(TRAIL, "");
|
|
10
|
+
}
|
|
11
|
+
/** Python `str.split()` with no argument. */
|
|
12
|
+
export function pySplit(s) {
|
|
13
|
+
const t = pyStrip(s);
|
|
14
|
+
return t === "" ? [] : t.split(WS_RUN);
|
|
15
|
+
}
|
|
16
|
+
/** Lines split on LF only, with a trailing CR removed. */
|
|
17
|
+
export function splitLines(text) {
|
|
18
|
+
const lines = text.split("\n");
|
|
19
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
20
|
+
lines.pop();
|
|
21
|
+
return lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
22
|
+
}
|
|
23
|
+
/** Join, collapse whitespace, lower-case. */
|
|
24
|
+
export function squash(lines) {
|
|
25
|
+
return pySplit(lines.join(" ")).join(" ").toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
/** Length in code points, as Python `len()` counts it. */
|
|
28
|
+
export function cpLen(s) {
|
|
29
|
+
let n = 0;
|
|
30
|
+
for (let i = 0; i < s.length; i++) {
|
|
31
|
+
const c = s.charCodeAt(i);
|
|
32
|
+
if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
|
|
33
|
+
const d = s.charCodeAt(i + 1);
|
|
34
|
+
if (d >= 0xdc00 && d <= 0xdfff)
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
n++;
|
|
38
|
+
}
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
/** Compare strings by code point, as Python sorts them. */
|
|
42
|
+
export function cmpStr(a, b) {
|
|
43
|
+
if (a === b)
|
|
44
|
+
return 0;
|
|
45
|
+
const n = Math.min(a.length, b.length);
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
const x = a.codePointAt(i);
|
|
48
|
+
const y = b.codePointAt(i);
|
|
49
|
+
if (x !== y)
|
|
50
|
+
return x < y ? -1 : 1;
|
|
51
|
+
if (x > 0xffff)
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
return a.length < b.length ? -1 : a.length > b.length ? 1 : 0;
|
|
55
|
+
}
|
|
56
|
+
/** Compare tuples of strings and numbers element by element. */
|
|
57
|
+
export function cmpTuple(a, b) {
|
|
58
|
+
const n = Math.min(a.length, b.length);
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
const x = a[i];
|
|
61
|
+
const y = b[i];
|
|
62
|
+
const c = typeof x === "number" && typeof y === "number" ? x - y : cmpStr(String(x), String(y));
|
|
63
|
+
if (c !== 0)
|
|
64
|
+
return c < 0 ? -1 : 1;
|
|
65
|
+
}
|
|
66
|
+
return a.length - b.length;
|
|
67
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// REQ-TASK-004 the clock. SPECCTX_NOW sets a fixed time for tests.
|
|
2
|
+
export function now() {
|
|
3
|
+
const fixed = process.env.SPECCTX_NOW;
|
|
4
|
+
return fixed ? new Date(fixed) : new Date();
|
|
5
|
+
}
|
|
6
|
+
export function isoNow() {
|
|
7
|
+
return now().toISOString();
|
|
8
|
+
}
|
|
9
|
+
export function today() {
|
|
10
|
+
return isoNow().slice(0, 10);
|
|
11
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// REQ-VER-001 the Verify command of a task line.
|
|
2
|
+
/** Code spans in a text, as CommonMark reads them: a run of N backticks closes at the next run of N. */
|
|
3
|
+
export function codeSpans(text) {
|
|
4
|
+
const spans = [];
|
|
5
|
+
let i = 0;
|
|
6
|
+
while (i < text.length) {
|
|
7
|
+
if (text[i] !== "`") {
|
|
8
|
+
i++;
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
let n = 0;
|
|
12
|
+
while (text[i + n] === "`")
|
|
13
|
+
n++;
|
|
14
|
+
const open = i + n;
|
|
15
|
+
let j = open;
|
|
16
|
+
let close = -1;
|
|
17
|
+
while (j < text.length) {
|
|
18
|
+
if (text[j] !== "`") {
|
|
19
|
+
j++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
let m = 0;
|
|
23
|
+
while (text[j + m] === "`")
|
|
24
|
+
m++;
|
|
25
|
+
if (m === n) {
|
|
26
|
+
close = j;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
j += m;
|
|
30
|
+
}
|
|
31
|
+
if (close < 0) {
|
|
32
|
+
i = open;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let body = text.slice(open, close).replace(/\n/g, " ");
|
|
36
|
+
if (body.length > 2 && body.startsWith(" ") && body.endsWith(" ") && body.trim() !== "")
|
|
37
|
+
body = body.slice(1, -1);
|
|
38
|
+
if (body.trim())
|
|
39
|
+
spans.push(body);
|
|
40
|
+
i = close + n;
|
|
41
|
+
}
|
|
42
|
+
return spans;
|
|
43
|
+
}
|
|
44
|
+
export function parseVerify(taskLine) {
|
|
45
|
+
const at = taskLine.indexOf("Verify:");
|
|
46
|
+
if (at < 0)
|
|
47
|
+
return { kind: "manual", command: null };
|
|
48
|
+
const spans = codeSpans(taskLine.slice(at + "Verify:".length));
|
|
49
|
+
if (spans.length === 0)
|
|
50
|
+
return { kind: "manual", command: null };
|
|
51
|
+
// REQ-VER-004 the first code span may start with cwd=<path> and a space
|
|
52
|
+
const m = /^cwd=(\S+) +(.+)$/s.exec(spans[0]);
|
|
53
|
+
if (m) {
|
|
54
|
+
spans[0] = m[2];
|
|
55
|
+
return { kind: "command", command: spans.join(" && "), cwd: m[1] };
|
|
56
|
+
}
|
|
57
|
+
return { kind: "command", command: spans.join(" && ") };
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// REQ-VER-001 REQ-VER-003 run a Verify command with sh -c in the project root, with a timeout.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
export const TAIL_LINES = 20;
|
|
4
|
+
export function tail(text, n = TAIL_LINES) {
|
|
5
|
+
const lines = text.replace(/\n$/, "").split("\n");
|
|
6
|
+
return text === "" ? "" : lines.slice(-n).join("\n");
|
|
7
|
+
}
|
|
8
|
+
export function runVerify(command, cwd, timeoutS) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
// a new process group, so the timeout stops the children of sh too
|
|
11
|
+
const child = spawn("sh", ["-c", command], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
const out = [];
|
|
13
|
+
const err = [];
|
|
14
|
+
let output = "";
|
|
15
|
+
let timedOut = false;
|
|
16
|
+
child.stdout.on("data", (b) => {
|
|
17
|
+
out.push(b);
|
|
18
|
+
output += b.toString("utf8");
|
|
19
|
+
});
|
|
20
|
+
child.stderr.on("data", (b) => {
|
|
21
|
+
err.push(b);
|
|
22
|
+
output += b.toString("utf8");
|
|
23
|
+
});
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
timedOut = true;
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-child.pid, "SIGKILL");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
child.kill("SIGKILL");
|
|
31
|
+
}
|
|
32
|
+
}, timeoutS * 1000);
|
|
33
|
+
child.on("error", (e) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(e);
|
|
36
|
+
});
|
|
37
|
+
child.on("close", (code) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve({ exit: timedOut ? null : code, timedOut, stdout: Buffer.concat(out), stderr: Buffer.concat(err), output });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
package/dist/version.js
ADDED