@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.
- 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 +52 -10
- package/dist/cli/commands/update.js +35 -5
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +15 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/diff-context.js +134 -0
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +134 -0
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -0
- package/dist/modules/compass/impact-summary.js +33 -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 +169 -75
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- package/dist/modules/foundation/context-budget.js +1 -14
- 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 +266 -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/register-core.js +57 -88
- package/dist/modules/foundation/register.js +1 -21
- package/dist/modules/foundation/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/setup-tool.js +96 -0
- package/dist/modules/foundation/verify.js +9 -1
- package/dist/modules/lawbook/assets/commands/archive.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +1 -1
- package/dist/modules/lawbook/assets/commands/explore.md +1 -1
- package/dist/modules/lawbook/assets/commands/sync.md +2 -2
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
- package/dist/modules/lawbook/change-tool.js +90 -0
- 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/register.js +96 -54
- package/dist/modules/lawbook/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/dist/modules/tools/register.js +4 -26
- package/dist/shared/deprecation.js +99 -0
- package/dist/shared/exposure.js +4 -19
- package/dist/shared/git.js +25 -0
- package/dist/shared/mcp.js +29 -3
- package/dist/shared/output-budget.js +68 -0
- package/dist/shared/tool-catalog.js +49 -0
- 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
|
|
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);
|