@esneiderbravo/speclaw 0.3.13 → 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.
Files changed (74) hide show
  1. package/README.md +88 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +52 -10
  7. package/dist/cli/commands/update.js +35 -5
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +15 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/diff-context.js +134 -0
  13. package/dist/modules/compass/embed-input.js +28 -0
  14. package/dist/modules/compass/embedder.js +3 -1
  15. package/dist/modules/compass/explore-rich.js +134 -0
  16. package/dist/modules/compass/extract.js +86 -0
  17. package/dist/modules/compass/hybrid.js +318 -0
  18. package/dist/modules/compass/impact-summary.js +33 -0
  19. package/dist/modules/compass/indexer.js +204 -33
  20. package/dist/modules/compass/merkle.js +76 -0
  21. package/dist/modules/compass/pagerank.js +122 -0
  22. package/dist/modules/compass/rank.js +95 -0
  23. package/dist/modules/compass/register.js +169 -75
  24. package/dist/modules/foundation/check.js +4 -2
  25. package/dist/modules/foundation/compile-laws.js +212 -0
  26. package/dist/modules/foundation/context-budget.js +1 -14
  27. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  28. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  29. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  30. package/dist/modules/foundation/dialects/copilot.js +35 -0
  31. package/dist/modules/foundation/dialects/index.js +5 -0
  32. package/dist/modules/foundation/dialects/types.js +58 -0
  33. package/dist/modules/foundation/doctor.js +266 -14
  34. package/dist/modules/foundation/import-rules.js +67 -0
  35. package/dist/modules/foundation/integrity.js +307 -0
  36. package/dist/modules/foundation/laws-parse.js +131 -0
  37. package/dist/modules/foundation/laws.js +5 -0
  38. package/dist/modules/foundation/lock.js +283 -0
  39. package/dist/modules/foundation/ownership.js +4 -0
  40. package/dist/modules/foundation/register-core.js +57 -88
  41. package/dist/modules/foundation/register.js +1 -21
  42. package/dist/modules/foundation/scaffold.js +25 -0
  43. package/dist/modules/foundation/scan.js +227 -0
  44. package/dist/modules/foundation/setup-tool.js +96 -0
  45. package/dist/modules/foundation/verify.js +9 -1
  46. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  47. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  48. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  49. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  50. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  51. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  52. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  53. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  54. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  55. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  56. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  57. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  58. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  59. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  60. package/dist/modules/lawbook/change-tool.js +90 -0
  61. package/dist/modules/lawbook/coverage.js +45 -6
  62. package/dist/modules/lawbook/ears.js +417 -0
  63. package/dist/modules/lawbook/engine.js +29 -0
  64. package/dist/modules/lawbook/register.js +96 -54
  65. package/dist/modules/lawbook/spec-items.js +4 -1
  66. package/dist/modules/team/owners.js +464 -0
  67. package/dist/modules/tools/register.js +4 -26
  68. package/dist/shared/deprecation.js +99 -0
  69. package/dist/shared/exposure.js +4 -19
  70. package/dist/shared/git.js +25 -0
  71. package/dist/shared/mcp.js +29 -3
  72. package/dist/shared/output-budget.js +68 -0
  73. package/dist/shared/tool-catalog.js +49 -0
  74. package/package.json +4 -3
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { openDb, indexExists } from "../compass/db.js";
4
+ import { detectPropertyRunnerInWindow, loadPropertyRunners } from "./ears.js";
4
5
  import { formatItemId, loadSpecItems, parseItemId, parseSpecItems, } from "./spec-items.js";
5
6
  export const DEFAULT_COVERAGE_CONFIG = {
6
7
  defaultNeeds: ["impl", "utest"],
@@ -10,8 +11,10 @@ export const DEFAULT_COVERAGE_CONFIG = {
10
11
  impl: ["src/**"],
11
12
  utest: ["test/unit/**", "test/**/*.test.ts", "test/**/*.test.js"],
12
13
  itest: ["test/integration/**"],
14
+ ptest: ["test/property/**"],
13
15
  },
14
16
  exclude: ["**/node_modules/**", "**/dist/**", "**/.speclaw/**"],
17
+ propertyRunners: [],
15
18
  };
16
19
  /**
17
20
  * Load coverage config from lawbook/config.yaml when present; otherwise defaults.
@@ -20,8 +23,10 @@ export const DEFAULT_COVERAGE_CONFIG = {
20
23
  export function loadCoverageConfig(projectPath) {
21
24
  const cfg = structuredClone(DEFAULT_COVERAGE_CONFIG);
22
25
  const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
23
- if (!fs.existsSync(cfgPath))
26
+ if (!fs.existsSync(cfgPath)) {
27
+ cfg.propertyRunners = loadPropertyRunners(projectPath);
24
28
  return cfg;
29
+ }
25
30
  const text = fs.readFileSync(cfgPath, "utf8");
26
31
  const gate = /^\s*gateArchive\s*:\s*(true|false)\s*$/im.exec(text);
27
32
  if (gate)
@@ -43,8 +48,21 @@ export function loadCoverageConfig(projectPath) {
43
48
  .toLowerCase())
44
49
  .filter(Boolean);
45
50
  }
51
+ cfg.propertyRunners = loadPropertyRunners(projectPath);
46
52
  return cfg;
47
53
  }
54
+ /**
55
+ * Effective coverage needs for an item: explicit `Needs:` or defaults, plus
56
+ * `ptest` when `Verification: property` is declared.
57
+ */
58
+ // Covers: req~ptest-need~1, req~ptest-archive-gate~1
59
+ export function effectiveNeeds(item, cfg) {
60
+ const needs = item.needs.length > 0 ? [...item.needs] : [...cfg.defaultNeeds];
61
+ if (item.verification === "property" && !needs.includes("ptest")) {
62
+ needs.push("ptest");
63
+ }
64
+ return needs;
65
+ }
48
66
  /** Glob match supporting `**`, `*`, and path separators. */
49
67
  export function matchGlob(relPath, pattern) {
50
68
  const norm = relPath.split("\\").join("/");
@@ -78,13 +96,33 @@ export function inferArtifactType(relPath, cfg) {
78
96
  const norm = relPath.split("\\").join("/");
79
97
  if (cfg.exclude.some((g) => matchGlob(norm, g)))
80
98
  return null;
81
- for (const type of ["itest", "utest", "impl"]) {
99
+ for (const type of ["ptest", "itest", "utest", "impl"]) {
82
100
  const globs = cfg.sources[type] ?? [];
83
101
  if (globs.some((g) => matchGlob(norm, g)))
84
102
  return type;
85
103
  }
86
104
  return null;
87
105
  }
106
+ /**
107
+ * Prefer `ptest` when a property-runner invocation sits near the link line.
108
+ */
109
+ // Covers: req~ptest-need~1
110
+ export function refineSourceType(projectPath, filePath, line, current, runners) {
111
+ if (runners.length === 0)
112
+ return current;
113
+ const abs = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath);
114
+ if (!fs.existsSync(abs))
115
+ return current;
116
+ let source;
117
+ try {
118
+ source = fs.readFileSync(abs, "utf8");
119
+ }
120
+ catch {
121
+ return current;
122
+ }
123
+ const hit = detectPropertyRunnerInWindow(source, line, runners);
124
+ return hit ? "ptest" : current;
125
+ }
88
126
  function readIndexLinks(projectPath) {
89
127
  if (!indexExists(projectPath))
90
128
  return [];
@@ -135,7 +173,7 @@ function inlineLinksAsRaw(projectPath, items, cfg) {
135
173
  }
136
174
  return out;
137
175
  }
138
- function classifyLink(link, item, idCounts, cfg) {
176
+ function classifyLink(link, item, idCounts, cfg, projectPath) {
139
177
  const base = {
140
178
  artifactType: link.artifactType,
141
179
  name: link.name,
@@ -172,6 +210,7 @@ function classifyLink(link, item, idCounts, cfg) {
172
210
  const inferred = inferArtifactType(link.filePath, cfg);
173
211
  if (inferred)
174
212
  base.sourceType = inferred;
213
+ base.sourceType = refineSourceType(projectPath, link.filePath, link.line, base.sourceType, cfg.propertyRunners);
175
214
  return base;
176
215
  }
177
216
  /**
@@ -198,12 +237,12 @@ export function buildCoverageReport(projectPath, opts = {}) {
198
237
  const matchedKeys = new Set();
199
238
  for (const item of identified) {
200
239
  const idText = item.idText;
201
- const needs = item.needs.length > 0 ? item.needs : [...cfg.defaultNeeds];
240
+ const needs = effectiveNeeds(item, cfg);
202
241
  const itemLinks = rawLinks
203
242
  .filter((l) => l.artifactType === item.id.artifactType && l.name === item.id.name)
204
243
  .map((l) => {
205
244
  matchedKeys.add(`${l.filePath}:${l.line}:${l.revision}:${l.kind}`);
206
- return classifyLink(l, item, idCounts, cfg);
245
+ return classifyLink(l, item, idCounts, cfg, projectPath);
207
246
  });
208
247
  const covering = itemLinks.filter((l) => l.status === "Covers");
209
248
  const coveredTypes = [...new Set(covering.map((l) => l.sourceType))];
@@ -282,7 +321,7 @@ export function buildCoverageReport(projectPath, opts = {}) {
282
321
  const key = `${l.filePath}:${l.line}:${l.revision}:${l.kind}`;
283
322
  if (matchedKeys.has(key))
284
323
  continue;
285
- orphans.push(classifyLink(l, undefined, idCounts, cfg));
324
+ orphans.push(classifyLink(l, undefined, idCounts, cfg, projectPath));
286
325
  }
287
326
  const gated = results.filter((r) => cfg.gateStatuses.includes(r.status));
288
327
  const directDefects = gated.reduce((n, r) => n + r.directDefects.length, 0);
@@ -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);