@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.
- package/README.md +36 -4
- package/package.json +4 -2
- package/src/grace-lint.ts +22 -732
- package/src/grace.ts +1 -1
- package/src/lint/adapters/base.ts +11 -0
- package/src/lint/adapters/typescript.ts +185 -0
- package/src/lint/config.ts +65 -0
- package/src/lint/core.ts +907 -0
- package/src/lint/types.ts +69 -0
package/src/grace-lint.ts
CHANGED
|
@@ -1,742 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
3
|
import { defineCommand, type CommandDef, runMain } from "citty";
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
import { formatTextReport, isValidTextFormat, lintGraceProject } from "./lint/core";
|
|
6
|
+
import type { LintOptions, LintResult } from "./lint/types";
|
|
8
7
|
|
|
9
|
-
export type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
export type {
|
|
9
|
+
GraceLintConfig,
|
|
10
|
+
LanguageAdapter,
|
|
11
|
+
LanguageAnalysis,
|
|
12
|
+
LintIssue,
|
|
13
|
+
LintOptions,
|
|
14
|
+
LintResult,
|
|
15
|
+
LintSeverity,
|
|
16
|
+
MapMode,
|
|
17
|
+
ModuleRole,
|
|
18
|
+
} from "./lint/types";
|
|
16
19
|
|
|
17
|
-
export
|
|
18
|
-
root: string;
|
|
19
|
-
filesChecked: number;
|
|
20
|
-
governedFiles: number;
|
|
21
|
-
xmlFilesChecked: number;
|
|
22
|
-
issues: LintIssue[];
|
|
23
|
-
};
|
|
20
|
+
export { formatTextReport, lintGraceProject } from "./lint/core";
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const REQUIRED_DOCS = [
|
|
30
|
-
"docs/knowledge-graph.xml",
|
|
31
|
-
"docs/development-plan.xml",
|
|
32
|
-
"docs/verification-plan.xml",
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
const OPTIONAL_PACKET_DOC = "docs/operational-packets.xml";
|
|
36
|
-
|
|
37
|
-
const CODE_EXTENSIONS = new Set([
|
|
38
|
-
".js",
|
|
39
|
-
".jsx",
|
|
40
|
-
".ts",
|
|
41
|
-
".tsx",
|
|
42
|
-
".mjs",
|
|
43
|
-
".cjs",
|
|
44
|
-
".mts",
|
|
45
|
-
".cts",
|
|
46
|
-
".py",
|
|
47
|
-
".go",
|
|
48
|
-
".java",
|
|
49
|
-
".kt",
|
|
50
|
-
".rs",
|
|
51
|
-
".rb",
|
|
52
|
-
".php",
|
|
53
|
-
".swift",
|
|
54
|
-
".scala",
|
|
55
|
-
".sql",
|
|
56
|
-
".sh",
|
|
57
|
-
".bash",
|
|
58
|
-
".zsh",
|
|
59
|
-
]);
|
|
60
|
-
|
|
61
|
-
const IGNORED_DIRS = new Set([
|
|
62
|
-
".git",
|
|
63
|
-
"node_modules",
|
|
64
|
-
"dist",
|
|
65
|
-
"build",
|
|
66
|
-
"coverage",
|
|
67
|
-
".next",
|
|
68
|
-
".turbo",
|
|
69
|
-
".cache",
|
|
70
|
-
]);
|
|
71
|
-
|
|
72
|
-
const TS_LIKE_EXTENSIONS = new Set([
|
|
73
|
-
".js",
|
|
74
|
-
".jsx",
|
|
75
|
-
".ts",
|
|
76
|
-
".tsx",
|
|
77
|
-
".mjs",
|
|
78
|
-
".cjs",
|
|
79
|
-
".mts",
|
|
80
|
-
".cts",
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
|
-
const UNIQUE_TAG_ANTI_PATTERNS = [
|
|
84
|
-
{
|
|
85
|
-
code: "xml.generic-module-tag",
|
|
86
|
-
regex: /<\/?Module(?=[\s>])/g,
|
|
87
|
-
message: 'Use unique module tags like `<M-AUTH>` instead of generic `<Module ID="...">`.',
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
code: "xml.generic-phase-tag",
|
|
91
|
-
regex: /<\/?Phase(?=[\s>])/g,
|
|
92
|
-
message: 'Use unique phase tags like `<Phase-1>` instead of generic `<Phase number="...">`.',
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
code: "xml.generic-flow-tag",
|
|
96
|
-
regex: /<\/?Flow(?=[\s>])/g,
|
|
97
|
-
message: 'Use unique flow tags like `<DF-LOGIN>` instead of generic `<Flow ID="...">`.',
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
code: "xml.generic-use-case-tag",
|
|
101
|
-
regex: /<\/?UseCase(?=[\s>])/g,
|
|
102
|
-
message: 'Use unique use-case tags like `<UC-001>` instead of generic `<UseCase ID="...">`.',
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
code: "xml.generic-step-tag",
|
|
106
|
-
regex: /<\/?step(?=[\s>])/g,
|
|
107
|
-
message: 'Use unique step tags like `<step-1>` instead of generic `<step order="...">`.',
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
code: "xml.generic-export-tag",
|
|
111
|
-
regex: /<\/?export(?=[\s>])/g,
|
|
112
|
-
message: 'Use unique export tags like `<export-run>` instead of generic `<export name="...">`.',
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
code: "xml.generic-function-tag",
|
|
116
|
-
regex: /<\/?function(?=[\s>])/g,
|
|
117
|
-
message: 'Use unique function tags like `<fn-run>` instead of generic `<function name="...">`.',
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
code: "xml.generic-type-tag",
|
|
121
|
-
regex: /<\/?type(?=[\s>])/g,
|
|
122
|
-
message: 'Use unique type tags like `<type-Result>` instead of generic `<type name="...">`.',
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
const TEXT_FORMAT_OPTIONS = new Set(["text", "json"]);
|
|
127
|
-
|
|
128
|
-
function normalizeRelative(root: string, filePath: string) {
|
|
129
|
-
return path.relative(root, filePath) || ".";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function lineNumberAt(text: string, index: number) {
|
|
133
|
-
return text.slice(0, index).split("\n").length;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function addIssue(result: LintResult, issue: LintIssue) {
|
|
137
|
-
result.issues.push(issue);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function readTextIfExists(filePath: string) {
|
|
141
|
-
return existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function collectCodeFiles(root: string, currentDir = root): string[] {
|
|
145
|
-
const files: string[] = [];
|
|
146
|
-
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
147
|
-
|
|
148
|
-
for (const entry of entries) {
|
|
149
|
-
if (entry.isDirectory()) {
|
|
150
|
-
if (IGNORED_DIRS.has(entry.name)) {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
files.push(...collectCodeFiles(root, path.join(currentDir, entry.name)));
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (!entry.isFile()) {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const filePath = path.join(currentDir, entry.name);
|
|
163
|
-
if (CODE_EXTENSIONS.has(path.extname(filePath))) {
|
|
164
|
-
files.push(filePath);
|
|
165
|
-
}
|
|
22
|
+
function writeResult(format: string, result: LintResult) {
|
|
23
|
+
if (format === "json") {
|
|
24
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
25
|
+
return;
|
|
166
26
|
}
|
|
167
27
|
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function stripQuotedStrings(text: string) {
|
|
172
|
-
let result = "";
|
|
173
|
-
let quote: '"' | "'" | "`" | null = null;
|
|
174
|
-
let escaped = false;
|
|
175
|
-
|
|
176
|
-
for (const char of text) {
|
|
177
|
-
if (!quote) {
|
|
178
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
179
|
-
quote = char;
|
|
180
|
-
result += " ";
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
result += char;
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (escaped) {
|
|
189
|
-
escaped = false;
|
|
190
|
-
result += char === "\n" ? "\n" : " ";
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (char === "\\") {
|
|
195
|
-
escaped = true;
|
|
196
|
-
result += " ";
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (char === quote) {
|
|
201
|
-
quote = null;
|
|
202
|
-
result += " ";
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
result += char === "\n" ? "\n" : " ";
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return result;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function hasGraceMarkers(text: string) {
|
|
213
|
-
const searchable = stripQuotedStrings(text);
|
|
214
|
-
return searchable.split("\n").some((line) => /^(\s*)(\/\/|#|--|\*)\s*(START_MODULE_CONTRACT|START_MODULE_MAP|START_CONTRACT:|START_BLOCK_|START_CHANGE_SUMMARY)/.test(line));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function ensureSectionPair(
|
|
218
|
-
result: LintResult,
|
|
219
|
-
root: string,
|
|
220
|
-
relativePath: string,
|
|
221
|
-
text: string,
|
|
222
|
-
startMarker: string,
|
|
223
|
-
endMarker: string,
|
|
224
|
-
code: string,
|
|
225
|
-
message: string,
|
|
226
|
-
) {
|
|
227
|
-
const startIndex = text.indexOf(startMarker);
|
|
228
|
-
const endIndex = text.indexOf(endMarker);
|
|
229
|
-
|
|
230
|
-
if (startIndex === -1 || endIndex === -1) {
|
|
231
|
-
addIssue(result, {
|
|
232
|
-
severity: "error",
|
|
233
|
-
code,
|
|
234
|
-
file: relativePath,
|
|
235
|
-
line: startIndex === -1 ? undefined : lineNumberAt(text, startIndex),
|
|
236
|
-
message,
|
|
237
|
-
});
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (startIndex > endIndex) {
|
|
242
|
-
addIssue(result, {
|
|
243
|
-
severity: "error",
|
|
244
|
-
code,
|
|
245
|
-
file: relativePath,
|
|
246
|
-
line: lineNumberAt(text, endIndex),
|
|
247
|
-
message: `${message} Found the end marker before the start marker.`,
|
|
248
|
-
});
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const sectionStart = startIndex + startMarker.length;
|
|
253
|
-
return text.slice(sectionStart, endIndex);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function lintScopedMarkers(
|
|
257
|
-
result: LintResult,
|
|
258
|
-
relativePath: string,
|
|
259
|
-
text: string,
|
|
260
|
-
startRegex: RegExp,
|
|
261
|
-
endRegex: RegExp,
|
|
262
|
-
kind: "block" | "contract",
|
|
263
|
-
) {
|
|
264
|
-
const lines = text.split("\n");
|
|
265
|
-
const stack: Array<{ name: string; line: number }> = [];
|
|
266
|
-
const seen = new Set<string>();
|
|
267
|
-
|
|
268
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
269
|
-
const line = lines[index];
|
|
270
|
-
const startMatch = line.match(startRegex);
|
|
271
|
-
const endMatch = line.match(endRegex);
|
|
272
|
-
|
|
273
|
-
if (startMatch?.[1]) {
|
|
274
|
-
const name = startMatch[1];
|
|
275
|
-
if (kind === "block") {
|
|
276
|
-
if (seen.has(name)) {
|
|
277
|
-
addIssue(result, {
|
|
278
|
-
severity: "error",
|
|
279
|
-
code: "markup.duplicate-block-name",
|
|
280
|
-
file: relativePath,
|
|
281
|
-
line: index + 1,
|
|
282
|
-
message: `Semantic block name \`${name}\` is duplicated in this file.`,
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
seen.add(name);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
stack.push({ name, line: index + 1 });
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (endMatch?.[1]) {
|
|
293
|
-
const name = endMatch[1];
|
|
294
|
-
const active = stack[stack.length - 1];
|
|
295
|
-
|
|
296
|
-
if (!active) {
|
|
297
|
-
addIssue(result, {
|
|
298
|
-
severity: "error",
|
|
299
|
-
code: kind === "block" ? "markup.unmatched-block-end" : "markup.unmatched-contract-end",
|
|
300
|
-
file: relativePath,
|
|
301
|
-
line: index + 1,
|
|
302
|
-
message: `Found an unmatched END marker for \`${name}\`.`,
|
|
303
|
-
});
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
if (active.name !== name) {
|
|
308
|
-
addIssue(result, {
|
|
309
|
-
severity: "error",
|
|
310
|
-
code: kind === "block" ? "markup.mismatched-block-end" : "markup.mismatched-contract-end",
|
|
311
|
-
file: relativePath,
|
|
312
|
-
line: index + 1,
|
|
313
|
-
message: `Expected END marker for \`${active.name}\`, found \`${name}\` instead.`,
|
|
314
|
-
});
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
stack.pop();
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
for (const active of stack) {
|
|
323
|
-
addIssue(result, {
|
|
324
|
-
severity: "error",
|
|
325
|
-
code: kind === "block" ? "markup.missing-block-end" : "markup.missing-contract-end",
|
|
326
|
-
file: relativePath,
|
|
327
|
-
line: active.line,
|
|
328
|
-
message: `Missing END marker for \`${active.name}\`.`,
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function parseModuleMapEntries(section: string) {
|
|
334
|
-
const entries = new Set<string>();
|
|
335
|
-
const lines = section.split("\n");
|
|
336
|
-
|
|
337
|
-
for (const line of lines) {
|
|
338
|
-
const cleaned = line.replace(/^\s*(\/\/|#|--|\*)?\s*/, "").trim();
|
|
339
|
-
if (!cleaned) {
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const match = cleaned.match(/^([A-Za-z_$][\w$]*)\s+-\s+/);
|
|
344
|
-
if (match?.[1]) {
|
|
345
|
-
entries.add(match[1]);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return entries;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function extractTypeScriptExports(text: string) {
|
|
353
|
-
const exports = new Set<string>();
|
|
354
|
-
const directPatterns = [
|
|
355
|
-
/^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
|
|
356
|
-
/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
|
|
357
|
-
/^\s*export\s+class\s+([A-Za-z_$][\w$]*)/gm,
|
|
358
|
-
/^\s*export\s+(?:interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm,
|
|
359
|
-
];
|
|
360
|
-
|
|
361
|
-
for (const pattern of directPatterns) {
|
|
362
|
-
for (const match of text.matchAll(pattern)) {
|
|
363
|
-
if (match[1]) {
|
|
364
|
-
exports.add(match[1]);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
for (const match of text.matchAll(/^\s*export\s*\{([^}]+)\}/gm)) {
|
|
370
|
-
const names = match[1]
|
|
371
|
-
.split(",")
|
|
372
|
-
.map((part) => part.trim())
|
|
373
|
-
.filter(Boolean);
|
|
374
|
-
|
|
375
|
-
for (const name of names) {
|
|
376
|
-
const aliasMatch = name.match(/^(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
377
|
-
if (!aliasMatch) {
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
exports.add(aliasMatch[2] ?? aliasMatch[1]);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return exports;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function lintGovernedFile(result: LintResult, root: string, filePath: string, text: string) {
|
|
389
|
-
const relativePath = normalizeRelative(root, filePath);
|
|
390
|
-
result.governedFiles += 1;
|
|
391
|
-
|
|
392
|
-
const moduleContract = ensureSectionPair(
|
|
393
|
-
result,
|
|
394
|
-
root,
|
|
395
|
-
relativePath,
|
|
396
|
-
text,
|
|
397
|
-
"START_MODULE_CONTRACT",
|
|
398
|
-
"END_MODULE_CONTRACT",
|
|
399
|
-
"markup.missing-module-contract",
|
|
400
|
-
"Governed files must include a paired MODULE_CONTRACT section.",
|
|
401
|
-
);
|
|
402
|
-
const moduleMap = ensureSectionPair(
|
|
403
|
-
result,
|
|
404
|
-
root,
|
|
405
|
-
relativePath,
|
|
406
|
-
text,
|
|
407
|
-
"START_MODULE_MAP",
|
|
408
|
-
"END_MODULE_MAP",
|
|
409
|
-
"markup.missing-module-map",
|
|
410
|
-
"Governed files must include a paired MODULE_MAP section.",
|
|
411
|
-
);
|
|
412
|
-
const changeSummary = ensureSectionPair(
|
|
413
|
-
result,
|
|
414
|
-
root,
|
|
415
|
-
relativePath,
|
|
416
|
-
text,
|
|
417
|
-
"START_CHANGE_SUMMARY",
|
|
418
|
-
"END_CHANGE_SUMMARY",
|
|
419
|
-
"markup.missing-change-summary",
|
|
420
|
-
"Governed files must include a paired CHANGE_SUMMARY section.",
|
|
421
|
-
);
|
|
422
|
-
|
|
423
|
-
lintScopedMarkers(
|
|
424
|
-
result,
|
|
425
|
-
relativePath,
|
|
426
|
-
text,
|
|
427
|
-
/START_CONTRACT:\s*([A-Za-z0-9_$.\-]+)/,
|
|
428
|
-
/END_CONTRACT:\s*([A-Za-z0-9_$.\-]+)/,
|
|
429
|
-
"contract",
|
|
430
|
-
);
|
|
431
|
-
lintScopedMarkers(
|
|
432
|
-
result,
|
|
433
|
-
relativePath,
|
|
434
|
-
text,
|
|
435
|
-
/START_BLOCK_([A-Za-z0-9_]+)/,
|
|
436
|
-
/END_BLOCK_([A-Za-z0-9_]+)/,
|
|
437
|
-
"block",
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
if (moduleContract && !/PURPOSE:|SCOPE:|DEPENDS:|LINKS:/s.test(moduleContract)) {
|
|
441
|
-
addIssue(result, {
|
|
442
|
-
severity: "error",
|
|
443
|
-
code: "markup.incomplete-module-contract",
|
|
444
|
-
file: relativePath,
|
|
445
|
-
message: "MODULE_CONTRACT should include PURPOSE, SCOPE, DEPENDS, and LINKS fields.",
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const moduleMapEntries = moduleMap ? parseModuleMapEntries(moduleMap) : new Set<string>();
|
|
450
|
-
if (moduleMap && moduleMapEntries.size === 0) {
|
|
451
|
-
addIssue(result, {
|
|
452
|
-
severity: "error",
|
|
453
|
-
code: "markup.empty-module-map",
|
|
454
|
-
file: relativePath,
|
|
455
|
-
message: "MODULE_MAP must list at least one exported symbol and description.",
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (changeSummary && !/LAST_CHANGE:/s.test(changeSummary)) {
|
|
460
|
-
addIssue(result, {
|
|
461
|
-
severity: "error",
|
|
462
|
-
code: "markup.empty-change-summary",
|
|
463
|
-
file: relativePath,
|
|
464
|
-
message: "CHANGE_SUMMARY must contain at least one LAST_CHANGE entry.",
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (TS_LIKE_EXTENSIONS.has(path.extname(filePath))) {
|
|
469
|
-
const actualExports = extractTypeScriptExports(text);
|
|
470
|
-
for (const exportName of actualExports) {
|
|
471
|
-
if (!moduleMapEntries.has(exportName)) {
|
|
472
|
-
addIssue(result, {
|
|
473
|
-
severity: "error",
|
|
474
|
-
code: "markup.module-map-missing-export",
|
|
475
|
-
file: relativePath,
|
|
476
|
-
message: `MODULE_MAP is missing the exported symbol \`${exportName}\`.`,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
for (const mapEntry of moduleMapEntries) {
|
|
482
|
-
if (!actualExports.has(mapEntry)) {
|
|
483
|
-
addIssue(result, {
|
|
484
|
-
severity: "warning",
|
|
485
|
-
code: "markup.module-map-extra-export",
|
|
486
|
-
file: relativePath,
|
|
487
|
-
message: `MODULE_MAP lists \`${mapEntry}\`, but no matching TypeScript export was found.`,
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function lintUniqueTags(result: LintResult, relativePath: string, text: string) {
|
|
495
|
-
for (const antiPattern of UNIQUE_TAG_ANTI_PATTERNS) {
|
|
496
|
-
for (const match of text.matchAll(antiPattern.regex)) {
|
|
497
|
-
addIssue(result, {
|
|
498
|
-
severity: "error",
|
|
499
|
-
code: antiPattern.code,
|
|
500
|
-
file: relativePath,
|
|
501
|
-
line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
|
|
502
|
-
message: antiPattern.message,
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function extractModuleIds(text: string) {
|
|
509
|
-
return new Set(
|
|
510
|
-
Array.from(text.matchAll(/<(M-[A-Za-z0-9-]+)(?=[\s>])/g), (match) => match[1]),
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function extractVerificationIds(text: string) {
|
|
515
|
-
return new Set(
|
|
516
|
-
Array.from(text.matchAll(/<(V-M-[A-Za-z0-9-]+)(?=[\s>])/g), (match) => match[1]),
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function extractVerificationRefs(text: string) {
|
|
521
|
-
return Array.from(text.matchAll(/<verification-ref>\s*([^<\s]+)\s*<\/verification-ref>/g)).map((match) => ({
|
|
522
|
-
value: match[1],
|
|
523
|
-
line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
|
|
524
|
-
}));
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function extractStepRefs(text: string) {
|
|
528
|
-
return Array.from(
|
|
529
|
-
text.matchAll(/<(step-[A-Za-z0-9-]+)([^>]*)>/g),
|
|
530
|
-
(match) => {
|
|
531
|
-
const attrs = match[2] ?? "";
|
|
532
|
-
const moduleMatch = attrs.match(/module="([^"]+)"/);
|
|
533
|
-
const verificationMatch = attrs.match(/verification="([^"]+)"/);
|
|
534
|
-
return {
|
|
535
|
-
stepTag: match[1],
|
|
536
|
-
moduleId: moduleMatch?.[1] ?? null,
|
|
537
|
-
verificationId: verificationMatch?.[1] ?? null,
|
|
538
|
-
line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
|
|
539
|
-
};
|
|
540
|
-
},
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function lintRequiredPacketSections(result: LintResult, relativePath: string, text: string) {
|
|
545
|
-
const requiredTags = [
|
|
546
|
-
"ExecutionPacketTemplate",
|
|
547
|
-
"GraphDeltaTemplate",
|
|
548
|
-
"VerificationDeltaTemplate",
|
|
549
|
-
"FailurePacketTemplate",
|
|
550
|
-
];
|
|
551
|
-
|
|
552
|
-
for (const tagName of requiredTags) {
|
|
553
|
-
const pattern = new RegExp(`<${tagName}(?=[\\s>])`);
|
|
554
|
-
if (!pattern.test(text)) {
|
|
555
|
-
addIssue(result, {
|
|
556
|
-
severity: "error",
|
|
557
|
-
code: "packets.missing-template-section",
|
|
558
|
-
file: relativePath,
|
|
559
|
-
message: `Operational packet reference is missing <${tagName}>.`,
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
export function lintGraceProject(projectRoot: string, options: LintOptions = {}): LintResult {
|
|
566
|
-
const root = path.resolve(projectRoot);
|
|
567
|
-
const result: LintResult = {
|
|
568
|
-
root,
|
|
569
|
-
filesChecked: 0,
|
|
570
|
-
governedFiles: 0,
|
|
571
|
-
xmlFilesChecked: 0,
|
|
572
|
-
issues: [],
|
|
573
|
-
};
|
|
574
|
-
|
|
575
|
-
const docs = Object.fromEntries(
|
|
576
|
-
REQUIRED_DOCS.map((relativePath) => [relativePath, readTextIfExists(path.join(root, relativePath))]),
|
|
577
|
-
) as Record<(typeof REQUIRED_DOCS)[number], string | null>;
|
|
578
|
-
const operationalPackets = readTextIfExists(path.join(root, OPTIONAL_PACKET_DOC));
|
|
579
|
-
|
|
580
|
-
if (!options.allowMissingDocs) {
|
|
581
|
-
for (const relativePath of REQUIRED_DOCS) {
|
|
582
|
-
if (!docs[relativePath]) {
|
|
583
|
-
addIssue(result, {
|
|
584
|
-
severity: "error",
|
|
585
|
-
code: "docs.missing-required-artifact",
|
|
586
|
-
file: relativePath,
|
|
587
|
-
message: `Missing required GRACE artifact \`${relativePath}\`.`,
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
for (const [relativePath, contents] of Object.entries(docs)) {
|
|
594
|
-
if (!contents) {
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
result.xmlFilesChecked += 1;
|
|
599
|
-
lintUniqueTags(result, relativePath, contents);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
if (operationalPackets) {
|
|
603
|
-
result.xmlFilesChecked += 1;
|
|
604
|
-
lintRequiredPacketSections(result, OPTIONAL_PACKET_DOC, operationalPackets);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const knowledgeGraph = docs["docs/knowledge-graph.xml"];
|
|
608
|
-
const developmentPlan = docs["docs/development-plan.xml"];
|
|
609
|
-
const verificationPlan = docs["docs/verification-plan.xml"];
|
|
610
|
-
|
|
611
|
-
const graphModuleIds = knowledgeGraph ? extractModuleIds(knowledgeGraph) : new Set<string>();
|
|
612
|
-
const planModuleIds = developmentPlan ? extractModuleIds(developmentPlan) : new Set<string>();
|
|
613
|
-
const verificationIds = verificationPlan ? extractVerificationIds(verificationPlan) : new Set<string>();
|
|
614
|
-
|
|
615
|
-
if (knowledgeGraph && verificationPlan) {
|
|
616
|
-
for (const ref of extractVerificationRefs(knowledgeGraph)) {
|
|
617
|
-
if (!verificationIds.has(ref.value)) {
|
|
618
|
-
addIssue(result, {
|
|
619
|
-
severity: "error",
|
|
620
|
-
code: "graph.missing-verification-entry",
|
|
621
|
-
file: "docs/knowledge-graph.xml",
|
|
622
|
-
line: ref.line,
|
|
623
|
-
message: `Knowledge graph references \`${ref.value}\`, but no matching verification entry exists.`,
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
if (developmentPlan && verificationPlan) {
|
|
630
|
-
for (const ref of extractVerificationRefs(developmentPlan)) {
|
|
631
|
-
if (!verificationIds.has(ref.value)) {
|
|
632
|
-
addIssue(result, {
|
|
633
|
-
severity: "error",
|
|
634
|
-
code: "plan.missing-verification-entry",
|
|
635
|
-
file: "docs/development-plan.xml",
|
|
636
|
-
line: ref.line,
|
|
637
|
-
message: `Development plan references \`${ref.value}\`, but no matching verification entry exists.`,
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
for (const step of extractStepRefs(developmentPlan)) {
|
|
643
|
-
if (step.moduleId && !planModuleIds.has(step.moduleId)) {
|
|
644
|
-
addIssue(result, {
|
|
645
|
-
severity: "error",
|
|
646
|
-
code: "plan.step-missing-module",
|
|
647
|
-
file: "docs/development-plan.xml",
|
|
648
|
-
line: step.line,
|
|
649
|
-
message: `${step.stepTag} references module \`${step.moduleId}\`, but no matching module tag exists in the plan.`,
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
if (step.verificationId && !verificationIds.has(step.verificationId)) {
|
|
654
|
-
addIssue(result, {
|
|
655
|
-
severity: "error",
|
|
656
|
-
code: "plan.step-missing-verification",
|
|
657
|
-
file: "docs/development-plan.xml",
|
|
658
|
-
line: step.line,
|
|
659
|
-
message: `${step.stepTag} references verification entry \`${step.verificationId}\`, but no matching tag exists in verification-plan.xml.`,
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
if (knowledgeGraph && developmentPlan) {
|
|
666
|
-
for (const moduleId of graphModuleIds) {
|
|
667
|
-
if (!planModuleIds.has(moduleId)) {
|
|
668
|
-
addIssue(result, {
|
|
669
|
-
severity: "error",
|
|
670
|
-
code: "graph.module-missing-from-plan",
|
|
671
|
-
file: "docs/knowledge-graph.xml",
|
|
672
|
-
message: `Module \`${moduleId}\` exists in the knowledge graph but not in the development plan.`,
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
for (const moduleId of planModuleIds) {
|
|
678
|
-
if (!graphModuleIds.has(moduleId)) {
|
|
679
|
-
addIssue(result, {
|
|
680
|
-
severity: "error",
|
|
681
|
-
code: "plan.module-missing-from-graph",
|
|
682
|
-
file: "docs/development-plan.xml",
|
|
683
|
-
message: `Module \`${moduleId}\` exists in the development plan but not in the knowledge graph.`,
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
for (const filePath of collectCodeFiles(root)) {
|
|
690
|
-
result.filesChecked += 1;
|
|
691
|
-
const text = readFileSync(filePath, "utf8");
|
|
692
|
-
if (!hasGraceMarkers(text)) {
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
lintGovernedFile(result, root, filePath, text);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
return result;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
export function formatTextReport(result: LintResult) {
|
|
703
|
-
const errors = result.issues.filter((issue) => issue.severity === "error");
|
|
704
|
-
const warnings = result.issues.filter((issue) => issue.severity === "warning");
|
|
705
|
-
const lines = [
|
|
706
|
-
"GRACE Lint Report",
|
|
707
|
-
"=================",
|
|
708
|
-
`Root: ${result.root}`,
|
|
709
|
-
`Code files checked: ${result.filesChecked}`,
|
|
710
|
-
`Governed files checked: ${result.governedFiles}`,
|
|
711
|
-
`XML files checked: ${result.xmlFilesChecked}`,
|
|
712
|
-
`Issues: ${result.issues.length} (errors: ${errors.length}, warnings: ${warnings.length})`,
|
|
713
|
-
];
|
|
714
|
-
|
|
715
|
-
if (errors.length > 0) {
|
|
716
|
-
lines.push("", "Errors:");
|
|
717
|
-
for (const issue of errors) {
|
|
718
|
-
lines.push(`- [${issue.code}] ${issue.file}${issue.line ? `:${issue.line}` : ""} ${issue.message}`);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (warnings.length > 0) {
|
|
723
|
-
lines.push("", "Warnings:");
|
|
724
|
-
for (const issue of warnings) {
|
|
725
|
-
lines.push(`- [${issue.code}] ${issue.file}${issue.line ? `:${issue.line}` : ""} ${issue.message}`);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
if (result.issues.length === 0) {
|
|
730
|
-
lines.push("", "No GRACE integrity issues found.");
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
return lines.join("\n");
|
|
28
|
+
process.stdout.write(`${formatTextReport(result)}\n`);
|
|
734
29
|
}
|
|
735
30
|
|
|
736
31
|
export const lintCommand = defineCommand({
|
|
737
32
|
meta: {
|
|
738
33
|
name: "lint",
|
|
739
|
-
description: "Lint GRACE artifacts, XML tag conventions, and
|
|
34
|
+
description: "Lint GRACE artifacts, XML tag conventions, semantic markup, and role-aware module maps.",
|
|
740
35
|
},
|
|
741
36
|
args: {
|
|
742
37
|
path: {
|
|
@@ -759,7 +54,7 @@ export const lintCommand = defineCommand({
|
|
|
759
54
|
},
|
|
760
55
|
async run(context) {
|
|
761
56
|
const format = String(context.args.format ?? "text");
|
|
762
|
-
if (!
|
|
57
|
+
if (!isValidTextFormat(format)) {
|
|
763
58
|
throw new Error(`Unsupported format \`${format}\`. Use \`text\` or \`json\`.`);
|
|
764
59
|
}
|
|
765
60
|
|
|
@@ -767,12 +62,7 @@ export const lintCommand = defineCommand({
|
|
|
767
62
|
allowMissingDocs: Boolean(context.args.allowMissingDocs),
|
|
768
63
|
});
|
|
769
64
|
|
|
770
|
-
|
|
771
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
772
|
-
} else {
|
|
773
|
-
process.stdout.write(`${formatTextReport(result)}\n`);
|
|
774
|
-
}
|
|
775
|
-
|
|
65
|
+
writeResult(format, result);
|
|
776
66
|
process.exitCode = result.issues.some((issue) => issue.severity === "error") ? 1 : 0;
|
|
777
67
|
},
|
|
778
68
|
});
|