@osovv/grace-cli 3.1.0 → 3.3.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.
@@ -0,0 +1,907 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { loadGraceLintConfig } from "./config";
5
+ import { getLanguageAdapter } from "./adapters/base";
6
+ import type {
7
+ GraceLintConfig,
8
+ LanguageAnalysis,
9
+ LintIssue,
10
+ LintOptions,
11
+ LintResult,
12
+ MapMode,
13
+ MarkupSection,
14
+ ModuleContractInfo,
15
+ ModuleMapItem,
16
+ ModuleRole,
17
+ } from "./types";
18
+
19
+ const REQUIRED_DOCS = ["docs/knowledge-graph.xml", "docs/development-plan.xml", "docs/verification-plan.xml"] as const;
20
+
21
+ const OPTIONAL_PACKET_DOC = "docs/operational-packets.xml";
22
+ const LINT_CONFIG_FILE = ".grace-lint.json";
23
+ const DEFAULT_IGNORED_DIRS = new Set([
24
+ ".git",
25
+ "node_modules",
26
+ "dist",
27
+ "build",
28
+ "coverage",
29
+ ".next",
30
+ ".turbo",
31
+ ".cache",
32
+ ]);
33
+
34
+ const CODE_EXTENSIONS = new Set([
35
+ ".js",
36
+ ".jsx",
37
+ ".ts",
38
+ ".tsx",
39
+ ".mjs",
40
+ ".cjs",
41
+ ".mts",
42
+ ".cts",
43
+ ".py",
44
+ ".go",
45
+ ".java",
46
+ ".kt",
47
+ ".rs",
48
+ ".rb",
49
+ ".php",
50
+ ".swift",
51
+ ".scala",
52
+ ".sql",
53
+ ".sh",
54
+ ".bash",
55
+ ".zsh",
56
+ ".clj",
57
+ ".cljs",
58
+ ".cljc",
59
+ ]);
60
+
61
+ const UNIQUE_TAG_ANTI_PATTERNS = [
62
+ {
63
+ code: "xml.generic-module-tag",
64
+ regex: /<\/?Module(?=[\s>])/g,
65
+ message: 'Use unique module tags like `<M-AUTH>` instead of generic `<Module ID="...">`.',
66
+ },
67
+ {
68
+ code: "xml.generic-phase-tag",
69
+ regex: /<\/?Phase(?=[\s>])/g,
70
+ message: 'Use unique phase tags like `<Phase-1>` instead of generic `<Phase number="...">`.',
71
+ },
72
+ {
73
+ code: "xml.generic-flow-tag",
74
+ regex: /<\/?Flow(?=[\s>])/g,
75
+ message: 'Use unique flow tags like `<DF-LOGIN>` instead of generic `<Flow ID="...">`.',
76
+ },
77
+ {
78
+ code: "xml.generic-use-case-tag",
79
+ regex: /<\/?UseCase(?=[\s>])/g,
80
+ message: 'Use unique use-case tags like `<UC-001>` instead of generic `<UseCase ID="...">`.',
81
+ },
82
+ {
83
+ code: "xml.generic-step-tag",
84
+ regex: /<\/?step(?=[\s>])/g,
85
+ message: 'Use unique step tags like `<step-1>` instead of generic `<step order="...">`.',
86
+ },
87
+ {
88
+ code: "xml.generic-export-tag",
89
+ regex: /<\/?export(?=[\s>])/g,
90
+ message: 'Use unique export tags like `<export-run>` instead of generic `<export name="...">`.',
91
+ },
92
+ {
93
+ code: "xml.generic-function-tag",
94
+ regex: /<\/?function(?=[\s>])/g,
95
+ message: 'Use unique function tags like `<fn-run>` instead of generic `<function name="...">`.',
96
+ },
97
+ {
98
+ code: "xml.generic-type-tag",
99
+ regex: /<\/?type(?=[\s>])/g,
100
+ message: 'Use unique type tags like `<type-Result>` instead of generic `<type name="...">`.',
101
+ },
102
+ ];
103
+
104
+ const VALID_ROLES = new Set<ModuleRole>(["RUNTIME", "TEST", "BARREL", "CONFIG", "TYPES", "SCRIPT"]);
105
+ const VALID_MAP_MODES = new Set<MapMode>(["EXPORTS", "LOCALS", "SUMMARY", "NONE"]);
106
+ const TEXT_FORMAT_OPTIONS = new Set(["text", "json"]);
107
+
108
+ function normalizeRelative(root: string, filePath: string) {
109
+ return path.relative(root, filePath) || ".";
110
+ }
111
+
112
+ function lineNumberAt(text: string, index: number) {
113
+ return text.slice(0, index).split("\n").length;
114
+ }
115
+
116
+ function readTextIfExists(filePath: string) {
117
+ return existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
118
+ }
119
+
120
+ function addIssue(result: LintResult, issue: LintIssue) {
121
+ result.issues.push(issue);
122
+ }
123
+
124
+ function stripQuotedStrings(text: string) {
125
+ let result = "";
126
+ let quote: '"' | "'" | "`" | null = null;
127
+ let escaped = false;
128
+
129
+ for (const char of text) {
130
+ if (!quote) {
131
+ if (char === '"' || char === "'" || char === "`") {
132
+ quote = char;
133
+ result += " ";
134
+ continue;
135
+ }
136
+
137
+ result += char;
138
+ continue;
139
+ }
140
+
141
+ if (escaped) {
142
+ escaped = false;
143
+ result += char === "\n" ? "\n" : " ";
144
+ continue;
145
+ }
146
+
147
+ if (char === "\\") {
148
+ escaped = true;
149
+ result += " ";
150
+ continue;
151
+ }
152
+
153
+ if (char === quote) {
154
+ quote = null;
155
+ result += " ";
156
+ continue;
157
+ }
158
+
159
+ result += char === "\n" ? "\n" : " ";
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ function hasGraceMarkers(text: string) {
166
+ const searchable = stripQuotedStrings(text);
167
+ return searchable.split("\n").some((line) => /^(\s*)(\/\/|#|--|;+|\*)\s*(START_MODULE_CONTRACT|START_MODULE_MAP|START_CONTRACT:|START_BLOCK_|START_CHANGE_SUMMARY)/.test(line));
168
+ }
169
+
170
+ function collectCodeFiles(root: string, config: GraceLintConfig | null, currentDir = root): string[] {
171
+ const files: string[] = [];
172
+ const ignoredDirs = new Set([...DEFAULT_IGNORED_DIRS, ...(config?.ignoredDirs ?? [])]);
173
+ const entries = readdirSync(currentDir, { withFileTypes: true });
174
+
175
+ for (const entry of entries) {
176
+ if (entry.isDirectory()) {
177
+ if (ignoredDirs.has(entry.name)) {
178
+ continue;
179
+ }
180
+
181
+ files.push(...collectCodeFiles(root, config, path.join(currentDir, entry.name)));
182
+ continue;
183
+ }
184
+
185
+ if (!entry.isFile()) {
186
+ continue;
187
+ }
188
+
189
+ const filePath = path.join(currentDir, entry.name);
190
+ if (CODE_EXTENSIONS.has(path.extname(filePath))) {
191
+ files.push(filePath);
192
+ }
193
+ }
194
+
195
+ return files;
196
+ }
197
+
198
+ function stripCommentPrefix(line: string) {
199
+ return line.replace(/^\s*(\/\/|#|--|;+|\*)?\s*/, "");
200
+ }
201
+
202
+ function findSection(text: string, startMarker: string, endMarker: string) {
203
+ const startIndex = text.indexOf(startMarker);
204
+ const endIndex = text.indexOf(endMarker);
205
+
206
+ if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) {
207
+ return null;
208
+ }
209
+
210
+ return {
211
+ content: text.slice(startIndex + startMarker.length, endIndex),
212
+ startLine: lineNumberAt(text, startIndex),
213
+ endLine: lineNumberAt(text, endIndex),
214
+ } satisfies MarkupSection;
215
+ }
216
+
217
+ function ensureSectionPair(
218
+ result: LintResult,
219
+ relativePath: string,
220
+ text: string,
221
+ startMarker: string,
222
+ endMarker: string,
223
+ code: string,
224
+ message: string,
225
+ ) {
226
+ const startIndex = text.indexOf(startMarker);
227
+ const endIndex = text.indexOf(endMarker);
228
+
229
+ if (startIndex === -1 || endIndex === -1) {
230
+ addIssue(result, {
231
+ severity: "error",
232
+ code,
233
+ file: relativePath,
234
+ line: startIndex === -1 ? undefined : lineNumberAt(text, startIndex),
235
+ message,
236
+ });
237
+ return null;
238
+ }
239
+
240
+ if (startIndex > endIndex) {
241
+ addIssue(result, {
242
+ severity: "error",
243
+ code,
244
+ file: relativePath,
245
+ line: lineNumberAt(text, endIndex),
246
+ message: `${message} Found the end marker before the start marker.`,
247
+ });
248
+ return null;
249
+ }
250
+
251
+ return {
252
+ content: text.slice(startIndex + startMarker.length, endIndex),
253
+ startLine: lineNumberAt(text, startIndex),
254
+ endLine: lineNumberAt(text, endIndex),
255
+ } satisfies MarkupSection;
256
+ }
257
+
258
+ function lintScopedMarkers(
259
+ result: LintResult,
260
+ relativePath: string,
261
+ text: string,
262
+ startRegex: RegExp,
263
+ endRegex: RegExp,
264
+ kind: "block" | "contract",
265
+ ) {
266
+ const lines = text.split("\n");
267
+ const stack: Array<{ name: string; line: number }> = [];
268
+ const seen = new Set<string>();
269
+
270
+ for (let index = 0; index < lines.length; index += 1) {
271
+ const line = lines[index];
272
+ const startMatch = line.match(startRegex);
273
+ const endMatch = line.match(endRegex);
274
+
275
+ if (startMatch?.[1]) {
276
+ const name = startMatch[1];
277
+ if (kind === "block") {
278
+ if (seen.has(name)) {
279
+ addIssue(result, {
280
+ severity: "error",
281
+ code: "markup.duplicate-block-name",
282
+ file: relativePath,
283
+ line: index + 1,
284
+ message: `Semantic block name \`${name}\` is duplicated in this file.`,
285
+ });
286
+ }
287
+
288
+ seen.add(name);
289
+ }
290
+
291
+ stack.push({ name, line: index + 1 });
292
+ }
293
+
294
+ if (endMatch?.[1]) {
295
+ const name = endMatch[1];
296
+ const active = stack[stack.length - 1];
297
+
298
+ if (!active) {
299
+ addIssue(result, {
300
+ severity: "error",
301
+ code: kind === "block" ? "markup.unmatched-block-end" : "markup.unmatched-contract-end",
302
+ file: relativePath,
303
+ line: index + 1,
304
+ message: `Found an unmatched END marker for \`${name}\`.`,
305
+ });
306
+ continue;
307
+ }
308
+
309
+ if (active.name !== name) {
310
+ addIssue(result, {
311
+ severity: "error",
312
+ code: kind === "block" ? "markup.mismatched-block-end" : "markup.mismatched-contract-end",
313
+ file: relativePath,
314
+ line: index + 1,
315
+ message: `Expected END marker for \`${active.name}\`, found \`${name}\` instead.`,
316
+ });
317
+ continue;
318
+ }
319
+
320
+ stack.pop();
321
+ }
322
+ }
323
+
324
+ for (const active of stack) {
325
+ addIssue(result, {
326
+ severity: "error",
327
+ code: kind === "block" ? "markup.missing-block-end" : "markup.missing-contract-end",
328
+ file: relativePath,
329
+ line: active.line,
330
+ message: `Missing END marker for \`${active.name}\`.`,
331
+ });
332
+ }
333
+ }
334
+
335
+ function parseModuleContract(section: MarkupSection) {
336
+ const fields: Record<string, string> = {};
337
+
338
+ for (const line of section.content.split("\n")) {
339
+ const cleaned = stripCommentPrefix(line).trim();
340
+ if (!cleaned) {
341
+ continue;
342
+ }
343
+
344
+ const match = cleaned.match(/^([A-Z_]+):\s*(.+)$/);
345
+ if (!match) {
346
+ continue;
347
+ }
348
+
349
+ fields[match[1]] = match[2].trim();
350
+ }
351
+
352
+ const roleValue = fields.ROLE?.toUpperCase() as ModuleRole | undefined;
353
+ const mapModeValue = fields.MAP_MODE?.toUpperCase() as MapMode | undefined;
354
+
355
+ return {
356
+ fields,
357
+ purpose: fields.PURPOSE,
358
+ scope: fields.SCOPE,
359
+ depends: fields.DEPENDS,
360
+ links: fields.LINKS,
361
+ role: roleValue && VALID_ROLES.has(roleValue) ? roleValue : undefined,
362
+ mapMode: mapModeValue && VALID_MAP_MODES.has(mapModeValue) ? mapModeValue : undefined,
363
+ } satisfies ModuleContractInfo;
364
+ }
365
+
366
+ function toSymbolName(label: string) {
367
+ return /^(?:default|[A-Za-z_$][\w$]*)$/.test(label) ? label : undefined;
368
+ }
369
+
370
+ function parseModuleMapItems(section: MarkupSection) {
371
+ const items: ModuleMapItem[] = [];
372
+ const lines = section.content.split("\n");
373
+
374
+ for (let index = 0; index < lines.length; index += 1) {
375
+ const cleaned = stripCommentPrefix(lines[index]).trim();
376
+ if (!cleaned) {
377
+ continue;
378
+ }
379
+
380
+ const match = cleaned.match(/^(.+?)\s+-\s+.+$/);
381
+ const label = (match?.[1] ?? cleaned).trim();
382
+ items.push({
383
+ label,
384
+ symbolName: toSymbolName(label),
385
+ line: section.startLine + index,
386
+ });
387
+ }
388
+
389
+ return items;
390
+ }
391
+
392
+ function isBarrelLike(analysis: LanguageAnalysis) {
393
+ return analysis.hasWildcardReExport || (analysis.directReExportCount > 0 && analysis.localImplementationCount <= 2);
394
+ }
395
+
396
+ function isAggregationSurface(analysis: LanguageAnalysis) {
397
+ return isBarrelLike(analysis) || (analysis.directReExportCount >= 2 && analysis.exports.size >= 8);
398
+ }
399
+
400
+ function inferRole(contract: ModuleContractInfo | null, analysis: LanguageAnalysis | null): ModuleRole {
401
+ if (contract?.role) {
402
+ return contract.role;
403
+ }
404
+
405
+ if (analysis?.usesTestFramework) {
406
+ return "TEST";
407
+ }
408
+
409
+ const contractText = `${contract?.purpose ?? ""} ${contract?.scope ?? ""}`.toLowerCase();
410
+ const mentionsTypes = /\b(type definition|type definitions|interface|interfaces|types?)\b/.test(contractText);
411
+ const mentionsConfig = /\b(config|configure|configuration|settings?)\b/.test(contractText);
412
+ const mentionsBarrel = /\b(barrel|re-export|re-exports|aggregate|entry point|bindings?)\b/.test(contractText);
413
+ const mentionsScript = /\b(script|scripts|cli|command|commands|bootstrap|smoke|runner|execute|execution|setup|check)\b/.test(contractText);
414
+
415
+ if (analysis && analysis.valueExports.size === 0 && analysis.typeExports.size > 0) {
416
+ return "TYPES";
417
+ }
418
+
419
+ if (analysis && (mentionsBarrel || isBarrelLike(analysis))) {
420
+ return "BARREL";
421
+ }
422
+
423
+ if (!analysis && mentionsTypes) {
424
+ return "TYPES";
425
+ }
426
+
427
+ if (analysis && mentionsTypes && analysis.valueExports.size === 0) {
428
+ return "TYPES";
429
+ }
430
+
431
+ if (analysis && mentionsConfig && analysis.hasDefaultExport && analysis.valueExports.size <= 1) {
432
+ return "CONFIG";
433
+ }
434
+
435
+ if (analysis && mentionsScript && analysis.exports.size === 0) {
436
+ return "SCRIPT";
437
+ }
438
+
439
+ if (mentionsConfig && !analysis) {
440
+ return "CONFIG";
441
+ }
442
+
443
+ if (!analysis && mentionsScript) {
444
+ return "SCRIPT";
445
+ }
446
+
447
+ return "RUNTIME";
448
+ }
449
+
450
+ function inferMapMode(
451
+ contract: ModuleContractInfo | null,
452
+ role: ModuleRole,
453
+ items: ModuleMapItem[],
454
+ analysis: LanguageAnalysis | null,
455
+ ) {
456
+ if (contract?.mapMode) {
457
+ return contract.mapMode;
458
+ }
459
+
460
+ if (role === "TEST") {
461
+ return "LOCALS" as const;
462
+ }
463
+
464
+ if (role === "SCRIPT") {
465
+ return "LOCALS" as const;
466
+ }
467
+
468
+ if (role === "BARREL") {
469
+ return "SUMMARY" as const;
470
+ }
471
+
472
+ if (role === "CONFIG") {
473
+ return "NONE" as const;
474
+ }
475
+
476
+ if (role === "TYPES") {
477
+ return "EXPORTS" as const;
478
+ }
479
+
480
+ if (items.some((item) => !item.symbolName)) {
481
+ return "SUMMARY" as const;
482
+ }
483
+
484
+ if (analysis && isAggregationSurface(analysis) && items.length > 0 && analysis.exports.size > items.length * 2) {
485
+ return "SUMMARY" as const;
486
+ }
487
+
488
+ return "EXPORTS" as const;
489
+ }
490
+
491
+ function lintUniqueTags(result: LintResult, relativePath: string, text: string) {
492
+ for (const antiPattern of UNIQUE_TAG_ANTI_PATTERNS) {
493
+ for (const match of text.matchAll(antiPattern.regex)) {
494
+ addIssue(result, {
495
+ severity: "error",
496
+ code: antiPattern.code,
497
+ file: relativePath,
498
+ line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
499
+ message: antiPattern.message,
500
+ });
501
+ }
502
+ }
503
+ }
504
+
505
+ function extractModuleIds(text: string) {
506
+ return new Set(Array.from(text.matchAll(/<(M-[A-Za-z0-9-]+)(?=[\s>])/g), (match) => match[1]));
507
+ }
508
+
509
+ function extractVerificationIds(text: string) {
510
+ return new Set(Array.from(text.matchAll(/<(V-M-[A-Za-z0-9-]+)(?=[\s>])/g), (match) => match[1]));
511
+ }
512
+
513
+ function extractVerificationRefs(text: string) {
514
+ return Array.from(text.matchAll(/<verification-ref>\s*([^<\s]+)\s*<\/verification-ref>/g)).map((match) => ({
515
+ value: match[1],
516
+ line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
517
+ }));
518
+ }
519
+
520
+ function extractStepRefs(text: string) {
521
+ return Array.from(text.matchAll(/<(step-[A-Za-z0-9-]+)([^>]*)>/g), (match) => {
522
+ const attrs = match[2] ?? "";
523
+ const moduleMatch = attrs.match(/module="([^"]+)"/);
524
+ const verificationMatch = attrs.match(/verification="([^"]+)"/);
525
+ return {
526
+ stepTag: match[1],
527
+ moduleId: moduleMatch?.[1] ?? null,
528
+ verificationId: verificationMatch?.[1] ?? null,
529
+ line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
530
+ };
531
+ });
532
+ }
533
+
534
+ function lintRequiredPacketSections(result: LintResult, relativePath: string, text: string) {
535
+ const requiredTags = [
536
+ "ExecutionPacketTemplate",
537
+ "GraphDeltaTemplate",
538
+ "VerificationDeltaTemplate",
539
+ "FailurePacketTemplate",
540
+ ];
541
+
542
+ for (const tagName of requiredTags) {
543
+ const pattern = new RegExp(`<${tagName}(?=[\\s>])`);
544
+ if (!pattern.test(text)) {
545
+ addIssue(result, {
546
+ severity: "error",
547
+ code: "packets.missing-template-section",
548
+ file: relativePath,
549
+ message: `Operational packet reference is missing <${tagName}>.`,
550
+ });
551
+ }
552
+ }
553
+ }
554
+
555
+ function lintExportMapParity(
556
+ result: LintResult,
557
+ relativePath: string,
558
+ items: ModuleMapItem[],
559
+ analysis: LanguageAnalysis,
560
+ role: ModuleRole,
561
+ mapMode: MapMode,
562
+ ) {
563
+ if (mapMode !== "EXPORTS") {
564
+ return;
565
+ }
566
+
567
+ if (analysis.hasWildcardReExport) {
568
+ addIssue(result, {
569
+ severity: "warning",
570
+ code: "analysis.wildcard-reexport-surface",
571
+ file: relativePath,
572
+ message: "This file uses wildcard re-exports. Exact export parity is skipped unless you use a more specific MAP_MODE or explicit barrel structure.",
573
+ });
574
+ return;
575
+ }
576
+
577
+ const mappedSymbols = new Set(items.flatMap((item) => (item.symbolName ? [item.symbolName] : [])));
578
+ if (mappedSymbols.size === 0 && analysis.exports.size > 0) {
579
+ addIssue(result, {
580
+ severity: "error",
581
+ code: "markup.module-map-missing-symbol-entries",
582
+ file: relativePath,
583
+ message: "MODULE_MAP should list concrete symbol names when MAP_MODE resolves to EXPORTS.",
584
+ });
585
+ return;
586
+ }
587
+
588
+ for (const exportName of analysis.exports) {
589
+ if (!mappedSymbols.has(exportName)) {
590
+ addIssue(result, {
591
+ severity: "error",
592
+ code: "markup.module-map-missing-export",
593
+ file: relativePath,
594
+ message: `MODULE_MAP is missing the exported symbol \`${exportName}\`.`,
595
+ });
596
+ }
597
+ }
598
+
599
+ for (const item of items) {
600
+ if (!item.symbolName) {
601
+ continue;
602
+ }
603
+
604
+ if (!analysis.exports.has(item.symbolName)) {
605
+ addIssue(result, {
606
+ severity: role === "RUNTIME" || role === "TYPES" ? "warning" : "warning",
607
+ code: "markup.module-map-extra-export",
608
+ file: relativePath,
609
+ line: item.line,
610
+ message: `MODULE_MAP lists \`${item.symbolName}\`, but no matching export was found by the ${analysis.adapterId} adapter.`,
611
+ });
612
+ }
613
+ }
614
+ }
615
+
616
+ function lintGovernedFile(result: LintResult, root: string, filePath: string, text: string) {
617
+ const relativePath = normalizeRelative(root, filePath);
618
+ result.governedFiles += 1;
619
+
620
+ const moduleContractSection = ensureSectionPair(
621
+ result,
622
+ relativePath,
623
+ text,
624
+ "START_MODULE_CONTRACT",
625
+ "END_MODULE_CONTRACT",
626
+ "markup.missing-module-contract",
627
+ "Governed files must include a paired MODULE_CONTRACT section.",
628
+ );
629
+ const moduleMapSection = findSection(text, "START_MODULE_MAP", "END_MODULE_MAP");
630
+ const changeSummarySection = ensureSectionPair(
631
+ result,
632
+ relativePath,
633
+ text,
634
+ "START_CHANGE_SUMMARY",
635
+ "END_CHANGE_SUMMARY",
636
+ "markup.missing-change-summary",
637
+ "Governed files must include a paired CHANGE_SUMMARY section.",
638
+ );
639
+
640
+ lintScopedMarkers(
641
+ result,
642
+ relativePath,
643
+ text,
644
+ /START_CONTRACT:\s*([A-Za-z0-9_$.\-]+)/,
645
+ /END_CONTRACT:\s*([A-Za-z0-9_$.\-]+)/,
646
+ "contract",
647
+ );
648
+ lintScopedMarkers(
649
+ result,
650
+ relativePath,
651
+ text,
652
+ /START_BLOCK_([A-Za-z0-9_]+)/,
653
+ /END_BLOCK_([A-Za-z0-9_]+)/,
654
+ "block",
655
+ );
656
+
657
+ const contract = moduleContractSection ? parseModuleContract(moduleContractSection) : null;
658
+ const mapItems = moduleMapSection ? parseModuleMapItems(moduleMapSection) : [];
659
+ const adapter = getLanguageAdapter(filePath);
660
+ const analysis = adapter ? adapter.analyze(filePath, text) : null;
661
+ const role = inferRole(contract, analysis);
662
+ const mapMode = inferMapMode(contract, role, mapItems, analysis);
663
+
664
+ if (moduleContractSection && contract) {
665
+ const missingContractFields = ["PURPOSE", "SCOPE", "DEPENDS", "LINKS"].filter((field) => !contract.fields[field]);
666
+ if (missingContractFields.length > 0) {
667
+ addIssue(result, {
668
+ severity: "error",
669
+ code: "markup.incomplete-module-contract",
670
+ file: relativePath,
671
+ message: `MODULE_CONTRACT should include PURPOSE, SCOPE, DEPENDS, and LINKS fields. Missing: ${missingContractFields.join(", ")}.`,
672
+ });
673
+ }
674
+ }
675
+
676
+ if (moduleContractSection && contract?.fields.ROLE && !contract.role) {
677
+ addIssue(result, {
678
+ severity: "error",
679
+ code: "markup.invalid-role",
680
+ file: relativePath,
681
+ message: `Unsupported ROLE \`${contract.fields.ROLE}\`. Use RUNTIME, TEST, BARREL, CONFIG, TYPES, or SCRIPT.`,
682
+ });
683
+ }
684
+
685
+ if (moduleContractSection && contract?.fields.MAP_MODE && !contract.mapMode) {
686
+ addIssue(result, {
687
+ severity: "error",
688
+ code: "markup.invalid-map-mode",
689
+ file: relativePath,
690
+ message: `Unsupported MAP_MODE \`${contract.fields.MAP_MODE}\`. Use EXPORTS, LOCALS, SUMMARY, or NONE.`,
691
+ });
692
+ }
693
+
694
+ if (!moduleMapSection && mapMode !== "NONE") {
695
+ addIssue(result, {
696
+ severity: "error",
697
+ code: "markup.missing-module-map",
698
+ file: relativePath,
699
+ message: `Governed files with ROLE ${role} and MAP_MODE ${mapMode} must include a paired MODULE_MAP section.`,
700
+ });
701
+ }
702
+
703
+ if (moduleMapSection && mapMode !== "NONE" && mapItems.length === 0) {
704
+ addIssue(result, {
705
+ severity: "error",
706
+ code: "markup.empty-module-map",
707
+ file: relativePath,
708
+ message: `MODULE_MAP must include at least one item when MAP_MODE resolves to ${mapMode}.`,
709
+ });
710
+ }
711
+
712
+ if (changeSummarySection && !/LAST_CHANGE:/s.test(changeSummarySection.content)) {
713
+ addIssue(result, {
714
+ severity: "error",
715
+ code: "markup.empty-change-summary",
716
+ file: relativePath,
717
+ message: "CHANGE_SUMMARY must contain at least one LAST_CHANGE entry.",
718
+ });
719
+ }
720
+
721
+ if (analysis) {
722
+ lintExportMapParity(result, relativePath, mapItems, analysis, role, mapMode);
723
+ }
724
+ }
725
+
726
+ export function lintGraceProject(projectRoot: string, options: LintOptions = {}): LintResult {
727
+ const root = path.resolve(projectRoot);
728
+ const { config, issues: configIssues } = loadGraceLintConfig(root);
729
+
730
+ const docs = {
731
+ "docs/knowledge-graph.xml": readTextIfExists(path.join(root, "docs/knowledge-graph.xml")),
732
+ "docs/development-plan.xml": readTextIfExists(path.join(root, "docs/development-plan.xml")),
733
+ "docs/verification-plan.xml": readTextIfExists(path.join(root, "docs/verification-plan.xml")),
734
+ } satisfies Record<string, string | null>;
735
+
736
+ const result: LintResult = {
737
+ root,
738
+ filesChecked: 0,
739
+ governedFiles: 0,
740
+ xmlFilesChecked: 0,
741
+ issues: [...configIssues],
742
+ };
743
+
744
+ if (configIssues.some((issue) => issue.severity === "error" && issue.file === LINT_CONFIG_FILE)) {
745
+ return result;
746
+ }
747
+
748
+ if (!options.allowMissingDocs) {
749
+ for (const relativePath of REQUIRED_DOCS) {
750
+ if (!docs[relativePath]) {
751
+ addIssue(result, {
752
+ severity: "error",
753
+ code: "docs.missing-required-artifact",
754
+ file: relativePath,
755
+ message: `Missing required current GRACE artifact \`${relativePath}\`.`,
756
+ });
757
+ }
758
+ }
759
+ }
760
+
761
+ for (const [relativePath, contents] of Object.entries(docs)) {
762
+ if (!contents) {
763
+ continue;
764
+ }
765
+
766
+ result.xmlFilesChecked += 1;
767
+ lintUniqueTags(result, relativePath, contents);
768
+ }
769
+
770
+ const operationalPackets = readTextIfExists(path.join(root, OPTIONAL_PACKET_DOC));
771
+ if (operationalPackets) {
772
+ result.xmlFilesChecked += 1;
773
+ lintRequiredPacketSections(result, OPTIONAL_PACKET_DOC, operationalPackets);
774
+ }
775
+
776
+ const knowledgeGraph = docs["docs/knowledge-graph.xml"];
777
+ const developmentPlan = docs["docs/development-plan.xml"];
778
+ const verificationPlan = docs["docs/verification-plan.xml"];
779
+
780
+ const graphModuleIds = knowledgeGraph ? extractModuleIds(knowledgeGraph) : new Set<string>();
781
+ const planModuleIds = developmentPlan ? extractModuleIds(developmentPlan) : new Set<string>();
782
+ const verificationIds = verificationPlan ? extractVerificationIds(verificationPlan) : new Set<string>();
783
+
784
+ if (knowledgeGraph && verificationPlan) {
785
+ for (const ref of extractVerificationRefs(knowledgeGraph)) {
786
+ if (!verificationIds.has(ref.value)) {
787
+ addIssue(result, {
788
+ severity: "error",
789
+ code: "graph.missing-verification-entry",
790
+ file: "docs/knowledge-graph.xml",
791
+ line: ref.line,
792
+ message: `Knowledge graph references \`${ref.value}\`, but no matching verification entry exists.`,
793
+ });
794
+ }
795
+ }
796
+ }
797
+
798
+ if (developmentPlan && verificationPlan) {
799
+ for (const ref of extractVerificationRefs(developmentPlan)) {
800
+ if (!verificationIds.has(ref.value)) {
801
+ addIssue(result, {
802
+ severity: "error",
803
+ code: "plan.missing-verification-entry",
804
+ file: "docs/development-plan.xml",
805
+ line: ref.line,
806
+ message: `Development plan references \`${ref.value}\`, but no matching verification entry exists.`,
807
+ });
808
+ }
809
+ }
810
+
811
+ for (const step of extractStepRefs(developmentPlan)) {
812
+ if (step.moduleId && !planModuleIds.has(step.moduleId)) {
813
+ addIssue(result, {
814
+ severity: "error",
815
+ code: "plan.step-missing-module",
816
+ file: "docs/development-plan.xml",
817
+ line: step.line,
818
+ message: `${step.stepTag} references module \`${step.moduleId}\`, but no matching module tag exists in the plan.`,
819
+ });
820
+ }
821
+
822
+ if (step.verificationId && verificationPlan && !verificationIds.has(step.verificationId)) {
823
+ addIssue(result, {
824
+ severity: "error",
825
+ code: "plan.step-missing-verification",
826
+ file: "docs/development-plan.xml",
827
+ line: step.line,
828
+ message: `${step.stepTag} references verification entry \`${step.verificationId}\`, but no matching tag exists in verification-plan.xml.`,
829
+ });
830
+ }
831
+ }
832
+ }
833
+
834
+ if (knowledgeGraph && developmentPlan) {
835
+ for (const moduleId of graphModuleIds) {
836
+ if (!planModuleIds.has(moduleId)) {
837
+ addIssue(result, {
838
+ severity: "error",
839
+ code: "graph.module-missing-from-plan",
840
+ file: "docs/knowledge-graph.xml",
841
+ message: `Module \`${moduleId}\` exists in the knowledge graph but not in the development plan.`,
842
+ });
843
+ }
844
+ }
845
+
846
+ for (const moduleId of planModuleIds) {
847
+ if (!graphModuleIds.has(moduleId)) {
848
+ addIssue(result, {
849
+ severity: "error",
850
+ code: "plan.module-missing-from-graph",
851
+ file: "docs/development-plan.xml",
852
+ message: `Module \`${moduleId}\` exists in the development plan but not in the knowledge graph.`,
853
+ });
854
+ }
855
+ }
856
+ }
857
+
858
+ for (const filePath of collectCodeFiles(root, config)) {
859
+ result.filesChecked += 1;
860
+ const text = readFileSync(filePath, "utf8");
861
+ if (!hasGraceMarkers(text)) {
862
+ continue;
863
+ }
864
+
865
+ lintGovernedFile(result, root, filePath, text);
866
+ }
867
+
868
+ return result;
869
+ }
870
+
871
+ export function formatTextReport(result: LintResult) {
872
+ const errors = result.issues.filter((issue) => issue.severity === "error");
873
+ const warnings = result.issues.filter((issue) => issue.severity === "warning");
874
+ const lines = [
875
+ "GRACE Lint Report",
876
+ "=================",
877
+ `Root: ${result.root}`,
878
+ `Code files checked: ${result.filesChecked}`,
879
+ `Governed files checked: ${result.governedFiles}`,
880
+ `XML files checked: ${result.xmlFilesChecked}`,
881
+ `Issues: ${result.issues.length} (errors: ${errors.length}, warnings: ${warnings.length})`,
882
+ ];
883
+
884
+ if (errors.length > 0) {
885
+ lines.push("", "Errors:");
886
+ for (const issue of errors) {
887
+ lines.push(`- [${issue.code}] ${issue.file}${issue.line ? `:${issue.line}` : ""} ${issue.message}`);
888
+ }
889
+ }
890
+
891
+ if (warnings.length > 0) {
892
+ lines.push("", "Warnings:");
893
+ for (const issue of warnings) {
894
+ lines.push(`- [${issue.code}] ${issue.file}${issue.line ? `:${issue.line}` : ""} ${issue.message}`);
895
+ }
896
+ }
897
+
898
+ if (result.issues.length === 0) {
899
+ lines.push("", "No GRACE integrity issues found.");
900
+ }
901
+
902
+ return lines.join("\n");
903
+ }
904
+
905
+ export function isValidTextFormat(format: string) {
906
+ return TEXT_FORMAT_OPTIONS.has(format);
907
+ }