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.
@@ -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
- const body = r.findings.map((x) => `${x.file}:${x.line} ${x.rule} ${x.id}\n`).join("")
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 `0 findings\n${base}`;
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
- + `\n${n} finding${n === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} (${r.findings.length} hard, ${r.warnings.length} advisory)\n`
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. The exit code stays 1.
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") + `\n${plan.edits.length} headers need an ID. Next: specctx adopt\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, DEFAULT_MARKDOWN } = await import("./gate.js");
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 ?? DEFAULT_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
- if (cmd === "query")
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\nnext: specctx status\n");
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
- // REQ-CLI-007 init ends with the next step
272
- io.out("next: specctx status\n");
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
- 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;
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 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));
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
- 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`);
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: "<term...>", summary: "Find the entries that contain all terms.",
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", "Install over files that exist. An existing spec-ctx.toml stays."],
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
  },
@@ -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
- c.io.err(`specctx: ${msg}. Task ${t.id} stays open.\n`);
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
@@ -9,5 +9,6 @@ const code = await main(process.argv.slice(2), {
9
9
  cwd: process.cwd(),
10
10
  out: (s) => process.stdout.write(s),
11
11
  err: (s) => process.stderr.write(s),
12
+ tty: process.stdout.isTTY === true,
12
13
  });
13
14
  process.exitCode = code;
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
  `;
@@ -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 ((o.kind && e.kind !== o.kind) || (o.change && !matchesChange(e.change, o.change)))
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();
@@ -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
- problems.push([rpath, i, "missing-id", title]);
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
- problems.push([rpath, i, "missing-id", title]);
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(INIT_TEXT)]];
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
- const same = files.every(([rel, data]) => existsSync(join(root, rel)) && readFileSync(join(root, rel)).equals(data));
102
- if (same && !o.force) {
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 clash = files.filter(([rel, data]) => existsSync(join(root, rel)) && !readFileSync(join(root, rel)).equals(data));
119
- if (clash.length)
120
- throw new UsageExit(`these files exist with other content: ${clash.map(([r]) => r).join(", ")}. Use --force to replace them`);
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
+ }
@@ -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
- export function computeStats(root, entries, strictFindings, warnings, only) {
96
- const specChars = entries
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.0";
2
+ export const VERSION = "0.1.2";