@esneiderbravo/speclaw 0.4.0 → 1.0.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/README.md +88 -72
- package/dist/cli/commands/index-build.js +12 -3
- package/dist/cli/commands/lawbook.js +1 -0
- package/dist/cli/commands/laws.js +149 -8
- package/dist/cli/commands/owners.js +44 -0
- package/dist/cli/commands/query.js +32 -10
- package/dist/cli/commands/update.js +28 -0
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +13 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +10 -5
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -0
- package/dist/modules/compass/indexer.js +204 -33
- package/dist/modules/compass/merkle.js +76 -0
- package/dist/modules/compass/pagerank.js +122 -0
- package/dist/modules/compass/rank.js +95 -0
- package/dist/modules/compass/register.js +8 -4
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- package/dist/modules/foundation/dialects/agentsmd.js +95 -0
- package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
- package/dist/modules/foundation/dialects/coderabbit.js +27 -0
- package/dist/modules/foundation/dialects/copilot.js +35 -0
- package/dist/modules/foundation/dialects/index.js +5 -0
- package/dist/modules/foundation/dialects/types.js +58 -0
- package/dist/modules/foundation/doctor.js +220 -14
- package/dist/modules/foundation/import-rules.js +67 -0
- package/dist/modules/foundation/integrity.js +307 -0
- package/dist/modules/foundation/laws-parse.js +131 -0
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/foundation/lock.js +283 -0
- package/dist/modules/foundation/ownership.js +4 -0
- package/dist/modules/foundation/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/verify.js +9 -1
- package/dist/modules/lawbook/coverage.js +45 -6
- package/dist/modules/lawbook/ears.js +417 -0
- package/dist/modules/lawbook/engine.js +29 -0
- package/dist/modules/lawbook/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/package.json +4 -3
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EARS (Easy Approach to Requirements Syntax) classifier and suggestor.
|
|
3
|
+
* File I/O is limited to loading optional knobs from lawbook/config.yaml.
|
|
4
|
+
* Does not rewrite requirement files.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export const DEFAULT_EARS_CONFIG = {
|
|
9
|
+
severity: "strict",
|
|
10
|
+
vagueWords: [
|
|
11
|
+
"appropriately",
|
|
12
|
+
"properly",
|
|
13
|
+
"as needed",
|
|
14
|
+
"efficiently",
|
|
15
|
+
"user-friendly",
|
|
16
|
+
"robust",
|
|
17
|
+
"adecuadamente",
|
|
18
|
+
"correctamente",
|
|
19
|
+
],
|
|
20
|
+
silentCodes: [],
|
|
21
|
+
};
|
|
22
|
+
export const DEFAULT_PROPERTY_RUNNERS = [
|
|
23
|
+
{
|
|
24
|
+
id: "fast-check",
|
|
25
|
+
languages: ["ts", "js"],
|
|
26
|
+
patterns: ["fc.assert(", "fc.property(", "fc.asyncProperty("],
|
|
27
|
+
minRuns: 25,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "hypothesis",
|
|
31
|
+
languages: ["py"],
|
|
32
|
+
patterns: ["@given(", "@settings("],
|
|
33
|
+
minRuns: 25,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "schemathesis",
|
|
37
|
+
languages: ["py"],
|
|
38
|
+
patterns: ["schemathesis.", "@schema.parametrize("],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const MODAL_RE = /\b(SHALL(?:\s+NOT)?|MUST(?:\s+NOT)?)\b/gi;
|
|
42
|
+
const MODAL = String.raw `(?:SHALL|MUST)(?:\s+NOT)?`;
|
|
43
|
+
/**
|
|
44
|
+
* Collapse whitespace and strip simple markdown emphasis for matching.
|
|
45
|
+
*
|
|
46
|
+
* @param text - Raw requirement body.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeRequirementText(text) {
|
|
49
|
+
return text
|
|
50
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
51
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
52
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
function findModal(normalized) {
|
|
57
|
+
const m = /\b(SHALL(?:\s+NOT)?|MUST(?:\s+NOT)?)\b/i.exec(normalized);
|
|
58
|
+
if (!m)
|
|
59
|
+
return null;
|
|
60
|
+
return m[1].toUpperCase().replace(/\s+/g, " ");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Classify a requirement's normative body into an EARS pattern.
|
|
64
|
+
*
|
|
65
|
+
* Precedence: complex → unwanted → state → event → optional → ubiquitous → unstructured.
|
|
66
|
+
*
|
|
67
|
+
* @param text - Normative prose (not the heading alone).
|
|
68
|
+
*/
|
|
69
|
+
// Covers: req~ears-validate~1
|
|
70
|
+
export function classifyEars(text) {
|
|
71
|
+
const normalized = normalizeRequirementText(text);
|
|
72
|
+
const modal = findModal(normalized);
|
|
73
|
+
if (!normalized) {
|
|
74
|
+
return { pattern: "unstructured", parts: {}, modal: null, normalized };
|
|
75
|
+
}
|
|
76
|
+
// Complex: two distinct EARS preconditions (WHILE/WHEN/WHERE/IF) before the modal.
|
|
77
|
+
// THEN is part of unwanted IF…THEN — it must not trigger "complex" alone.
|
|
78
|
+
const complex = new RegExp(String.raw `^(?:WHILE|WHEN|WHERE|IF)\b.*\b(?:WHILE|WHEN|WHERE|IF)\b.*\b${MODAL}\b`, "i");
|
|
79
|
+
if (complex.test(normalized)) {
|
|
80
|
+
return { pattern: "complex", parts: { response: normalized }, modal, normalized };
|
|
81
|
+
}
|
|
82
|
+
const unwanted = new RegExp(String.raw `^IF\b(?<condition>.+?),?\s*THEN\b(?<response>.+\b${MODAL}\b.+)$`, "i");
|
|
83
|
+
const uw = unwanted.exec(normalized);
|
|
84
|
+
if (uw?.groups) {
|
|
85
|
+
return {
|
|
86
|
+
pattern: "unwanted",
|
|
87
|
+
parts: {
|
|
88
|
+
condition: uw.groups["condition"]?.trim(),
|
|
89
|
+
response: uw.groups["response"]?.trim(),
|
|
90
|
+
},
|
|
91
|
+
modal,
|
|
92
|
+
normalized,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const state = new RegExp(String.raw `^WHILE\b(?<state>.+?),\s*(?<response>.+\b${MODAL}\b.+)$`, "i");
|
|
96
|
+
const st = state.exec(normalized);
|
|
97
|
+
if (st?.groups) {
|
|
98
|
+
return {
|
|
99
|
+
pattern: "state",
|
|
100
|
+
parts: { state: st.groups["state"]?.trim(), response: st.groups["response"]?.trim() },
|
|
101
|
+
modal,
|
|
102
|
+
normalized,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const event = new RegExp(String.raw `^WHEN\b(?<trigger>.+?),\s*(?<response>.+\b${MODAL}\b.+)$`, "i");
|
|
106
|
+
const ev = event.exec(normalized);
|
|
107
|
+
if (ev?.groups) {
|
|
108
|
+
return {
|
|
109
|
+
pattern: "event",
|
|
110
|
+
parts: {
|
|
111
|
+
trigger: ev.groups["trigger"]?.trim(),
|
|
112
|
+
response: ev.groups["response"]?.trim(),
|
|
113
|
+
},
|
|
114
|
+
modal,
|
|
115
|
+
normalized,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const optional = new RegExp(String.raw `^WHERE\b(?<feature>.+?),\s*(?<response>.+\b${MODAL}\b.+)$`, "i");
|
|
119
|
+
const op = optional.exec(normalized);
|
|
120
|
+
if (op?.groups) {
|
|
121
|
+
return {
|
|
122
|
+
pattern: "optional",
|
|
123
|
+
parts: {
|
|
124
|
+
feature: op.groups["feature"]?.trim(),
|
|
125
|
+
response: op.groups["response"]?.trim(),
|
|
126
|
+
},
|
|
127
|
+
modal,
|
|
128
|
+
normalized,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const ubiquitous = new RegExp(String.raw `^(?!WHEN\b|WHILE\b|WHERE\b|IF\b).*\b${MODAL}\b.+`, "i");
|
|
132
|
+
if (ubiquitous.test(normalized)) {
|
|
133
|
+
return { pattern: "ubiquitous", parts: { response: normalized }, modal, normalized };
|
|
134
|
+
}
|
|
135
|
+
return { pattern: "unstructured", parts: {}, modal, normalized };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Emit diagnostics for a classified requirement.
|
|
139
|
+
*
|
|
140
|
+
* @param classification - Result of `classifyEars`.
|
|
141
|
+
* @param opts - Scenario presence and ears config.
|
|
142
|
+
*/
|
|
143
|
+
export function diagnoseEars(classification, opts = {}) {
|
|
144
|
+
const cfg = opts.config ?? DEFAULT_EARS_CONFIG;
|
|
145
|
+
const hasScenarios = opts.hasScenarios ?? true;
|
|
146
|
+
const out = [];
|
|
147
|
+
const push = (d) => {
|
|
148
|
+
if (cfg.silentCodes.includes(d.code))
|
|
149
|
+
return;
|
|
150
|
+
out.push(d);
|
|
151
|
+
};
|
|
152
|
+
const { normalized, pattern, modal } = classification;
|
|
153
|
+
if (!modal) {
|
|
154
|
+
push({
|
|
155
|
+
code: "ears/no-modal",
|
|
156
|
+
severity: "error",
|
|
157
|
+
message: "Requirement has no SHALL/MUST modal — it is not a normative obligation.",
|
|
158
|
+
suggestion: suggestEars(normalized),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (pattern === "unstructured" && modal) {
|
|
162
|
+
push({
|
|
163
|
+
code: "ears/unstructured",
|
|
164
|
+
severity: cfg.severity === "strict" ? "error" : "warn",
|
|
165
|
+
message: "Requirement does not fit an EARS mold (WHEN/WHILE/IF…THEN/WHERE/ubiquitous).",
|
|
166
|
+
suggestion: suggestEars(normalized),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const modalCount = [...normalized.matchAll(MODAL_RE)].length;
|
|
170
|
+
if (modalCount > 1) {
|
|
171
|
+
push({
|
|
172
|
+
code: "ears/multiple-modals",
|
|
173
|
+
severity: "warn",
|
|
174
|
+
message: `Found ${modalCount} modals — consider splitting into separate requirements.`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const hasIf = /\bIF\b/i.test(normalized);
|
|
178
|
+
const hasThen = /\bTHEN\b/i.test(normalized);
|
|
179
|
+
if (hasThen && !hasIf) {
|
|
180
|
+
push({
|
|
181
|
+
code: "ears/then-without-if",
|
|
182
|
+
severity: "error",
|
|
183
|
+
message: "THEN present without an opening IF.",
|
|
184
|
+
suggestion: suggestEars(normalized),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (hasIf && !hasThen && pattern !== "complex") {
|
|
188
|
+
// IF…THEN unwanted requires THEN; bare IF mid-sentence is common English — only
|
|
189
|
+
// flag when the body starts with IF.
|
|
190
|
+
if (/^IF\b/i.test(normalized)) {
|
|
191
|
+
push({
|
|
192
|
+
code: "ears/if-without-then",
|
|
193
|
+
severity: "error",
|
|
194
|
+
message: "IF at the start of the requirement without THEN.",
|
|
195
|
+
suggestion: suggestEars(normalized),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const word of cfg.vagueWords) {
|
|
200
|
+
const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
201
|
+
if (re.test(normalized)) {
|
|
202
|
+
push({
|
|
203
|
+
code: "ears/vague-response",
|
|
204
|
+
severity: "warn",
|
|
205
|
+
message: `'${word}' is not an observable acceptance criterion — what would a test assert?`,
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (/\bshall be\b/i.test(normalized) &&
|
|
211
|
+
!/\bthe (system|cli|tool|agent|archive|index)\b/i.test(normalized)) {
|
|
212
|
+
push({
|
|
213
|
+
code: "ears/passive-voice",
|
|
214
|
+
severity: "info",
|
|
215
|
+
message: "Response may be passive ('shall be …') — name the actor when possible.",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (!hasScenarios) {
|
|
219
|
+
push({
|
|
220
|
+
code: "ears/no-scenarios",
|
|
221
|
+
severity: "warn",
|
|
222
|
+
message: "Requirement has no #### Scenario: acceptance criteria.",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Deterministic rewrite suggestion. Never writes files.
|
|
229
|
+
*
|
|
230
|
+
* @param text - Raw or normalized requirement body.
|
|
231
|
+
*/
|
|
232
|
+
export function suggestEars(text) {
|
|
233
|
+
const normalized = normalizeRequirementText(text);
|
|
234
|
+
if (!normalized)
|
|
235
|
+
return "The <system> SHALL <response>.";
|
|
236
|
+
const modalMatch = /\b(SHALL(?:\s+NOT)?|MUST(?:\s+NOT)?)\b/i.exec(normalized);
|
|
237
|
+
if (!modalMatch) {
|
|
238
|
+
return `The system SHALL ${normalized.replace(/\.$/, "")}.`;
|
|
239
|
+
}
|
|
240
|
+
const modalIdx = modalMatch.index;
|
|
241
|
+
const before = normalized
|
|
242
|
+
.slice(0, modalIdx)
|
|
243
|
+
.trim()
|
|
244
|
+
.replace(/[,:]+$/, "")
|
|
245
|
+
.trim();
|
|
246
|
+
const after = normalized.slice(modalIdx).trim();
|
|
247
|
+
if (!before) {
|
|
248
|
+
return after.endsWith(".") ? after : `${after}.`;
|
|
249
|
+
}
|
|
250
|
+
const lower = before.toLowerCase();
|
|
251
|
+
if (/\b(during|while|mientras|whilst)\b/.test(lower) ||
|
|
252
|
+
/\bing\b/.test(lower.split(/\s+/).slice(-1)[0] ?? "")) {
|
|
253
|
+
return `WHILE ${before}, ${after.endsWith(".") ? after : `${after}.`}`;
|
|
254
|
+
}
|
|
255
|
+
if (/\b(fail|invalid|error|missing|denied|unauthorized|no\b)/i.test(before)) {
|
|
256
|
+
return `IF ${before}, THEN ${after.endsWith(".") ? after : `${after}.`}`;
|
|
257
|
+
}
|
|
258
|
+
if (/\b(enabled|included|feature|capability|flag|opt-?in)\b/i.test(before)) {
|
|
259
|
+
return `WHERE ${before}, ${after.endsWith(".") ? after : `${after}.`}`;
|
|
260
|
+
}
|
|
261
|
+
return `WHEN ${before}, ${after.endsWith(".") ? after : `${after}.`}`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Extract normative prose from a requirement block (between heading and scenarios/keywords).
|
|
265
|
+
*
|
|
266
|
+
* @param block - Full requirement section including heading line.
|
|
267
|
+
*/
|
|
268
|
+
export function extractNormativeBody(block) {
|
|
269
|
+
const lines = block.split(/\r?\n/);
|
|
270
|
+
// skip heading
|
|
271
|
+
const bodyLines = [];
|
|
272
|
+
let hasScenarios = false;
|
|
273
|
+
for (let i = 1; i < lines.length; i++) {
|
|
274
|
+
const line = lines[i];
|
|
275
|
+
if (/^####\s+Scenario:/i.test(line)) {
|
|
276
|
+
hasScenarios = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if (/^###?\s+/.test(line) && !/^####\s+/.test(line))
|
|
280
|
+
break;
|
|
281
|
+
if (/^(Status|Needs|Tags|Depends|Covers|Verification)\s*:/i.test(line))
|
|
282
|
+
continue;
|
|
283
|
+
if (/^`req~/.test(line.trim()))
|
|
284
|
+
continue;
|
|
285
|
+
bodyLines.push(line);
|
|
286
|
+
}
|
|
287
|
+
return { body: bodyLines.join("\n").trim(), hasScenarios };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Split a markdown spec into requirement blocks starting at each `### Requirement:`.
|
|
291
|
+
*
|
|
292
|
+
* @param content - Full spec markdown.
|
|
293
|
+
*/
|
|
294
|
+
export function splitRequirementBlocks(content) {
|
|
295
|
+
const lines = content.split(/\r?\n/);
|
|
296
|
+
const starts = [];
|
|
297
|
+
for (let i = 0; i < lines.length; i++) {
|
|
298
|
+
if (/^###\s+Requirement:/i.test(lines[i]))
|
|
299
|
+
starts.push(i);
|
|
300
|
+
}
|
|
301
|
+
const out = [];
|
|
302
|
+
for (let s = 0; s < starts.length; s++) {
|
|
303
|
+
const start = starts[s];
|
|
304
|
+
const end = s + 1 < starts.length ? starts[s + 1] : lines.length;
|
|
305
|
+
const blockLines = lines.slice(start, end);
|
|
306
|
+
const heading = blockLines[0] ?? "";
|
|
307
|
+
out.push({ heading, line: start + 1, block: blockLines.join("\n") });
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* True when a source window near a coverage link invokes a known property runner.
|
|
313
|
+
*
|
|
314
|
+
* @param source - Full file text.
|
|
315
|
+
* @param line - 1-based line of the Covers comment (or link).
|
|
316
|
+
* @param runners - Configured runners.
|
|
317
|
+
* @param window - Lines to scan after `line` (inclusive of line).
|
|
318
|
+
*/
|
|
319
|
+
export function detectPropertyRunnerInWindow(source, line, runners, window = 6) {
|
|
320
|
+
const lines = source.split(/\r?\n/);
|
|
321
|
+
const from = Math.max(0, line - 1);
|
|
322
|
+
const to = Math.min(lines.length, from + window);
|
|
323
|
+
for (let i = from; i < to; i++) {
|
|
324
|
+
const raw = lines[i];
|
|
325
|
+
const trimmed = raw.trim();
|
|
326
|
+
if (!trimmed)
|
|
327
|
+
continue;
|
|
328
|
+
// Skip full-line comments (TS/JS/Python).
|
|
329
|
+
if (trimmed.startsWith("//") ||
|
|
330
|
+
trimmed.startsWith("#") ||
|
|
331
|
+
trimmed.startsWith("*") ||
|
|
332
|
+
trimmed.startsWith("/*")) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
for (const runner of runners) {
|
|
336
|
+
for (const pat of runner.patterns) {
|
|
337
|
+
if (lineHasRunnerCall(raw, pat))
|
|
338
|
+
return { runnerId: runner.id };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
/** Match a runner call that is not inside a string/template quote. */
|
|
345
|
+
function lineHasRunnerCall(raw, pat) {
|
|
346
|
+
let idx = 0;
|
|
347
|
+
while ((idx = raw.indexOf(pat, idx)) !== -1) {
|
|
348
|
+
const before = idx > 0 ? raw[idx - 1] : "";
|
|
349
|
+
if (before !== '"' && before !== "'" && before !== "`")
|
|
350
|
+
return true;
|
|
351
|
+
idx += pat.length;
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Load ears severity / vague words / silent codes from lawbook/config.yaml.
|
|
357
|
+
* Line-oriented subset (no YAML dependency). Defaults to strict.
|
|
358
|
+
*/
|
|
359
|
+
export function loadEarsConfig(projectPath) {
|
|
360
|
+
const cfg = structuredClone(DEFAULT_EARS_CONFIG);
|
|
361
|
+
const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
|
|
362
|
+
if (!fs.existsSync(cfgPath))
|
|
363
|
+
return cfg;
|
|
364
|
+
const text = fs.readFileSync(cfgPath, "utf8");
|
|
365
|
+
const sev = /^\s*severity\s*:\s*(strict|lenient)\s*$/im.exec(text);
|
|
366
|
+
// Prefer nested `ears:` block severity when present; fall back to first match.
|
|
367
|
+
const earsBlock = /(?:^|\n)ears:\s*\n((?:[ \t]+.+\n?)*)/i.exec(text);
|
|
368
|
+
const block = earsBlock?.[1] ?? text;
|
|
369
|
+
const sev2 = /^\s*severity\s*:\s*(strict|lenient)\s*$/im.exec(block);
|
|
370
|
+
if (sev2)
|
|
371
|
+
cfg.severity = sev2[1].toLowerCase();
|
|
372
|
+
else if (sev)
|
|
373
|
+
cfg.severity = sev[1].toLowerCase();
|
|
374
|
+
const vague = /^\s*vagueWords\s*:\s*\[([^\]]*)\]\s*$/im.exec(block);
|
|
375
|
+
if (vague) {
|
|
376
|
+
cfg.vagueWords = vague[1]
|
|
377
|
+
.split(",")
|
|
378
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
379
|
+
.filter(Boolean);
|
|
380
|
+
}
|
|
381
|
+
const silent = /^\s*silentCodes\s*:\s*\[([^\]]*)\]\s*$/im.exec(block);
|
|
382
|
+
if (silent) {
|
|
383
|
+
cfg.silentCodes = silent[1]
|
|
384
|
+
.split(",")
|
|
385
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
386
|
+
.filter(Boolean);
|
|
387
|
+
}
|
|
388
|
+
return cfg;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Load property runner patterns from lawbook/config.yaml, or defaults.
|
|
392
|
+
*/
|
|
393
|
+
export function loadPropertyRunners(projectPath) {
|
|
394
|
+
const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
|
|
395
|
+
if (!fs.existsSync(cfgPath))
|
|
396
|
+
return structuredClone(DEFAULT_PROPERTY_RUNNERS);
|
|
397
|
+
const text = fs.readFileSync(cfgPath, "utf8");
|
|
398
|
+
// Keep defaults; optional override via a flat `propertyRunnerPatterns:` list
|
|
399
|
+
// of substrings (shared across runners) for simple projects.
|
|
400
|
+
const flat = /^\s*propertyRunnerPatterns\s*:\s*\[([^\]]*)\]\s*$/im.exec(text);
|
|
401
|
+
if (!flat)
|
|
402
|
+
return structuredClone(DEFAULT_PROPERTY_RUNNERS);
|
|
403
|
+
const patterns = flat[1]
|
|
404
|
+
.split(",")
|
|
405
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
406
|
+
.filter(Boolean);
|
|
407
|
+
if (patterns.length === 0)
|
|
408
|
+
return structuredClone(DEFAULT_PROPERTY_RUNNERS);
|
|
409
|
+
return [
|
|
410
|
+
{
|
|
411
|
+
id: "configured",
|
|
412
|
+
languages: ["ts", "js", "py"],
|
|
413
|
+
patterns,
|
|
414
|
+
minRuns: 25,
|
|
415
|
+
},
|
|
416
|
+
];
|
|
417
|
+
}
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { coverageArchiveBlockers } from "./coverage.js";
|
|
4
4
|
import { sealCapability } from "./anchors.js";
|
|
5
|
+
import { classifyEars, diagnoseEars, extractNormativeBody, loadEarsConfig, splitRequirementBlocks, } from "./ears.js";
|
|
5
6
|
import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, readChangeType, readCeremonyRecord, } from "./levels.js";
|
|
6
7
|
import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
|
|
7
8
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
@@ -41,6 +42,17 @@ mandatory_task_steps:
|
|
|
41
42
|
ceremony:
|
|
42
43
|
cuts: [3, 8, 15]
|
|
43
44
|
hotspotFloor: 0.7
|
|
45
|
+
|
|
46
|
+
# Requirement → impl → test coverage (\`speclaw coverage\`).
|
|
47
|
+
coverage:
|
|
48
|
+
gateArchive: true
|
|
49
|
+
defaultNeeds: [impl, utest]
|
|
50
|
+
|
|
51
|
+
# EARS requirement linter (strict by default for new projects).
|
|
52
|
+
ears:
|
|
53
|
+
severity: strict
|
|
54
|
+
vagueWords: [appropriately, properly, as needed, efficiently, user-friendly, robust, adecuadamente, correctamente]
|
|
55
|
+
silentCodes: []
|
|
44
56
|
`;
|
|
45
57
|
const README_MD = `# lawbook/ — the spec-driven workflow (speclaw)
|
|
46
58
|
|
|
@@ -234,6 +246,7 @@ export function specValidate(projectPath, change, remeasure) {
|
|
|
234
246
|
const root = specRoot(projectPath);
|
|
235
247
|
const changeSpecs = path.join(changeDir, "specs");
|
|
236
248
|
const capabilities = canonicalCapabilities(root);
|
|
249
|
+
const earsCfg = loadEarsConfig(projectPath);
|
|
237
250
|
for (const file of deltas) {
|
|
238
251
|
const rel = path.relative(changeDir, file);
|
|
239
252
|
const content = fs.readFileSync(file, "utf8");
|
|
@@ -246,6 +259,22 @@ export function specValidate(projectPath, change, remeasure) {
|
|
|
246
259
|
if (!/^###\s+Requirement:/m.test(content)) {
|
|
247
260
|
issues.push(`${rel}: no "### Requirement:" header`);
|
|
248
261
|
}
|
|
262
|
+
for (const req of splitRequirementBlocks(content)) {
|
|
263
|
+
const { body, hasScenarios } = extractNormativeBody(req.block);
|
|
264
|
+
if (!body.trim())
|
|
265
|
+
continue;
|
|
266
|
+
// Covers: req~ears-validate~1, req~ptest-archive-gate~1
|
|
267
|
+
const classification = classifyEars(body);
|
|
268
|
+
const diags = diagnoseEars(classification, { hasScenarios, config: earsCfg });
|
|
269
|
+
for (const d of diags) {
|
|
270
|
+
const loc = `${rel}:${req.line}`;
|
|
271
|
+
const msg = `${loc}: ${d.code}: ${d.message}` + (d.suggestion ? ` Suggested: ${d.suggestion}` : "");
|
|
272
|
+
if (d.severity === "error")
|
|
273
|
+
issues.push(msg);
|
|
274
|
+
else if (d.severity === "warn" || d.severity === "info")
|
|
275
|
+
warnings.push(msg);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
249
278
|
const relFromSpecs = path.relative(changeSpecs, file);
|
|
250
279
|
const capability = relFromSpecs.split(path.sep)[0];
|
|
251
280
|
const nearMatch = nearMatchCapability(capability, capabilities);
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
const RE_REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
4
4
|
const RE_ID = /`([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)`/;
|
|
5
|
-
const RE_KEYWORD = /^(Status|Needs|Tags|Depends|Covers)\s*:\s*(.+?)\s*$/i;
|
|
5
|
+
const RE_KEYWORD = /^(Status|Needs|Tags|Depends|Covers|Verification)\s*:\s*(.+?)\s*$/i;
|
|
6
6
|
const RE_INLINE = /\[@(test|impl)\s+([^\]]+)\]/gi;
|
|
7
7
|
const RE_ID_LOOSE = /\b([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)\b/g;
|
|
8
8
|
/** Format a SpecItemId as `type~name~rev`. */
|
|
@@ -75,6 +75,7 @@ export function parseSpecItems(specPath, content) {
|
|
|
75
75
|
tags: [],
|
|
76
76
|
depends: [],
|
|
77
77
|
covers: [],
|
|
78
|
+
verification: null,
|
|
78
79
|
inlineLinks: [],
|
|
79
80
|
specPath,
|
|
80
81
|
line: i + 1,
|
|
@@ -106,6 +107,8 @@ export function parseSpecItems(specPath, content) {
|
|
|
106
107
|
current.depends = parseIdList(value);
|
|
107
108
|
else if (key === "covers")
|
|
108
109
|
current.covers = parseIdList(value);
|
|
110
|
+
else if (key === "verification")
|
|
111
|
+
current.verification = value.trim().toLowerCase();
|
|
109
112
|
continue;
|
|
110
113
|
}
|
|
111
114
|
for (const m of line.matchAll(RE_INLINE)) {
|