@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/lisa-runtime.js +8797 -0
  4. package/bin/lisa.js +21 -0
  5. package/completion.ts +58 -0
  6. package/install.ps1 +51 -0
  7. package/install.sh +93 -0
  8. package/lisa.ts +6 -0
  9. package/package.json +66 -0
  10. package/skills/README.md +28 -0
  11. package/skills/claude-code/CLAUDE.md +151 -0
  12. package/skills/codex/AGENTS.md +151 -0
  13. package/skills/gemini/GEMINI.md +151 -0
  14. package/skills/opencode/AGENTS.md +152 -0
  15. package/src/cli.ts +85 -0
  16. package/src/harness/base-adapter.ts +47 -0
  17. package/src/harness/claude-code.ts +106 -0
  18. package/src/harness/codex.ts +80 -0
  19. package/src/harness/command.ts +173 -0
  20. package/src/harness/gemini.ts +74 -0
  21. package/src/harness/opencode.ts +84 -0
  22. package/src/harness/registry.ts +29 -0
  23. package/src/harness/runner.ts +19 -0
  24. package/src/harness/types.ts +73 -0
  25. package/src/output-mode.ts +32 -0
  26. package/src/skill/artifacts.ts +174 -0
  27. package/src/skill/cli.ts +29 -0
  28. package/src/skill/install.ts +317 -0
  29. package/src/spec/agent-guidance.ts +466 -0
  30. package/src/spec/cli.ts +151 -0
  31. package/src/spec/commands/check.ts +1 -0
  32. package/src/spec/commands/config.ts +146 -0
  33. package/src/spec/commands/diff.ts +1 -0
  34. package/src/spec/commands/generate.ts +1 -0
  35. package/src/spec/commands/guide.ts +1 -0
  36. package/src/spec/commands/harness-list.ts +36 -0
  37. package/src/spec/commands/implement.ts +1 -0
  38. package/src/spec/commands/import.ts +1 -0
  39. package/src/spec/commands/init.ts +1 -0
  40. package/src/spec/commands/status.ts +87 -0
  41. package/src/spec/config.ts +63 -0
  42. package/src/spec/diff.ts +791 -0
  43. package/src/spec/extensions/benchmark.ts +347 -0
  44. package/src/spec/extensions/registry.ts +59 -0
  45. package/src/spec/extensions/types.ts +56 -0
  46. package/src/spec/grammar/index.ts +14 -0
  47. package/src/spec/grammar/parser.ts +443 -0
  48. package/src/spec/grammar/types.ts +70 -0
  49. package/src/spec/grammar/validator.ts +104 -0
  50. package/src/spec/loader.ts +174 -0
  51. package/src/spec/local-config.ts +59 -0
  52. package/src/spec/parser.ts +226 -0
  53. package/src/spec/path-utils.ts +73 -0
  54. package/src/spec/planner.ts +299 -0
  55. package/src/spec/prompt-renderer.ts +318 -0
  56. package/src/spec/skill-content.ts +119 -0
  57. package/src/spec/types.ts +239 -0
  58. package/src/spec/validator.ts +443 -0
  59. package/src/spec/workflows/check.ts +1534 -0
  60. package/src/spec/workflows/diff.ts +209 -0
  61. package/src/spec/workflows/generate.ts +1270 -0
  62. package/src/spec/workflows/guide.ts +190 -0
  63. package/src/spec/workflows/implement.ts +797 -0
  64. package/src/spec/workflows/import.ts +986 -0
  65. package/src/spec/workflows/init.ts +548 -0
  66. package/src/spec/workflows/status.ts +22 -0
  67. package/src/spec/workspace.ts +541 -0
  68. package/uninstall.ps1 +21 -0
  69. 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
+ }