@osovv/grace-cli 3.1.0 → 3.2.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 +41 -4
- package/package.json +4 -2
- package/src/grace-lint.ts +30 -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 +51 -0
- package/src/lint/core.ts +961 -0
- package/src/lint/types.ts +75 -0
package/src/grace-lint.ts
CHANGED
|
@@ -1,742 +1,39 @@
|
|
|
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
|
+
EffectiveProfile,
|
|
10
|
+
GraceLintConfig,
|
|
11
|
+
LanguageAdapter,
|
|
12
|
+
LanguageAnalysis,
|
|
13
|
+
LintIssue,
|
|
14
|
+
LintOptions,
|
|
15
|
+
LintResult,
|
|
16
|
+
LintSeverity,
|
|
17
|
+
MapMode,
|
|
18
|
+
ModuleRole,
|
|
19
|
+
RepoProfile,
|
|
20
|
+
} from "./lint/types";
|
|
16
21
|
|
|
17
|
-
export
|
|
18
|
-
root: string;
|
|
19
|
-
filesChecked: number;
|
|
20
|
-
governedFiles: number;
|
|
21
|
-
xmlFilesChecked: number;
|
|
22
|
-
issues: LintIssue[];
|
|
23
|
-
};
|
|
22
|
+
export { formatTextReport, lintGraceProject } from "./lint/core";
|
|
24
23
|
|
|
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
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return files;
|
|
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
|
-
}
|
|
24
|
+
function writeResult(format: string, result: LintResult) {
|
|
25
|
+
if (format === "json") {
|
|
26
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
27
|
+
return;
|
|
347
28
|
}
|
|
348
29
|
|
|
349
|
-
|
|
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");
|
|
30
|
+
process.stdout.write(`${formatTextReport(result)}\n`);
|
|
734
31
|
}
|
|
735
32
|
|
|
736
33
|
export const lintCommand = defineCommand({
|
|
737
34
|
meta: {
|
|
738
35
|
name: "lint",
|
|
739
|
-
description: "Lint GRACE artifacts, XML tag conventions, and
|
|
36
|
+
description: "Lint GRACE artifacts, XML tag conventions, semantic markup, and role-aware module maps.",
|
|
740
37
|
},
|
|
741
38
|
args: {
|
|
742
39
|
path: {
|
|
@@ -751,6 +48,11 @@ export const lintCommand = defineCommand({
|
|
|
751
48
|
description: "Output format: text or json",
|
|
752
49
|
default: "text",
|
|
753
50
|
},
|
|
51
|
+
profile: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Lint profile: auto, current, or legacy",
|
|
54
|
+
default: "auto",
|
|
55
|
+
},
|
|
754
56
|
allowMissingDocs: {
|
|
755
57
|
type: "boolean",
|
|
756
58
|
description: "Allow repositories that do not yet have full GRACE docs",
|
|
@@ -759,20 +61,16 @@ export const lintCommand = defineCommand({
|
|
|
759
61
|
},
|
|
760
62
|
async run(context) {
|
|
761
63
|
const format = String(context.args.format ?? "text");
|
|
762
|
-
if (!
|
|
64
|
+
if (!isValidTextFormat(format)) {
|
|
763
65
|
throw new Error(`Unsupported format \`${format}\`. Use \`text\` or \`json\`.`);
|
|
764
66
|
}
|
|
765
67
|
|
|
766
68
|
const result = lintGraceProject(String(context.args.path ?? "."), {
|
|
767
69
|
allowMissingDocs: Boolean(context.args.allowMissingDocs),
|
|
70
|
+
profile: String(context.args.profile ?? "auto") as LintOptions["profile"],
|
|
768
71
|
});
|
|
769
72
|
|
|
770
|
-
|
|
771
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
772
|
-
} else {
|
|
773
|
-
process.stdout.write(`${formatTextReport(result)}\n`);
|
|
774
|
-
}
|
|
775
|
-
|
|
73
|
+
writeResult(format, result);
|
|
776
74
|
process.exitCode = result.issues.some((issue) => issue.severity === "error") ? 1 : 0;
|
|
777
75
|
},
|
|
778
76
|
});
|