spec-ctx 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -94
- package/dist/assets.generated.js +3 -3
- package/dist/baseline/baseline.js +35 -9
- package/dist/cli/color.js +23 -0
- package/dist/cli/commands.js +27 -17
- package/dist/cli/gate.js +18 -25
- package/dist/cli/help.js +5 -5
- package/dist/cli/workflow.js +21 -7
- package/dist/cli.js +1 -0
- package/dist/config.js +9 -0
- package/dist/graph/query.js +4 -1
- package/dist/index/scan.js +5 -2
- package/dist/init/init.js +103 -13
- package/dist/lint/counts.js +60 -0
- package/dist/stats/stats.js +6 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/schemas/spec-driven-ids/README.md +18 -5
- package/schemas/spec-driven-ids/config-fragment.yaml +13 -13
- package/skills/specctx-done/SKILL.md +1 -1
package/dist/cli/commands.js
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { UsageExit } from "./errors.js";
|
|
4
4
|
import { findRoot, requireOpenSpec } from "./root.js";
|
|
5
5
|
import { hintOf } from "../check/hints.js";
|
|
6
|
+
import { paint } from "./color.js";
|
|
6
7
|
/** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
|
|
7
8
|
async function runCheck(root, cfg, data, strict) {
|
|
8
9
|
const { check } = await import("../check/check.js");
|
|
@@ -20,13 +21,14 @@ async function runCheck(root, cfg, data, strict) {
|
|
|
20
21
|
* REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
|
|
21
22
|
* A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
|
|
22
23
|
*/
|
|
23
|
-
export function checkText(r) {
|
|
24
|
-
|
|
24
|
+
export function checkText(r, tty) {
|
|
25
|
+
// REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
|
|
26
|
+
const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}\n`, tty)).join("")
|
|
25
27
|
+ r.warnings.map((w) => `warning: ${w}\n`).join("");
|
|
26
28
|
const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
|
|
27
29
|
const n = r.findings.length + r.warnings.length;
|
|
28
30
|
if (!n)
|
|
29
|
-
return
|
|
31
|
+
return paint("dim", "0 findings\n", tty) + base;
|
|
30
32
|
const files = new Set(r.findings.map((x) => x.file));
|
|
31
33
|
const count = new Map();
|
|
32
34
|
for (const x of r.findings)
|
|
@@ -40,7 +42,7 @@ export function checkText(r) {
|
|
|
40
42
|
}
|
|
41
43
|
const rules = [...count.keys()].sort();
|
|
42
44
|
return body
|
|
43
|
-
+
|
|
45
|
+
+ "\n" + paint("dim", `${n} finding${n === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} (${r.findings.length} hard, ${r.warnings.length} advisory)\n`, tty)
|
|
44
46
|
+ rules.map((rule) => `${rule} ${count.get(rule)} ${hintOf(rule)}\n`).join("")
|
|
45
47
|
+ base;
|
|
46
48
|
}
|
|
@@ -88,9 +90,9 @@ export async function run(p, io) {
|
|
|
88
90
|
print(plan);
|
|
89
91
|
else if (!plan.edits.length)
|
|
90
92
|
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.
|
|
93
|
+
// REQ-CLI-007 the dry run ends with the next step. REQ-ADOPT-001 the exit code stays 1 and the last line says why (017/D5).
|
|
92
94
|
else
|
|
93
|
-
io.out(planLines(plan).join("\n") + `\
|
|
95
|
+
io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
|
|
94
96
|
return plan.edits.length ? 1 : 0;
|
|
95
97
|
}
|
|
96
98
|
if (cmd === "baseline write" || cmd === "baseline prune") {
|
|
@@ -104,15 +106,18 @@ export async function run(p, io) {
|
|
|
104
106
|
const data = buildIndex(root, cfg);
|
|
105
107
|
const findings = check(root, cfg, data, true);
|
|
106
108
|
const warnings = manualWarnings(root, data.entries, listEvidence(root));
|
|
109
|
+
// REQ-ADOPT-007 the lint debt of the gate globs joins the baseline
|
|
110
|
+
const { lintCounts } = await import("../lint/counts.js");
|
|
111
|
+
const lint = lintCounts(root, cfg.gate.markdown.join(" ")).counts;
|
|
107
112
|
if (cmd === "baseline write") {
|
|
108
|
-
const list = writeBaseline(root, findings, warnings);
|
|
113
|
+
const list = writeBaseline(root, findings, warnings, lint);
|
|
109
114
|
if (json)
|
|
110
115
|
print({ baseline: BASELINE_PATH, entries: list.length });
|
|
111
116
|
else
|
|
112
117
|
io.out(`wrote ${BASELINE_PATH} with ${list.length} entr${list.length === 1 ? "y" : "ies"}\n`);
|
|
113
118
|
}
|
|
114
119
|
else {
|
|
115
|
-
const n = pruneBaseline(root, findings, warnings);
|
|
120
|
+
const n = pruneBaseline(root, findings, warnings, lint);
|
|
116
121
|
if (json)
|
|
117
122
|
print({ pruned: n });
|
|
118
123
|
else
|
|
@@ -123,18 +128,19 @@ export async function run(p, io) {
|
|
|
123
128
|
if (cmd === "gate") {
|
|
124
129
|
if (p.positional.length)
|
|
125
130
|
throw new UsageExit("gate takes no arguments");
|
|
126
|
-
const { runGate
|
|
131
|
+
const { runGate } = await import("./gate.js");
|
|
127
132
|
const strict = !!f.strict;
|
|
128
133
|
return runGate(root, {
|
|
129
134
|
strict,
|
|
130
|
-
markdown: f.markdown ??
|
|
135
|
+
markdown: f.markdown ?? cfg.gate.markdown.join(" "),
|
|
136
|
+
json,
|
|
131
137
|
check: async () => {
|
|
132
138
|
const { buildIndex } = await import("../index/build.js");
|
|
133
139
|
const { check } = await import("../check/check.js");
|
|
134
140
|
void check;
|
|
135
141
|
const data = buildIndex(root, cfg);
|
|
136
142
|
const r = await runCheck(root, cfg, data, strict);
|
|
137
|
-
return { code: r.findings.length ? 1 : 0, out: checkText(r) };
|
|
143
|
+
return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty) };
|
|
138
144
|
},
|
|
139
145
|
}, io);
|
|
140
146
|
}
|
|
@@ -143,7 +149,8 @@ export async function run(p, io) {
|
|
|
143
149
|
if (p.positional.length < n)
|
|
144
150
|
throw new UsageExit(`${cmd} needs ${what}`);
|
|
145
151
|
};
|
|
146
|
-
|
|
152
|
+
// REQ-QRY-001 no term lists a whole change. Without --change a term is required (017/D6).
|
|
153
|
+
if (cmd === "query" && !f.change)
|
|
147
154
|
need(1, "at least one term");
|
|
148
155
|
if (cmd === "related") {
|
|
149
156
|
need(1, "one ID");
|
|
@@ -169,8 +176,9 @@ export async function run(p, io) {
|
|
|
169
176
|
const r = statusReport(root, entries);
|
|
170
177
|
if (json)
|
|
171
178
|
print(r);
|
|
179
|
+
// REQ-CLI-008 the next: line is dim
|
|
172
180
|
else
|
|
173
|
-
io.out(statusLines(r).map((l) => l + "\n").join(""));
|
|
181
|
+
io.out(statusLines(r).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
|
|
174
182
|
return 0;
|
|
175
183
|
}
|
|
176
184
|
case "query": {
|
|
@@ -248,7 +256,7 @@ export async function run(p, io) {
|
|
|
248
256
|
if (json)
|
|
249
257
|
print({ baselined: r.baselined, count: r.findings.length, findings: r.findings, warnings: r.warnings });
|
|
250
258
|
else
|
|
251
|
-
io.out(checkText(r));
|
|
259
|
+
io.out(checkText(r, io.tty));
|
|
252
260
|
return r.findings.length ? 1 : 0;
|
|
253
261
|
}
|
|
254
262
|
}
|
|
@@ -264,12 +272,14 @@ async function init(p, io) {
|
|
|
264
272
|
if (p.flags.json)
|
|
265
273
|
io.out(JSON.stringify(r, null, 2) + "\n");
|
|
266
274
|
else if (r.nothing)
|
|
267
|
-
io.out("nothing to do\
|
|
275
|
+
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
|
|
268
276
|
else {
|
|
269
277
|
io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
|
|
270
278
|
io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
|
|
271
|
-
|
|
272
|
-
|
|
279
|
+
io.out(r.notes.map((n) => `${n}\n`).join(""));
|
|
280
|
+
// REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
|
|
281
|
+
const again = ["specctx init", all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
|
|
282
|
+
io.out(paint("dim", `next: ${r.changed.length ? again : "specctx status"}\n`, io.tty));
|
|
273
283
|
}
|
|
274
284
|
return 0;
|
|
275
285
|
}
|
package/dist/cli/gate.js
CHANGED
|
@@ -40,6 +40,8 @@ export function markdownFiles(root, globs) {
|
|
|
40
40
|
}
|
|
41
41
|
export const GATE_LOG = ".ctx/gate-log.jsonl";
|
|
42
42
|
/** REQ-STAT-005 the gate steps in order. The log names the step that failed. */
|
|
43
|
+
/** REQ-CLI-004 the lint detail stops here unless --json asks for every line. */
|
|
44
|
+
export const LINT_DETAIL_CAP = 50;
|
|
43
45
|
export const GATE_STEPS = ["check", "lint tells", "lint ste", "validate", "spec-length"];
|
|
44
46
|
/** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
|
|
45
47
|
export function appendGateLog(root, line) {
|
|
@@ -66,33 +68,24 @@ async function gateSteps(root, o, io, ran) {
|
|
|
66
68
|
}
|
|
67
69
|
io.out(`check: ok${o.strict ? " (strict)" : ""}\n`);
|
|
68
70
|
// gate 2: REQ-LINT-003 both linters of this package on the markdown globs. No python3.
|
|
69
|
-
|
|
70
|
-
const {
|
|
71
|
-
const {
|
|
72
|
-
const files = markdownFiles(root, o.markdown);
|
|
73
|
-
const bad = [];
|
|
74
|
-
let tellsFailed = false;
|
|
71
|
+
// REQ-ADOPT-007 the step fails only on a count above its baseline line.
|
|
72
|
+
const { lintCounts, lintRises } = await import("../lint/counts.js");
|
|
73
|
+
const { readBaseline } = await import("../baseline/baseline.js");
|
|
75
74
|
ran.push("lint tells", "lint ste");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (steHard)
|
|
89
|
-
bad.push(`${f}\n` + steReport(ste, r.words, false, steHard, 0));
|
|
75
|
+
const { counts, files } = lintCounts(root, o.markdown);
|
|
76
|
+
const base = readBaseline(root);
|
|
77
|
+
const rises = lintRises(counts, base);
|
|
78
|
+
const baselined = [...base ?? []].filter((l) => l.startsWith("lint ")).length;
|
|
79
|
+
if (rises.length) {
|
|
80
|
+
const newFindings = rises.reduce((a, r) => a + r.current - r.baseline, 0);
|
|
81
|
+
const inFiles = new Set(rises.map((r) => r.key.split(" ")[1])).size;
|
|
82
|
+
const lines = rises.map((r) => `${r.key.slice(5).replace(" ", " ")} ${r.current} (baseline ${r.baseline})`);
|
|
83
|
+
const shown = o.json ? lines : lines.slice(0, LINT_DETAIL_CAP);
|
|
84
|
+
const more = lines.length - shown.length;
|
|
85
|
+
io.out(`linters: fail, ${newFindings} new finding(s) in ${inFiles} file(s)\n` + shown.map((l) => l + "\n").join("") + (more ? `... ${more} more line(s)\n` : ""));
|
|
86
|
+
return rises.some((r) => r.key.includes(" tells:")) ? "lint tells" : "lint ste";
|
|
90
87
|
}
|
|
91
|
-
|
|
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`);
|
|
88
|
+
io.out(`linters: ok (${files} file(s)${baselined ? `, ${baselined} baselined` : ""})\n`);
|
|
96
89
|
ran.push("validate");
|
|
97
90
|
// gate 3: openspec validate. SPECCTX_OPENSPEC replaces the command, for tests and offline use.
|
|
98
91
|
const cmd = process.env.SPECCTX_OPENSPEC ?? "npx -y @fission-ai/openspec@latest";
|
package/dist/cli/help.js
CHANGED
|
@@ -23,9 +23,9 @@ export const TABLE = [
|
|
|
23
23
|
// Read
|
|
24
24
|
{ name: "status", group: "Read", args: "", summary: "Show the active changes, the current task, and the open questions.", flags: [], exit: PLAIN, example: "specctx status" },
|
|
25
25
|
{
|
|
26
|
-
name: "query", group: "Read", args: "
|
|
26
|
+
name: "query", group: "Read", args: "[term...]", summary: "Find the entries that contain all terms. With --change and no term, list every entry of that change.",
|
|
27
27
|
flags: [
|
|
28
|
-
["--kind <kind>", "Show only entries of this kind."],
|
|
28
|
+
["--kind <kind>", "Show only entries of this kind. Any case works."],
|
|
29
29
|
["--change <name>", "Show only entries of this change. The key or the full folder name works."],
|
|
30
30
|
["--limit <n>", "Show n lines at most. The default is 20. Use 0 for no limit."],
|
|
31
31
|
["--all", "Show all copies of an ID."],
|
|
@@ -56,7 +56,7 @@ export const TABLE = [
|
|
|
56
56
|
summary: "Run `check`, the markdown linters, `openspec validate`, and the length check. Stop at the first failure.",
|
|
57
57
|
flags: [
|
|
58
58
|
["--strict", "Run `check` and `openspec validate` in strict mode."],
|
|
59
|
-
["--markdown <globs>", "Lint these markdown globs. Separate them with spaces."],
|
|
59
|
+
["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
|
|
60
60
|
],
|
|
61
61
|
exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
|
|
62
62
|
},
|
|
@@ -122,7 +122,7 @@ export const TABLE = [
|
|
|
122
122
|
["--schema", "Also install the spec-driven-ids schema and add its rules to openspec/config.yaml."],
|
|
123
123
|
["--skills", "Also install the three skills in .claude/skills and .agents/skills."],
|
|
124
124
|
["--all", "Do all three."],
|
|
125
|
-
["--force", "
|
|
125
|
+
["--force", "Replace the files that have other content. spec-ctx.toml stays."],
|
|
126
126
|
],
|
|
127
127
|
exit: PLAIN, example: "specctx init --all",
|
|
128
128
|
},
|
|
@@ -133,7 +133,7 @@ export const TABLE = [
|
|
|
133
133
|
example: "specctx adopt --dry-run",
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task that has none.",
|
|
136
|
+
name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task of an active change that has none.",
|
|
137
137
|
flags: [["--manual <note>", "The note for the evidence. The flag is required."], ["--change <name>", "Look only in this change."]],
|
|
138
138
|
exit: PLAIN, example: "specctx task backfill --manual \"done before spec-ctx\"",
|
|
139
139
|
},
|
package/dist/cli/workflow.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { dumps } from "../util/json.js";
|
|
5
|
+
import { paint } from "./color.js";
|
|
5
6
|
import { UsageExit } from "./errors.js";
|
|
6
7
|
const arg = (c, n, what) => {
|
|
7
8
|
const v = c.p.positional[n];
|
|
@@ -91,10 +92,12 @@ export async function runWorkflow(c) {
|
|
|
91
92
|
const { cpLen } = await import("../util/pystr.js");
|
|
92
93
|
// REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
|
|
93
94
|
writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id }) + "\n");
|
|
95
|
+
// REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
|
|
96
|
+
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run specctx query --change ${t.change} --kind req to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
|
|
94
97
|
if (c.json)
|
|
95
|
-
print(c, { bundle: b, task: t.id });
|
|
98
|
+
print(c, { bundle: b, hint, task: t.id });
|
|
96
99
|
else
|
|
97
|
-
c.io.out(b.text);
|
|
100
|
+
c.io.out(b.text + (hint ? hint + "\n" : "") + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
|
|
98
101
|
return 0;
|
|
99
102
|
}
|
|
100
103
|
case "task verify":
|
|
@@ -118,7 +121,7 @@ export async function runWorkflow(c) {
|
|
|
118
121
|
if (c.json)
|
|
119
122
|
print(c, { evidence: rel, kind: "manual", task: t.id });
|
|
120
123
|
else
|
|
121
|
-
c.io.out(`done ${t.id} (manual). Evidence: ${rel}\n
|
|
124
|
+
c.io.out(paint("ok", `done ${t.id} (manual). Evidence: ${rel}\n`, c.io.tty));
|
|
122
125
|
return 0;
|
|
123
126
|
}
|
|
124
127
|
if (!dry && t.done)
|
|
@@ -154,7 +157,12 @@ export async function runWorkflow(c) {
|
|
|
154
157
|
print(c, { command: t.verify.command, error: msg, exit: r.exit, tail: outTail, task: t.id });
|
|
155
158
|
else
|
|
156
159
|
c.io.out(outTail ? outTail + "\n" : "");
|
|
157
|
-
|
|
160
|
+
// REQ-VER-003 a timeout keeps its own line on stderr
|
|
161
|
+
if (r.timedOut)
|
|
162
|
+
c.io.err(`specctx: ${msg}. Task ${t.id} stays open.\n`);
|
|
163
|
+
// REQ-TASK-003 the refusal is the last line of the output. REQ-CLI-008 it is red on a TTY.
|
|
164
|
+
else if (!c.json)
|
|
165
|
+
c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
|
|
158
166
|
return 1;
|
|
159
167
|
}
|
|
160
168
|
const { sha256 } = await import("../util/hash.js");
|
|
@@ -170,11 +178,12 @@ export async function runWorkflow(c) {
|
|
|
170
178
|
if (c.json)
|
|
171
179
|
print(c, { evidence: rel, kind: "command", task: t.id });
|
|
172
180
|
else
|
|
173
|
-
c.io.out(`done ${t.id}. Evidence: ${rel}\n
|
|
181
|
+
c.io.out(paint("ok", `done ${t.id}. Evidence: ${rel}\n`, c.io.tty)); // REQ-CLI-008
|
|
174
182
|
return 0;
|
|
175
183
|
}
|
|
176
184
|
case "task backfill": {
|
|
177
|
-
// REQ-TASK-006 manual evidence for every ticked task that has none, through the task done --manual path
|
|
185
|
+
// REQ-TASK-006 manual evidence for every ticked task of an active change that has none, through the task done --manual path.
|
|
186
|
+
// Archived changes stay untouched (017/D1).
|
|
178
187
|
noMore(c, 0);
|
|
179
188
|
const note = f.manual?.trim();
|
|
180
189
|
if (!note)
|
|
@@ -185,7 +194,7 @@ export async function runWorkflow(c) {
|
|
|
185
194
|
const want = f.change;
|
|
186
195
|
const counts = new Map();
|
|
187
196
|
for (const e of tasksOf(entries)) {
|
|
188
|
-
if (e.status !== "done")
|
|
197
|
+
if (e.status !== "done" || e.file.includes("/archive/"))
|
|
189
198
|
continue;
|
|
190
199
|
if (want && !matchesChange(e.change, want))
|
|
191
200
|
continue;
|
|
@@ -272,3 +281,8 @@ function clearCurrent(root, task) {
|
|
|
272
281
|
// a damaged current.json stays for the user to read
|
|
273
282
|
}
|
|
274
283
|
}
|
|
284
|
+
// REQ-TASK-002 the footer of task start: the bundle size and its share of the spec text. bundle never prints it.
|
|
285
|
+
export function footer(n, m) {
|
|
286
|
+
const p = m > 0 ? Math.round((n / m) * 1000) / 10 : 0;
|
|
287
|
+
return `-- bundle ${n} chars, ${p}% of ${m} chars of spec text\n`;
|
|
288
|
+
}
|
package/dist/cli.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -16,6 +16,7 @@ export function defaults() {
|
|
|
16
16
|
ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {} },
|
|
17
17
|
bundle: { budget: 8000, depth: 1 },
|
|
18
18
|
verify: { timeout_s: 600 },
|
|
19
|
+
gate: { markdown: ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"] },
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
export class ConfigError extends Error {
|
|
@@ -81,6 +82,10 @@ export function loadConfig(root) {
|
|
|
81
82
|
throw new ConfigError(`${CONFIG_FILE}: verify.timeout_s must be a number above 0.`);
|
|
82
83
|
cfg.verify.timeout_s = v;
|
|
83
84
|
}
|
|
85
|
+
// REQ-DIST-002 the globs that gate lints
|
|
86
|
+
const gate = sec("gate");
|
|
87
|
+
if ("markdown" in gate)
|
|
88
|
+
cfg.gate.markdown = strList(gate.markdown, "gate.markdown");
|
|
84
89
|
return cfg;
|
|
85
90
|
}
|
|
86
91
|
/** The file that `specctx init` writes. Each key has a comment above it. */
|
|
@@ -116,4 +121,8 @@ depth = 1
|
|
|
116
121
|
[verify]
|
|
117
122
|
# Seconds before task done stops a Verify command.
|
|
118
123
|
timeout_s = 600
|
|
124
|
+
|
|
125
|
+
[gate]
|
|
126
|
+
# Markdown globs that gate lints. The baseline holds the lint debt of these files.
|
|
127
|
+
markdown = ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"]
|
|
119
128
|
`;
|
package/dist/graph/query.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// REQ-QRY-001 token AND over id, title, text. Score 3 for id, 2 for title, 1 for text, per term.
|
|
2
|
+
// With no term and a change filter, every entry of the change matches with score 0 (017/D6).
|
|
2
3
|
import { poolOf } from "../index/canonical.js";
|
|
3
4
|
import { matchesChange } from "../index/scan.js";
|
|
4
5
|
import { cmpTuple, pySplit } from "../util/pystr.js";
|
|
5
6
|
export function query(entries, args, o = {}) {
|
|
6
7
|
const terms = args.flatMap((a) => pySplit(a.toLowerCase()));
|
|
8
|
+
// REQ-QRY-001 the index stores kinds in lower case. The user may type the kind as the ID shows it.
|
|
9
|
+
const kind = o.kind?.toLowerCase();
|
|
7
10
|
const hits = [];
|
|
8
11
|
for (const e of poolOf(entries, !!o.all)) {
|
|
9
|
-
if ((
|
|
12
|
+
if ((kind && e.kind !== kind) || (o.change && !matchesChange(e.change, o.change)))
|
|
10
13
|
continue;
|
|
11
14
|
// REQ-IDX-005 --change takes a key or a full folder name
|
|
12
15
|
const idl = e.id.toLowerCase();
|
package/dist/index/scan.js
CHANGED
|
@@ -222,7 +222,9 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
222
222
|
const rid = m[1];
|
|
223
223
|
const title = pyStrip(m[2]);
|
|
224
224
|
if (!rid) {
|
|
225
|
-
|
|
225
|
+
// REQ-CHK-001 an archived header without an ID is history. adopt never writes it.
|
|
226
|
+
if (status !== "archived")
|
|
227
|
+
problems.push([rpath, i, "missing-id", title]);
|
|
226
228
|
curReq = null;
|
|
227
229
|
curEntry = null;
|
|
228
230
|
continue;
|
|
@@ -246,7 +248,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
246
248
|
const sid = m[1];
|
|
247
249
|
const title = pyStrip(m[2]);
|
|
248
250
|
if (!sid) {
|
|
249
|
-
|
|
251
|
+
if (status !== "archived")
|
|
252
|
+
problems.push([rpath, i, "missing-id", title]);
|
|
250
253
|
continue;
|
|
251
254
|
}
|
|
252
255
|
const st = curEntry ? curEntry.status : status;
|
package/dist/init/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// REQ-DIST-002 REQ-SCH-003 REQ-SKL-002 specctx init: config file, schema bundle, skills.
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { ASSETS } from "../assets.generated.js";
|
|
5
5
|
import { UsageExit } from "../cli/errors.js";
|
|
@@ -12,9 +12,22 @@ function asset(rel) {
|
|
|
12
12
|
throw new Error(`asset ${rel} is missing from the package`);
|
|
13
13
|
return Buffer.from(b64, "base64");
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* REQ-DIST-002 the config text for one project (017/D4). `tests/**` becomes `test/**` when only `test/`
|
|
17
|
+
* exists at the root. `src/**` becomes `lib/**` when only `lib/` exists. The comments stay.
|
|
18
|
+
*/
|
|
19
|
+
export function configText(root) {
|
|
20
|
+
const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
|
|
21
|
+
let text = INIT_TEXT;
|
|
22
|
+
if (!isDir("tests") && isDir("test"))
|
|
23
|
+
text = text.replace('\ntests = ["tests/**"]\n', '\ntests = ["test/**"]\n');
|
|
24
|
+
if (!isDir("src") && isDir("lib"))
|
|
25
|
+
text = text.replace('\ncode = ["src/**"]\n', '\ncode = ["lib/**"]\n');
|
|
26
|
+
return text;
|
|
27
|
+
}
|
|
15
28
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
16
|
-
export function plan(o) {
|
|
17
|
-
const out = [[CONFIG_FILE, Buffer.from(
|
|
29
|
+
export function plan(o, root) {
|
|
30
|
+
const out = [[CONFIG_FILE, Buffer.from(configText(root))]];
|
|
18
31
|
for (const rel of Object.keys(ASSETS).sort()) {
|
|
19
32
|
if (o.schema && rel.startsWith(`schemas/${SCHEMA}/`) && !rel.endsWith("/config-fragment.yaml")) {
|
|
20
33
|
out.push([`openspec/${rel}`, asset(rel)]);
|
|
@@ -64,6 +77,29 @@ function topBlocks(text) {
|
|
|
64
77
|
flush();
|
|
65
78
|
return blocks;
|
|
66
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* REQ-SCH-003 quote list items under `rules` and `operations` that hold `: ` and are not quoted.
|
|
82
|
+
* YAML reads such an item as a one key mapping. Every other line stays byte for byte.
|
|
83
|
+
*/
|
|
84
|
+
export function repairItems(text) {
|
|
85
|
+
let inBlock = false;
|
|
86
|
+
let fixed = 0;
|
|
87
|
+
const out = text.split("\n").map((line) => {
|
|
88
|
+
const top = TOP_KEY.exec(line);
|
|
89
|
+
if (top) {
|
|
90
|
+
inBlock = top[1] === "rules" || top[1] === "operations";
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
if (!inBlock)
|
|
94
|
+
return line;
|
|
95
|
+
const m = /^(\s+- )(.*?)(\s*)$/.exec(line);
|
|
96
|
+
if (!m || /^['"]/.test(m[2]) || !/:(\s|$)/.test(m[2]))
|
|
97
|
+
return line;
|
|
98
|
+
fixed++;
|
|
99
|
+
return `${m[1]}'${m[2].replace(/'/g, "''")}'${m[3]}`;
|
|
100
|
+
});
|
|
101
|
+
return { text: out.join("\n"), fixed };
|
|
102
|
+
}
|
|
67
103
|
/**
|
|
68
104
|
* REQ-SCH-003 add each top-level key of the fragment that the project config lacks.
|
|
69
105
|
* Keys that exist stay as they are. The schema key names the installed bundle.
|
|
@@ -92,32 +128,77 @@ export function mergeConfig(current, fragment) {
|
|
|
92
128
|
text = text.replace(/\n*$/, "\n\n") + block;
|
|
93
129
|
added.push(key);
|
|
94
130
|
}
|
|
131
|
+
const r = repairItems(text);
|
|
132
|
+
if (r.fixed) {
|
|
133
|
+
text = r.text;
|
|
134
|
+
added.push("repaired items");
|
|
135
|
+
}
|
|
95
136
|
return { text, added, kept };
|
|
96
137
|
}
|
|
138
|
+
/** REQ-DIST-002 the two lines init owns in .gitignore (017/D2). */
|
|
139
|
+
export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md"];
|
|
140
|
+
const GITIGNORE = ".gitignore";
|
|
141
|
+
/** The lines of a .gitignore, trimmed, with a leading slash dropped, so `/x` and `x` compare equal. */
|
|
142
|
+
function ignoreLines(text) {
|
|
143
|
+
return new Set(text.split(/\r?\n/).map((l) => l.trim().replace(/^\//, "")));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* REQ-DIST-002 add the absent lines under a `# spec-ctx` comment. Create the file when it is missing.
|
|
147
|
+
* Returns the number of lines added, 0 when both are present.
|
|
148
|
+
*/
|
|
149
|
+
export function writeGitignore(root) {
|
|
150
|
+
const p = join(root, GITIGNORE);
|
|
151
|
+
const cur = existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
152
|
+
const have = cur === null ? new Set() : ignoreLines(cur);
|
|
153
|
+
const add = GITIGNORE_LINES.filter((l) => !have.has(l));
|
|
154
|
+
if (!add.length)
|
|
155
|
+
return 0;
|
|
156
|
+
const block = `# spec-ctx\n${add.join("\n")}\n`;
|
|
157
|
+
let text;
|
|
158
|
+
if (cur === null || cur === "")
|
|
159
|
+
text = block;
|
|
160
|
+
else
|
|
161
|
+
text = cur.replace(/\n*$/, "\n") + "\n" + block;
|
|
162
|
+
writeFileSync(p, text);
|
|
163
|
+
return add.length;
|
|
164
|
+
}
|
|
165
|
+
/** REQ-DIST-002 true when .gitignore has a line that ignores the whole .claude directory (017/D3). */
|
|
166
|
+
export function ignoresClaude(root) {
|
|
167
|
+
const p = join(root, GITIGNORE);
|
|
168
|
+
if (!existsSync(p))
|
|
169
|
+
return false;
|
|
170
|
+
const lines = ignoreLines(readFileSync(p, "utf8"));
|
|
171
|
+
return lines.has(".claude") || lines.has(".claude/");
|
|
172
|
+
}
|
|
173
|
+
export const CLAUDE_NOTE = "note: .gitignore ignores .claude/. The skills under .claude/skills/ stay out of git. The copies under .agents/skills/ are tracked.";
|
|
97
174
|
export function runInit(root, o) {
|
|
98
|
-
const res = { written: [], kept: [], notes: [] };
|
|
99
|
-
let files = plan(o);
|
|
175
|
+
const res = { written: [], kept: [], changed: [], notes: [] };
|
|
176
|
+
let files = plan(o, root);
|
|
100
177
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
101
|
-
|
|
102
|
-
|
|
178
|
+
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
179
|
+
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
180
|
+
const ignoreDone = existsSync(join(root, GITIGNORE)) && GITIGNORE_LINES.every((l) => ignoreLines(readFileSync(join(root, GITIGNORE), "utf8")).has(l));
|
|
181
|
+
if (same && ignoreDone && !o.force) {
|
|
103
182
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
104
183
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
105
184
|
if (!o.schema || !mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")).added.length) {
|
|
106
185
|
res.nothing = true;
|
|
186
|
+
if (o.skills && ignoresClaude(root))
|
|
187
|
+
res.notes.push(CLAUDE_NOTE);
|
|
107
188
|
return res;
|
|
108
189
|
}
|
|
109
190
|
}
|
|
191
|
+
// REQ-DIST-002 init never replaces spec-ctx.toml, and never fails because a file exists (014/D1)
|
|
110
192
|
if (existsSync(join(root, CONFIG_FILE))) {
|
|
111
|
-
if (!o.force)
|
|
112
|
-
throw new UsageExit(`${CONFIG_FILE} exists. Use --force to install the other files over it`);
|
|
113
|
-
// --force never replaces a config that the user wrote
|
|
114
193
|
files = files.filter(([rel]) => rel !== CONFIG_FILE);
|
|
115
194
|
res.kept.push(CONFIG_FILE);
|
|
116
195
|
}
|
|
117
196
|
if (!o.force) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
197
|
+
const differs = (rel, data) => !readFileSync(join(root, rel)).equals(data);
|
|
198
|
+
res.changed = files.filter(([rel, data]) => existsSync(join(root, rel)) && differs(rel, data)).map(([rel]) => rel);
|
|
199
|
+
for (const rel of res.changed)
|
|
200
|
+
res.kept.push(`${rel} (other content)`);
|
|
201
|
+
files = files.filter(([rel]) => !existsSync(join(root, rel)));
|
|
121
202
|
}
|
|
122
203
|
for (const [rel, data] of files) {
|
|
123
204
|
const p = join(root, rel);
|
|
@@ -137,5 +218,14 @@ export function runInit(root, o) {
|
|
|
137
218
|
for (const k of m.kept)
|
|
138
219
|
res.kept.push(`openspec/config.yaml ${k}`);
|
|
139
220
|
}
|
|
221
|
+
// REQ-DIST-002 the two .gitignore lines (017/D2)
|
|
222
|
+
const added = writeGitignore(root);
|
|
223
|
+
if (added)
|
|
224
|
+
res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
|
|
225
|
+
else
|
|
226
|
+
res.kept.push(GITIGNORE);
|
|
227
|
+
// REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
|
|
228
|
+
if (o.skills && ignoresClaude(root))
|
|
229
|
+
res.notes.push(CLAUDE_NOTE);
|
|
140
230
|
return res;
|
|
141
231
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// REQ-ADOPT-007 hard lint findings per file and rule on the gate globs. One count per key, so the baseline survives line shifts.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { markdownFiles } from "../cli/gate.js";
|
|
5
|
+
import { lintSte } from "./ste.js";
|
|
6
|
+
import { lintTells } from "./tells.js";
|
|
7
|
+
/** Rules that the gate never counts. The repo lints by hand with the same disable. */
|
|
8
|
+
const SKIP = new Set(["synonym-rotation"]);
|
|
9
|
+
export const LINT_PREFIX = "lint ";
|
|
10
|
+
/** Key of one lint count: `lint <file> <linter>:<rule>`. */
|
|
11
|
+
export const lintKey = (file, linter, rule) => `${LINT_PREFIX}${file} ${linter}:${rule}`;
|
|
12
|
+
/** Hard findings per `lint <file> <linter>:<rule>` for the markdown globs. Files with no hard finding give no key. */
|
|
13
|
+
export function lintCounts(root, globs) {
|
|
14
|
+
const counts = new Map();
|
|
15
|
+
const files = markdownFiles(root, globs);
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
const text = readFileSync(join(root, f), "utf8").replace(/\r\n?/g, "\n");
|
|
18
|
+
for (const x of lintTells(text)) {
|
|
19
|
+
if (x.level !== "hard")
|
|
20
|
+
continue;
|
|
21
|
+
const k = lintKey(f, "tells", x.rule);
|
|
22
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
for (const x of lintSte(text, f).findings) {
|
|
25
|
+
if (x.level !== "advisory-free" || SKIP.has(x.rule))
|
|
26
|
+
continue;
|
|
27
|
+
const k = lintKey(f, "ste", x.rule);
|
|
28
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { counts, files: files.length };
|
|
32
|
+
}
|
|
33
|
+
/** Parse a baseline line `lint <file> <rule> <count>`. Returns null for any other line. */
|
|
34
|
+
export function parseLintLine(line) {
|
|
35
|
+
if (!line.startsWith(LINT_PREFIX))
|
|
36
|
+
return null;
|
|
37
|
+
const i = line.lastIndexOf(" ");
|
|
38
|
+
const n = Number(line.slice(i + 1));
|
|
39
|
+
if (i < 0 || !Number.isInteger(n) || n < 0)
|
|
40
|
+
return null;
|
|
41
|
+
return { key: line.slice(0, i), count: n };
|
|
42
|
+
}
|
|
43
|
+
export const lintLine = (key, count) => `${key} ${count}`;
|
|
44
|
+
/** REQ-CLI-004 the keys whose count rose above the baseline, worst first. A key with no line has a baseline of 0. */
|
|
45
|
+
export function lintRises(counts, baseline) {
|
|
46
|
+
const base = new Map();
|
|
47
|
+
for (const line of baseline ?? []) {
|
|
48
|
+
const p = parseLintLine(line);
|
|
49
|
+
if (p)
|
|
50
|
+
base.set(p.key, p.count);
|
|
51
|
+
}
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const [key, current] of counts) {
|
|
54
|
+
const b = base.get(key) ?? 0;
|
|
55
|
+
if (current > b)
|
|
56
|
+
out.push({ key, current, baseline: b });
|
|
57
|
+
}
|
|
58
|
+
out.sort((a, b) => b.current - b.baseline - (a.current - a.baseline) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
59
|
+
return out;
|
|
60
|
+
}
|
package/dist/stats/stats.js
CHANGED
|
@@ -92,10 +92,14 @@ export function gateMetrics(root, last = 100) {
|
|
|
92
92
|
gate_failures_caught: { value: failed, n: runs.length, text: String(failed) },
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// REQ-STAT-002 REQ-TASK-002 the size of all canonical req, scn, and decision text. stats and the task start footer share it.
|
|
96
|
+
export function specCharsOf(entries) {
|
|
97
|
+
return entries
|
|
97
98
|
.filter((e) => e.canonical && (e.kind === "req" || e.kind === "scn" || e.kind === "decision"))
|
|
98
99
|
.reduce((a, e) => a + cpLen(e.text ?? ""), 0);
|
|
100
|
+
}
|
|
101
|
+
export function computeStats(root, entries, strictFindings, warnings, only) {
|
|
102
|
+
const specChars = specCharsOf(entries);
|
|
99
103
|
const { live, reopened } = readEvidence(root);
|
|
100
104
|
// REQ-IDX-005 the change name of each change key, from the task entries
|
|
101
105
|
const names = new Map();
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// REQ-CLI-001 version string. tests/cli/version.test.ts keeps it equal to package.json.
|
|
2
|
-
export const VERSION = "0.1.
|
|
2
|
+
export const VERSION = "0.1.2";
|