spec-ctx 0.1.1 → 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 +6 -5
- package/dist/baseline/baseline.js +35 -9
- package/dist/cli/commands.js +17 -10
- package/dist/cli/gate.js +18 -25
- package/dist/cli/help.js +5 -5
- package/dist/cli/workflow.js +7 -4
- 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 +75 -13
- package/dist/lint/counts.js +60 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,7 +181,8 @@ specctx gate --strict
|
|
|
181
181
|
- `adopt` derives a capability code from each folder name, for example `UA` for `user-auth`. It writes the codes into `spec-ctx.toml`. Rename a code there before the IDs spread.
|
|
182
182
|
- `adopt` runs `check` before and after the write. A finding that the write added undoes every file. A second run prints `nothing to adopt`.
|
|
183
183
|
- `task backfill` writes manual evidence for every ticked task that has none.
|
|
184
|
-
- `baseline write` records the debt of the day in `.ctx/baseline.json
|
|
184
|
+
- `baseline write` records the debt of the day in `.ctx/baseline.json`. The debt has five kinds. Untested requirements and scenarios. Tasks without evidence. Legacy tasks without a citation or a Verify code span. Delta headers that match no main requirement. Hard lint findings per file and rule on the gate globs. `check` hides that debt and prints `baselined: <n>`. `gate` fails the lint step only when a count rises. New debt still shows.
|
|
185
|
+
- `[gate] markdown` in `spec-ctx.toml` names the markdown globs that `gate` lints. Narrow it when a folder is not yours to fix.
|
|
185
186
|
- Legacy tasks stay in the baseline until an agent adds `[REQ-...]` citations and a `Verify:` code span. `specctx baseline prune` then drops them. No command adds a single entry.
|
|
186
187
|
|
|
187
188
|
Both paths have an end to end test: `tests/paths/fresh.sh` and `tests/paths/existing.sh`.
|
|
@@ -269,18 +270,18 @@ The action runs `specctx gate`: `check --strict`, both markdown linters, `opensp
|
|
|
269
270
|
|
|
270
271
|
## Status
|
|
271
272
|
|
|
272
|
-
- Published: `spec-ctx@0.1.
|
|
273
|
-
- Archived: 001 to
|
|
273
|
+
- Published: `spec-ctx@0.1.2` on npm, with tags `v0.1.2` and `v0` and binaries on the GitHub release.
|
|
274
|
+
- Archived: 001 to 017. No change is open. See `CHANGELOG.md` for each release.
|
|
274
275
|
- A version bump on `main` publishes the next release. See `docs/CONTRIBUTING.md`.
|
|
275
276
|
- Upstream: the ID scheme is proposed as a comment on [OpenSpec#1967](https://github.com/Fission-AI/OpenSpec/issues/1967#issuecomment-6414314730). The schema bundle is submitted to the community table in [OpenSpec#2157](https://github.com/Fission-AI/OpenSpec/issues/2157). No pull request until a maintainer asks.
|
|
276
277
|
|
|
277
278
|
## Measured
|
|
278
279
|
|
|
279
|
-
`specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-
|
|
280
|
+
`specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-27.md`. Its total row:
|
|
280
281
|
|
|
281
282
|
| change | context_ratio | evidence_ratio | stale_catches | cycle_time_median_s | debt |
|
|
282
283
|
| --- | --- | --- | --- | --- | --- |
|
|
283
|
-
| total | 0.
|
|
284
|
+
| total | 0.0435 (n=52) | 0.87 (106/122) | 36 (of 68 reopens) | 70 (n=52) | 0 (0 baselined + 0 new) |
|
|
284
285
|
|
|
285
286
|
- `context_ratio`: the size of one task bundle divided by the size of all spec text. Lower is better.
|
|
286
287
|
- `evidence_ratio`: done tasks with a passed Verify command, divided by all done tasks. The rest have a manual note. Higher is better.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// REQ-ADOPT-005 REQ-CHK-002 the baseline ratchet in .ctx/baseline.json: sorted "rule id" pairs.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { lintLine, parseLintLine } from "../lint/counts.js";
|
|
4
5
|
import { cmpStr } from "../util/pystr.js";
|
|
5
6
|
export const BASELINE_PATH = ".ctx/baseline.json";
|
|
6
|
-
export const BASELINE_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-req", "task-without-verify"];
|
|
7
|
-
|
|
7
|
+
export const BASELINE_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-req", "task-without-verify", "missing-id"];
|
|
8
|
+
/** REQ-ADOPT-005 a missing-id finding has no ID. Its key carries the file and the header title. */
|
|
9
|
+
const key = (f) => (f.rule === "missing-id" ? `${f.rule} ${f.file} ${f.id}` : `${f.rule} ${f.id}`);
|
|
8
10
|
/** A warning line reads "manual-evidence <task>". */
|
|
9
11
|
const warningKey = (w) => w;
|
|
10
12
|
export function readBaseline(root) {
|
|
@@ -24,24 +26,48 @@ function save(root, entries) {
|
|
|
24
26
|
}
|
|
25
27
|
/** The current debt that the baseline may hold: strict findings and warnings of the listed rules. */
|
|
26
28
|
export function debt(findings, warnings) {
|
|
27
|
-
const out = findings.filter((f) => BASELINE_RULES.includes(f.rule)).map((f) => key(f
|
|
29
|
+
const out = findings.filter((f) => BASELINE_RULES.includes(f.rule)).map((f) => key(f));
|
|
28
30
|
for (const w of warnings)
|
|
29
31
|
if (BASELINE_RULES.includes(w.split(" ")[0]))
|
|
30
32
|
out.push(warningKey(w));
|
|
31
33
|
return out;
|
|
32
34
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
/** REQ-ADOPT-007 one `lint <file> <rule> <count>` line per key with hard lint findings. */
|
|
36
|
+
export function lintDebt(counts) {
|
|
37
|
+
return [...counts].filter(([, n]) => n > 0).map(([k, n]) => lintLine(k, n));
|
|
38
|
+
}
|
|
39
|
+
export function writeBaseline(root, findings, warnings, lint = new Map()) {
|
|
40
|
+
return save(root, [...debt(findings, warnings), ...lintDebt(lint)]);
|
|
35
41
|
}
|
|
36
42
|
/** Drop entries that no longer fire. Returns the number dropped. */
|
|
37
|
-
export function pruneBaseline(root, findings, warnings) {
|
|
43
|
+
export function pruneBaseline(root, findings, warnings, lint = new Map()) {
|
|
38
44
|
const cur = readBaseline(root);
|
|
39
45
|
if (!cur)
|
|
40
46
|
return 0;
|
|
41
47
|
const live = new Set(debt(findings, warnings));
|
|
42
|
-
const kept = [
|
|
48
|
+
const kept = [];
|
|
49
|
+
let pruned = 0;
|
|
50
|
+
for (const e of cur) {
|
|
51
|
+
const l = parseLintLine(e);
|
|
52
|
+
if (l) {
|
|
53
|
+
// REQ-ADOPT-007 a lint line drops at zero and lowers when the count fell
|
|
54
|
+
const now = lint.get(l.key) ?? 0;
|
|
55
|
+
if (now === 0)
|
|
56
|
+
pruned++;
|
|
57
|
+
else if (now < l.count) {
|
|
58
|
+
pruned++;
|
|
59
|
+
kept.push(lintLine(l.key, now));
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
kept.push(e);
|
|
63
|
+
}
|
|
64
|
+
else if (live.has(e))
|
|
65
|
+
kept.push(e);
|
|
66
|
+
else
|
|
67
|
+
pruned++;
|
|
68
|
+
}
|
|
43
69
|
save(root, kept);
|
|
44
|
-
return
|
|
70
|
+
return pruned;
|
|
45
71
|
}
|
|
46
72
|
/** REQ-CHK-002 a finding or warning in the baseline leaves the output and counts as baselined. */
|
|
47
73
|
export function applyBaseline(root, findings, warnings) {
|
|
@@ -50,7 +76,7 @@ export function applyBaseline(root, findings, warnings) {
|
|
|
50
76
|
return { findings, warnings, baselined: 0 };
|
|
51
77
|
let n = 0;
|
|
52
78
|
const f2 = findings.filter((f) => {
|
|
53
|
-
const hit = base.has(key(f
|
|
79
|
+
const hit = base.has(key(f));
|
|
54
80
|
if (hit)
|
|
55
81
|
n++;
|
|
56
82
|
return !hit;
|
package/dist/cli/commands.js
CHANGED
|
@@ -90,9 +90,9 @@ export async function run(p, io) {
|
|
|
90
90
|
print(plan);
|
|
91
91
|
else if (!plan.edits.length)
|
|
92
92
|
io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
|
|
93
|
-
// 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).
|
|
94
94
|
else
|
|
95
|
-
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`);
|
|
96
96
|
return plan.edits.length ? 1 : 0;
|
|
97
97
|
}
|
|
98
98
|
if (cmd === "baseline write" || cmd === "baseline prune") {
|
|
@@ -106,15 +106,18 @@ export async function run(p, io) {
|
|
|
106
106
|
const data = buildIndex(root, cfg);
|
|
107
107
|
const findings = check(root, cfg, data, true);
|
|
108
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;
|
|
109
112
|
if (cmd === "baseline write") {
|
|
110
|
-
const list = writeBaseline(root, findings, warnings);
|
|
113
|
+
const list = writeBaseline(root, findings, warnings, lint);
|
|
111
114
|
if (json)
|
|
112
115
|
print({ baseline: BASELINE_PATH, entries: list.length });
|
|
113
116
|
else
|
|
114
117
|
io.out(`wrote ${BASELINE_PATH} with ${list.length} entr${list.length === 1 ? "y" : "ies"}\n`);
|
|
115
118
|
}
|
|
116
119
|
else {
|
|
117
|
-
const n = pruneBaseline(root, findings, warnings);
|
|
120
|
+
const n = pruneBaseline(root, findings, warnings, lint);
|
|
118
121
|
if (json)
|
|
119
122
|
print({ pruned: n });
|
|
120
123
|
else
|
|
@@ -125,11 +128,12 @@ export async function run(p, io) {
|
|
|
125
128
|
if (cmd === "gate") {
|
|
126
129
|
if (p.positional.length)
|
|
127
130
|
throw new UsageExit("gate takes no arguments");
|
|
128
|
-
const { runGate
|
|
131
|
+
const { runGate } = await import("./gate.js");
|
|
129
132
|
const strict = !!f.strict;
|
|
130
133
|
return runGate(root, {
|
|
131
134
|
strict,
|
|
132
|
-
markdown: f.markdown ??
|
|
135
|
+
markdown: f.markdown ?? cfg.gate.markdown.join(" "),
|
|
136
|
+
json,
|
|
133
137
|
check: async () => {
|
|
134
138
|
const { buildIndex } = await import("../index/build.js");
|
|
135
139
|
const { check } = await import("../check/check.js");
|
|
@@ -145,7 +149,8 @@ export async function run(p, io) {
|
|
|
145
149
|
if (p.positional.length < n)
|
|
146
150
|
throw new UsageExit(`${cmd} needs ${what}`);
|
|
147
151
|
};
|
|
148
|
-
|
|
152
|
+
// REQ-QRY-001 no term lists a whole change. Without --change a term is required (017/D6).
|
|
153
|
+
if (cmd === "query" && !f.change)
|
|
149
154
|
need(1, "at least one term");
|
|
150
155
|
if (cmd === "related") {
|
|
151
156
|
need(1, "one ID");
|
|
@@ -267,12 +272,14 @@ async function init(p, io) {
|
|
|
267
272
|
if (p.flags.json)
|
|
268
273
|
io.out(JSON.stringify(r, null, 2) + "\n");
|
|
269
274
|
else if (r.nothing)
|
|
270
|
-
io.out("nothing to do\n" + paint("dim", "next: specctx status\n", io.tty));
|
|
275
|
+
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
|
|
271
276
|
else {
|
|
272
277
|
io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
|
|
273
278
|
io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
|
|
274
|
-
|
|
275
|
-
|
|
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));
|
|
276
283
|
}
|
|
277
284
|
return 0;
|
|
278
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
|
@@ -92,10 +92,12 @@ export async function runWorkflow(c) {
|
|
|
92
92
|
const { cpLen } = await import("../util/pystr.js");
|
|
93
93
|
// REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
|
|
94
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.`;
|
|
95
97
|
if (c.json)
|
|
96
|
-
print(c, { bundle: b, task: t.id });
|
|
98
|
+
print(c, { bundle: b, hint, task: t.id });
|
|
97
99
|
else
|
|
98
|
-
c.io.out(b.text + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
|
|
100
|
+
c.io.out(b.text + (hint ? hint + "\n" : "") + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
|
|
99
101
|
return 0;
|
|
100
102
|
}
|
|
101
103
|
case "task verify":
|
|
@@ -180,7 +182,8 @@ export async function runWorkflow(c) {
|
|
|
180
182
|
return 0;
|
|
181
183
|
}
|
|
182
184
|
case "task backfill": {
|
|
183
|
-
// 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).
|
|
184
187
|
noMore(c, 0);
|
|
185
188
|
const note = f.manual?.trim();
|
|
186
189
|
if (!note)
|
|
@@ -191,7 +194,7 @@ export async function runWorkflow(c) {
|
|
|
191
194
|
const want = f.change;
|
|
192
195
|
const counts = new Map();
|
|
193
196
|
for (const e of tasksOf(entries)) {
|
|
194
|
-
if (e.status !== "done")
|
|
197
|
+
if (e.status !== "done" || e.file.includes("/archive/"))
|
|
195
198
|
continue;
|
|
196
199
|
if (want && !matchesChange(e.change, want))
|
|
197
200
|
continue;
|
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)]);
|
|
@@ -122,30 +135,70 @@ export function mergeConfig(current, fragment) {
|
|
|
122
135
|
}
|
|
123
136
|
return { text, added, kept };
|
|
124
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.";
|
|
125
174
|
export function runInit(root, o) {
|
|
126
|
-
const res = { written: [], kept: [], notes: [] };
|
|
127
|
-
let files = plan(o);
|
|
175
|
+
const res = { written: [], kept: [], changed: [], notes: [] };
|
|
176
|
+
let files = plan(o, root);
|
|
128
177
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
129
|
-
|
|
130
|
-
|
|
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) {
|
|
131
182
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
132
183
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
133
184
|
if (!o.schema || !mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")).added.length) {
|
|
134
185
|
res.nothing = true;
|
|
186
|
+
if (o.skills && ignoresClaude(root))
|
|
187
|
+
res.notes.push(CLAUDE_NOTE);
|
|
135
188
|
return res;
|
|
136
189
|
}
|
|
137
190
|
}
|
|
191
|
+
// REQ-DIST-002 init never replaces spec-ctx.toml, and never fails because a file exists (014/D1)
|
|
138
192
|
if (existsSync(join(root, CONFIG_FILE))) {
|
|
139
|
-
if (!o.force)
|
|
140
|
-
throw new UsageExit(`${CONFIG_FILE} exists. Use --force to install the other files over it`);
|
|
141
|
-
// --force never replaces a config that the user wrote
|
|
142
193
|
files = files.filter(([rel]) => rel !== CONFIG_FILE);
|
|
143
194
|
res.kept.push(CONFIG_FILE);
|
|
144
195
|
}
|
|
145
196
|
if (!o.force) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
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)));
|
|
149
202
|
}
|
|
150
203
|
for (const [rel, data] of files) {
|
|
151
204
|
const p = join(root, rel);
|
|
@@ -165,5 +218,14 @@ export function runInit(root, o) {
|
|
|
165
218
|
for (const k of m.kept)
|
|
166
219
|
res.kept.push(`openspec/config.yaml ${k}`);
|
|
167
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);
|
|
168
230
|
return res;
|
|
169
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/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";
|