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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -2
  3. package/action.yml +33 -0
  4. package/dist/adopt/config-write.js +59 -0
  5. package/dist/adopt/plan.js +276 -0
  6. package/dist/adopt/write.js +126 -0
  7. package/dist/assets.generated.js +14 -0
  8. package/dist/baseline/baseline.js +65 -0
  9. package/dist/check/check.js +126 -0
  10. package/dist/check/evidence.js +70 -0
  11. package/dist/check/hints.js +26 -0
  12. package/dist/cli/args.js +90 -0
  13. package/dist/cli/commands.js +275 -0
  14. package/dist/cli/errors.js +3 -0
  15. package/dist/cli/gate.js +187 -0
  16. package/dist/cli/help.js +230 -0
  17. package/dist/cli/root.js +32 -0
  18. package/dist/cli/status.js +116 -0
  19. package/dist/cli/workflow.js +274 -0
  20. package/dist/cli.js +13 -0
  21. package/dist/config.js +119 -0
  22. package/dist/graph/bundle.js +94 -0
  23. package/dist/graph/query.js +31 -0
  24. package/dist/graph/related.js +76 -0
  25. package/dist/handoff/handoff.js +148 -0
  26. package/dist/ids/ids.js +115 -0
  27. package/dist/index/build.js +57 -0
  28. package/dist/index/canonical.js +44 -0
  29. package/dist/index/scan.js +357 -0
  30. package/dist/index/types.js +2 -0
  31. package/dist/init/init.js +141 -0
  32. package/dist/lint/cli.js +253 -0
  33. package/dist/lint/py.js +152 -0
  34. package/dist/lint/selftest.js +246 -0
  35. package/dist/lint/ste.js +395 -0
  36. package/dist/lint/tells.js +116 -0
  37. package/dist/main.js +74 -0
  38. package/dist/stats/stats.js +143 -0
  39. package/dist/stats/write.js +26 -0
  40. package/dist/tasks/evidence.js +102 -0
  41. package/dist/tasks/tasks.js +105 -0
  42. package/dist/util/glob.js +43 -0
  43. package/dist/util/hash.js +14 -0
  44. package/dist/util/json.js +16 -0
  45. package/dist/util/pystr.js +67 -0
  46. package/dist/util/time.js +11 -0
  47. package/dist/verify/parse.js +58 -0
  48. package/dist/verify/run.js +42 -0
  49. package/dist/version.js +2 -0
  50. package/package.json +50 -4
  51. package/schemas/spec-driven-ids/README.md +18 -0
  52. package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
  53. package/schemas/spec-driven-ids/schema.yaml +301 -0
  54. package/schemas/spec-driven-ids/templates/design.md +22 -0
  55. package/schemas/spec-driven-ids/templates/proposal.md +34 -0
  56. package/schemas/spec-driven-ids/templates/spec.md +15 -0
  57. package/schemas/spec-driven-ids/templates/tasks.md +10 -0
  58. package/skills/specctx-bootstrap/SKILL.md +27 -0
  59. package/skills/specctx-done/SKILL.md +25 -0
  60. 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
+ }
@@ -0,0 +1,2 @@
1
+ // REQ-CLI-001 version string. tests/cli/version.test.ts keeps it equal to package.json.
2
+ export const VERSION = "0.1.0";