@osovv/grace-cli 3.4.0 → 3.6.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 +26 -4
- package/package.json +5 -2
- package/src/grace-file.ts +78 -0
- package/src/grace-module.ts +126 -0
- package/src/grace.ts +6 -2
- package/src/query/core.ts +817 -0
- package/src/query/render.ts +188 -0
- package/src/query/types.ts +134 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { loadGraceLintConfig } from "../lint/config";
|
|
5
|
+
import type {
|
|
6
|
+
FileBlockRecord,
|
|
7
|
+
FileContractRecord,
|
|
8
|
+
FileFieldSection,
|
|
9
|
+
FileListItem,
|
|
10
|
+
FileMarkupRecord,
|
|
11
|
+
GraceArtifactIndex,
|
|
12
|
+
ModuleFindOptions,
|
|
13
|
+
ModuleGraphRecord,
|
|
14
|
+
ModuleInterfaceItem,
|
|
15
|
+
ModuleMatch,
|
|
16
|
+
ModulePlanContract,
|
|
17
|
+
ModulePlanParam,
|
|
18
|
+
ModulePlanRecord,
|
|
19
|
+
ModuleRecord,
|
|
20
|
+
ModuleVerificationRecord,
|
|
21
|
+
PlanStepRecord,
|
|
22
|
+
VerificationScenario,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
const REQUIRED_DOCS = ["docs/knowledge-graph.xml", "docs/development-plan.xml", "docs/verification-plan.xml"] as const;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_IGNORED_DIRS = new Set([
|
|
28
|
+
".git",
|
|
29
|
+
"node_modules",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
"coverage",
|
|
33
|
+
".next",
|
|
34
|
+
".turbo",
|
|
35
|
+
".cache",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const CODE_EXTENSIONS = new Set([
|
|
39
|
+
".js",
|
|
40
|
+
".jsx",
|
|
41
|
+
".ts",
|
|
42
|
+
".tsx",
|
|
43
|
+
".mjs",
|
|
44
|
+
".cjs",
|
|
45
|
+
".mts",
|
|
46
|
+
".cts",
|
|
47
|
+
".py",
|
|
48
|
+
".pyi",
|
|
49
|
+
".go",
|
|
50
|
+
".java",
|
|
51
|
+
".kt",
|
|
52
|
+
".rs",
|
|
53
|
+
".rb",
|
|
54
|
+
".php",
|
|
55
|
+
".swift",
|
|
56
|
+
".scala",
|
|
57
|
+
".sql",
|
|
58
|
+
".sh",
|
|
59
|
+
".bash",
|
|
60
|
+
".zsh",
|
|
61
|
+
".clj",
|
|
62
|
+
".cljs",
|
|
63
|
+
".cljc",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
type XmlElement = {
|
|
67
|
+
tag: string;
|
|
68
|
+
attrs: Record<string, string>;
|
|
69
|
+
text?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type MarkupSection = {
|
|
73
|
+
content: string;
|
|
74
|
+
startLine: number;
|
|
75
|
+
endLine: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function toPosixPath(filePath: string) {
|
|
79
|
+
return filePath.replaceAll(path.sep, "/");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeRelative(root: string, filePath: string) {
|
|
83
|
+
return toPosixPath(path.relative(root, filePath) || ".");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeInputPath(root: string, input: string) {
|
|
87
|
+
const absolutePath = path.isAbsolute(input) ? path.normalize(input) : path.resolve(root, input);
|
|
88
|
+
const relativePath = path.relative(root, absolutePath);
|
|
89
|
+
if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
|
|
90
|
+
return toPosixPath(relativePath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return toPosixPath(input);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function lineNumberAt(text: string, index: number) {
|
|
97
|
+
return text.slice(0, index).split("\n").length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function decodeXmlEntities(text: string) {
|
|
101
|
+
return text
|
|
102
|
+
.replaceAll("<", "<")
|
|
103
|
+
.replaceAll(">", ">")
|
|
104
|
+
.replaceAll(""", '"')
|
|
105
|
+
.replaceAll("'", "'")
|
|
106
|
+
.replaceAll("&", "&");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeWhitespace(text: string) {
|
|
110
|
+
return text.replace(/\s+/g, " ").trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function splitList(text?: string) {
|
|
114
|
+
if (!text) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return text
|
|
119
|
+
.split(",")
|
|
120
|
+
.map((item) => item.trim())
|
|
121
|
+
.filter((item) => item.length > 0 && item.toLowerCase() !== "none");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseAttributes(attrText: string) {
|
|
125
|
+
const attrs: Record<string, string> = {};
|
|
126
|
+
for (const match of attrText.matchAll(/([A-Za-z_:][A-Za-z0-9_:-]*)="([^"]*)"/g)) {
|
|
127
|
+
attrs[match[1]] = decodeXmlEntities(match[2]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return attrs;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getAttr(attrs: Record<string, string>, name: string) {
|
|
134
|
+
if (attrs[name] !== undefined) {
|
|
135
|
+
return attrs[name];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const foundKey = Object.keys(attrs).find((key) => key.toLowerCase() === name.toLowerCase());
|
|
139
|
+
return foundKey ? attrs[foundKey] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractBlock(text: string, tag: string) {
|
|
143
|
+
const match = text.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`));
|
|
144
|
+
return match ? match[1] : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractTextChild(text: string, tag: string) {
|
|
148
|
+
const block = extractBlock(text, tag);
|
|
149
|
+
return block === undefined ? undefined : normalizeWhitespace(decodeXmlEntities(block));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractElements(text: string) {
|
|
153
|
+
const elements: XmlElement[] = [];
|
|
154
|
+
for (const match of text.matchAll(/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g)) {
|
|
155
|
+
elements.push({
|
|
156
|
+
tag: match[1],
|
|
157
|
+
attrs: parseAttributes(match[2] ?? ""),
|
|
158
|
+
text: match[3] === undefined ? undefined : normalizeWhitespace(decodeXmlEntities(match[3])),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return elements;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseInterfaceItems(block?: string) {
|
|
166
|
+
if (!block) {
|
|
167
|
+
return [] as ModuleInterfaceItem[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return extractElements(block).map((element) => ({
|
|
171
|
+
tag: element.tag,
|
|
172
|
+
purpose: getAttr(element.attrs, "PURPOSE"),
|
|
173
|
+
text: element.text,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseParamList(block?: string) {
|
|
178
|
+
if (!block) {
|
|
179
|
+
return [] as ModulePlanParam[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return extractElements(block).map((element) => {
|
|
183
|
+
const name = getAttr(element.attrs, "name");
|
|
184
|
+
const type = getAttr(element.attrs, "type");
|
|
185
|
+
const text = element.text ?? [name, type].filter(Boolean).join(": ");
|
|
186
|
+
return {
|
|
187
|
+
name,
|
|
188
|
+
type,
|
|
189
|
+
text: normalizeWhitespace(text),
|
|
190
|
+
} satisfies ModulePlanParam;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseErrorList(block?: string) {
|
|
195
|
+
if (!block) {
|
|
196
|
+
return [] as string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return extractElements(block)
|
|
200
|
+
.map((element) => getAttr(element.attrs, "code") ?? element.text)
|
|
201
|
+
.filter((value): value is string => Boolean(value));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parsePlanContract(moduleBody: string): ModulePlanContract {
|
|
205
|
+
const contractBlock = extractBlock(moduleBody, "contract") ?? "";
|
|
206
|
+
return {
|
|
207
|
+
purpose: extractTextChild(contractBlock, "purpose"),
|
|
208
|
+
inputs: parseParamList(extractBlock(contractBlock, "inputs")),
|
|
209
|
+
outputs: parseParamList(extractBlock(contractBlock, "outputs")),
|
|
210
|
+
errors: parseErrorList(extractBlock(contractBlock, "errors")),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parsePlanModules(text: string) {
|
|
215
|
+
const modules = new Map<string, ModulePlanRecord>();
|
|
216
|
+
for (const match of text.matchAll(/<(M-[A-Za-z0-9-]+)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
|
|
217
|
+
const id = match[1];
|
|
218
|
+
const attrs = parseAttributes(match[2] ?? "");
|
|
219
|
+
const body = match[3] ?? "";
|
|
220
|
+
|
|
221
|
+
modules.set(id, {
|
|
222
|
+
id,
|
|
223
|
+
name: getAttr(attrs, "NAME"),
|
|
224
|
+
type: getAttr(attrs, "TYPE"),
|
|
225
|
+
layer: getAttr(attrs, "LAYER"),
|
|
226
|
+
order: getAttr(attrs, "ORDER"),
|
|
227
|
+
depends: splitList(extractTextChild(body, "depends")),
|
|
228
|
+
contract: parsePlanContract(body),
|
|
229
|
+
interfaceItems: parseInterfaceItems(extractBlock(body, "interface")),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return modules;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseGraphModules(text: string) {
|
|
237
|
+
const modules = new Map<string, ModuleGraphRecord>();
|
|
238
|
+
for (const match of text.matchAll(/<(M-[A-Za-z0-9-]+)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
|
|
239
|
+
const id = match[1];
|
|
240
|
+
const attrs = parseAttributes(match[2] ?? "");
|
|
241
|
+
const body = match[3] ?? "";
|
|
242
|
+
|
|
243
|
+
modules.set(id, {
|
|
244
|
+
id,
|
|
245
|
+
name: getAttr(attrs, "NAME"),
|
|
246
|
+
type: getAttr(attrs, "TYPE"),
|
|
247
|
+
status: getAttr(attrs, "STATUS"),
|
|
248
|
+
purpose: extractTextChild(body, "purpose"),
|
|
249
|
+
path: extractTextChild(body, "path"),
|
|
250
|
+
depends: splitList(extractTextChild(body, "depends")),
|
|
251
|
+
annotations: parseInterfaceItems(extractBlock(body, "annotations")),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return modules;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parsePlanSteps(text: string) {
|
|
259
|
+
const steps: PlanStepRecord[] = [];
|
|
260
|
+
for (const phaseMatch of text.matchAll(/<(Phase-[A-Za-z0-9-]+)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
|
|
261
|
+
const phaseTag = phaseMatch[1];
|
|
262
|
+
const phaseAttrs = parseAttributes(phaseMatch[2] ?? "");
|
|
263
|
+
const phaseBody = phaseMatch[3] ?? "";
|
|
264
|
+
|
|
265
|
+
for (const stepMatch of phaseBody.matchAll(/<(step-[A-Za-z0-9-]+)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
|
|
266
|
+
const stepAttrs = parseAttributes(stepMatch[2] ?? "");
|
|
267
|
+
steps.push({
|
|
268
|
+
phaseTag,
|
|
269
|
+
phaseName: getAttr(phaseAttrs, "name"),
|
|
270
|
+
phaseStatus: getAttr(phaseAttrs, "status"),
|
|
271
|
+
stepTag: stepMatch[1],
|
|
272
|
+
stepStatus: getAttr(stepAttrs, "status"),
|
|
273
|
+
moduleId: getAttr(stepAttrs, "module"),
|
|
274
|
+
verificationId: getAttr(stepAttrs, "verification"),
|
|
275
|
+
text: normalizeWhitespace(decodeXmlEntities(stepMatch[3] ?? "")),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return steps;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseTextListBlock(block?: string) {
|
|
284
|
+
if (!block) {
|
|
285
|
+
return [] as string[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return extractElements(block)
|
|
289
|
+
.map((element) => element.text)
|
|
290
|
+
.filter((value): value is string => Boolean(value));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseScenarioList(block?: string) {
|
|
294
|
+
if (!block) {
|
|
295
|
+
return [] as VerificationScenario[];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return extractElements(block)
|
|
299
|
+
.map((element) => {
|
|
300
|
+
if (!element.text) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
tag: element.tag,
|
|
306
|
+
kind: getAttr(element.attrs, "kind"),
|
|
307
|
+
text: element.text,
|
|
308
|
+
} satisfies VerificationScenario;
|
|
309
|
+
})
|
|
310
|
+
.filter((scenario): scenario is VerificationScenario => Boolean(scenario));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseVerificationEntries(text: string) {
|
|
314
|
+
const entries: ModuleVerificationRecord[] = [];
|
|
315
|
+
for (const match of text.matchAll(/<(V-M-[A-Za-z0-9-]+)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
|
|
316
|
+
const id = match[1];
|
|
317
|
+
const attrs = parseAttributes(match[2] ?? "");
|
|
318
|
+
const body = match[3] ?? "";
|
|
319
|
+
entries.push({
|
|
320
|
+
id,
|
|
321
|
+
moduleId: getAttr(attrs, "MODULE"),
|
|
322
|
+
priority: getAttr(attrs, "PRIORITY"),
|
|
323
|
+
testFiles: parseTextListBlock(extractBlock(body, "test-files")),
|
|
324
|
+
moduleChecks: parseTextListBlock(extractBlock(body, "module-checks")),
|
|
325
|
+
scenarios: parseScenarioList(extractBlock(body, "scenarios")),
|
|
326
|
+
requiredLogMarkers: parseTextListBlock(extractBlock(body, "required-log-markers")),
|
|
327
|
+
requiredTraceAssertions: parseTextListBlock(extractBlock(body, "required-trace-assertions")),
|
|
328
|
+
waveFollowUp: extractTextChild(body, "wave-follow-up"),
|
|
329
|
+
phaseFollowUp: extractTextChild(body, "phase-follow-up"),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return entries;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function stripQuotedStrings(text: string) {
|
|
337
|
+
let result = "";
|
|
338
|
+
let quote: '"' | "'" | "`" | null = null;
|
|
339
|
+
let escaped = false;
|
|
340
|
+
|
|
341
|
+
for (const char of text) {
|
|
342
|
+
if (!quote) {
|
|
343
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
344
|
+
quote = char;
|
|
345
|
+
result += " ";
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
result += char;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (escaped) {
|
|
354
|
+
escaped = false;
|
|
355
|
+
result += char === "\n" ? "\n" : " ";
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (char === "\\") {
|
|
360
|
+
escaped = true;
|
|
361
|
+
result += " ";
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (char === quote) {
|
|
366
|
+
quote = null;
|
|
367
|
+
result += " ";
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
result += char === "\n" ? "\n" : " ";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function hasGraceMarkers(text: string) {
|
|
378
|
+
const searchable = stripQuotedStrings(text);
|
|
379
|
+
return searchable.split("\n").some((line) => /^\s*(\/\/|#|--|;+|\*)\s*(START_MODULE_CONTRACT|START_MODULE_MAP|START_CONTRACT:|START_BLOCK_|START_CHANGE_SUMMARY)/.test(line));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function collectCodeFiles(root: string, ignoredDirs: string[], currentDir = root): string[] {
|
|
383
|
+
const files: string[] = [];
|
|
384
|
+
const ignoredDirSet = new Set([...DEFAULT_IGNORED_DIRS, ...ignoredDirs]);
|
|
385
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
386
|
+
|
|
387
|
+
for (const entry of entries) {
|
|
388
|
+
if (entry.isDirectory()) {
|
|
389
|
+
if (ignoredDirSet.has(entry.name)) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
files.push(...collectCodeFiles(root, ignoredDirs, path.join(currentDir, entry.name)));
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!entry.isFile()) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const filePath = path.join(currentDir, entry.name);
|
|
402
|
+
if (CODE_EXTENSIONS.has(path.extname(filePath))) {
|
|
403
|
+
files.push(filePath);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return files;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function stripCommentPrefix(line: string) {
|
|
411
|
+
return line.replace(/^\s*(\/\/|#|--|;+|\*)?\s*/, "");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function findSection(text: string, startMarker: string, endMarker: string) {
|
|
415
|
+
const startIndex = text.indexOf(startMarker);
|
|
416
|
+
const endIndex = text.indexOf(endMarker);
|
|
417
|
+
if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
content: text.slice(startIndex + startMarker.length, endIndex),
|
|
423
|
+
startLine: lineNumberAt(text, startIndex),
|
|
424
|
+
endLine: lineNumberAt(text, endIndex),
|
|
425
|
+
} satisfies MarkupSection;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseFieldSection(section: MarkupSection | null): FileFieldSection | null {
|
|
429
|
+
if (!section) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const fields: Record<string, string> = {};
|
|
434
|
+
for (const line of section.content.split("\n")) {
|
|
435
|
+
const cleaned = stripCommentPrefix(line).trim();
|
|
436
|
+
if (!cleaned) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const match = cleaned.match(/^([A-Z_]+):\s*(.+)$/);
|
|
441
|
+
if (!match) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
fields[match[1]] = match[2].trim();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
fields,
|
|
450
|
+
startLine: section.startLine,
|
|
451
|
+
endLine: section.endLine,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parseListSection(section: MarkupSection | null) {
|
|
456
|
+
if (!section) {
|
|
457
|
+
return [] as FileListItem[];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const items: FileListItem[] = [];
|
|
461
|
+
const lines = section.content.split("\n");
|
|
462
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
463
|
+
const cleaned = stripCommentPrefix(lines[index]).trim();
|
|
464
|
+
if (!cleaned) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
items.push({
|
|
469
|
+
label: cleaned,
|
|
470
|
+
line: section.startLine + index,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return items;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function parseScopedFieldSections(text: string) {
|
|
478
|
+
const sections: FileContractRecord[] = [];
|
|
479
|
+
for (const match of text.matchAll(/START_CONTRACT:\s*([A-Za-z0-9_$.\-]+)([\s\S]*?)END_CONTRACT:\s*\1/g)) {
|
|
480
|
+
const content = match[2] ?? "";
|
|
481
|
+
const startIndex = match.index ?? 0;
|
|
482
|
+
const endIndex = startIndex + match[0].length;
|
|
483
|
+
const section = parseFieldSection({
|
|
484
|
+
content,
|
|
485
|
+
startLine: lineNumberAt(text, startIndex),
|
|
486
|
+
endLine: lineNumberAt(text, endIndex),
|
|
487
|
+
});
|
|
488
|
+
sections.push({
|
|
489
|
+
name: match[1],
|
|
490
|
+
fields: section?.fields ?? {},
|
|
491
|
+
startLine: lineNumberAt(text, startIndex),
|
|
492
|
+
endLine: lineNumberAt(text, endIndex),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return sections;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function parseBlocks(text: string) {
|
|
500
|
+
const blocks: FileBlockRecord[] = [];
|
|
501
|
+
for (const match of text.matchAll(/START_BLOCK_([A-Za-z0-9_]+)([\s\S]*?)END_BLOCK_\1/g)) {
|
|
502
|
+
const startIndex = match.index ?? 0;
|
|
503
|
+
const endIndex = startIndex + match[0].length;
|
|
504
|
+
blocks.push({
|
|
505
|
+
name: match[1],
|
|
506
|
+
startLine: lineNumberAt(text, startIndex),
|
|
507
|
+
endLine: lineNumberAt(text, endIndex),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return blocks;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function extractLinkedModuleIds(moduleContract: FileFieldSection | null) {
|
|
515
|
+
return splitList(moduleContract?.fields.LINKS).filter((item) => /^M-[A-Za-z0-9-]+$/.test(item));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function parseGovernedFile(root: string, filePath: string): FileMarkupRecord {
|
|
519
|
+
const text = readFileSync(filePath, "utf8");
|
|
520
|
+
const moduleContract = parseFieldSection(findSection(text, "START_MODULE_CONTRACT", "END_MODULE_CONTRACT"));
|
|
521
|
+
return {
|
|
522
|
+
path: normalizeRelative(root, filePath),
|
|
523
|
+
moduleContract,
|
|
524
|
+
moduleMap: parseListSection(findSection(text, "START_MODULE_MAP", "END_MODULE_MAP")),
|
|
525
|
+
changeSummary: parseFieldSection(findSection(text, "START_CHANGE_SUMMARY", "END_CHANGE_SUMMARY")),
|
|
526
|
+
contracts: parseScopedFieldSections(text),
|
|
527
|
+
blocks: parseBlocks(text),
|
|
528
|
+
linkedModuleIds: extractLinkedModuleIds(moduleContract),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function loadGovernedFiles(root: string) {
|
|
533
|
+
const { config, issues } = loadGraceLintConfig(root);
|
|
534
|
+
const configErrors = issues.filter((issue) => issue.severity === "error");
|
|
535
|
+
if (configErrors.length > 0) {
|
|
536
|
+
throw new Error(configErrors.map((issue) => issue.message).join("\n"));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const files: FileMarkupRecord[] = [];
|
|
540
|
+
for (const filePath of collectCodeFiles(root, config?.ignoredDirs ?? [])) {
|
|
541
|
+
const text = readFileSync(filePath, "utf8");
|
|
542
|
+
if (!hasGraceMarkers(text)) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
files.push(parseGovernedFile(root, filePath));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function ensureRequiredDocs(root: string) {
|
|
553
|
+
const missingDocs = REQUIRED_DOCS.filter((relativePath) => !existsSync(path.join(root, relativePath)));
|
|
554
|
+
if (missingDocs.length > 0) {
|
|
555
|
+
throw new Error(`Missing required GRACE artifacts: ${missingDocs.join(", ")}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function readDoc(root: string, relativePath: string) {
|
|
560
|
+
return readFileSync(path.join(root, relativePath), "utf8");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function getModuleName(moduleRecord: ModuleRecord) {
|
|
564
|
+
return moduleRecord.plan?.name ?? moduleRecord.graph?.name ?? moduleRecord.name ?? moduleRecord.id;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function getModuleType(moduleRecord: ModuleRecord) {
|
|
568
|
+
return moduleRecord.plan?.type ?? moduleRecord.graph?.type ?? moduleRecord.type;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function getModulePath(moduleRecord: ModuleRecord) {
|
|
572
|
+
return moduleRecord.graph?.path ?? moduleRecord.localFiles[0]?.path;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function getModuleDepends(moduleRecord: ModuleRecord) {
|
|
576
|
+
const depends = new Set<string>();
|
|
577
|
+
for (const value of moduleRecord.plan?.depends ?? []) {
|
|
578
|
+
depends.add(value);
|
|
579
|
+
}
|
|
580
|
+
for (const value of moduleRecord.graph?.depends ?? []) {
|
|
581
|
+
depends.add(value);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return Array.from(depends).sort();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function getModuleVerificationIds(moduleRecord: ModuleRecord) {
|
|
588
|
+
return moduleRecord.verifications.map((entry) => entry.id).sort();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function loadGraceArtifactIndex(projectRoot: string): GraceArtifactIndex {
|
|
592
|
+
const root = path.resolve(projectRoot);
|
|
593
|
+
ensureRequiredDocs(root);
|
|
594
|
+
|
|
595
|
+
const planModules = parsePlanModules(readDoc(root, "docs/development-plan.xml"));
|
|
596
|
+
const graphModules = parseGraphModules(readDoc(root, "docs/knowledge-graph.xml"));
|
|
597
|
+
const verifications = parseVerificationEntries(readDoc(root, "docs/verification-plan.xml"));
|
|
598
|
+
const governedFiles = loadGovernedFiles(root);
|
|
599
|
+
const steps = parsePlanSteps(readDoc(root, "docs/development-plan.xml"));
|
|
600
|
+
|
|
601
|
+
const moduleIds = new Set<string>([
|
|
602
|
+
...planModules.keys(),
|
|
603
|
+
...graphModules.keys(),
|
|
604
|
+
...verifications.flatMap((entry) => (entry.moduleId ? [entry.moduleId] : [])),
|
|
605
|
+
...governedFiles.flatMap((file) => file.linkedModuleIds),
|
|
606
|
+
]);
|
|
607
|
+
|
|
608
|
+
const modules = Array.from(moduleIds)
|
|
609
|
+
.sort()
|
|
610
|
+
.map((id) => {
|
|
611
|
+
const planRecord = planModules.get(id) ?? null;
|
|
612
|
+
const graphRecord = graphModules.get(id) ?? null;
|
|
613
|
+
return {
|
|
614
|
+
id,
|
|
615
|
+
name: planRecord?.name ?? graphRecord?.name,
|
|
616
|
+
type: planRecord?.type ?? graphRecord?.type,
|
|
617
|
+
plan: planRecord,
|
|
618
|
+
graph: graphRecord,
|
|
619
|
+
verifications: verifications.filter((entry) => entry.moduleId === id).sort((left, right) => left.id.localeCompare(right.id)),
|
|
620
|
+
localFiles: governedFiles.filter((file) => file.linkedModuleIds.includes(id)).sort((left, right) => left.path.localeCompare(right.path)),
|
|
621
|
+
steps: steps.filter((step) => step.moduleId === id),
|
|
622
|
+
} satisfies ModuleRecord;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
root,
|
|
627
|
+
modules,
|
|
628
|
+
verifications: verifications.sort((left, right) => left.id.localeCompare(right.id)),
|
|
629
|
+
files: governedFiles,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function applyTextMatch(
|
|
634
|
+
matchedBy: Set<string>,
|
|
635
|
+
label: string,
|
|
636
|
+
query: string,
|
|
637
|
+
candidate: string | undefined,
|
|
638
|
+
exactScore: number,
|
|
639
|
+
containsScore: number,
|
|
640
|
+
) {
|
|
641
|
+
if (!candidate) {
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const normalizedCandidate = candidate.toLowerCase();
|
|
646
|
+
if (normalizedCandidate === query) {
|
|
647
|
+
matchedBy.add(label);
|
|
648
|
+
return exactScore;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (normalizedCandidate.includes(query)) {
|
|
652
|
+
matchedBy.add(label);
|
|
653
|
+
return containsScore;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function pathMatchScore(moduleRecord: ModuleRecord, targetPath: string) {
|
|
660
|
+
let bestScore = 0;
|
|
661
|
+
const graphPath = moduleRecord.graph?.path;
|
|
662
|
+
|
|
663
|
+
if (graphPath) {
|
|
664
|
+
if (graphPath === targetPath) {
|
|
665
|
+
bestScore = Math.max(bestScore, 100000 + graphPath.length);
|
|
666
|
+
} else if (targetPath.startsWith(`${graphPath}/`)) {
|
|
667
|
+
bestScore = Math.max(bestScore, 90000 + graphPath.length);
|
|
668
|
+
} else if (graphPath.startsWith(`${targetPath}/`)) {
|
|
669
|
+
bestScore = Math.max(bestScore, 70000 + graphPath.length);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const file of moduleRecord.localFiles) {
|
|
674
|
+
if (file.path === targetPath) {
|
|
675
|
+
bestScore = Math.max(bestScore, 85000 + file.path.length);
|
|
676
|
+
} else if (file.path.startsWith(`${targetPath}/`)) {
|
|
677
|
+
bestScore = Math.max(bestScore, 65000 + file.path.length);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return bestScore;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function matchesTypeFilter(moduleRecord: ModuleRecord, type?: string) {
|
|
685
|
+
if (!type) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return (getModuleType(moduleRecord) ?? "").toLowerCase() === type.toLowerCase();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function matchesDependencyFilter(moduleRecord: ModuleRecord, dependsOn?: string) {
|
|
693
|
+
if (!dependsOn) {
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const needle = dependsOn.toLowerCase();
|
|
698
|
+
return getModuleDepends(moduleRecord).some((dependency) => dependency.toLowerCase() === needle);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function findModules(index: GraceArtifactIndex, options: ModuleFindOptions = {}) {
|
|
702
|
+
const query = options.query?.trim();
|
|
703
|
+
const normalizedQuery = query?.toLowerCase();
|
|
704
|
+
const normalizedPathQuery = query ? normalizeInputPath(index.root, query) : undefined;
|
|
705
|
+
|
|
706
|
+
const matches: ModuleMatch[] = [];
|
|
707
|
+
for (const moduleRecord of index.modules) {
|
|
708
|
+
if (!matchesTypeFilter(moduleRecord, options.type) || !matchesDependencyFilter(moduleRecord, options.dependsOn)) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!normalizedQuery) {
|
|
713
|
+
matches.push({
|
|
714
|
+
module: moduleRecord,
|
|
715
|
+
score: 1,
|
|
716
|
+
matchedBy: ["filters"],
|
|
717
|
+
});
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const matchedBy = new Set<string>();
|
|
722
|
+
let score = 0;
|
|
723
|
+
|
|
724
|
+
score = Math.max(score, applyTextMatch(matchedBy, "id", normalizedQuery, moduleRecord.id, 100, 70));
|
|
725
|
+
score = Math.max(score, applyTextMatch(matchedBy, "name", normalizedQuery, getModuleName(moduleRecord), 90, 60));
|
|
726
|
+
score = Math.max(score, applyTextMatch(matchedBy, "type", normalizedQuery, getModuleType(moduleRecord), 80, 45));
|
|
727
|
+
score = Math.max(score, applyTextMatch(matchedBy, "plan-purpose", normalizedQuery, moduleRecord.plan?.contract.purpose, 55, 30));
|
|
728
|
+
score = Math.max(score, applyTextMatch(matchedBy, "graph-purpose", normalizedQuery, moduleRecord.graph?.purpose, 55, 30));
|
|
729
|
+
|
|
730
|
+
for (const dependency of getModuleDepends(moduleRecord)) {
|
|
731
|
+
score = Math.max(score, applyTextMatch(matchedBy, "dependency", normalizedQuery, dependency, 60, 35));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
for (const verificationId of getModuleVerificationIds(moduleRecord)) {
|
|
735
|
+
score = Math.max(score, applyTextMatch(matchedBy, "verification", normalizedQuery, verificationId, 75, 40));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
for (const item of moduleRecord.plan?.interfaceItems ?? []) {
|
|
739
|
+
score = Math.max(score, applyTextMatch(matchedBy, "plan-interface", normalizedQuery, item.tag, 45, 25));
|
|
740
|
+
score = Math.max(score, applyTextMatch(matchedBy, "plan-interface", normalizedQuery, item.purpose, 35, 20));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
for (const item of moduleRecord.graph?.annotations ?? []) {
|
|
744
|
+
score = Math.max(score, applyTextMatch(matchedBy, "graph-annotation", normalizedQuery, item.tag, 45, 25));
|
|
745
|
+
score = Math.max(score, applyTextMatch(matchedBy, "graph-annotation", normalizedQuery, item.purpose, 35, 20));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
for (const file of moduleRecord.localFiles) {
|
|
749
|
+
score = Math.max(score, applyTextMatch(matchedBy, "file-path", normalizedQuery, file.path, 85, 50));
|
|
750
|
+
score = Math.max(score, applyTextMatch(matchedBy, "file-purpose", normalizedQuery, file.moduleContract?.fields.PURPOSE, 40, 20));
|
|
751
|
+
score = Math.max(score, applyTextMatch(matchedBy, "file-scope", normalizedQuery, file.moduleContract?.fields.SCOPE, 35, 20));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (normalizedPathQuery) {
|
|
755
|
+
const pathScore = pathMatchScore(moduleRecord, normalizedPathQuery);
|
|
756
|
+
if (pathScore > 0) {
|
|
757
|
+
matchedBy.add("path");
|
|
758
|
+
score = Math.max(score, pathScore / 1000);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (score > 0) {
|
|
763
|
+
matches.push({
|
|
764
|
+
module: moduleRecord,
|
|
765
|
+
score,
|
|
766
|
+
matchedBy: Array.from(matchedBy).sort(),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return matches.sort((left, right) => {
|
|
772
|
+
if (right.score !== left.score) {
|
|
773
|
+
return right.score - left.score;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return left.module.id.localeCompare(right.module.id);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function resolveModule(index: GraceArtifactIndex, target: string) {
|
|
781
|
+
const normalizedTarget = target.trim();
|
|
782
|
+
const exactId = index.modules.find((moduleRecord) => moduleRecord.id.toLowerCase() === normalizedTarget.toLowerCase());
|
|
783
|
+
if (exactId) {
|
|
784
|
+
return exactId;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const normalizedPath = normalizeInputPath(index.root, normalizedTarget);
|
|
788
|
+
const candidates = index.modules
|
|
789
|
+
.map((moduleRecord) => ({
|
|
790
|
+
module: moduleRecord,
|
|
791
|
+
score: pathMatchScore(moduleRecord, normalizedPath),
|
|
792
|
+
}))
|
|
793
|
+
.filter((candidate) => candidate.score > 0)
|
|
794
|
+
.sort((left, right) => right.score - left.score || left.module.id.localeCompare(right.module.id));
|
|
795
|
+
|
|
796
|
+
if (candidates.length === 0) {
|
|
797
|
+
throw new Error(`No module found for \`${target}\`. Use \`grace module find ${target}\` to inspect candidates.`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const topScore = candidates[0].score;
|
|
801
|
+
const tiedCandidates = candidates.filter((candidate) => candidate.score === topScore);
|
|
802
|
+
if (tiedCandidates.length > 1) {
|
|
803
|
+
throw new Error(`Path \`${target}\` is ambiguous. Matching modules: ${tiedCandidates.map((candidate) => candidate.module.id).join(", ")}.`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return candidates[0].module;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export function resolveGovernedFile(index: GraceArtifactIndex, target: string) {
|
|
810
|
+
const normalizedTarget = normalizeInputPath(index.root, target.trim());
|
|
811
|
+
const fileRecord = index.files.find((record) => record.path === normalizedTarget);
|
|
812
|
+
if (!fileRecord) {
|
|
813
|
+
throw new Error(`No governed file found for \`${target}\`.`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return fileRecord;
|
|
817
|
+
}
|