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,275 @@
1
+ // REQ-CLI-001 REQ-CLI-002 command actions.
2
+ import { resolve } from "node:path";
3
+ import { UsageExit } from "./errors.js";
4
+ import { findRoot, requireOpenSpec } from "./root.js";
5
+ import { hintOf } from "../check/hints.js";
6
+ /** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
7
+ async function runCheck(root, cfg, data, strict) {
8
+ const { check } = await import("../check/check.js");
9
+ const { applyBaseline } = await import("../baseline/baseline.js");
10
+ const findings = check(root, cfg, data, strict);
11
+ let warnings = [];
12
+ if (strict) {
13
+ const { manualWarnings } = await import("../check/evidence.js");
14
+ const { listEvidence } = await import("../tasks/evidence.js");
15
+ warnings = manualWarnings(root, data.entries, listEvidence(root));
16
+ }
17
+ return applyBaseline(root, findings, warnings);
18
+ }
19
+ /**
20
+ * REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
21
+ * A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
22
+ */
23
+ export function checkText(r) {
24
+ const body = r.findings.map((x) => `${x.file}:${x.line} ${x.rule} ${x.id}\n`).join("")
25
+ + r.warnings.map((w) => `warning: ${w}\n`).join("");
26
+ const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
27
+ const n = r.findings.length + r.warnings.length;
28
+ if (!n)
29
+ return `0 findings\n${base}`;
30
+ const files = new Set(r.findings.map((x) => x.file));
31
+ const count = new Map();
32
+ for (const x of r.findings)
33
+ count.set(x.rule, (count.get(x.rule) ?? 0) + 1);
34
+ for (const w of r.warnings) {
35
+ const [rule, task] = w.split(" ");
36
+ count.set(rule, (count.get(rule) ?? 0) + 1);
37
+ // a manual-evidence warning names a task. Its file is the evidence file of the task.
38
+ if (task?.includes("/"))
39
+ files.add(`.ctx/evidence/${task}.json`);
40
+ }
41
+ const rules = [...count.keys()].sort();
42
+ return body
43
+ + `\n${n} finding${n === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} (${r.findings.length} hard, ${r.warnings.length} advisory)\n`
44
+ + rules.map((rule) => `${rule} ${count.get(rule)} ${hintOf(rule)}\n`).join("")
45
+ + base;
46
+ }
47
+ export async function run(p, io) {
48
+ const f = p.flags;
49
+ const json = !!f.json;
50
+ const cmd = p.command;
51
+ if (cmd === "init")
52
+ return init(p, io);
53
+ const root = findRoot(io.cwd, f.root);
54
+ requireOpenSpec(root);
55
+ const { loadConfig, ConfigError } = await import("../config.js");
56
+ let cfg;
57
+ try {
58
+ cfg = loadConfig(root);
59
+ }
60
+ catch (e) {
61
+ if (e instanceof ConfigError)
62
+ throw new UsageExit(e.message);
63
+ throw e;
64
+ }
65
+ const { dumps } = await import("../util/json.js");
66
+ const print = (v) => io.out(dumps(v) + "\n");
67
+ if (cmd === "build") {
68
+ if (p.positional.length)
69
+ throw new UsageExit("build takes no arguments");
70
+ const { buildIndex, INDEX_PATH } = await import("../index/build.js");
71
+ const data = buildIndex(root, cfg);
72
+ if (json)
73
+ print({ entries: data.entries.length, index: INDEX_PATH, problems: data.problems.length });
74
+ else
75
+ io.out(`indexed ${data.entries.length} entries, ${data.problems.length} problem(s) -> ${INDEX_PATH}\n`);
76
+ return 0;
77
+ }
78
+ if (cmd === "adopt") {
79
+ if (p.positional.length)
80
+ throw new UsageExit("adopt takes no arguments");
81
+ const { planAdopt, planLines } = await import("../adopt/plan.js");
82
+ const plan = planAdopt(root, cfg);
83
+ if (!f["dry-run"]) {
84
+ const { applyAdopt } = await import("../adopt/write.js");
85
+ return applyAdopt(root, cfg, plan, io, json);
86
+ }
87
+ if (json)
88
+ print(plan);
89
+ else if (!plan.edits.length)
90
+ io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
91
+ // REQ-CLI-007 the dry run ends with the next step. The exit code stays 1.
92
+ else
93
+ io.out(planLines(plan).join("\n") + `\n${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
94
+ return plan.edits.length ? 1 : 0;
95
+ }
96
+ if (cmd === "baseline write" || cmd === "baseline prune") {
97
+ if (p.positional.length)
98
+ throw new UsageExit(`${cmd} takes no arguments`);
99
+ const { buildIndex } = await import("../index/build.js");
100
+ const { check } = await import("../check/check.js");
101
+ const { manualWarnings } = await import("../check/evidence.js");
102
+ const { listEvidence } = await import("../tasks/evidence.js");
103
+ const { writeBaseline, pruneBaseline, BASELINE_PATH } = await import("../baseline/baseline.js");
104
+ const data = buildIndex(root, cfg);
105
+ const findings = check(root, cfg, data, true);
106
+ const warnings = manualWarnings(root, data.entries, listEvidence(root));
107
+ if (cmd === "baseline write") {
108
+ const list = writeBaseline(root, findings, warnings);
109
+ if (json)
110
+ print({ baseline: BASELINE_PATH, entries: list.length });
111
+ else
112
+ io.out(`wrote ${BASELINE_PATH} with ${list.length} entr${list.length === 1 ? "y" : "ies"}\n`);
113
+ }
114
+ else {
115
+ const n = pruneBaseline(root, findings, warnings);
116
+ if (json)
117
+ print({ pruned: n });
118
+ else
119
+ io.out(`pruned ${n}\n`);
120
+ }
121
+ return 0;
122
+ }
123
+ if (cmd === "gate") {
124
+ if (p.positional.length)
125
+ throw new UsageExit("gate takes no arguments");
126
+ const { runGate, DEFAULT_MARKDOWN } = await import("./gate.js");
127
+ const strict = !!f.strict;
128
+ return runGate(root, {
129
+ strict,
130
+ markdown: f.markdown ?? DEFAULT_MARKDOWN,
131
+ check: async () => {
132
+ const { buildIndex } = await import("../index/build.js");
133
+ const { check } = await import("../check/check.js");
134
+ void check;
135
+ const data = buildIndex(root, cfg);
136
+ const r = await runCheck(root, cfg, data, strict);
137
+ return { code: r.findings.length ? 1 : 0, out: checkText(r) };
138
+ },
139
+ }, io);
140
+ }
141
+ const { loadIndex } = await import("../index/build.js");
142
+ const need = (n, what) => {
143
+ if (p.positional.length < n)
144
+ throw new UsageExit(`${cmd} needs ${what}`);
145
+ };
146
+ if (cmd === "query")
147
+ need(1, "at least one term");
148
+ if (cmd === "related") {
149
+ need(1, "one ID");
150
+ if (p.positional.length > 1)
151
+ throw new UsageExit("related takes one ID");
152
+ }
153
+ if (cmd === "bundle")
154
+ need(1, "at least one ID");
155
+ if ((cmd === "status" || cmd === "check") && p.positional.length)
156
+ throw new UsageExit(`${cmd} takes no arguments`);
157
+ const { data, rebuilt } = loadIndex(root, cfg);
158
+ if (rebuilt)
159
+ io.err("index rebuilt\n");
160
+ const entries = data.entries;
161
+ if (cmd.includes(" ")) {
162
+ const { runWorkflow } = await import("./workflow.js");
163
+ return runWorkflow({ root, cfg, data, p, io, json });
164
+ }
165
+ const all = !!f.all;
166
+ switch (cmd) {
167
+ case "status": {
168
+ const { statusReport, statusLines } = await import("./status.js");
169
+ const r = statusReport(root, entries);
170
+ if (json)
171
+ print(r);
172
+ else
173
+ io.out(statusLines(r).map((l) => l + "\n").join(""));
174
+ return 0;
175
+ }
176
+ case "query": {
177
+ const { query, queryLine } = await import("../graph/query.js");
178
+ const hits = query(entries, p.positional, {
179
+ kind: f.kind, change: f.change, all, limit: f.limit ?? 20,
180
+ });
181
+ if (json)
182
+ print(hits);
183
+ else
184
+ io.out(hits.map((e) => queryLine(e) + "\n").join(""));
185
+ return 0;
186
+ }
187
+ case "related": {
188
+ const { canon } = await import("../index/canonical.js");
189
+ const { related } = await import("../graph/related.js");
190
+ const id = p.positional[0];
191
+ const ids = canon(entries);
192
+ if (!ids.has(id) && !(all && entries.some((e) => e.id === id))) {
193
+ io.err(`unknown id ${id}\n`);
194
+ return 2;
195
+ }
196
+ const rows = [];
197
+ for (const [i, reason] of related(entries, id, f.depth ?? 1, all)) {
198
+ const copies = all ? entries.filter((e) => e.id === i) : ids.has(i) ? [ids.get(i)] : [];
199
+ // canonical copy first, the rest in index order
200
+ copies.sort((a, b) => Number(!a.canonical) - Number(!b.canonical));
201
+ for (const e of copies)
202
+ rows.push([e, reason]);
203
+ }
204
+ if (json)
205
+ print(rows.map(([e, reason]) => ({ ...e, reason })));
206
+ else
207
+ io.out(rows.map(([e, r]) => `${e.kind} ${e.id} ${e.status} ${e.file}:${e.line} ${r}\n`).join(""));
208
+ return 0;
209
+ }
210
+ case "bundle": {
211
+ const { canon } = await import("../index/canonical.js");
212
+ const { bundle } = await import("../graph/bundle.js");
213
+ const ids = canon(entries);
214
+ const unknown = p.positional.filter((i) => !ids.has(i));
215
+ if (unknown.length) {
216
+ io.err(`unknown id(s): ${unknown.join(", ")}\n`);
217
+ return 2;
218
+ }
219
+ const b = bundle(root, entries, p.positional, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, all);
220
+ if (json)
221
+ print(b);
222
+ else
223
+ io.out(b.text);
224
+ return 0;
225
+ }
226
+ case "stats": {
227
+ if (p.positional.length)
228
+ throw new UsageExit("stats takes no arguments");
229
+ const { check } = await import("../check/check.js");
230
+ const { manualWarnings } = await import("../check/evidence.js");
231
+ const { listEvidence } = await import("../tasks/evidence.js");
232
+ const { computeStats, statsTable } = await import("../stats/stats.js");
233
+ const s = computeStats(root, entries, check(root, cfg, data, true), manualWarnings(root, entries, listEvidence(root)), f.change);
234
+ if (f.write) {
235
+ const { writeStats } = await import("../stats/write.js");
236
+ const rel = writeStats(root, s);
237
+ if (!json)
238
+ io.out(`wrote ${rel}\n`);
239
+ }
240
+ if (json)
241
+ print(s);
242
+ else
243
+ io.out(statsTable(s).map((l) => l + "\n").join(""));
244
+ return 0;
245
+ }
246
+ case "check": {
247
+ const r = await runCheck(root, cfg, data, !!f.strict);
248
+ if (json)
249
+ print({ baselined: r.baselined, count: r.findings.length, findings: r.findings, warnings: r.warnings });
250
+ else
251
+ io.out(checkText(r));
252
+ return r.findings.length ? 1 : 0;
253
+ }
254
+ }
255
+ throw new UsageExit(`unknown command: ${cmd}`);
256
+ }
257
+ async function init(p, io) {
258
+ const { runInit } = await import("../init/init.js");
259
+ if (p.positional.length)
260
+ throw new UsageExit("init takes no arguments");
261
+ const root = resolve(io.cwd, p.flags.root ?? ".");
262
+ const all = !!p.flags.all;
263
+ const r = runInit(root, { schema: all || !!p.flags.schema, skills: all || !!p.flags.skills, force: !!p.flags.force });
264
+ if (p.flags.json)
265
+ io.out(JSON.stringify(r, null, 2) + "\n");
266
+ else if (r.nothing)
267
+ io.out("nothing to do\nnext: specctx status\n");
268
+ else {
269
+ io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
270
+ io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
271
+ // REQ-CLI-007 init ends with the next step
272
+ io.out("next: specctx status\n");
273
+ }
274
+ return 0;
275
+ }
@@ -0,0 +1,3 @@
1
+ // REQ-CLI-002 a usage or missing input error exits 2.
2
+ export class UsageExit extends Error {
3
+ }
@@ -0,0 +1,187 @@
1
+ // REQ-CLI-004 specctx gate: check, both linters, openspec validate, spec length. Stops at the first failure.
2
+ import { spawnSync } from "node:child_process";
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { anyMatch, globRe } from "../util/glob.js";
6
+ import { cmpStr } from "../util/pystr.js";
7
+ export const DEFAULT_MARKDOWN = "openspec/**/*.md decisions/*.md docs/**/*.md *.md";
8
+ const SKIP = new Set(["node_modules", ".git", ".ctx", "dist", "build"]);
9
+ export function markdownFiles(root, globs) {
10
+ const res = globs.split(/\s+/).filter(Boolean).map(globRe);
11
+ const out = [];
12
+ const walk = (abs, rel) => {
13
+ let names;
14
+ try {
15
+ names = readdirSync(abs).sort(cmpStr);
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ for (const n of names) {
21
+ const p = join(abs, n);
22
+ const r = rel ? `${rel}/${n}` : n;
23
+ let dir = false;
24
+ try {
25
+ dir = statSync(p).isDirectory();
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ if (dir) {
31
+ if (!SKIP.has(n))
32
+ walk(p, r);
33
+ }
34
+ else if (anyMatch(res, r))
35
+ out.push(r);
36
+ }
37
+ };
38
+ walk(root, "");
39
+ return out;
40
+ }
41
+ export const GATE_LOG = ".ctx/gate-log.jsonl";
42
+ /** REQ-STAT-005 the gate steps in order. The log names the step that failed. */
43
+ export const GATE_STEPS = ["check", "lint tells", "lint ste", "validate", "spec-length"];
44
+ /** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
45
+ export function appendGateLog(root, line) {
46
+ mkdirSync(join(root, ".ctx"), { recursive: true });
47
+ const sorted = { at: line.at, duration_s: line.duration_s, failed_step: line.failed_step, ok: line.ok, steps: line.steps };
48
+ appendFileSync(join(root, GATE_LOG), JSON.stringify(sorted) + "\n");
49
+ }
50
+ export async function runGate(root, o, io) {
51
+ const start = Date.now();
52
+ const ran = [];
53
+ const r = await gateSteps(root, o, io, ran);
54
+ const { isoNow } = await import("../util/time.js");
55
+ appendGateLog(root, { at: isoNow(), ok: r === null, failed_step: r, duration_s: Math.round((Date.now() - start) / 1000), steps: [...ran] });
56
+ return r === null ? 0 : 1;
57
+ }
58
+ /** The gate steps. Returns null when every step passed, else the name of the failed step. */
59
+ async function gateSteps(root, o, io, ran) {
60
+ // gate 1: build and check
61
+ ran.push("check");
62
+ const c = await o.check();
63
+ if (c.code !== 0) {
64
+ io.out("check: fail\n" + c.out);
65
+ return "check";
66
+ }
67
+ io.out(`check: ok${o.strict ? " (strict)" : ""}\n`);
68
+ // gate 2: REQ-LINT-003 both linters of this package on the markdown globs. No python3.
69
+ const { lintSte } = await import("../lint/ste.js");
70
+ const { lintTells } = await import("../lint/tells.js");
71
+ const { steReport } = await import("../lint/cli.js");
72
+ const files = markdownFiles(root, o.markdown);
73
+ const bad = [];
74
+ let tellsFailed = false;
75
+ ran.push("lint tells", "lint ste");
76
+ const noRotation = new Set(["synonym-rotation"]);
77
+ for (const f of files) {
78
+ const text = readFileSync(join(root, f), "utf8").replace(/\r\n?/g, "\n");
79
+ const tells = lintTells(text);
80
+ const hard = tells.filter((x) => x.level === "hard");
81
+ if (hard.length)
82
+ tellsFailed = true;
83
+ if (hard.length)
84
+ bad.push(`${f}\n` + tells.map((x) => `${f}:${x.line}: [${x.level}] ${x.rule}: ${x.match}\n`).join("") + `${hard.length} hard finding(s), baseline 0\n`);
85
+ const r = lintSte(text, f);
86
+ const ste = r.findings.filter((x) => !noRotation.has(x.rule));
87
+ const steHard = ste.filter((x) => x.level === "advisory-free").length;
88
+ if (steHard)
89
+ bad.push(`${f}\n` + steReport(ste, r.words, false, steHard, 0));
90
+ }
91
+ if (bad.length) {
92
+ io.out("linters: fail\n" + bad.join(""));
93
+ return tellsFailed ? "lint tells" : "lint ste";
94
+ }
95
+ io.out(`linters: ok (${files.length} file(s))\n`);
96
+ ran.push("validate");
97
+ // gate 3: openspec validate. SPECCTX_OPENSPEC replaces the command, for tests and offline use.
98
+ const cmd = process.env.SPECCTX_OPENSPEC ?? "npx -y @fission-ai/openspec@latest";
99
+ const v = spawnSync("/bin/sh", ["-c", `${cmd} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8" });
100
+ if (v.status !== 0) {
101
+ io.out("validate: fail\n" + (v.stdout ?? "") + (v.stderr ?? ""));
102
+ return "validate";
103
+ }
104
+ io.out(`validate: ok${o.strict ? " (strict)" : ""}\n`);
105
+ ran.push("spec-length");
106
+ // gate 4: the length of every requirement in the deltas of active changes
107
+ const long = longRequirements(root);
108
+ if (long.length) {
109
+ io.out(long.map((l) => `spec-length: fail ${l.id} ${l.chars} chars\n`).join(""));
110
+ return "spec-length";
111
+ }
112
+ io.out("spec-length: ok\n");
113
+ return null;
114
+ }
115
+ // REQ-CLI-005 the limit strict validate applies to a requirement after archive
116
+ export const MAX_REQUIREMENT_CHARS = 500;
117
+ /** Lines inside a fenced code block, fence lines included. Same rule as OpenSpec. */
118
+ function fenceMask(lines) {
119
+ const mask = new Array(lines.length).fill(false);
120
+ let open = null;
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i];
123
+ if (!open) {
124
+ const m = /^\s*(`{3,}|~{3,})/.exec(line);
125
+ if (m) {
126
+ open = { ch: m[1][0], len: m[1].length };
127
+ mask[i] = true;
128
+ }
129
+ continue;
130
+ }
131
+ mask[i] = true;
132
+ const c = /^\s*(`{3,}|~{3,})\s*$/.exec(line);
133
+ if (c && c[1][0] === open.ch && c[1].length >= open.len)
134
+ open = null;
135
+ }
136
+ return mask;
137
+ }
138
+ /**
139
+ * The requirement text as OpenSpec counts it: the trimmed, non-blank lines after the header,
140
+ * up to the first header line, joined with a newline. Metadata lines count only when nothing else is there.
141
+ */
142
+ export function requirementText(body) {
143
+ const mask = fenceMask(body);
144
+ const text = [];
145
+ const meta = [];
146
+ for (let i = 0; i < body.length; i++) {
147
+ if (mask[i])
148
+ continue;
149
+ if (/^#{1,6}\s/.test(body[i]))
150
+ break;
151
+ const t = body[i].trim();
152
+ if (!t)
153
+ continue;
154
+ if (/^\*\*[^*]+\*\*:/.test(t))
155
+ meta.push(t);
156
+ else
157
+ text.push(t);
158
+ }
159
+ return (text.length ? text : meta).join("\n");
160
+ }
161
+ /** Every requirement over the limit in openspec/changes/<active>/specs/** /spec.md. */
162
+ export function longRequirements(root) {
163
+ const out = [];
164
+ const changes = join(root, "openspec", "changes");
165
+ if (!existsSync(changes))
166
+ return out;
167
+ for (const c of readdirSync(changes).sort(cmpStr)) {
168
+ if (c === "archive" || !statSync(join(changes, c)).isDirectory())
169
+ continue;
170
+ for (const rel of markdownFiles(join(changes, c), "specs/**/spec.md")) {
171
+ const lines = readFileSync(join(changes, c, rel), "utf8").split(/\r?\n/);
172
+ const mask = fenceMask(lines);
173
+ for (let i = 0; i < lines.length; i++) {
174
+ const h = mask[i] ? null : /^###\s+Requirement:\s*(.*)$/.exec(lines[i]);
175
+ if (!h)
176
+ continue;
177
+ let end = i + 1;
178
+ while (end < lines.length && (mask[end] || !/^#{1,3}\s/.test(lines[end])))
179
+ end++;
180
+ const chars = requirementText(lines.slice(i + 1, end)).length; // UTF-16 units, as OpenSpec counts
181
+ if (chars > MAX_REQUIREMENT_CHARS)
182
+ out.push({ id: h[1].trim().split(/\s+/)[0], chars, file: `openspec/changes/${c}/${rel}` });
183
+ }
184
+ }
185
+ }
186
+ return out;
187
+ }