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,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
|
+
}
|
package/dist/cli/gate.js
ADDED
|
@@ -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
|
+
}
|