@wkronmiller/lisa 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +407 -0
- package/bin/lisa-runtime.js +8797 -0
- package/bin/lisa.js +21 -0
- package/completion.ts +58 -0
- package/install.ps1 +51 -0
- package/install.sh +93 -0
- package/lisa.ts +6 -0
- package/package.json +66 -0
- package/skills/README.md +28 -0
- package/skills/claude-code/CLAUDE.md +151 -0
- package/skills/codex/AGENTS.md +151 -0
- package/skills/gemini/GEMINI.md +151 -0
- package/skills/opencode/AGENTS.md +152 -0
- package/src/cli.ts +85 -0
- package/src/harness/base-adapter.ts +47 -0
- package/src/harness/claude-code.ts +106 -0
- package/src/harness/codex.ts +80 -0
- package/src/harness/command.ts +173 -0
- package/src/harness/gemini.ts +74 -0
- package/src/harness/opencode.ts +84 -0
- package/src/harness/registry.ts +29 -0
- package/src/harness/runner.ts +19 -0
- package/src/harness/types.ts +73 -0
- package/src/output-mode.ts +32 -0
- package/src/skill/artifacts.ts +174 -0
- package/src/skill/cli.ts +29 -0
- package/src/skill/install.ts +317 -0
- package/src/spec/agent-guidance.ts +466 -0
- package/src/spec/cli.ts +151 -0
- package/src/spec/commands/check.ts +1 -0
- package/src/spec/commands/config.ts +146 -0
- package/src/spec/commands/diff.ts +1 -0
- package/src/spec/commands/generate.ts +1 -0
- package/src/spec/commands/guide.ts +1 -0
- package/src/spec/commands/harness-list.ts +36 -0
- package/src/spec/commands/implement.ts +1 -0
- package/src/spec/commands/import.ts +1 -0
- package/src/spec/commands/init.ts +1 -0
- package/src/spec/commands/status.ts +87 -0
- package/src/spec/config.ts +63 -0
- package/src/spec/diff.ts +791 -0
- package/src/spec/extensions/benchmark.ts +347 -0
- package/src/spec/extensions/registry.ts +59 -0
- package/src/spec/extensions/types.ts +56 -0
- package/src/spec/grammar/index.ts +14 -0
- package/src/spec/grammar/parser.ts +443 -0
- package/src/spec/grammar/types.ts +70 -0
- package/src/spec/grammar/validator.ts +104 -0
- package/src/spec/loader.ts +174 -0
- package/src/spec/local-config.ts +59 -0
- package/src/spec/parser.ts +226 -0
- package/src/spec/path-utils.ts +73 -0
- package/src/spec/planner.ts +299 -0
- package/src/spec/prompt-renderer.ts +318 -0
- package/src/spec/skill-content.ts +119 -0
- package/src/spec/types.ts +239 -0
- package/src/spec/validator.ts +443 -0
- package/src/spec/workflows/check.ts +1534 -0
- package/src/spec/workflows/diff.ts +209 -0
- package/src/spec/workflows/generate.ts +1270 -0
- package/src/spec/workflows/guide.ts +190 -0
- package/src/spec/workflows/implement.ts +797 -0
- package/src/spec/workflows/import.ts +986 -0
- package/src/spec/workflows/init.ts +548 -0
- package/src/spec/workflows/status.ts +22 -0
- package/src/spec/workspace.ts +541 -0
- package/uninstall.ps1 +21 -0
- package/uninstall.sh +22 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ConstraintStrength,
|
|
6
|
+
FieldLine,
|
|
7
|
+
FileRef,
|
|
8
|
+
GrammarParseResult,
|
|
9
|
+
GrammarWarning,
|
|
10
|
+
ParsedStatement,
|
|
11
|
+
RiskSeverity,
|
|
12
|
+
StatementKind,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/** Section titles where specific statement kinds are expected. */
|
|
16
|
+
const SECTION_EXPECTED_KINDS: Record<string, Set<StatementKind>> = {
|
|
17
|
+
invariants: new Set(["must", "must_not", "should", "should_not", "may", "note"]),
|
|
18
|
+
"acceptance criteria": new Set(["given_then", "given_when_then", "when_then", "note"]),
|
|
19
|
+
declarations: new Set([
|
|
20
|
+
"defines", "references", "exposes", "accepts", "returns",
|
|
21
|
+
"emits", "consumes", "stores", "transitions", "note",
|
|
22
|
+
]),
|
|
23
|
+
dependencies: new Set(["requires", "after", "calls", "note"]),
|
|
24
|
+
performance: new Set(["metric", "tolerance", "baseline", "note"]),
|
|
25
|
+
"failure modes": new Set(["risk", "mitigated_by", "note"]),
|
|
26
|
+
"out of scope": new Set(["excludes", "deferred", "assumes", "note"]),
|
|
27
|
+
"use cases": new Set(["note"]),
|
|
28
|
+
summary: new Set(["note"]),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function normalizeSection(title: string): string {
|
|
32
|
+
return title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripBullet(line: string): string {
|
|
36
|
+
return line.replace(/^\s*[-*]\s+/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isBulletLine(line: string): boolean {
|
|
40
|
+
return /^\s*[-*]\s+/.test(line);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isContinuationLine(line: string): boolean {
|
|
44
|
+
return /^\s{2,}/.test(line) && !isBulletLine(line);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseFileRef(line: string, lineNumber: number, repoRoot?: string): FileRef | undefined {
|
|
48
|
+
const match = line.trim().match(/^@\s+(.+)$/);
|
|
49
|
+
if (!match) return undefined;
|
|
50
|
+
const filePath = match[1].trim();
|
|
51
|
+
const resolved = repoRoot ? existsSync(resolve(repoRoot, filePath)) : false;
|
|
52
|
+
return { path: filePath, line: lineNumber, resolved };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseFieldLine(line: string, lineNumber: number): FieldLine | undefined {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (trimmed.startsWith("@")) return undefined;
|
|
58
|
+
const match = trimmed.match(/^([^:]+):\s+(.+)$/);
|
|
59
|
+
if (!match) return undefined;
|
|
60
|
+
return { name: match[1].trim(), description: match[2].trim(), line: lineNumber };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tryParseConstraint(text: string): { kind: ConstraintStrength; body: string } | undefined {
|
|
64
|
+
const patterns: [RegExp, ConstraintStrength][] = [
|
|
65
|
+
[/^MUST NOT:\s*(.+)$/i, "must_not"],
|
|
66
|
+
[/^MUST:\s*(.+)$/i, "must"],
|
|
67
|
+
[/^SHOULD NOT:\s*(.+)$/i, "should_not"],
|
|
68
|
+
[/^SHOULD:\s*(.+)$/i, "should"],
|
|
69
|
+
[/^MAY:\s*(.+)$/i, "may"],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const [pattern, strength] of patterns) {
|
|
73
|
+
const match = text.match(pattern);
|
|
74
|
+
if (match) {
|
|
75
|
+
return { kind: strength, body: match[1].trim() };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tryParseCondition(text: string): { kind: StatementKind; body: string } | undefined {
|
|
82
|
+
if (/^GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+.+$/i.test(text)) {
|
|
83
|
+
return { kind: "given_when_then", body: text };
|
|
84
|
+
}
|
|
85
|
+
if (/^GIVEN\s+.+\s+THEN\s+.+$/i.test(text)) {
|
|
86
|
+
return { kind: "given_then", body: text };
|
|
87
|
+
}
|
|
88
|
+
if (/^WHEN\s+.+\s+THEN\s+.+$/i.test(text)) {
|
|
89
|
+
return { kind: "when_then", body: text };
|
|
90
|
+
}
|
|
91
|
+
// GIVEN without THEN => note (caller handles warning)
|
|
92
|
+
if (/^GIVEN\s+/i.test(text) && !/THEN\s+/i.test(text) && !/[,.;:]/.test(text)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const TYPED_DECLARATION_KEYWORDS: StatementKind[] = ["defines", "references", "exposes", "accepts", "returns"];
|
|
99
|
+
const UNTYPED_DECLARATION_KEYWORDS: { pattern: RegExp; kind: StatementKind }[] = [
|
|
100
|
+
{ pattern: /^EMITS\s+(\S+)(?::\s*(.*))?$/i, kind: "emits" },
|
|
101
|
+
{ pattern: /^CONSUMES\s+(\S+)(?::\s*(.*))?$/i, kind: "consumes" },
|
|
102
|
+
{ pattern: /^STORES\s+(\S+)(?::\s*(.*))?$/i, kind: "stores" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
function tryParseDeclaration(text: string): { kind: StatementKind; body: string; typeLabel?: string; name?: string } | undefined {
|
|
106
|
+
// TRANSITIONS has a unique syntax
|
|
107
|
+
const transMatch = text.match(/^TRANSITIONS\s+(.+?)\s+FROM\s+(.+?)\s+TO\s+(.+?)(?:\s+WHEN\s+(.+))?$/i);
|
|
108
|
+
if (transMatch) {
|
|
109
|
+
return { kind: "transitions", body: text, typeLabel: undefined, name: transMatch[1] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Typed declarations: KEYWORD <typeLabel> <name...>: <description>
|
|
113
|
+
for (const kind of TYPED_DECLARATION_KEYWORDS) {
|
|
114
|
+
const keyword = kind.toUpperCase();
|
|
115
|
+
const re = new RegExp(`^${keyword}\\s+(\\S+)\\s+(.+?)(?::\\s*(.*))?$`, "i");
|
|
116
|
+
const match = text.match(re);
|
|
117
|
+
if (match) {
|
|
118
|
+
// Split name from body: name is everything before `:`, but the regex already handles that
|
|
119
|
+
// However (.+?) is lazy and the optional colon group may not capture correctly.
|
|
120
|
+
// Use a two-step parse: first check if there's a colon separator.
|
|
121
|
+
const afterKeyword = text.replace(new RegExp(`^${keyword}\\s+`, "i"), "");
|
|
122
|
+
const typeLabel = afterKeyword.match(/^(\S+)/)?.[1] || "";
|
|
123
|
+
const rest = afterKeyword.slice(typeLabel.length).trim();
|
|
124
|
+
const colonIndex = rest.indexOf(":");
|
|
125
|
+
if (colonIndex >= 0) {
|
|
126
|
+
const name = rest.slice(0, colonIndex).trim();
|
|
127
|
+
const body = rest.slice(colonIndex + 1).trim();
|
|
128
|
+
return { kind, body, typeLabel, name };
|
|
129
|
+
}
|
|
130
|
+
return { kind, body: "", typeLabel, name: rest };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Untyped declarations: EMITS/CONSUMES/STORES <name>: <description>
|
|
135
|
+
for (const { pattern, kind } of UNTYPED_DECLARATION_KEYWORDS) {
|
|
136
|
+
const match = text.match(pattern);
|
|
137
|
+
if (match) {
|
|
138
|
+
return { kind, body: match[2]?.trim() || "", typeLabel: undefined, name: match[1] };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function tryParseDependency(text: string): { kind: StatementKind; body: string; name?: string } | undefined {
|
|
146
|
+
const requiresFor = text.match(/^REQUIRES\s+(.+?)\s+FOR\s+(.+)$/i);
|
|
147
|
+
if (requiresFor) {
|
|
148
|
+
return { kind: "requires", body: requiresFor[2].trim(), name: requiresFor[1].trim() };
|
|
149
|
+
}
|
|
150
|
+
const requires = text.match(/^REQUIRES\s+(.+)$/i);
|
|
151
|
+
if (requires) {
|
|
152
|
+
return { kind: "requires", body: "", name: requires[1].trim() };
|
|
153
|
+
}
|
|
154
|
+
const after = text.match(/^AFTER\s+(.+)$/i);
|
|
155
|
+
if (after) {
|
|
156
|
+
return { kind: "after", body: "", name: after[1].trim() };
|
|
157
|
+
}
|
|
158
|
+
const calls = text.match(/^CALLS\s+(.+?):\s*(.*)$/i);
|
|
159
|
+
if (calls) {
|
|
160
|
+
return { kind: "calls", body: calls[2].trim(), name: calls[1].trim() };
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function tryParseMetric(text: string): { kind: StatementKind; body: string; name?: string } | undefined {
|
|
166
|
+
const metricPer = text.match(/^METRIC\s+(\S+)\s*(<=|>=|<|>|==)\s*(\S+)\s+PER\s+(.+)$/i);
|
|
167
|
+
if (metricPer) {
|
|
168
|
+
return { kind: "metric", body: text, name: metricPer[1] };
|
|
169
|
+
}
|
|
170
|
+
const metric = text.match(/^METRIC\s+(\S+)\s*(<=|>=|<|>|==)\s*(\S+)$/i);
|
|
171
|
+
if (metric) {
|
|
172
|
+
return { kind: "metric", body: text, name: metric[1] };
|
|
173
|
+
}
|
|
174
|
+
const tolerance = text.match(/^TOLERANCE\s+(.+)$/i);
|
|
175
|
+
if (tolerance) {
|
|
176
|
+
return { kind: "tolerance", body: tolerance[1].trim() };
|
|
177
|
+
}
|
|
178
|
+
const baseline = text.match(/^BASELINE\s+(.+)$/i);
|
|
179
|
+
if (baseline) {
|
|
180
|
+
return { kind: "baseline", body: baseline[1].trim() };
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function tryParseRisk(text: string): { kind: StatementKind; body: string; severity?: RiskSeverity } | undefined {
|
|
186
|
+
const risk = text.match(/^RISK\s+(critical|high|medium|low):\s*(.+)$/i);
|
|
187
|
+
if (risk) {
|
|
188
|
+
return { kind: "risk", body: risk[2].trim(), severity: risk[1].toLowerCase() as RiskSeverity };
|
|
189
|
+
}
|
|
190
|
+
const mitigated = text.match(/^MITIGATED BY:\s*(.+)$/i);
|
|
191
|
+
if (mitigated) {
|
|
192
|
+
return { kind: "mitigated_by", body: mitigated[1].trim() };
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function tryParseScope(text: string): { kind: StatementKind; body: string } | undefined {
|
|
198
|
+
const excludes = text.match(/^EXCLUDES:\s*(.+)$/i);
|
|
199
|
+
if (excludes) return { kind: "excludes", body: excludes[1].trim() };
|
|
200
|
+
const deferred = text.match(/^DEFERRED:\s*(.+)$/i);
|
|
201
|
+
if (deferred) return { kind: "deferred", body: deferred[1].trim() };
|
|
202
|
+
const assumes = text.match(/^ASSUMES:\s*(.+)$/i);
|
|
203
|
+
if (assumes) return { kind: "assumes", body: assumes[1].trim() };
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse a spec body into typed grammar statements.
|
|
209
|
+
* Lines that don't match any pattern become `note` statements.
|
|
210
|
+
*/
|
|
211
|
+
export function parseGrammarStatements(
|
|
212
|
+
body: string,
|
|
213
|
+
sections: { title: string; level: number; content: string }[],
|
|
214
|
+
options?: { repoRoot?: string },
|
|
215
|
+
): GrammarParseResult {
|
|
216
|
+
const statements: ParsedStatement[] = [];
|
|
217
|
+
const warnings: GrammarWarning[] = [];
|
|
218
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
219
|
+
|
|
220
|
+
let currentSection = "";
|
|
221
|
+
let fenceState: { marker: string; length: number } | undefined;
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < lines.length; i++) {
|
|
224
|
+
const lineNumber = i + 1;
|
|
225
|
+
const line = lines[i];
|
|
226
|
+
|
|
227
|
+
// Track code fences
|
|
228
|
+
const fenceMatch = line.match(/^([`~]{3,})(.*)$/);
|
|
229
|
+
if (fenceMatch) {
|
|
230
|
+
const fence = fenceMatch[1];
|
|
231
|
+
const marker = fence[0];
|
|
232
|
+
const trailingText = fenceMatch[2].trim();
|
|
233
|
+
if (!fenceState) {
|
|
234
|
+
fenceState = { marker, length: fence.length };
|
|
235
|
+
} else if (fenceState.marker === marker && fence.length >= fenceState.length && trailingText.length === 0) {
|
|
236
|
+
fenceState = undefined;
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (fenceState) continue;
|
|
242
|
+
|
|
243
|
+
// Track section headings
|
|
244
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
245
|
+
if (headingMatch) {
|
|
246
|
+
currentSection = headingMatch[2];
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Skip empty lines and continuation lines at top level
|
|
251
|
+
if (!line.trim()) continue;
|
|
252
|
+
if (isContinuationLine(line)) continue; // handled when parsing bullet parent
|
|
253
|
+
|
|
254
|
+
const bulletLine = isBulletLine(line);
|
|
255
|
+
const text = bulletLine ? stripBullet(line) : line.trim();
|
|
256
|
+
if (!text) continue;
|
|
257
|
+
|
|
258
|
+
// Collect continuation lines
|
|
259
|
+
const continuationLines: { text: string; lineNumber: number }[] = [];
|
|
260
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
261
|
+
const nextLine = lines[j];
|
|
262
|
+
if (isContinuationLine(nextLine)) {
|
|
263
|
+
continuationLines.push({ text: nextLine.trim(), lineNumber: j + 1 });
|
|
264
|
+
} else {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const statement = parseLine(text, lineNumber, currentSection, line, continuationLines, warnings, options?.repoRoot);
|
|
270
|
+
statements.push(statement);
|
|
271
|
+
|
|
272
|
+
// Check for misplaced statements
|
|
273
|
+
const normalizedSection = normalizeSection(currentSection);
|
|
274
|
+
const expectedKinds = SECTION_EXPECTED_KINDS[normalizedSection];
|
|
275
|
+
if (expectedKinds && statement.kind !== "note" && !expectedKinds.has(statement.kind)) {
|
|
276
|
+
warnings.push({ line: lineNumber, message: `Statement \`${statement.kind}\` is unexpected in section \`${currentSection}\`.` });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { statements, warnings };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseLine(
|
|
284
|
+
text: string,
|
|
285
|
+
lineNumber: number,
|
|
286
|
+
section: string,
|
|
287
|
+
raw: string,
|
|
288
|
+
continuations: { text: string; lineNumber: number }[],
|
|
289
|
+
warnings: GrammarWarning[],
|
|
290
|
+
repoRoot?: string,
|
|
291
|
+
): ParsedStatement {
|
|
292
|
+
// Try constraint
|
|
293
|
+
const constraint = tryParseConstraint(text);
|
|
294
|
+
if (constraint) {
|
|
295
|
+
const stmt: ParsedStatement = {
|
|
296
|
+
kind: constraint.kind,
|
|
297
|
+
raw,
|
|
298
|
+
body: constraint.body,
|
|
299
|
+
section,
|
|
300
|
+
line: lineNumber,
|
|
301
|
+
strength: constraint.kind as ConstraintStrength,
|
|
302
|
+
};
|
|
303
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
304
|
+
return stmt;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Try condition (GIVEN/WHEN/THEN)
|
|
308
|
+
const condition = tryParseCondition(text);
|
|
309
|
+
if (condition) {
|
|
310
|
+
const stmt: ParsedStatement = {
|
|
311
|
+
kind: condition.kind,
|
|
312
|
+
raw,
|
|
313
|
+
body: condition.body,
|
|
314
|
+
section,
|
|
315
|
+
line: lineNumber,
|
|
316
|
+
};
|
|
317
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
318
|
+
return stmt;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check for GIVEN without THEN (uppercase keyword only)
|
|
322
|
+
if (/^GIVEN\s+/i.test(text) && !/THEN\s+/i.test(text) && !/[,.;:]/.test(text)) {
|
|
323
|
+
warnings.push({ line: lineNumber, message: "GIVEN without THEN — treated as note." });
|
|
324
|
+
const stmt: ParsedStatement = { kind: "note", raw, body: text, section, line: lineNumber };
|
|
325
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
326
|
+
return stmt;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Try declaration
|
|
330
|
+
const declaration = tryParseDeclaration(text);
|
|
331
|
+
if (declaration) {
|
|
332
|
+
const stmt: ParsedStatement = {
|
|
333
|
+
kind: declaration.kind,
|
|
334
|
+
raw,
|
|
335
|
+
body: declaration.body,
|
|
336
|
+
section,
|
|
337
|
+
line: lineNumber,
|
|
338
|
+
typeLabel: declaration.typeLabel,
|
|
339
|
+
name: declaration.name,
|
|
340
|
+
};
|
|
341
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
342
|
+
return stmt;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Try dependency
|
|
346
|
+
const dependency = tryParseDependency(text);
|
|
347
|
+
if (dependency) {
|
|
348
|
+
const stmt: ParsedStatement = {
|
|
349
|
+
kind: dependency.kind,
|
|
350
|
+
raw,
|
|
351
|
+
body: dependency.body,
|
|
352
|
+
section,
|
|
353
|
+
line: lineNumber,
|
|
354
|
+
name: dependency.name,
|
|
355
|
+
};
|
|
356
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
357
|
+
return stmt;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Try metric
|
|
361
|
+
const metric = tryParseMetric(text);
|
|
362
|
+
if (metric) {
|
|
363
|
+
const stmt: ParsedStatement = {
|
|
364
|
+
kind: metric.kind,
|
|
365
|
+
raw,
|
|
366
|
+
body: metric.body,
|
|
367
|
+
section,
|
|
368
|
+
line: lineNumber,
|
|
369
|
+
name: metric.name,
|
|
370
|
+
};
|
|
371
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
372
|
+
return stmt;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Try risk
|
|
376
|
+
const risk = tryParseRisk(text);
|
|
377
|
+
if (risk) {
|
|
378
|
+
const stmt: ParsedStatement = {
|
|
379
|
+
kind: risk.kind,
|
|
380
|
+
raw,
|
|
381
|
+
body: risk.body,
|
|
382
|
+
section,
|
|
383
|
+
line: lineNumber,
|
|
384
|
+
severity: risk.severity,
|
|
385
|
+
};
|
|
386
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
387
|
+
return stmt;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Try scope
|
|
391
|
+
const scope = tryParseScope(text);
|
|
392
|
+
if (scope) {
|
|
393
|
+
const stmt: ParsedStatement = {
|
|
394
|
+
kind: scope.kind,
|
|
395
|
+
raw,
|
|
396
|
+
body: scope.body,
|
|
397
|
+
section,
|
|
398
|
+
line: lineNumber,
|
|
399
|
+
};
|
|
400
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
401
|
+
return stmt;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Default: note
|
|
405
|
+
const stmt: ParsedStatement = { kind: "note", raw, body: text, section, line: lineNumber };
|
|
406
|
+
attachContinuations(stmt, continuations, warnings, repoRoot);
|
|
407
|
+
return stmt;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function attachContinuations(
|
|
411
|
+
stmt: ParsedStatement,
|
|
412
|
+
continuations: { text: string; lineNumber: number }[],
|
|
413
|
+
warnings: GrammarWarning[],
|
|
414
|
+
repoRoot?: string,
|
|
415
|
+
): void {
|
|
416
|
+
if (continuations.length === 0) return;
|
|
417
|
+
|
|
418
|
+
for (const cont of continuations) {
|
|
419
|
+
const fileRef = parseFileRef(cont.text, cont.lineNumber, repoRoot);
|
|
420
|
+
if (fileRef) {
|
|
421
|
+
if (!stmt.files) stmt.files = [];
|
|
422
|
+
stmt.files.push(fileRef);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const structuredChild = parseLine(cont.text, cont.lineNumber, stmt.section, cont.text, [], warnings, repoRoot);
|
|
427
|
+
if (structuredChild.kind !== "note") {
|
|
428
|
+
if (!stmt.children) stmt.children = [];
|
|
429
|
+
stmt.children.push(structuredChild);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const field = parseFieldLine(cont.text, cont.lineNumber);
|
|
434
|
+
if (field) {
|
|
435
|
+
if (!stmt.fields) stmt.fields = [];
|
|
436
|
+
stmt.fields.push(field);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!stmt.children) stmt.children = [];
|
|
441
|
+
stmt.children.push(structuredChild);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type StatementKind =
|
|
2
|
+
| "must"
|
|
3
|
+
| "must_not"
|
|
4
|
+
| "should"
|
|
5
|
+
| "should_not"
|
|
6
|
+
| "may"
|
|
7
|
+
| "given_then"
|
|
8
|
+
| "given_when_then"
|
|
9
|
+
| "when_then"
|
|
10
|
+
| "defines"
|
|
11
|
+
| "references"
|
|
12
|
+
| "exposes"
|
|
13
|
+
| "accepts"
|
|
14
|
+
| "returns"
|
|
15
|
+
| "emits"
|
|
16
|
+
| "consumes"
|
|
17
|
+
| "stores"
|
|
18
|
+
| "transitions"
|
|
19
|
+
| "requires"
|
|
20
|
+
| "after"
|
|
21
|
+
| "calls"
|
|
22
|
+
| "metric"
|
|
23
|
+
| "tolerance"
|
|
24
|
+
| "baseline"
|
|
25
|
+
| "risk"
|
|
26
|
+
| "mitigated_by"
|
|
27
|
+
| "excludes"
|
|
28
|
+
| "deferred"
|
|
29
|
+
| "assumes"
|
|
30
|
+
| "note";
|
|
31
|
+
|
|
32
|
+
export type ConstraintStrength = "must" | "must_not" | "should" | "should_not" | "may";
|
|
33
|
+
export type RiskSeverity = "critical" | "high" | "medium" | "low";
|
|
34
|
+
|
|
35
|
+
export interface FileRef {
|
|
36
|
+
path: string;
|
|
37
|
+
line: number;
|
|
38
|
+
resolved: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FieldLine {
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
line: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ParsedStatement {
|
|
48
|
+
kind: StatementKind;
|
|
49
|
+
raw: string;
|
|
50
|
+
body: string;
|
|
51
|
+
section: string;
|
|
52
|
+
line: number;
|
|
53
|
+
strength?: ConstraintStrength;
|
|
54
|
+
severity?: RiskSeverity;
|
|
55
|
+
typeLabel?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
files?: FileRef[];
|
|
58
|
+
fields?: FieldLine[];
|
|
59
|
+
children?: ParsedStatement[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GrammarWarning {
|
|
63
|
+
line: number;
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GrammarParseResult {
|
|
68
|
+
statements: ParsedStatement[];
|
|
69
|
+
warnings: GrammarWarning[];
|
|
70
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ParsedStatement, GrammarWarning } from "./types";
|
|
2
|
+
import type { ParsedSpecDocument, ValidationIssue } from "../types";
|
|
3
|
+
import { parseGrammarStatements } from "./parser";
|
|
4
|
+
|
|
5
|
+
/** Spec ID pattern: dotted segments like `backend.payment-gateway`. */
|
|
6
|
+
const SPEC_ID_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
|
|
7
|
+
|
|
8
|
+
function isSpecIdLike(target: string): boolean {
|
|
9
|
+
return SPEC_ID_PATTERN.test(target);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GrammarValidationOptions {
|
|
13
|
+
strict?: boolean;
|
|
14
|
+
repoRoot?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GrammarValidationResult {
|
|
18
|
+
issues: ValidationIssue[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate grammar-level cross-references across a workspace of parsed spec documents.
|
|
23
|
+
*
|
|
24
|
+
* - REFERENCES cross-referencing: warns when no matching DEFINES exists.
|
|
25
|
+
* - REQUIRES cross-referencing: warns when target looks like a spec ID and no matching spec exists.
|
|
26
|
+
* - File reference warnings: warns when `@ path` targets don't exist.
|
|
27
|
+
* - Strict mode: promotes advisory warnings to errors.
|
|
28
|
+
*/
|
|
29
|
+
export function validateGrammarCrossReferences(
|
|
30
|
+
documents: ParsedSpecDocument[],
|
|
31
|
+
options?: GrammarValidationOptions,
|
|
32
|
+
): GrammarValidationResult {
|
|
33
|
+
const issues: ValidationIssue[] = [];
|
|
34
|
+
const severity: ValidationIssue["severity"] = options?.strict ? "error" : "warning";
|
|
35
|
+
|
|
36
|
+
// Collect all DEFINES across workspace: key = "typeLabel name" (lowercased)
|
|
37
|
+
const allDefines = new Set<string>();
|
|
38
|
+
// Collect all spec IDs
|
|
39
|
+
const allSpecIds = new Set<string>();
|
|
40
|
+
|
|
41
|
+
for (const doc of documents) {
|
|
42
|
+
if (doc.id) allSpecIds.add(doc.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// First pass: collect all DEFINES
|
|
46
|
+
for (const doc of documents) {
|
|
47
|
+
const { statements } = parseGrammarStatements(doc.body, doc.sections, { repoRoot: options?.repoRoot });
|
|
48
|
+
for (const stmt of statements) {
|
|
49
|
+
if (stmt.kind === "defines" && stmt.typeLabel && stmt.name) {
|
|
50
|
+
allDefines.add(`${stmt.typeLabel.toLowerCase()} ${stmt.name.toLowerCase()}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Second pass: validate REFERENCES and REQUIRES
|
|
56
|
+
for (const doc of documents) {
|
|
57
|
+
const { statements, warnings } = parseGrammarStatements(doc.body, doc.sections, { repoRoot: options?.repoRoot });
|
|
58
|
+
|
|
59
|
+
// Promote grammar warnings (misplaced statements, GIVEN without THEN) to issues
|
|
60
|
+
for (const warning of warnings) {
|
|
61
|
+
issues.push({ path: doc.path, message: warning.message, severity });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const stmt of statements) {
|
|
65
|
+
// REFERENCES cross-referencing
|
|
66
|
+
if (stmt.kind === "references" && stmt.typeLabel && stmt.name) {
|
|
67
|
+
const key = `${stmt.typeLabel.toLowerCase()} ${stmt.name.toLowerCase()}`;
|
|
68
|
+
if (!allDefines.has(key)) {
|
|
69
|
+
issues.push({
|
|
70
|
+
path: doc.path,
|
|
71
|
+
message: `REFERENCES ${stmt.typeLabel} ${stmt.name} has no matching DEFINES in the workspace.`,
|
|
72
|
+
severity,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// REQUIRES / AFTER cross-referencing (only spec-ID-like targets)
|
|
78
|
+
if ((stmt.kind === "requires" || stmt.kind === "after") && stmt.name && isSpecIdLike(stmt.name)) {
|
|
79
|
+
if (!allSpecIds.has(stmt.name)) {
|
|
80
|
+
issues.push({
|
|
81
|
+
path: doc.path,
|
|
82
|
+
message: `${stmt.kind.toUpperCase()} ${stmt.name} references a spec that does not exist in the workspace.`,
|
|
83
|
+
severity,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// File reference warnings
|
|
89
|
+
if (stmt.files) {
|
|
90
|
+
for (const file of stmt.files) {
|
|
91
|
+
if (!file.resolved) {
|
|
92
|
+
issues.push({
|
|
93
|
+
path: doc.path,
|
|
94
|
+
message: `File reference \`@ ${file.path}\` on line ${file.line} does not exist.`,
|
|
95
|
+
severity,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { issues };
|
|
104
|
+
}
|