spec-ctx 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -2
- package/action.yml +33 -0
- package/dist/adopt/config-write.js +59 -0
- package/dist/adopt/plan.js +276 -0
- package/dist/adopt/write.js +126 -0
- package/dist/assets.generated.js +14 -0
- package/dist/baseline/baseline.js +65 -0
- package/dist/check/check.js +126 -0
- package/dist/check/evidence.js +70 -0
- package/dist/check/hints.js +26 -0
- package/dist/cli/args.js +90 -0
- package/dist/cli/commands.js +275 -0
- package/dist/cli/errors.js +3 -0
- package/dist/cli/gate.js +187 -0
- package/dist/cli/help.js +230 -0
- package/dist/cli/root.js +32 -0
- package/dist/cli/status.js +116 -0
- package/dist/cli/workflow.js +274 -0
- package/dist/cli.js +13 -0
- package/dist/config.js +119 -0
- package/dist/graph/bundle.js +94 -0
- package/dist/graph/query.js +31 -0
- package/dist/graph/related.js +76 -0
- package/dist/handoff/handoff.js +148 -0
- package/dist/ids/ids.js +115 -0
- package/dist/index/build.js +57 -0
- package/dist/index/canonical.js +44 -0
- package/dist/index/scan.js +357 -0
- package/dist/index/types.js +2 -0
- package/dist/init/init.js +141 -0
- package/dist/lint/cli.js +253 -0
- package/dist/lint/py.js +152 -0
- package/dist/lint/selftest.js +246 -0
- package/dist/lint/ste.js +395 -0
- package/dist/lint/tells.js +116 -0
- package/dist/main.js +74 -0
- package/dist/stats/stats.js +143 -0
- package/dist/stats/write.js +26 -0
- package/dist/tasks/evidence.js +102 -0
- package/dist/tasks/tasks.js +105 -0
- package/dist/util/glob.js +43 -0
- package/dist/util/hash.js +14 -0
- package/dist/util/json.js +16 -0
- package/dist/util/pystr.js +67 -0
- package/dist/util/time.js +11 -0
- package/dist/verify/parse.js +58 -0
- package/dist/verify/run.js +42 -0
- package/dist/version.js +2 -0
- package/package.json +50 -4
- package/schemas/spec-driven-ids/README.md +18 -0
- package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
- package/schemas/spec-driven-ids/schema.yaml +301 -0
- package/schemas/spec-driven-ids/templates/design.md +22 -0
- package/schemas/spec-driven-ids/templates/proposal.md +34 -0
- package/schemas/spec-driven-ids/templates/spec.md +15 -0
- package/schemas/spec-driven-ids/templates/tasks.md +10 -0
- package/skills/specctx-bootstrap/SKILL.md +27 -0
- package/skills/specctx-done/SKILL.md +25 -0
- package/skills/specctx-next/SKILL.md +23 -0
package/dist/lint/ste.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// REQ-LINT-001 REQ-LINT-002 ste-lint: the structural STE rules. A port of scripts/ste-lint.py, rule by rule.
|
|
2
|
+
// Columns count code points, as Python does. Masks keep the code point count of the text they replace.
|
|
3
|
+
import { B, cpAt, isUpper, lstrip, lstripChars, NS, pySplit, pyStrip, re, rstrip, S, splitlines, W, WN } from "./py.js";
|
|
4
|
+
const IRREGULAR_PARTICIPLES = "given|taken|made|done|found|seen|known|shown|written|built|sent|set|run|read|kept|held|left|put|cut|hit|let|shut|split|spread|begun|become|come|gone|got|gotten|lost|met|paid|said|sold|told|thought|brought|bought|caught|taught|won|worn|torn|born|drawn|grown|thrown|flown|driven|risen|chosen|broken|spoken|frozen|hidden|ridden|forgotten|fallen|eaten|beaten|understood|stood|struck|stuck|swung|hung|led|fed|bled|fled|sped|bound|wound|dug|spun|slid|bit|lit|quit";
|
|
5
|
+
const PASSIVE_PARTICIPLES = "given|taken|made|done|found|seen|known|shown|written|built|sent|set|run|read|kept|held|left|put";
|
|
6
|
+
const MODAL_PERFECT_PREFIX = re(`${B}(?:may|might|could|should|would|must|can|will|shall)(?:${S}+not|n['’]t)?${S}+$`, "i");
|
|
7
|
+
export const MAX_WORDS = 25;
|
|
8
|
+
export const STE_RULES = [
|
|
9
|
+
{ id: "semicolon", level: "advisory-free", pattern: re(";", "g"), message: "STE bans the semicolon (Rule 8.1). Split into separate sentences." },
|
|
10
|
+
{
|
|
11
|
+
id: "phrasal-verb",
|
|
12
|
+
level: "advisory-free",
|
|
13
|
+
pattern: re(`${B}(spin(?:ning|s)? up|spun up|reach(?:ing|es|ed)? out|div(?:e|es|ing|ed) into|dove into|kick(?:ing|s|ed)? off|circl(?:e|es|ing|ed) back|touch(?:ing|es|ed)? base)${B}`, "gi"),
|
|
14
|
+
message: "Soft phrasal verb. Use the single plain verb (start, contact, read, begin).",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "marketing-adjective",
|
|
18
|
+
level: "advisory-free",
|
|
19
|
+
pattern: re(`${B}(seamless(?:ly)?|robust(?:ly)?|cutting-edge|effortless(?:ly)?|blazing[- ]fast|world-class|state-of-the-art|game-chang(?:ing|er))${B}`, "gi"),
|
|
20
|
+
message: "Marketing adjective. Delete, or replace with the measurement that earns the claim.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "nominalization",
|
|
24
|
+
level: "advisory-free",
|
|
25
|
+
pattern: re(`${B}(perform|performs|performed|conduct|conducts|conducted|carry out|carries out|carried out)${S}+(?:a|an|the)${S}+${W}+(?:tion|sion|ment|ance|ence|ysis)${B}`, "gi"),
|
|
26
|
+
message: "Action frozen into a noun. Use the verb (analyze, not perform an analysis of).",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "passive-voice",
|
|
30
|
+
level: "advisory",
|
|
31
|
+
pattern: re(`${B}(is|are|was|were|been|being)${S}+(${W}+ed|${PASSIVE_PARTICIPLES})${B}(?!${S}+(?:to|for|by)${S}+${W}+ing)`, "gi"),
|
|
32
|
+
message: "Possible passive voice. Name the actor and use an active verb, unless the actor is unknown or irrelevant.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "present-perfect",
|
|
36
|
+
level: "advisory",
|
|
37
|
+
// modal + perfect infinitive ("may have failed") is a protected hedge, not present perfect
|
|
38
|
+
pattern: re(["may", "might", "could", "should", "would", "must"].map((m) => `(?<!${B}${m} )`).join("") +
|
|
39
|
+
`${B}(has|have|had)${S}+(?:been${S}+)?(?:${W}+(?:ed|en)|${IRREGULAR_PARTICIPLES})${B}`, "gi"),
|
|
40
|
+
message: "Compound tense. Use simple past/present unless current relevance is the point (then keep and flag).",
|
|
41
|
+
},
|
|
42
|
+
{ id: "long-sentence", level: "advisory-free", pattern: null, message: `Sentence has N words (cap ${MAX_WORDS}). Split it.` },
|
|
43
|
+
{ id: "synonym-rotation", level: "advisory-free", pattern: null, message: "Two members of one synonym group name the same action. Pick one and use it every time." },
|
|
44
|
+
{ id: "dangling-conjunction", level: "advisory-free", pattern: null, message: "List item ends with a coordinating conjunction. Complete the item or join it with the next item." },
|
|
45
|
+
];
|
|
46
|
+
export const STE_IDS = STE_RULES.map((r) => r.id);
|
|
47
|
+
const REGEX_RULES = STE_RULES.filter((r) => r.pattern !== null);
|
|
48
|
+
const SYNONYM_GROUPS = [
|
|
49
|
+
["check", "verify", "confirm", "validate"],
|
|
50
|
+
["delete", "remove", "erase"],
|
|
51
|
+
["start", "launch", "begin", "initiate"],
|
|
52
|
+
["stop", "halt", "terminate"],
|
|
53
|
+
["show", "display"],
|
|
54
|
+
["use", "utilize", "employ"],
|
|
55
|
+
["fix", "repair", "correct"],
|
|
56
|
+
["send", "transmit"],
|
|
57
|
+
["get", "retrieve", "fetch", "obtain"],
|
|
58
|
+
["change", "modify", "alter"],
|
|
59
|
+
];
|
|
60
|
+
const WORD_RES = SYNONYM_GROUPS.map((g) => g.map((base) => re(`${B}${base}(?:s|es|ed|d|ing)?${B}`, "i")));
|
|
61
|
+
const CODE_FENCE = re("^(```|~~~)");
|
|
62
|
+
const INLINE_CODE = re("`[^`]*`", "g");
|
|
63
|
+
const LIST_ITEM_START = re("^(?<indent> {0,3})(?<marker>[-*+]|[0-9]+[.)])(?<gap> +)(?<body>.*)$");
|
|
64
|
+
const CONJUNCTION_END = re(`${B}(?:and|or)${S}*$`, "i");
|
|
65
|
+
const TABLE_SEPARATOR_CELL = re("^:?-{3,}:?$");
|
|
66
|
+
const STANDALONE_LINE = re("^ {0,3}(?:#|<|(?:[-*_=] *){3,}$)");
|
|
67
|
+
const BLOCKQUOTE_PREFIX = re("^ {0,3}(?:> ?)+");
|
|
68
|
+
const GITHUB_ALERT = re("^\\[!(?:note|tip|important|warning|caution)\\]$", "i");
|
|
69
|
+
const HTML_TAGS = "address|article|aside|blockquote|body|caption|center|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|" +
|
|
70
|
+
"header|hgroup|html|iframe|legend|li|main|menu|nav|noframes|ol|optgroup|option|p|pre|script|search|section|style|summary|table|" +
|
|
71
|
+
"tbody|td|textarea|tfoot|th|thead|title|tr|ul";
|
|
72
|
+
const HTML_BLOCK_TAG_SRC = `<(/?)(${HTML_TAGS})(?=[${S.slice(1, -1)}/>])[^>]*>`;
|
|
73
|
+
const HTML_BLOCK_START = re("^" + HTML_BLOCK_TAG_SRC, "i");
|
|
74
|
+
const HTML_BLOCK_TAG = re(HTML_BLOCK_TAG_SRC, "gi");
|
|
75
|
+
const ANY_LIST_ITEM = re(`^${S}*(?:[-*+]|[0-9]+[.)])(?:${S}|$)`);
|
|
76
|
+
const WORD_CHAR = re(WN);
|
|
77
|
+
const SENTENCE_END = re("[.!?][\"'”’)\\]*_]*$");
|
|
78
|
+
const OPENERS = "\"'“‘([*_";
|
|
79
|
+
const ABBREVIATIONS = new Set(["vs.", "cf.", "approx.", "mr.", "mrs.", "ms.", "dr.", "fig.", "no."]);
|
|
80
|
+
const DOTTED_ABBREVIATION = re("^(?:[a-z]\\.){2,}$", "i");
|
|
81
|
+
const TOKEN = re(`${NS}+`, "g");
|
|
82
|
+
/** A count of the tokens that hold a letter or a digit. */
|
|
83
|
+
export function countWords(text) {
|
|
84
|
+
return pySplit(text).filter((t) => WORD_CHAR.test(t)).length;
|
|
85
|
+
}
|
|
86
|
+
/** Blank code spans with as many spaces as they have code points. */
|
|
87
|
+
export function maskInlineCode(text) {
|
|
88
|
+
return text.replace(INLINE_CODE, (m) => " ".repeat(Array.from(m).length));
|
|
89
|
+
}
|
|
90
|
+
/** The UTF-16 offset of each sentence start in text. */
|
|
91
|
+
export function sentenceStarts(text) {
|
|
92
|
+
const words = [...text.matchAll(TOKEN)];
|
|
93
|
+
if (!words.length)
|
|
94
|
+
return [0];
|
|
95
|
+
const starts = [words[0].index];
|
|
96
|
+
for (let i = 0; i + 1 < words.length; i++) {
|
|
97
|
+
const w = words[i][0];
|
|
98
|
+
const next = words[i + 1];
|
|
99
|
+
const end = SENTENCE_END.exec(w);
|
|
100
|
+
if (!end)
|
|
101
|
+
continue;
|
|
102
|
+
const core = lstripChars(w.slice(0, end.index + 1), OPENERS);
|
|
103
|
+
if (ABBREVIATIONS.has(core.toLowerCase()))
|
|
104
|
+
continue;
|
|
105
|
+
if (DOTTED_ABBREVIATION.test(core)) {
|
|
106
|
+
const first = lstripChars(next[0], OPENERS).codePointAt(0);
|
|
107
|
+
if (!(first !== undefined && isUpper(String.fromCodePoint(first))))
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
starts.push(next.index);
|
|
111
|
+
}
|
|
112
|
+
return starts;
|
|
113
|
+
}
|
|
114
|
+
function longSentenceFindings(pieces, file) {
|
|
115
|
+
const offsets = [];
|
|
116
|
+
let position = 0;
|
|
117
|
+
for (const [, , text] of pieces) {
|
|
118
|
+
offsets.push(position);
|
|
119
|
+
position += text.length + 1;
|
|
120
|
+
}
|
|
121
|
+
const joined = pieces.map((p) => p[2]).join(" ");
|
|
122
|
+
const findings = [];
|
|
123
|
+
const starts = sentenceStarts(joined);
|
|
124
|
+
for (let k = 0; k < starts.length; k++) {
|
|
125
|
+
const start = starts[k];
|
|
126
|
+
const end = k + 1 < starts.length ? starts[k + 1] : joined.length;
|
|
127
|
+
const n = countWords(joined.slice(start, end));
|
|
128
|
+
if (n <= MAX_WORDS)
|
|
129
|
+
continue;
|
|
130
|
+
// bisect_right(offsets, start) - 1
|
|
131
|
+
let lo = 0;
|
|
132
|
+
let hi = offsets.length;
|
|
133
|
+
while (lo < hi) {
|
|
134
|
+
const mid = (lo + hi) >> 1;
|
|
135
|
+
if (start < offsets[mid])
|
|
136
|
+
hi = mid;
|
|
137
|
+
else
|
|
138
|
+
lo = mid + 1;
|
|
139
|
+
}
|
|
140
|
+
const index = lo - 1;
|
|
141
|
+
const offset = offsets[index];
|
|
142
|
+
const [lineno, sourceColumn] = pieces[index];
|
|
143
|
+
findings.push({
|
|
144
|
+
file,
|
|
145
|
+
line: lineno,
|
|
146
|
+
col: sourceColumn + cpAt(joined.slice(offset), start - offset) + 1,
|
|
147
|
+
rule: "long-sentence",
|
|
148
|
+
level: "advisory-free",
|
|
149
|
+
match: `${n} words`,
|
|
150
|
+
message: `Sentence has ${n} words (cap ${MAX_WORDS}). Split it.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return findings;
|
|
154
|
+
}
|
|
155
|
+
const leadingSpaces = (line) => line.length - line.replace(/^ +/, "").length;
|
|
156
|
+
function isListContinuation(line, contentIndent) {
|
|
157
|
+
if (!pyStrip(line))
|
|
158
|
+
return true;
|
|
159
|
+
if (LIST_ITEM_START.test(line))
|
|
160
|
+
return false;
|
|
161
|
+
return leadingSpaces(line) >= contentIndent;
|
|
162
|
+
}
|
|
163
|
+
/** Trimmed table cells and their code point columns, or null when the line is not a table row. */
|
|
164
|
+
export function splitTableRow(line) {
|
|
165
|
+
let left = line.length - lstrip(line).length;
|
|
166
|
+
const right = rstrip(line).length;
|
|
167
|
+
let content = line.slice(left, Math.max(left, right));
|
|
168
|
+
if (!content.includes("|"))
|
|
169
|
+
return null;
|
|
170
|
+
if (content.startsWith("|")) {
|
|
171
|
+
content = content.slice(1);
|
|
172
|
+
left += 1;
|
|
173
|
+
}
|
|
174
|
+
if (content.endsWith("|"))
|
|
175
|
+
content = content.slice(0, -1);
|
|
176
|
+
const raw = content.split(/(?<!\\)\|/);
|
|
177
|
+
if (raw.length < 2)
|
|
178
|
+
return null;
|
|
179
|
+
const cells = [];
|
|
180
|
+
let column = left;
|
|
181
|
+
for (const cell of raw) {
|
|
182
|
+
const leading = cell.length - lstrip(cell).length;
|
|
183
|
+
cells.push([pyStrip(cell), cpAt(line, column + leading)]);
|
|
184
|
+
column += cell.length + 1;
|
|
185
|
+
}
|
|
186
|
+
return cells;
|
|
187
|
+
}
|
|
188
|
+
function markdownTableCells(lines) {
|
|
189
|
+
const table = new Map();
|
|
190
|
+
let index = 1;
|
|
191
|
+
while (index < lines.length) {
|
|
192
|
+
const separator = splitTableRow(lines[index]);
|
|
193
|
+
const header = splitTableRow(lines[index - 1]);
|
|
194
|
+
if (!separator || !header || separator.length !== header.length || !separator.every(([c]) => TABLE_SEPARATOR_CELL.test(c))) {
|
|
195
|
+
index++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
table.set(index - 1, header);
|
|
199
|
+
table.set(index, []);
|
|
200
|
+
index++;
|
|
201
|
+
while (index < lines.length) {
|
|
202
|
+
const row = splitTableRow(lines[index]);
|
|
203
|
+
if (!row || row.length !== separator.length)
|
|
204
|
+
break;
|
|
205
|
+
table.set(index, row);
|
|
206
|
+
index++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return table;
|
|
210
|
+
}
|
|
211
|
+
function danglingConjunctionFindings(text, file) {
|
|
212
|
+
const lines = splitlines(text);
|
|
213
|
+
const findings = [];
|
|
214
|
+
let inFence = false;
|
|
215
|
+
let index = 0;
|
|
216
|
+
while (index < lines.length) {
|
|
217
|
+
const line = lines[index];
|
|
218
|
+
if (CODE_FENCE.test(pyStrip(line))) {
|
|
219
|
+
inFence = !inFence;
|
|
220
|
+
index++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (inFence) {
|
|
224
|
+
index++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const start = LIST_ITEM_START.exec(line);
|
|
228
|
+
if (!start) {
|
|
229
|
+
index++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const g = start.groups;
|
|
233
|
+
const contentIndent = g.indent.length + g.marker.length + g.gap.length;
|
|
234
|
+
const itemLines = [[index, g.body]];
|
|
235
|
+
let next = index + 1;
|
|
236
|
+
let itemFence = false;
|
|
237
|
+
while (next < lines.length) {
|
|
238
|
+
const candidate = lines[next];
|
|
239
|
+
if (CODE_FENCE.test(pyStrip(candidate))) {
|
|
240
|
+
itemFence = !itemFence;
|
|
241
|
+
next++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (itemFence) {
|
|
245
|
+
next++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!isListContinuation(candidate, contentIndent))
|
|
249
|
+
break;
|
|
250
|
+
itemLines.push([next, candidate]);
|
|
251
|
+
next++;
|
|
252
|
+
}
|
|
253
|
+
const meaningful = [];
|
|
254
|
+
for (const [i, l] of itemLines) {
|
|
255
|
+
const cleaned = pyStrip(l.replace(INLINE_CODE, " CODE "));
|
|
256
|
+
if (cleaned)
|
|
257
|
+
meaningful.push([i, cleaned]);
|
|
258
|
+
}
|
|
259
|
+
if (meaningful.length) {
|
|
260
|
+
const [endIndex, endLine] = meaningful[meaningful.length - 1];
|
|
261
|
+
if (CONJUNCTION_END.test(endLine)) {
|
|
262
|
+
let line_;
|
|
263
|
+
let col;
|
|
264
|
+
if (endIndex === index) {
|
|
265
|
+
line_ = index + 1;
|
|
266
|
+
col = g.indent.length + 1;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const raw = itemLines.find(([i]) => i === endIndex)[1];
|
|
270
|
+
const masked = maskInlineCode(raw);
|
|
271
|
+
const c = CONJUNCTION_END.exec(masked);
|
|
272
|
+
line_ = endIndex + 1;
|
|
273
|
+
col = c ? cpAt(masked, c.index) + 1 : 1;
|
|
274
|
+
}
|
|
275
|
+
findings.push({
|
|
276
|
+
file,
|
|
277
|
+
line: line_,
|
|
278
|
+
col,
|
|
279
|
+
rule: "dangling-conjunction",
|
|
280
|
+
level: "advisory-free",
|
|
281
|
+
match: endLine,
|
|
282
|
+
message: "List item ends with a coordinating conjunction. Complete the item or join it with the next item.",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
index = next;
|
|
287
|
+
}
|
|
288
|
+
return findings;
|
|
289
|
+
}
|
|
290
|
+
const cmpSeen = (a, b) => {
|
|
291
|
+
const [x, xb] = a;
|
|
292
|
+
const [y, yb] = b;
|
|
293
|
+
return x[0] - y[0] || x[1] - y[1] || (x[2] < y[2] ? -1 : x[2] > y[2] ? 1 : 0) || (xb < yb ? -1 : xb > yb ? 1 : 0);
|
|
294
|
+
};
|
|
295
|
+
export function lintSte(text, file = "<stdin>") {
|
|
296
|
+
const findings = [];
|
|
297
|
+
let words = 0;
|
|
298
|
+
let inFence = false;
|
|
299
|
+
const html = [];
|
|
300
|
+
const lines = splitlines(text);
|
|
301
|
+
const tableCells = markdownTableCells(lines);
|
|
302
|
+
const seen = new Map();
|
|
303
|
+
let block = [];
|
|
304
|
+
let inQuote = false;
|
|
305
|
+
let afterBoundary = true;
|
|
306
|
+
const flush = () => {
|
|
307
|
+
findings.push(...longSentenceFindings(block, file));
|
|
308
|
+
block = [];
|
|
309
|
+
};
|
|
310
|
+
for (let i = 0; i < lines.length; i++) {
|
|
311
|
+
const lineno = i + 1;
|
|
312
|
+
const rawLine = lines[i];
|
|
313
|
+
if (CODE_FENCE.test(pyStrip(rawLine))) {
|
|
314
|
+
flush();
|
|
315
|
+
inFence = !inFence;
|
|
316
|
+
afterBoundary = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (inFence)
|
|
320
|
+
continue;
|
|
321
|
+
if (HTML_BLOCK_START.test(lstrip(rawLine))) {
|
|
322
|
+
for (const tag of maskInlineCode(rawLine).matchAll(HTML_BLOCK_TAG)) {
|
|
323
|
+
const name = tag[2].toLowerCase();
|
|
324
|
+
if (tag[1]) {
|
|
325
|
+
if (html.includes(name)) {
|
|
326
|
+
while (html.length)
|
|
327
|
+
if (html.pop() === name)
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else if (!tag[0].endsWith("/>"))
|
|
332
|
+
html.push(name);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const isTable = tableCells.has(i);
|
|
336
|
+
const segments = tableCells.get(i) ?? [[rawLine, 0]];
|
|
337
|
+
const quote = BLOCKQUOTE_PREFIX.exec(rawLine);
|
|
338
|
+
const isQuote = !!quote;
|
|
339
|
+
const content = quote ? rawLine.slice(quote[0].length) : rawLine;
|
|
340
|
+
const alert = !html.length && rawLine.startsWith(">") && afterBoundary && quote[0].split(">").length - 1 === 1 && GITHUB_ALERT.test(pyStrip(content));
|
|
341
|
+
const standalone = STANDALONE_LINE.test(content) || alert;
|
|
342
|
+
if (isTable || !pyStrip(content) || standalone || ANY_LIST_ITEM.test(content) || isQuote !== inQuote)
|
|
343
|
+
flush();
|
|
344
|
+
inQuote = isQuote;
|
|
345
|
+
afterBoundary = !pyStrip(rawLine);
|
|
346
|
+
for (const [segment, sourceColumn] of segments) {
|
|
347
|
+
const line = maskInlineCode(segment);
|
|
348
|
+
words += pySplit(line).length;
|
|
349
|
+
for (const r of REGEX_RULES) {
|
|
350
|
+
for (const m of line.matchAll(r.pattern)) {
|
|
351
|
+
if (r.id === "present-perfect" && MODAL_PERFECT_PREFIX.test(line.slice(0, m.index)))
|
|
352
|
+
continue;
|
|
353
|
+
findings.push({ file, line: lineno, col: sourceColumn + cpAt(line, m.index) + 1, rule: r.id, level: r.level, match: m[0], message: r.message });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
SYNONYM_GROUPS.forEach((group, gi) => {
|
|
357
|
+
group.forEach((base, bi) => {
|
|
358
|
+
const key = `${gi} ${base}`;
|
|
359
|
+
if (seen.has(key))
|
|
360
|
+
return;
|
|
361
|
+
const m = WORD_RES[gi][bi].exec(line);
|
|
362
|
+
if (m)
|
|
363
|
+
seen.set(key, [lineno, sourceColumn + cpAt(line, m.index) + 1, m[0]]);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
// the blockquote prefix and the list marker are markup, not words in the sentence
|
|
367
|
+
let skip = quote && !isTable ? quote[0].length : 0;
|
|
368
|
+
const marker = isTable ? null : ANY_LIST_ITEM.exec(segment.slice(skip));
|
|
369
|
+
if (marker)
|
|
370
|
+
skip += marker[0].length;
|
|
371
|
+
const rest = line.slice(skip);
|
|
372
|
+
if (pyStrip(rest))
|
|
373
|
+
block.push([lineno, sourceColumn + skip, rest]);
|
|
374
|
+
if (isTable)
|
|
375
|
+
flush(); // each table cell is its own block
|
|
376
|
+
}
|
|
377
|
+
if (standalone)
|
|
378
|
+
flush();
|
|
379
|
+
}
|
|
380
|
+
flush();
|
|
381
|
+
// synonym rotation: flag each member after the first, at its first occurrence
|
|
382
|
+
SYNONYM_GROUPS.forEach((group, gi) => {
|
|
383
|
+
const present = group.filter((b) => seen.has(`${gi} ${b}`)).map((b) => [seen.get(`${gi} ${b}`), b]);
|
|
384
|
+
if (present.length > 1) {
|
|
385
|
+
present.sort(cmpSeen);
|
|
386
|
+
const firstBase = present[0][1];
|
|
387
|
+
for (const [[lineno, col, match], base] of present.slice(1)) {
|
|
388
|
+
findings.push({ file, line: lineno, col, rule: "synonym-rotation", level: "advisory-free", match, message: `'${base}' and '${firstBase}' name the same action. Pick one and use it every time.` });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
findings.push(...danglingConjunctionFindings(text, file));
|
|
393
|
+
findings.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
394
|
+
return { findings, words };
|
|
395
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// REQ-LINT-001 REQ-LINT-002 tells-lint: the AI writing tells a regex can see. A port of scripts/tells-lint.py, rule by rule.
|
|
2
|
+
import { B, cpLen, cpSlice, D, esc, lineIndex, NS, pyStrip, re, S, splitlines } from "./py.js";
|
|
3
|
+
const STOCK_WORDS = [
|
|
4
|
+
"additionally", "align with", "bolster", "crucial", "deep dive", "delve",
|
|
5
|
+
"enduring", "enhance", "garner", "interplay", "intricate", "intricacies",
|
|
6
|
+
"leverage", "meticulous", "meticulously", "pivotal", "seamless", "seamlessly",
|
|
7
|
+
"showcase", "tapestry", "testament", "underscore", "underscores", "vibrant",
|
|
8
|
+
"game-changer", "game changer", "cutting-edge", "cutting edge",
|
|
9
|
+
];
|
|
10
|
+
// REQ-LINT-001 stock words that take verb or noun inflections, as in the oracle.
|
|
11
|
+
const STOCK_VERBS = new Set(["align with", "bolster", "delve", "enhance", "garner", "leverage", "showcase", "underscore"]);
|
|
12
|
+
const STOCK_NOUNS = new Set(["deep dive", "game-changer", "game changer", "interplay", "tapestry", "testament"]);
|
|
13
|
+
/** The pattern for one stock word with its inflections. */
|
|
14
|
+
function stockPattern(word) {
|
|
15
|
+
if (STOCK_VERBS.has(word)) {
|
|
16
|
+
const i = word.indexOf(" ");
|
|
17
|
+
const head = i < 0 ? word : word.slice(0, i);
|
|
18
|
+
const tail = i < 0 ? "" : word.slice(i + 1);
|
|
19
|
+
const core = head.endsWith("e") ? `${esc(head.slice(0, -1))}(?:e|es|ed|ing)` : `${esc(head)}(?:s|es|ed|ing)?`;
|
|
20
|
+
return core + (i < 0 ? "" : `${S}+${esc(tail)}`);
|
|
21
|
+
}
|
|
22
|
+
if (STOCK_NOUNS.has(word))
|
|
23
|
+
return `${esc(word)}(?:s|es)?`;
|
|
24
|
+
return esc(word);
|
|
25
|
+
}
|
|
26
|
+
const SALES_WORDS = ["nestled", "breathtaking", "renowned", "must-visit", "stunning", "diverse array", "in the heart of", "profound"];
|
|
27
|
+
const CHAT_RESIDUE = [
|
|
28
|
+
"great question", "i hope this helps", `${B}of course!`, `${B}certainly!`,
|
|
29
|
+
"you're absolutely right", "would you like me to", "want me to",
|
|
30
|
+
"let me know if", "should i continue",
|
|
31
|
+
];
|
|
32
|
+
const RUN_UP = [
|
|
33
|
+
"let'?s dive in", "let'?s explore", "let'?s break this down",
|
|
34
|
+
"here'?s what you need to know", "without further ado", "here'?s the thing",
|
|
35
|
+
"the thing is,", "real talk", "let'?s be honest",
|
|
36
|
+
];
|
|
37
|
+
const DEEP_SAYINGS = [
|
|
38
|
+
`${B}at its core${B}`, `${B}the real question is${B}`, `${B}what really matters${B}`,
|
|
39
|
+
`${B}the heart of the matter${B}`, `${B}is the (?:language|currency|architecture) of${B}`,
|
|
40
|
+
];
|
|
41
|
+
const words = (ws) => `${B}(?:${ws.map(esc).join("|")})${B}`;
|
|
42
|
+
export const TELLS_RULES = [
|
|
43
|
+
// hard rules
|
|
44
|
+
{ id: "dash", level: "hard", pattern: re(`—|–|(?<=${NS}) -- (?=${NS})|${S}--${S}`, "g"), message: "An em dash, en dash, or double hyphen." },
|
|
45
|
+
{
|
|
46
|
+
id: "not-x-but-y",
|
|
47
|
+
level: "hard",
|
|
48
|
+
pattern: re(`${B}(?:not|isn'?t|aren'?t|wasn'?t|it'?s not)${S}+(?:just|only|merely|simply|about)?${S}*[^.?!;:]{1,60}?[,;]?${S}+but${S}+(?:rather${S}+)?(?:also${S}+)?`, "gi"),
|
|
49
|
+
message: "A not X but Y contrast.",
|
|
50
|
+
},
|
|
51
|
+
// REQ-LINT-001 the oracle accepts , . : ; an em or en dash, or a spaced hyphen before "it's" or "it is".
|
|
52
|
+
{ id: "not-x-its-y", level: "hard", pattern: re(`${B}(?:it'?s|this is|that'?s) not [^.?!]{1,60}?(?:[,.:;]|${S}-(?=${S})|${S}*[—–])${S}*(?:it'?s|it is|this is|that'?s)${B}`, "gi"), message: "A not X, it is Y contrast." },
|
|
53
|
+
{ id: "chat-residue", level: "hard", pattern: re(CHAT_RESIDUE.join("|"), "gi"), message: "Chat text left in the document." },
|
|
54
|
+
{ id: "run-up", level: "hard", pattern: re(RUN_UP.join("|"), "gi"), message: "A run up before the point." },
|
|
55
|
+
{ id: "deep-saying", level: "hard", pattern: re(DEEP_SAYINGS.join("|"), "gi"), message: "A deep saying." },
|
|
56
|
+
{ id: "stock-word", level: "hard", pattern: re(`${B}(?:${STOCK_WORDS.map(stockPattern).join("|")})${B}`, "gi"), message: "A stock AI word." },
|
|
57
|
+
{ id: "bold-label", level: "hard", pattern: re(`^${S}*(?:[-*+]|${D}+[.)])${S}+\\*\\*[^*\\n]+?(?::\\*\\*|\\*\\*${S}*:)`, "gm"), message: "A list item that starts with a bold label." },
|
|
58
|
+
{ id: "emoji-heading", level: "hard", pattern: re(`^#{1,6}${S}*[\\u{1F300}-\\u{1FAFF}\\u2600-\\u27BF]`, "gm"), message: "A heading that starts with an emoji." },
|
|
59
|
+
// advisory rules
|
|
60
|
+
{ id: "sales-word", level: "advisory", pattern: re(words(SALES_WORDS), "gi"), message: "A sales word." },
|
|
61
|
+
{ id: "title-case-heading", level: "advisory", pattern: re(`^#{1,6}${S}+(?:[A-Z][a-z]+${S}+){2,}[A-Z][a-z]+${S}*$`, "gm"), message: "A heading in title case." },
|
|
62
|
+
{ id: "stacked-qualifier", level: "advisory", pattern: re(`${B}(?:could|might|may)${S}+(?:potentially|possibly|arguably)${S}+(?:potentially|possibly|arguably|be)${B}`, "gi"), message: "Stacked qualifiers." },
|
|
63
|
+
{ id: "serves-as", level: "advisory", pattern: re(`${B}(?:serves as|stands as|functions as|boasts|represents a)${B}`, "gi"), message: "A serves as phrase in place of is." },
|
|
64
|
+
{ id: "closer", level: "advisory", pattern: re(`^(?:That is the real win|That distinction matters|Read that again|Let that sink in)\\.?${S}*$`, "gim"), message: "A one line closer." },
|
|
65
|
+
{ id: "repeated-closer", level: "advisory", pattern: null, message: "The same short paragraph two or more times." },
|
|
66
|
+
];
|
|
67
|
+
export const TELLS_IDS = TELLS_RULES.map((r) => r.id);
|
|
68
|
+
const FENCE = re(`^${S}*(\`\`\`|~~~)`);
|
|
69
|
+
const INLINE_CODE = re("`[^`\\n]*`", "g");
|
|
70
|
+
const URL = re(`https?://${NS}+|${NS}+/${NS}+`, "g");
|
|
71
|
+
/** Blank fenced code, inline code, and URLs. Line numbers stay true. */
|
|
72
|
+
export function stripCode(text) {
|
|
73
|
+
const out = [];
|
|
74
|
+
let fenced = false;
|
|
75
|
+
for (let line of splitlines(text)) {
|
|
76
|
+
if (FENCE.test(line)) {
|
|
77
|
+
fenced = !fenced;
|
|
78
|
+
out.push("");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (fenced) {
|
|
82
|
+
out.push("");
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
line = line.replace(INLINE_CODE, " ").replace(URL, " ");
|
|
86
|
+
out.push(line);
|
|
87
|
+
}
|
|
88
|
+
return out.join("\n");
|
|
89
|
+
}
|
|
90
|
+
export function lintTells(text, disabled = new Set()) {
|
|
91
|
+
const clean = stripCode(text);
|
|
92
|
+
const lineOf = lineIndex(clean);
|
|
93
|
+
const findings = [];
|
|
94
|
+
for (const r of TELLS_RULES) {
|
|
95
|
+
if (disabled.has(r.id) || r.pattern === null)
|
|
96
|
+
continue;
|
|
97
|
+
r.pattern.lastIndex = 0;
|
|
98
|
+
for (const m of clean.matchAll(r.pattern)) {
|
|
99
|
+
findings.push({ rule: r.id, level: r.level, line: lineOf(m.index), match: cpSlice(pyStrip(m[0]), 80) });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!disabled.has("repeated-closer")) {
|
|
103
|
+
const paras = clean.split(re(`\\n${S}*\\n`)).map(pyStrip).filter(Boolean);
|
|
104
|
+
const seen = new Map();
|
|
105
|
+
for (const p of paras) {
|
|
106
|
+
if (cpLen(p) < 60 && !p.includes("\n") && !p.startsWith("#"))
|
|
107
|
+
seen.set(p, (seen.get(p) ?? 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
for (const [p, n] of seen) {
|
|
110
|
+
if (n >= 2)
|
|
111
|
+
findings.push({ rule: "repeated-closer", level: "advisory", line: lineOf(clean.indexOf(p)), match: p });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// stable sort by (line, rule), as Python sorts
|
|
115
|
+
return findings.sort((a, b) => a.line - b.line || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0));
|
|
116
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// REQ-CLI-001 REQ-CLI-002 REQ-CLI-003 command dispatch. Each command loads its modules inside its action.
|
|
2
|
+
import { COMMANDS, GROUPS, parseArgs, UsageError } from "./cli/args.js";
|
|
3
|
+
import { VERSION } from "./version.js";
|
|
4
|
+
export async function main(argv, io) {
|
|
5
|
+
// REQ-LINT-001 the linters keep the argument rules of the Python scripts, so they skip this parser
|
|
6
|
+
// REQ-CLI-006 `specctx help <cmd>` prints the page of one command
|
|
7
|
+
if (argv[0] === "help") {
|
|
8
|
+
const { HELP, helpFor, commandList } = await import("./cli/help.js");
|
|
9
|
+
const name = argv.slice(1).filter((a) => a !== "--help" && a !== "-h").join(" ");
|
|
10
|
+
if (!name) {
|
|
11
|
+
io.out(HELP);
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
const page = helpFor(name);
|
|
15
|
+
if (page === null) {
|
|
16
|
+
io.err(`specctx: unknown command: ${name}\n${commandList()}`);
|
|
17
|
+
return 2;
|
|
18
|
+
}
|
|
19
|
+
io.out(page);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (argv[0] === "lint") {
|
|
23
|
+
const { runLint } = await import("./lint/cli.js");
|
|
24
|
+
return runLint(argv.slice(1), io);
|
|
25
|
+
}
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = parseArgs(argv);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (e instanceof UsageError) {
|
|
32
|
+
io.err(`specctx: ${e.message}\n`);
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
if (parsed.flags.version) {
|
|
38
|
+
io.out(VERSION + "\n");
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (parsed.flags.help || parsed.command === null) {
|
|
42
|
+
const { HELP, helpFor } = await import("./cli/help.js");
|
|
43
|
+
if (parsed.command === null && !parsed.flags.help) {
|
|
44
|
+
io.err(HELP);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
// REQ-CLI-006 `specctx <cmd> --help` prints the page of that command only
|
|
48
|
+
io.out((parsed.command !== null && helpFor(parsed.command)) || HELP);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (GROUPS.includes(parsed.command)) {
|
|
52
|
+
const subs = COMMANDS.filter((c) => c.startsWith(parsed.command + " ")).map((c) => c.split(" ")[1]);
|
|
53
|
+
io.err(`specctx: ${parsed.command} needs a subcommand: ${subs.join(", ")}\n`);
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
if (!COMMANDS.includes(parsed.command)) {
|
|
57
|
+
const { commandList } = await import("./cli/help.js");
|
|
58
|
+
io.err(`specctx: unknown command: ${parsed.command}\n${commandList()}`);
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const { run } = await import("./cli/commands.js");
|
|
63
|
+
return await run(parsed, io);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const { UsageExit } = await import("./cli/errors.js");
|
|
67
|
+
if (e instanceof UsageExit) {
|
|
68
|
+
io.err(`specctx: ${e.message}\n`);
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
io.err(`specctx: internal error: ${e.stack ?? String(e)}\n`);
|
|
72
|
+
return 3;
|
|
73
|
+
}
|
|
74
|
+
}
|