@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.
Files changed (45) 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 +32 -10
  7. package/dist/cli/commands/update.js +28 -0
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +13 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/embed-input.js +28 -0
  13. package/dist/modules/compass/embedder.js +3 -1
  14. package/dist/modules/compass/explore-rich.js +10 -5
  15. package/dist/modules/compass/extract.js +86 -0
  16. package/dist/modules/compass/hybrid.js +318 -0
  17. package/dist/modules/compass/indexer.js +204 -33
  18. package/dist/modules/compass/merkle.js +76 -0
  19. package/dist/modules/compass/pagerank.js +122 -0
  20. package/dist/modules/compass/rank.js +95 -0
  21. package/dist/modules/compass/register.js +8 -4
  22. package/dist/modules/foundation/check.js +4 -2
  23. package/dist/modules/foundation/compile-laws.js +212 -0
  24. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  25. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  26. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  27. package/dist/modules/foundation/dialects/copilot.js +35 -0
  28. package/dist/modules/foundation/dialects/index.js +5 -0
  29. package/dist/modules/foundation/dialects/types.js +58 -0
  30. package/dist/modules/foundation/doctor.js +220 -14
  31. package/dist/modules/foundation/import-rules.js +67 -0
  32. package/dist/modules/foundation/integrity.js +307 -0
  33. package/dist/modules/foundation/laws-parse.js +131 -0
  34. package/dist/modules/foundation/laws.js +5 -0
  35. package/dist/modules/foundation/lock.js +283 -0
  36. package/dist/modules/foundation/ownership.js +4 -0
  37. package/dist/modules/foundation/scaffold.js +25 -0
  38. package/dist/modules/foundation/scan.js +227 -0
  39. package/dist/modules/foundation/verify.js +9 -1
  40. package/dist/modules/lawbook/coverage.js +45 -6
  41. package/dist/modules/lawbook/ears.js +417 -0
  42. package/dist/modules/lawbook/engine.js +29 -0
  43. package/dist/modules/lawbook/spec-items.js +4 -1
  44. package/dist/modules/team/owners.js +464 -0
  45. 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)) {