spec-ctx 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -2
  3. package/action.yml +33 -0
  4. package/dist/adopt/config-write.js +59 -0
  5. package/dist/adopt/plan.js +276 -0
  6. package/dist/adopt/write.js +126 -0
  7. package/dist/assets.generated.js +14 -0
  8. package/dist/baseline/baseline.js +65 -0
  9. package/dist/check/check.js +126 -0
  10. package/dist/check/evidence.js +70 -0
  11. package/dist/check/hints.js +26 -0
  12. package/dist/cli/args.js +90 -0
  13. package/dist/cli/commands.js +275 -0
  14. package/dist/cli/errors.js +3 -0
  15. package/dist/cli/gate.js +187 -0
  16. package/dist/cli/help.js +230 -0
  17. package/dist/cli/root.js +32 -0
  18. package/dist/cli/status.js +116 -0
  19. package/dist/cli/workflow.js +274 -0
  20. package/dist/cli.js +13 -0
  21. package/dist/config.js +119 -0
  22. package/dist/graph/bundle.js +94 -0
  23. package/dist/graph/query.js +31 -0
  24. package/dist/graph/related.js +76 -0
  25. package/dist/handoff/handoff.js +148 -0
  26. package/dist/ids/ids.js +115 -0
  27. package/dist/index/build.js +57 -0
  28. package/dist/index/canonical.js +44 -0
  29. package/dist/index/scan.js +357 -0
  30. package/dist/index/types.js +2 -0
  31. package/dist/init/init.js +141 -0
  32. package/dist/lint/cli.js +253 -0
  33. package/dist/lint/py.js +152 -0
  34. package/dist/lint/selftest.js +246 -0
  35. package/dist/lint/ste.js +395 -0
  36. package/dist/lint/tells.js +116 -0
  37. package/dist/main.js +74 -0
  38. package/dist/stats/stats.js +143 -0
  39. package/dist/stats/write.js +26 -0
  40. package/dist/tasks/evidence.js +102 -0
  41. package/dist/tasks/tasks.js +105 -0
  42. package/dist/util/glob.js +43 -0
  43. package/dist/util/hash.js +14 -0
  44. package/dist/util/json.js +16 -0
  45. package/dist/util/pystr.js +67 -0
  46. package/dist/util/time.js +11 -0
  47. package/dist/verify/parse.js +58 -0
  48. package/dist/verify/run.js +42 -0
  49. package/dist/version.js +2 -0
  50. package/package.json +50 -4
  51. package/schemas/spec-driven-ids/README.md +18 -0
  52. package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
  53. package/schemas/spec-driven-ids/schema.yaml +301 -0
  54. package/schemas/spec-driven-ids/templates/design.md +22 -0
  55. package/schemas/spec-driven-ids/templates/proposal.md +34 -0
  56. package/schemas/spec-driven-ids/templates/spec.md +15 -0
  57. package/schemas/spec-driven-ids/templates/tasks.md +10 -0
  58. package/skills/specctx-bootstrap/SKILL.md +27 -0
  59. package/skills/specctx-done/SKILL.md +25 -0
  60. package/skills/specctx-next/SKILL.md +23 -0
@@ -0,0 +1,253 @@
1
+ // REQ-LINT-001 `specctx lint tells|ste`: the flags, output, and exit codes of scripts/tells-lint.py and scripts/ste-lint.py.
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { PyFloat, pyInt, pyJson, pyStrip, round1 } from "./py.js";
5
+ import { lintSte, STE_IDS } from "./ste.js";
6
+ import { lintTells, TELLS_IDS } from "./tells.js";
7
+ export const LINTERS = ["tells", "ste"];
8
+ export class LintUsage extends Error {
9
+ }
10
+ const OPTIONS = ["--json", "--baseline", "--disable", "--selftest", "--help"];
11
+ const TAKES_VALUE = new Set(["--baseline", "--disable"]);
12
+ const NEGATIVE_NUMBER = /^-\d+$|^-\d*\.\d+$/;
13
+ /**
14
+ * The argparse rules of the two scripts. `tells` accepts option prefixes and one run of files.
15
+ * `ste` accepts no prefixes and files between options.
16
+ */
17
+ export function parseLintArgs(linter, argv) {
18
+ const a = { files: [], json: false, baseline: 0, disable: null, selftest: false, help: false };
19
+ const extra = [];
20
+ let runs = 0;
21
+ let lastWasFile = false;
22
+ let onlyFiles = false;
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ const isOption = !onlyFiles && arg.startsWith("-") && arg !== "-" && !NEGATIVE_NUMBER.test(arg);
26
+ if (!isOption) {
27
+ if (!lastWasFile)
28
+ runs++;
29
+ lastWasFile = true;
30
+ if (linter === "ste" || runs === 1)
31
+ a.files.push(arg);
32
+ else
33
+ extra.push(arg);
34
+ continue;
35
+ }
36
+ if (arg === "--") {
37
+ onlyFiles = true;
38
+ lastWasFile = false;
39
+ continue;
40
+ }
41
+ lastWasFile = false;
42
+ let name = arg;
43
+ let value;
44
+ const eq = arg.indexOf("=");
45
+ if (arg.startsWith("--") && eq > 0) {
46
+ name = arg.slice(0, eq);
47
+ value = arg.slice(eq + 1);
48
+ }
49
+ if (name === "-h")
50
+ name = "--help";
51
+ if (!OPTIONS.includes(name)) {
52
+ const hits = linter === "tells" && name.startsWith("--") ? OPTIONS.filter((o) => o.startsWith(name)) : [];
53
+ if (hits.length === 1)
54
+ name = hits[0];
55
+ else if (hits.length > 1)
56
+ throw new LintUsage(`ambiguous option: ${name} could match ${hits.join(", ")}`);
57
+ else {
58
+ extra.push(arg);
59
+ continue;
60
+ }
61
+ }
62
+ if (TAKES_VALUE.has(name)) {
63
+ if (value === undefined) {
64
+ const next = argv[i + 1];
65
+ if (next === undefined || (next.startsWith("-") && !NEGATIVE_NUMBER.test(next)))
66
+ throw new LintUsage(`argument ${name}: expected one argument`);
67
+ value = next;
68
+ i++;
69
+ }
70
+ if (name === "--baseline") {
71
+ const n = pyInt(value);
72
+ if (linter === "tells" && Number.isNaN(n))
73
+ throw new LintUsage(`argument --baseline: invalid int value: '${value}'`);
74
+ if (linter === "ste" && !(n >= 0))
75
+ throw new LintUsage(`argument --baseline: expected a non-negative integer, got '${value}'`);
76
+ a.baseline = n;
77
+ }
78
+ else {
79
+ if (linter === "ste")
80
+ checkRules(linter, value, true);
81
+ a.disable = value;
82
+ }
83
+ }
84
+ else if (value !== undefined) {
85
+ throw new LintUsage(`argument ${name}: ignored explicit argument '${value}'`);
86
+ }
87
+ else if (name === "--json")
88
+ a.json = true;
89
+ else if (name === "--selftest")
90
+ a.selftest = true;
91
+ else
92
+ a.help = true;
93
+ if (a.help)
94
+ return a;
95
+ }
96
+ if (extra.length)
97
+ throw new LintUsage(`unrecognized arguments: ${extra.join(" ")}`);
98
+ return a;
99
+ }
100
+ /** The rule IDs in a --disable value. An unknown ID is a usage error. */
101
+ export function checkRules(linter, value, argparse = false) {
102
+ const ids = linter === "tells" ? TELLS_IDS : STE_IDS;
103
+ const rules = new Set(linter === "tells" ? value.split(",").map(pyStrip).filter(Boolean) : value.split(",").filter(Boolean));
104
+ const unknown = [...rules].filter((r) => !ids.includes(r)).sort();
105
+ if (unknown.length) {
106
+ const msg = `unknown rule(s): ${unknown.join(", ")}`;
107
+ throw new LintUsage(argparse ? `argument --disable: ${msg}. Known rules: ${[...ids].sort().join(", ")}` : msg);
108
+ }
109
+ return rules;
110
+ }
111
+ /** The file text as Python reads it: UTF-8, universal newlines. */
112
+ function readText(io, p) {
113
+ const abs = resolve(io.cwd, p);
114
+ let st;
115
+ try {
116
+ st = statSync(abs);
117
+ }
118
+ catch {
119
+ throw new LintUsage(`cannot read ${p}: No such file or directory`);
120
+ }
121
+ if (st.isDirectory())
122
+ throw new LintUsage(`cannot read ${p}: Is a directory`);
123
+ try {
124
+ return readFileSync(abs, "utf8").replace(/\r\n?/g, "\n");
125
+ }
126
+ catch {
127
+ throw new LintUsage(`cannot read ${p}: Permission denied`);
128
+ }
129
+ }
130
+ function readStdin() {
131
+ try {
132
+ return readFileSync(0, "utf8").replace(/\r\n?/g, "\n");
133
+ }
134
+ catch {
135
+ return "";
136
+ }
137
+ }
138
+ const USAGE = {
139
+ tells: "usage: specctx lint tells [-h] [--json] [--baseline BASELINE] [--disable DISABLE] [--selftest] [files ...]\n",
140
+ ste: "usage: specctx lint ste [-h] [--json] [--baseline N] [--disable RULES] [--selftest] [FILE ...]\n",
141
+ };
142
+ /** `specctx lint <linter> ...`. Exit 0, 1 when hard findings exceed the baseline, 2 on a usage error. */
143
+ export async function runLint(argv, io) {
144
+ const linter = argv[0];
145
+ if ((linter === "--help" || linter === "-h") && argv.length === 1) {
146
+ const { helpFor } = await import("../cli/help.js");
147
+ io.out(helpFor("lint"));
148
+ return 0;
149
+ }
150
+ if (linter !== "tells" && linter !== "ste") {
151
+ io.err(`specctx: lint needs a subcommand: ${LINTERS.join(", ")}\n`);
152
+ return 2;
153
+ }
154
+ const usage = (msg) => {
155
+ io.err(`${USAGE[linter]}specctx lint ${linter}: error: ${msg}\n`);
156
+ return 2;
157
+ };
158
+ let a;
159
+ try {
160
+ a = parseLintArgs(linter, argv.slice(1));
161
+ }
162
+ catch (e) {
163
+ if (e instanceof LintUsage)
164
+ return usage(e.message);
165
+ throw e;
166
+ }
167
+ if (a.help) {
168
+ // REQ-CLI-006 the lint pages render from the help table in the same layout
169
+ const { helpFor } = await import("../cli/help.js");
170
+ io.out(helpFor(`lint ${linter}`));
171
+ return 0;
172
+ }
173
+ if (a.selftest) {
174
+ const { selftestTells, selftestSte } = await import("./selftest.js");
175
+ return linter === "tells" ? selftestTells(io) : selftestSte(io);
176
+ }
177
+ return linter === "tells" ? runTells(a, io) : runSte(a, io, usage);
178
+ }
179
+ function runTells(a, io) {
180
+ let disabled;
181
+ try {
182
+ disabled = checkRules("tells", a.disable ?? "");
183
+ }
184
+ catch (e) {
185
+ io.err(`${e.message}\n`);
186
+ return 2;
187
+ }
188
+ const sources = [];
189
+ if (a.files.length) {
190
+ for (const p of a.files) {
191
+ try {
192
+ sources.push([p, readText(io, p)]);
193
+ }
194
+ catch (e) {
195
+ io.err(`${e.message}\n`);
196
+ return 2;
197
+ }
198
+ }
199
+ }
200
+ else
201
+ sources.push(["<stdin>", readStdin()]);
202
+ let hard = 0;
203
+ const report = [];
204
+ let out = "";
205
+ for (const [name, text] of sources) {
206
+ const fs = lintTells(text, disabled);
207
+ hard += fs.filter((f) => f.level === "hard").length;
208
+ report.push({ file: name, findings: fs });
209
+ if (!a.json)
210
+ for (const f of fs)
211
+ out += `${name}:${f.line}: [${f.level}] ${f.rule}: ${f.match}\n`;
212
+ }
213
+ out += a.json ? pyJson(report, false) + "\n" : `${hard} hard finding(s), baseline ${a.baseline}\n`;
214
+ io.out(out);
215
+ return hard > a.baseline ? 1 : 0;
216
+ }
217
+ function runSte(a, io, usage) {
218
+ const disabled = new Set(a.disable === null ? [] : a.disable.split(",").filter(Boolean));
219
+ let findings = [];
220
+ let words = 0;
221
+ if (a.files.length) {
222
+ for (const p of a.files) {
223
+ let text;
224
+ try {
225
+ text = readText(io, p);
226
+ }
227
+ catch (e) {
228
+ return usage(e.message);
229
+ }
230
+ const r = lintSte(text, p);
231
+ findings.push(...r.findings);
232
+ words += r.words;
233
+ }
234
+ }
235
+ else
236
+ ({ findings, words } = lintSte(readStdin()));
237
+ findings = findings.filter((f) => !disabled.has(f.rule));
238
+ const hard = findings.filter((f) => f.level === "advisory-free").length;
239
+ io.out(steReport(findings, words, a.json, hard, a.baseline));
240
+ return hard > a.baseline ? 1 : 0;
241
+ }
242
+ export function steReport(findings, words, json, hard, baseline) {
243
+ const rate = words ? round1((findings.length * 100) / words) : "0.0";
244
+ if (json) {
245
+ return pyJson({ violations: findings, count: findings.length, hard_count: hard, baseline, words, per_100_words: new PyFloat(rate) }, true) + "\n";
246
+ }
247
+ let out = "";
248
+ for (const f of findings)
249
+ out += `${f.file}:${f.line}:${f.col} ${f.rule}: ${f.message} [${f.match}]\n`;
250
+ out += `\n${findings.length} violations (${hard} hard, baseline ${baseline}), ${words} words, ${rate} per 100 words\n`;
251
+ out += "Hedges/modality (may, might, could) are never flagged: confidence is content.\n";
252
+ return out;
253
+ }
@@ -0,0 +1,152 @@
1
+ // REQ-LINT-001 Python `re`, `str`, and `json` behavior that the two linters depend on.
2
+ // Python patterns are Unicode aware. JavaScript `\w`, `\b`, `\s`, and `\d` are not, so the ports use these classes.
3
+ import { cpLen, pyStrip } from "../util/pystr.js";
4
+ export { cpLen, pyStrip };
5
+ /** Python `\w`: a letter, a number, or the underscore. */
6
+ export const W = "[\\p{L}\\p{N}_]";
7
+ /** Python `[^\W_]`: a letter or a number. */
8
+ export const WN = "[\\p{L}\\p{N}]";
9
+ /** Python `\b`. */
10
+ export const B = `(?:(?<=${W})(?!${W})|(?<!${W})(?=${W}))`;
11
+ const SP = "\\t-\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
12
+ /** Python `\s`, the set of `str.isspace()`. */
13
+ export const S = `[${SP}]`;
14
+ /** Python `\S`. */
15
+ export const NS = `[^${SP}]`;
16
+ /** Python `\d`. */
17
+ export const D = "\\p{Nd}";
18
+ /** A Unicode pattern. `flags` holds the Python flags as JavaScript letters: i, m, g. */
19
+ export function re(src, flags = "") {
20
+ return new RegExp(src, "u" + flags);
21
+ }
22
+ /** Python `re.escape` for the word lists. Only the characters that JavaScript needs. */
23
+ export function esc(s) {
24
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
25
+ }
26
+ /** Python `str.splitlines()`. */
27
+ export function splitlines(s) {
28
+ const out = s.split(/\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/);
29
+ if (out.length && out[out.length - 1] === "")
30
+ out.pop();
31
+ return out;
32
+ }
33
+ const LEAD = new RegExp(`^${S}+`, "u");
34
+ const TRAIL = new RegExp(`${S}+$`, "u");
35
+ export const lstrip = (s) => s.replace(LEAD, "");
36
+ export const rstrip = (s) => s.replace(TRAIL, "");
37
+ /** Python `str.lstrip(chars)`. */
38
+ export function lstripChars(s, chars) {
39
+ let i = 0;
40
+ while (i < s.length && chars.includes(s[i]))
41
+ i++;
42
+ return s.slice(i);
43
+ }
44
+ /** Python `str.split()` with no argument. */
45
+ export function pySplit(s) {
46
+ const t = pyStrip(s);
47
+ return t === "" ? [] : t.split(new RegExp(`${S}+`, "u"));
48
+ }
49
+ /** Python `s[:n]` on code points. */
50
+ export function cpSlice(s, n) {
51
+ return Array.from(s).slice(0, n).join("");
52
+ }
53
+ /** The code point index of a UTF-16 index in s. */
54
+ export function cpAt(s, i) {
55
+ return cpLen(s.slice(0, i));
56
+ }
57
+ /** Python `str.isupper()` on one character. */
58
+ export function isUpper(ch) {
59
+ return /^\p{Uppercase}$/u.test(ch);
60
+ }
61
+ /** Python `int(s)` for the flag values, ASCII digits only. NaN on a bad value. */
62
+ export function pyInt(s) {
63
+ const t = pyStrip(s);
64
+ return /^[+-]?\d+(?:_\d+)*$/.test(t) ? Number(t.replace(/_/g, "")) : NaN;
65
+ }
66
+ /** Python `round(x, 1)`: half to even on the exact binary value. The result as Python prints it. */
67
+ export function round1(x) {
68
+ const neg = x < 0;
69
+ const [int, frac = ""] = Math.abs(x).toFixed(100).split(".");
70
+ const d = Number(frac[0] ?? "0");
71
+ const rest = frac.slice(1).replace(/0+$/, "");
72
+ let tenths = BigInt(int) * 10n + BigInt(d);
73
+ if (rest > "5" || (rest === "5" && d % 2 === 1) || (rest.startsWith("5") && rest.length > 1))
74
+ tenths += 1n;
75
+ const s = `${tenths / 10n}.${tenths % 10n}`;
76
+ return neg && tenths !== 0n ? "-" + s : s;
77
+ }
78
+ /** A float for `pyJson`. Python prints it with a decimal point. */
79
+ export class PyFloat {
80
+ text;
81
+ constructor(text) {
82
+ this.text = text;
83
+ }
84
+ }
85
+ function jsonStr(s, ascii) {
86
+ let out = '"';
87
+ for (let i = 0; i < s.length; i++) {
88
+ const c = s.charCodeAt(i);
89
+ const ch = s[i];
90
+ if (ch === '"')
91
+ out += '\\"';
92
+ else if (ch === "\\")
93
+ out += "\\\\";
94
+ else if (ch === "\n")
95
+ out += "\\n";
96
+ else if (ch === "\r")
97
+ out += "\\r";
98
+ else if (ch === "\t")
99
+ out += "\\t";
100
+ else if (ch === "\b")
101
+ out += "\\b";
102
+ else if (ch === "\f")
103
+ out += "\\f";
104
+ else if (c < 0x20 || (ascii && c > 0x7e))
105
+ out += "\\u" + c.toString(16).padStart(4, "0");
106
+ else
107
+ out += ch;
108
+ }
109
+ return out + '"';
110
+ }
111
+ /** Python `json.dumps(v, indent=2, ensure_ascii=ascii)`. */
112
+ export function pyJson(v, ascii, level = 0) {
113
+ const pad = " ".repeat(level + 1);
114
+ const end = " ".repeat(level);
115
+ if (v instanceof PyFloat)
116
+ return v.text;
117
+ if (typeof v === "string")
118
+ return jsonStr(v, ascii);
119
+ if (typeof v === "number")
120
+ return String(v);
121
+ if (typeof v === "boolean")
122
+ return v ? "true" : "false";
123
+ if (v === null || v === undefined)
124
+ return "null";
125
+ if (Array.isArray(v)) {
126
+ if (!v.length)
127
+ return "[]";
128
+ return "[\n" + v.map((x) => pad + pyJson(x, ascii, level + 1)).join(",\n") + "\n" + end + "]";
129
+ }
130
+ const keys = Object.keys(v);
131
+ if (!keys.length)
132
+ return "{}";
133
+ return "{\n" + keys.map((k) => `${pad}${jsonStr(k, ascii)}: ${pyJson(v[k], ascii, level + 1)}`).join(",\n") + "\n" + end + "}";
134
+ }
135
+ /** The line number of a UTF-16 position: one more than the count of newlines before it. */
136
+ export function lineIndex(text) {
137
+ const nl = [];
138
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1))
139
+ nl.push(i);
140
+ return (pos) => {
141
+ let lo = 0;
142
+ let hi = nl.length;
143
+ while (lo < hi) {
144
+ const mid = (lo + hi) >> 1;
145
+ if (nl[mid] < pos)
146
+ lo = mid + 1;
147
+ else
148
+ hi = mid;
149
+ }
150
+ return lo + 1;
151
+ };
152
+ }
@@ -0,0 +1,246 @@
1
+ // REQ-LINT-001 the --selftest suites of scripts/tells-lint.py and scripts/ste-lint.py, case by case.
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { checkRules, runLint } from "./cli.js";
6
+ import { lintSte } from "./ste.js";
7
+ import { lintTells } from "./tells.js";
8
+ class SelftestFail extends Error {
9
+ }
10
+ function ok(cond, what) {
11
+ if (!cond)
12
+ throw new SelftestFail(typeof what === "string" ? what : JSON.stringify(what));
13
+ }
14
+ function report(io, name, fn, pass) {
15
+ const done = (e) => {
16
+ if (e === undefined) {
17
+ io.out(pass + "\n");
18
+ return 0;
19
+ }
20
+ if (e instanceof SelftestFail) {
21
+ io.err(`${name}: selftest failed: ${e.message}\n`);
22
+ return 1;
23
+ }
24
+ throw e;
25
+ };
26
+ try {
27
+ const r = fn();
28
+ return r instanceof Promise ? r.then(() => done(), done) : done();
29
+ }
30
+ catch (e) {
31
+ return done(e);
32
+ }
33
+ }
34
+ export function selftestTells(io) {
35
+ return report(io, "tells", () => {
36
+ const bad = "It's not just a tool, it's a mirror. The policy — announced today — applies. " +
37
+ "Great question! Let's dive in. At its core it is robust and seamless.\n" +
38
+ "- **Speed:** fast\n";
39
+ const good = "The request may have failed. Check the client version. The tool shows your habits.";
40
+ const fBad = new Set(lintTells(bad).map((f) => f.rule));
41
+ const fGood = lintTells(good);
42
+ const want = ["not-x-its-y", "dash", "chat-residue", "run-up", "deep-saying", "stock-word", "bold-label"];
43
+ const missing = want.filter((w) => !fBad.has(w));
44
+ ok(!missing.length, `selftest: missing ${missing.join(", ")}`);
45
+ ok(!fGood.length, fGood);
46
+ // REQ-LINT-001 the cases the oracle added for the dash contrast and the inflected stock words
47
+ const hits = [
48
+ ["It's not a tool: it's a mirror.", "not-x-its-y"],
49
+ ["It's not a tool; it is a mirror.", "not-x-its-y"],
50
+ ["It's not just a linter \u2014 it's a gate.", "not-x-its-y"],
51
+ ["It's not just a linter - it's a gate.", "not-x-its-y"],
52
+ ["The team leverages the cache and leveraged it, leveraging more.", "stock-word"],
53
+ ["Two deep dives and a tapestry of testaments.", "stock-word"],
54
+ ];
55
+ for (const [text, rule] of hits)
56
+ ok(lintTells(text).some((x) => x.rule === rule), `selftest: ${rule} missed ${JSON.stringify(text)}`);
57
+ const misses = [
58
+ ["It's not here. The tool works.", "not-x-its-y"],
59
+ ["It's not a well-known fact, so we check.", "not-x-its-y"],
60
+ ["Use a lever to lift the load.", "stock-word"],
61
+ ];
62
+ for (const [text, rule] of misses)
63
+ ok(!lintTells(text).some((x) => x.rule === rule), `selftest: ${rule} false positive on ${JSON.stringify(text)}`);
64
+ }, "selftest ok");
65
+ }
66
+ const rules = (fs, id) => fs.filter((f) => f.rule === id);
67
+ const has = (fs, id) => fs.some((f) => f.rule === id);
68
+ const L = (text, file) => lintSte(text, file).findings;
69
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
70
+ export function selftestSte(io) {
71
+ return report(io, "ste", async () => {
72
+ let f = L("The panel is removed; spin up the job. Perform an analysis of the seamless log. We have received the report.");
73
+ for (const e of ["semicolon", "phrasal-verb", "nominalization", "marketing-adjective", "passive-voice", "present-perfect"])
74
+ ok(has(f, e), e);
75
+ // hedges must never be flagged, including modal + perfect infinitive
76
+ f = L("The request may have failed. It could be a timeout. The disk might have filled.");
77
+ ok(!f.length, f);
78
+ // irregular participles: "has run" is a compound tense as much as "has failed"
79
+ f = L("The task has run. The job has set the flag. We have begun.");
80
+ ok(rules(f, "present-perfect").length === 3, f);
81
+ ok(!has(L("The job may have run."), "present-perfect"), "may have run");
82
+ // modal perfects remain protected across negation and variable whitespace
83
+ for (const modal of ["may", "might", "could", "should", "would", "must"]) {
84
+ for (const gap of [" ", " ", "\t", " not "])
85
+ ok(!has(L(`The task ${modal}${gap}have run.`), "present-perfect"), modal + gap);
86
+ }
87
+ ok(!has(L("The task couldn't have run. The task MAY NOT HAVE RUN."), "present-perfect"), "couldn't");
88
+ f = L("The task has run. We have begun. The flag is set.");
89
+ ok(rules(f, "present-perfect").length === 2, f);
90
+ ok(has(f, "passive-voice"), f);
91
+ ok(!has(L("The task is gone."), "passive-voice"), "is gone");
92
+ // code blocks skipped
93
+ ok(!L("```\nx = a; y = b\n```").length, "fence");
94
+ // all supported list markers, case variants, and trailing whitespace
95
+ f = rules(L("- Confirm the target and\n* Record the result OR \n+ Close the panel\n1. Start the task and\n2) Stop the task OR"), "dangling-conjunction");
96
+ ok(f.length === 4, f);
97
+ ok(eq(f.map((x) => x.line), [1, 2, 4, 5]), f);
98
+ ok(eq(f.map((x) => x.col), [1, 1, 1, 1]), f);
99
+ ok(f.every((x) => x.level === "advisory-free"), f);
100
+ // valid continuation lines and standalone four-space code are ignored
101
+ ok(!has(L(" - Confirm the target and\n record the result."), "dangling-conjunction"), "continuation");
102
+ f = rules(L("- Confirm the target\n and"), "dangling-conjunction");
103
+ ok(f.length === 1 && f[0].line === 2, f);
104
+ ok(f[0].col === 3, f);
105
+ ok(!has(L(" - code and"), "dangling-conjunction"), "code");
106
+ ok(!has(L("> - Confirm the target and\n> - Record the result or"), "dangling-conjunction"), "quote");
107
+ f = rules(L("- Do this and\n~~~\ncode and\n~~~"), "dangling-conjunction");
108
+ ok(f.length === 1 && f[0].line === 1, f);
109
+ ok(!has(L("```text\n- code and\n```"), "dangling-conjunction"), "fenced");
110
+ // one- and three-space markers and ordered continuation width
111
+ ok(!has(L(" - Start the task and\n record the result."), "dangling-conjunction"), "one space");
112
+ ok(!has(L("- Start the task and\n record the result."), "dangling-conjunction"), "two gap");
113
+ ok(!has(L("-\tStart the task and"), "dangling-conjunction"), "tab");
114
+ f = rules(L(" - Start the task and", "fixture.md"), "dangling-conjunction");
115
+ ok(f.length === 1 && f[0].col === 4, f);
116
+ ok(f[0].file === "fixture.md", f);
117
+ ok(f[0].match.endsWith("and"), f);
118
+ ok(f[0].message.includes("Complete the item"), f);
119
+ ok(rules(L("100. Start the task and\n unrelated text"), "dangling-conjunction").length === 1, "100.");
120
+ ok(!has(L("- Start the task and.\n- Stop the task or,"), "dangling-conjunction"), "punctuated");
121
+ ok(!has(L("- Start the task and\n\n record the result."), "dangling-conjunction"), "blank");
122
+ ok(eq(rules(L("- Parent item and\n - Nested item or"), "dangling-conjunction").map((x) => x.line), [1, 2]), "nested");
123
+ // ordinary prose, inline code, and fenced code are ignored
124
+ ok(!has(L("The process may include steps and"), "dangling-conjunction"), "prose");
125
+ ok(!has(L("- Use `and` as a label"), "dangling-conjunction"), "code and");
126
+ ok(!has(L("- Combine `left` and `right`"), "dangling-conjunction"), "combine");
127
+ ok(!has(L("~~~\n- code and\n~~~"), "dangling-conjunction"), "tilde");
128
+ ok(has(L(Array(30).fill("word").join(" ") + "."), "long-sentence"), "30 words");
129
+ // Markdown table syntax is layout, not prose. Each cell stays lintable.
130
+ const range = (a, b) => Array.from({ length: b - a }, (_, i) => `term${a + i}`).join(" ");
131
+ const shortCell = range(1, 25) + ".";
132
+ for (const table of [`| Label | Detail |\n| --- | --- |\n| Clear | ${shortCell} |`, `Label | Detail\n--- | ---\nClear | ${shortCell}`]) {
133
+ const r = lintSte(table);
134
+ ok(!has(r.findings, "long-sentence"), r.findings);
135
+ ok(r.words === 27, r.words);
136
+ }
137
+ const longCell = range(1, 27) + ".";
138
+ f = rules(L(`| Label | Detail |\n| --- | --- |\n| Clear | ${longCell} |`), "long-sentence");
139
+ ok(f.length === 1, f);
140
+ ok(f[0].match === "26 words", f);
141
+ // synonym rotation: second member flagged, first named as the keeper
142
+ f = rules(L("Check the config file. Then verify the output. Verify twice."), "synonym-rotation");
143
+ ok(f.length === 1 && f[0].message.includes("'verify' and 'check'"), f);
144
+ ok(!has(L("Check the config. Check the output."), "synonym-rotation"), "one term");
145
+ // per-file labels
146
+ ok(L("a; b", "x.md")[0].file === "x.md", "file");
147
+ // inline code is masked, not deleted, so columns match the source
148
+ f = rules(L("Run `make all` then spin up the node."), "phrasal-verb");
149
+ ok(f.length === 1 && f[0].col === 21, f);
150
+ // a sentence ends after closing quotes and brackets
151
+ const twenty = Array(20).fill("word").join(" ");
152
+ ok(!has(L(`He asked "${twenty}?" Then ${twenty} ended. (It ${twenty}.) Next.`), "long-sentence"), "quotes");
153
+ // abbreviations do not end a sentence
154
+ ok(has(L(`Use a tool, e.g. the ${twenty} linter here now.`), "long-sentence"), "e.g.");
155
+ ok(has(L(`Compare A vs. B in the ${twenty} test.`), "long-sentence"), "vs.");
156
+ // a sentence wrapped across lines is counted once, at its start
157
+ f = rules(L(`Intro text here.\nThe ${twenty}\nwrapped tail words go here.`), "long-sentence");
158
+ ok(f.length === 1, f);
159
+ ok(f[0].line === 2 && f[0].col === 1, f);
160
+ ok(has(L(`- Item ${twenty}\n continued in the same item.`), "long-sentence"), "item");
161
+ // blank lines, headings, rules, list items, and quotes end a prose block
162
+ for (const sep of ["\n\n", "\n# Heading\n", "\n---\n", "\n- ", "\n> "])
163
+ ok(!has(L(`First ${twenty} line${sep}Second ${twenty} line.`), "long-sentence"), sep);
164
+ // a GitHub alert marker on the first line of a quote is not a word of the body
165
+ const body = Array(25).fill("word").join(" ") + ".";
166
+ for (const alert of ["> [!NOTE]", "> [!tip]", ">[!Warning]", "> [!CAUTION] "])
167
+ ok(!has(L(`${alert}\n> ${body}`), "long-sentence"), alert);
168
+ for (const text of [`Text.\n\n> [!NOTE]\n> ${body}`, `> Text.\n~~~\ncode\n~~~\n> [!NOTE]\n> ${body}`])
169
+ ok(!has(L(text), "long-sentence"), text);
170
+ const words26 = (fs) => eq(rules(fs, "long-sentence").map((x) => x.match), ["26 words"]);
171
+ // inside HTML containers the marker is text: marker plus body is 26 words
172
+ for (const [open, close] of [["<details>", "</details>"], ['<DIV class="note">', "</DIV>"], ["<section><details>", "</details></section>"]]) {
173
+ ok(words26(L(`${open}\n\n> [!NOTE]\n> ${body}\n\n${close}`)), open);
174
+ ok(!has(L(`${open}\n${close}\n\n> [!NOTE]\n> ${body}`), "long-sentence"), open);
175
+ }
176
+ ok(!has(L(`> [!NOTE]\n> ${body}`), "long-sentence"), "note");
177
+ // an inner close or a fenced closing tag must not close the outer container
178
+ for (const content of ["<details>\n<div>\n</div>", "<details>\n~~~\n</details>\n~~~"])
179
+ ok(words26(L(`${content}\n\n> [!NOTE]\n> ${body}\n\n</details>`)), content);
180
+ // fenced, commented, inline, void, and self-closing tags do not open containers
181
+ for (const content of ["```html\n<details>\n```", "<!-- <details> -->", "<span>Text.</span>", "<hr>", "<div />", "<details></details>", "</details>"]) {
182
+ ok(!has(L(`${content}\n\n> [!NOTE]\n> ${body}`), "long-sentence"), content);
183
+ }
184
+ // GitHub shows these markers as text, so they stay words
185
+ for (const text of [
186
+ `> [!FOO]\n> ${body}`, `> [!NOTE] ${body}`, `> Intro.\n> [!NOTE]\n> ${body}`,
187
+ `> > [!NOTE]\n> > ${body}`, `> Intro.\ncontinuation.\n> [!NOTE]\n> ${body}`,
188
+ `- Item.\n\n > [!NOTE]\n > ${body}`,
189
+ `Text.\n> [!NOTE]\n> ${body}`, ` > [!NOTE]\n > ${body}`,
190
+ ])
191
+ ok(words26(L(text)), text);
192
+ // a bold or italic lead sentence ends at its closing emphasis marker
193
+ ok(!has(L(`**Lead.** The ${twenty} more words here now.`), "long-sentence"), "bold lead");
194
+ ok(!has(L(`_Note._ The ${twenty} more words here now.`), "long-sentence"), "italic lead");
195
+ ok(has(L(`See **e.g.** the ${twenty} linter here now.`), "long-sentence"), "bold e.g.");
196
+ // a dotted abbreviation ends a sentence before a capitalized word
197
+ ok(!has(L(`The team shipped it in the U.S. Then ${twenty} today.`), "long-sentence"), "U.S.");
198
+ // nested list items, quoted list items, and HTML lines end a prose block
199
+ for (const text of [
200
+ `- First item\n - First ${twenty} line\n - Second ${twenty} line`,
201
+ `> - First ${twenty} line\n> - Second ${twenty} line`,
202
+ `<!-- First ${twenty} line -->\nSecond ${twenty} line.`,
203
+ `> First ${twenty} line.\n>\n> Second ${twenty} line.`,
204
+ ])
205
+ ok(!has(L(text), "long-sentence"), text);
206
+ // a blockquote prefix is not a word in a wrapped quoted sentence
207
+ ok(!has(L("> " + Array(5).fill("word word word word word").join("\n> ") + "."), "long-sentence"), "quote prefix");
208
+ // a list marker is not a word either, in every marker style
209
+ const twentyFive = range(1, 26) + ".";
210
+ for (const marker of ["-", "*", "+", "1.", "1)", " -", "> -"])
211
+ ok(!has(L(`${marker} ${twentyFive}`), "long-sentence"), marker);
212
+ f = rules(L(`- term0 ${twentyFive}`), "long-sentence");
213
+ ok(eq(f.map((x) => [x.match, x.col]), [["26 words", 3]]), f);
214
+ // a code span at the line start does not turn the next token into a list marker
215
+ ok(words26(L(`\`label\` 1) ${twentyFive}`)), "label");
216
+ // bare punctuation is not a word: code span separators and spaced dashes
217
+ for (const separator of [", ", " ", " / "]) {
218
+ const names = Array.from({ length: 30 }, (_, i) => `\`a${i}\``).join(separator);
219
+ ok(!has(L(`The adapters are ${names}.`), "long-sentence"), separator);
220
+ }
221
+ ok(!has(L(range(1, 25) + " — term25."), "long-sentence"), "dash");
222
+ ok(words26(L(`Use \`make\` — ${twentyFive}`)), "make");
223
+ // a long block stays linear: one short sentence per line, no blank lines
224
+ ok(!has(L("The agent read the log.\n".repeat(20000)), "long-sentence"), "linear");
225
+ // flags may appear between file paths
226
+ const tmp = mkdtempSync(join(tmpdir(), "specctx-ste-"));
227
+ try {
228
+ const paths = ["a.md", "b.md"].map((n) => join(tmp, n));
229
+ for (const p of paths)
230
+ writeFileSync(p, "Short line here.\n");
231
+ let out = "";
232
+ const sink = { cwd: tmp, out: (s) => (out += s), err: () => { } };
233
+ ok((await runLint(["ste", paths[0], "--json", paths[1]], sink)) === 0, "intermixed");
234
+ ok(JSON.parse(out).words === 6, out);
235
+ }
236
+ finally {
237
+ rmSync(tmp, { recursive: true, force: true });
238
+ }
239
+ // command line parsing rejects typos and bad values
240
+ for (const bad of [["--basline", "3"], ["--baseline"], ["--baseline", "x"], ["--baseline", "-1"], ["--disable", "passive"], ["--base", "3"]]) {
241
+ const sink = { cwd: tmpdir(), out: () => { }, err: () => { } };
242
+ ok((await runLint(["ste", ...bad], sink)) === 2, bad);
243
+ }
244
+ ok(eq([...checkRules("ste", "passive-voice,present-perfect")].sort(), ["passive-voice", "present-perfect"]), "rule list");
245
+ }, "selftest OK");
246
+ }