@osovv/vv-opencode 1.3.6-rc.5 → 1.3.6-rc.7
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/CHANGELOG.md +16 -0
- package/README.md +33 -3
- package/dist/cli.js +5 -3
- package/dist/cli.js.map +1 -1
- package/dist/commands/lint.d.ts +74 -0
- package/dist/commands/lint.js +269 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/lib/opencode.js +3 -2
- package/dist/lib/opencode.js.map +1 -1
- package/dist/lib/plugin-toggle-config.d.ts +13 -1
- package/dist/lib/plugin-toggle-config.js +38 -4
- package/dist/lib/plugin-toggle-config.js.map +1 -1
- package/dist/lib/spec-lint-cache.d.ts +34 -0
- package/dist/lib/spec-lint-cache.js +140 -0
- package/dist/lib/spec-lint-cache.js.map +1 -0
- package/dist/lib/spec-lint.d.ts +57 -0
- package/dist/lib/spec-lint.js +963 -0
- package/dist/lib/spec-lint.js.map +1 -0
- package/dist/lib/vvoc-config.d.ts +25 -0
- package/dist/lib/vvoc-config.js +9 -0
- package/dist/lib/vvoc-config.js.map +1 -1
- package/dist/lib/vvoc-paths.d.ts +1 -0
- package/dist/lib/vvoc-paths.js +15 -5
- package/dist/lib/vvoc-paths.js.map +1 -1
- package/dist/plugins/hashline-edit/routing.js +9 -12
- package/dist/plugins/hashline-edit/routing.js.map +1 -1
- package/dist/plugins/spec-guard/index.d.ts +39 -0
- package/dist/plugins/spec-guard/index.js +251 -0
- package/dist/plugins/spec-guard/index.js.map +1 -0
- package/package.json +6 -2
- package/schemas/vvoc/v3.json +23 -6
- package/templates/skills/vv-execute/SKILL.md +8 -3
- package/templates/skills/vv-plan/SKILL.md +7 -7
- package/templates/skills/vv-plan/references/plan-template.xml +11 -6
- package/templates/skills/vv-spec/SKILL.md +5 -3
- package/templates/skills/vv-spec/references/design-context-template.xml +2 -2
- package/templates/skills/vv-spec/references/spec-template.xml +17 -9
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
// FILE: src/lib/spec-lint.ts
|
|
2
|
+
// VERSION: 1.0.0
|
|
3
|
+
// START_MODULE_CONTRACT
|
|
4
|
+
// PURPOSE: Pure strict linter for .vvoc spec-package XML artifacts (spec.xml, plan.xml, design-context.xml) enforcing the element-name identity format.
|
|
5
|
+
// SCOPE: Strict well-formedness validation over the htmlparser2 xmlMode event stream, template-contract checks per artifact kind, component/task/wave identity rules, reference integrity, lifecycle-aware severity, plan-subset-of-spec cross-file checking, and package layout checks. No filesystem access — callers pass artifact contents.
|
|
6
|
+
// DEPENDS: [htmlparser2]
|
|
7
|
+
// LINKS: [M-SPEC-LINT, M-PLUGIN-SPEC-GUARD, M-CLI-COMMANDS]
|
|
8
|
+
// ROLE: RUNTIME
|
|
9
|
+
// MAP_MODE: EXPORTS
|
|
10
|
+
// END_MODULE_CONTRACT
|
|
11
|
+
//
|
|
12
|
+
// START_MODULE_MAP
|
|
13
|
+
// LINT_VERSION - Rule-set version constant; cache keys include it so rule changes invalidate cached verdicts.
|
|
14
|
+
// SpecLintArtifactKind - Artifact kinds detected by root element name.
|
|
15
|
+
// SpecLintSeverity - Finding severity levels error and warning.
|
|
16
|
+
// SpecLintFinding - One rule violation with rule id, message, file label, and 1-based line.
|
|
17
|
+
// SpecLintVerdict - Per-artifact lint result with kind, ok flag, and findings.
|
|
18
|
+
// SpecLintArtifactInput - One artifact to lint identified by a file label and raw content.
|
|
19
|
+
// SpecLintOptions - Options for lint runs (skipCrossFile for single-file contexts).
|
|
20
|
+
// parseSpecXml - Strict xmlMode parse producing a positioned element tree or well-formedness findings.
|
|
21
|
+
// lintSpecArtifacts - Lint a set of artifacts together, applying cross-file rules between plans and specs.
|
|
22
|
+
// detectSpecArtifactKind - Map a root element name onto an artifact kind.
|
|
23
|
+
// isSpecArchivePath - True when a file label sits inside an archive directory.
|
|
24
|
+
// END_MODULE_MAP
|
|
25
|
+
//
|
|
26
|
+
// START_CHANGE_SUMMARY
|
|
27
|
+
// LAST_CHANGE: [C-SPEC-IDENTITY-LINT - Initial engine: strict stack validation, template contracts, identity rules, references, lifecycle severity, cross-file subset check, layout checks.]
|
|
28
|
+
// END_CHANGE_SUMMARY
|
|
29
|
+
import { Tokenizer } from "htmlparser2";
|
|
30
|
+
// START_BLOCK_PUBLIC_TYPES
|
|
31
|
+
export const LINT_VERSION = 1;
|
|
32
|
+
const IDENTITY_PATTERNS = {
|
|
33
|
+
component: /^COMPONENT-[A-Z0-9]+(-[A-Z0-9]+)*$/,
|
|
34
|
+
task: /^TASK-T-\d{3,}$/,
|
|
35
|
+
wave: /^WAVE-\d+$/,
|
|
36
|
+
};
|
|
37
|
+
const TASK_ID_REF = /^T-\d{3,}$/;
|
|
38
|
+
const DOC_STATUSES = new Set(["draft", "approved", "applied"]);
|
|
39
|
+
const TASK_STATUSES = new Set(["pending", "in_progress", "done", "skipped"]);
|
|
40
|
+
const RESERVED_PACKAGE_SLUGS = new Set(["draft", "archive", "template", "plan", "spec", "vvoc"]);
|
|
41
|
+
const PACKAGE_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]([a-z0-9_-]*[a-z0-9_])?$/;
|
|
42
|
+
// END_BLOCK_PARSER_TYPES
|
|
43
|
+
// START_BLOCK_LINE_INDEX
|
|
44
|
+
/** 1-based line number for a character index in source text. */
|
|
45
|
+
function lineAt(content, index) {
|
|
46
|
+
let line = 1;
|
|
47
|
+
const stop = Math.max(0, Math.min(index, content.length));
|
|
48
|
+
for (let i = 0; i < stop; i++) {
|
|
49
|
+
if (content.charCodeAt(i) === 10)
|
|
50
|
+
line++;
|
|
51
|
+
}
|
|
52
|
+
return line;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Strict XML parse over htmlparser2's xmlMode Tokenizer event stream.
|
|
56
|
+
* The low-level tokenizer is used directly (not Parser) because the Parser
|
|
57
|
+
* silently drops unmatched closing tags and implies closes at end-of-input;
|
|
58
|
+
* strict artifacts need both violation classes reported. Builds a positioned
|
|
59
|
+
* element tree and reports well-formedness violations: tokenizer errors,
|
|
60
|
+
* mismatched or unmatched closing tags, unclosed elements, stray top-level
|
|
61
|
+
* content, multiple roots, and content after the root element.
|
|
62
|
+
*/
|
|
63
|
+
export function parseSpecXml(content, file) {
|
|
64
|
+
const findings = [];
|
|
65
|
+
const rootCandidates = [];
|
|
66
|
+
const stack = [];
|
|
67
|
+
let rootClosed = false;
|
|
68
|
+
let pendingAttribNames = [];
|
|
69
|
+
let pendingAttribName = null;
|
|
70
|
+
const current = () => stack[stack.length - 1];
|
|
71
|
+
const tokenizer = new Tokenizer({ xmlMode: true, recognizeSelfClosing: true, decodeEntities: false }, {
|
|
72
|
+
onopentagname(start, endIndex) {
|
|
73
|
+
const name = content.slice(start, endIndex);
|
|
74
|
+
const line = lineAt(content, start);
|
|
75
|
+
pendingAttribNames = [];
|
|
76
|
+
pendingAttribName = null;
|
|
77
|
+
if (rootClosed) {
|
|
78
|
+
findings.push({
|
|
79
|
+
severity: "error",
|
|
80
|
+
rule: "xml.content-after-root",
|
|
81
|
+
message: `element <${name}> appears after the root element closed`,
|
|
82
|
+
file,
|
|
83
|
+
line,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const node = {
|
|
88
|
+
name,
|
|
89
|
+
line,
|
|
90
|
+
attribs: {},
|
|
91
|
+
children: [],
|
|
92
|
+
text: "",
|
|
93
|
+
cdataCount: 0,
|
|
94
|
+
attribNames: [],
|
|
95
|
+
};
|
|
96
|
+
const parent = current();
|
|
97
|
+
if (parent) {
|
|
98
|
+
parent.children.push(node);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
rootCandidates.push(node);
|
|
102
|
+
}
|
|
103
|
+
stack.push(node);
|
|
104
|
+
},
|
|
105
|
+
onattribname(start, endIndex) {
|
|
106
|
+
pendingAttribName = content.slice(start, endIndex);
|
|
107
|
+
},
|
|
108
|
+
onattribdata() {
|
|
109
|
+
// attribute values are irrelevant: the format forbids attributes outright
|
|
110
|
+
},
|
|
111
|
+
onattribentity() {
|
|
112
|
+
// no-op: attribute values are not inspected
|
|
113
|
+
},
|
|
114
|
+
onattribend() {
|
|
115
|
+
if (pendingAttribName)
|
|
116
|
+
pendingAttribNames.push(pendingAttribName);
|
|
117
|
+
pendingAttribName = null;
|
|
118
|
+
},
|
|
119
|
+
onopentagend(endIndex) {
|
|
120
|
+
const node = current();
|
|
121
|
+
if (!node)
|
|
122
|
+
return;
|
|
123
|
+
if (pendingAttribNames.length > 0) {
|
|
124
|
+
findings.push({
|
|
125
|
+
severity: "error",
|
|
126
|
+
rule: "attr.forbidden",
|
|
127
|
+
message: `element <${node.name}> uses XML attributes (${pendingAttribNames.join(", ")}); the artifact format allows child elements only`,
|
|
128
|
+
file,
|
|
129
|
+
line: node.line,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
void endIndex;
|
|
133
|
+
},
|
|
134
|
+
onselfclosingtag() {
|
|
135
|
+
const node = current();
|
|
136
|
+
if (!node)
|
|
137
|
+
return;
|
|
138
|
+
if (pendingAttribNames.length > 0) {
|
|
139
|
+
findings.push({
|
|
140
|
+
severity: "error",
|
|
141
|
+
rule: "attr.forbidden",
|
|
142
|
+
message: `element <${node.name}> uses XML attributes (${pendingAttribNames.join(", ")}); the artifact format allows child elements only`,
|
|
143
|
+
file,
|
|
144
|
+
line: node.line,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
pendingAttribNames = [];
|
|
148
|
+
stack.pop();
|
|
149
|
+
if (stack.length === 0)
|
|
150
|
+
rootClosed = true;
|
|
151
|
+
},
|
|
152
|
+
onclosetag(start, endIndex) {
|
|
153
|
+
const name = content.slice(start, endIndex);
|
|
154
|
+
const line = lineAt(content, start);
|
|
155
|
+
const top = current();
|
|
156
|
+
if (!top) {
|
|
157
|
+
findings.push({
|
|
158
|
+
severity: "error",
|
|
159
|
+
rule: rootClosed ? "xml.content-after-root" : "xml.stray-close",
|
|
160
|
+
message: `closing tag </${name}> has no matching open element`,
|
|
161
|
+
file,
|
|
162
|
+
line,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (top.name !== name) {
|
|
167
|
+
const namedIndex = stack.findIndex((n) => n.name === name);
|
|
168
|
+
if (namedIndex === -1) {
|
|
169
|
+
findings.push({
|
|
170
|
+
severity: "error",
|
|
171
|
+
rule: "xml.mismatched-close",
|
|
172
|
+
message: `closing tag </${name}> does not match any open element (innermost open is <${top.name}> from line ${top.line})`,
|
|
173
|
+
file,
|
|
174
|
+
line,
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
for (let i = stack.length - 1; i > namedIndex; i--) {
|
|
179
|
+
findings.push({
|
|
180
|
+
severity: "error",
|
|
181
|
+
rule: "xml.unclosed",
|
|
182
|
+
message: `element <${stack[i].name}> opened on line ${stack[i].line} is closed implicitly by </${name}> and must be closed explicitly`,
|
|
183
|
+
file,
|
|
184
|
+
line: stack[i].line,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
stack.length = namedIndex;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
stack.pop();
|
|
191
|
+
}
|
|
192
|
+
if (stack.length === 0)
|
|
193
|
+
rootClosed = true;
|
|
194
|
+
},
|
|
195
|
+
ontext(start, endIndex) {
|
|
196
|
+
const raw = content.slice(start, endIndex);
|
|
197
|
+
const parent = current();
|
|
198
|
+
if (parent) {
|
|
199
|
+
parent.text += raw;
|
|
200
|
+
}
|
|
201
|
+
else if (raw.trim()) {
|
|
202
|
+
findings.push({
|
|
203
|
+
severity: "error",
|
|
204
|
+
rule: rootClosed ? "xml.content-after-root" : "xml.stray-text",
|
|
205
|
+
message: `text content outside the root element: ${JSON.stringify(raw.trim().slice(0, 40))}`,
|
|
206
|
+
file,
|
|
207
|
+
line: lineAt(content, start),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
oncdata() {
|
|
212
|
+
const parent = current();
|
|
213
|
+
if (parent)
|
|
214
|
+
parent.cdataCount++;
|
|
215
|
+
},
|
|
216
|
+
oncomment() {
|
|
217
|
+
// comments are legal noise in artifacts and carry no contract meaning
|
|
218
|
+
},
|
|
219
|
+
ondeclaration() {
|
|
220
|
+
// XML declarations and DOCTYPE are tolerated without contract meaning
|
|
221
|
+
},
|
|
222
|
+
onprocessinginstruction() {
|
|
223
|
+
// processing instructions are tolerated without contract meaning
|
|
224
|
+
},
|
|
225
|
+
ontextentity() {
|
|
226
|
+
// unreachable with decodeEntities disabled; raw slices keep entities verbatim
|
|
227
|
+
},
|
|
228
|
+
onend() {
|
|
229
|
+
// required by the tokenizer interface; end-of-input handling happens after tokenizer.end() returns
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
tokenizer.write(content);
|
|
233
|
+
tokenizer.end();
|
|
234
|
+
for (const unclosed of stack) {
|
|
235
|
+
findings.push({
|
|
236
|
+
severity: "error",
|
|
237
|
+
rule: "xml.unclosed",
|
|
238
|
+
message: `element <${unclosed.name}> opened on line ${unclosed.line} is never closed`,
|
|
239
|
+
file,
|
|
240
|
+
line: unclosed.line,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (rootCandidates.length > 1) {
|
|
244
|
+
findings.push({
|
|
245
|
+
severity: "error",
|
|
246
|
+
rule: "xml.multiple-roots",
|
|
247
|
+
message: `document has ${rootCandidates.length} root elements; exactly one is allowed`,
|
|
248
|
+
file,
|
|
249
|
+
line: rootCandidates[1].line,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return { root: rootCandidates[0] ?? null, findings };
|
|
253
|
+
}
|
|
254
|
+
const SPEC_CONTRACT = {
|
|
255
|
+
spec: {
|
|
256
|
+
names: [
|
|
257
|
+
"status",
|
|
258
|
+
"goal",
|
|
259
|
+
"architecture",
|
|
260
|
+
"tech_stack",
|
|
261
|
+
"components",
|
|
262
|
+
"data_flow",
|
|
263
|
+
"error_handling",
|
|
264
|
+
"testing",
|
|
265
|
+
"non_goals",
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
testing: { names: ["strategy", "coverage"] },
|
|
269
|
+
components: { identity: "component" },
|
|
270
|
+
non_goals: { names: ["non_goal"] },
|
|
271
|
+
};
|
|
272
|
+
const COMPONENT_CHILDREN = ["name", "responsibility", "depends_on"];
|
|
273
|
+
const PLAN_CONTRACT = {
|
|
274
|
+
plan: { names: ["spec", "design_context", "created", "status", "meta", "architecture", "tasks"] },
|
|
275
|
+
meta: { names: ["summary", "waves", "affected_modules", "complexity"] },
|
|
276
|
+
architecture: { identity: "component" },
|
|
277
|
+
tasks: { identity: "wave" },
|
|
278
|
+
};
|
|
279
|
+
const TASK_CHILDREN = [
|
|
280
|
+
"title",
|
|
281
|
+
"file",
|
|
282
|
+
"status",
|
|
283
|
+
"description",
|
|
284
|
+
"depends_on",
|
|
285
|
+
"snippet",
|
|
286
|
+
"acceptance",
|
|
287
|
+
"verification",
|
|
288
|
+
];
|
|
289
|
+
const TASK_DEPENDS_CHILDREN = ["task_id"];
|
|
290
|
+
const ACCEPTANCE_CHILDREN = ["criterion"];
|
|
291
|
+
const VERIFICATION_CHILDREN = ["command"];
|
|
292
|
+
const WAVE_CHILDREN = ["goal"];
|
|
293
|
+
const PLAN_FILE_CHILDREN = ["path", "role"];
|
|
294
|
+
const DESIGN_CONTEXT_CONTRACT = {
|
|
295
|
+
"design-context": {
|
|
296
|
+
names: ["decisions", "assumptions", "deferred", "scenarios", "external_constraints"],
|
|
297
|
+
},
|
|
298
|
+
decisions: { names: ["decision"] },
|
|
299
|
+
decision: { names: ["topic", "choice", "rationale", "alternatives_considered"] },
|
|
300
|
+
alternatives_considered: { names: ["alternative"] },
|
|
301
|
+
alternative: { names: ["name", "reason_rejected"] },
|
|
302
|
+
assumptions: { names: ["assumption"] },
|
|
303
|
+
assumption: { names: ["statement", "confidence", "fragile_if"] },
|
|
304
|
+
deferred: { names: ["deferred_decision"] },
|
|
305
|
+
deferred_decision: { names: ["decision", "why_deferred", "revisit_trigger"] },
|
|
306
|
+
scenarios: { names: ["scenario"] },
|
|
307
|
+
scenario: { names: ["name", "context", "implications"] },
|
|
308
|
+
external_constraints: { names: ["constraint"] },
|
|
309
|
+
constraint: { names: ["source", "impact"] },
|
|
310
|
+
};
|
|
311
|
+
// END_BLOCK_CONTRACT_TABLES
|
|
312
|
+
// START_BLOCK_LIFECYNESS_HELPER
|
|
313
|
+
/**
|
|
314
|
+
* Completeness rules apply only to known non-draft statuses. An invalid
|
|
315
|
+
* status is reported once by the vocabulary rule without cascading
|
|
316
|
+
* emptiness noise for a document that may still be mid-composition.
|
|
317
|
+
*/
|
|
318
|
+
function requiresCompleteness(root) {
|
|
319
|
+
const status = textOf(child(root, "status"));
|
|
320
|
+
return status === "approved" || status === "applied";
|
|
321
|
+
}
|
|
322
|
+
// END_BLOCK_LIFECYNESS_HELPER
|
|
323
|
+
// START_BLOCK_TREE_HELPERS
|
|
324
|
+
function child(node, name) {
|
|
325
|
+
return node.children.find((c) => c.name === name);
|
|
326
|
+
}
|
|
327
|
+
function children(node, name) {
|
|
328
|
+
return node.children.filter((c) => c.name === name);
|
|
329
|
+
}
|
|
330
|
+
function textOf(node) {
|
|
331
|
+
return node ? node.text.trim() : "";
|
|
332
|
+
}
|
|
333
|
+
function nonEmpty(node) {
|
|
334
|
+
return textOf(node) !== "";
|
|
335
|
+
}
|
|
336
|
+
// END_BLOCK_TREE_HELPERS
|
|
337
|
+
// START_BLOCK_CONTRACT_CHECKS
|
|
338
|
+
/** Report children that are neither allowed fixed names nor valid identity elements. */
|
|
339
|
+
function checkChildren(node, rule, file, findings, extraAllowed = []) {
|
|
340
|
+
const allowed = new Set([...(rule?.names ?? []), ...extraAllowed]);
|
|
341
|
+
for (const c of node.children) {
|
|
342
|
+
if (allowed.has(c.name))
|
|
343
|
+
continue;
|
|
344
|
+
if (rule?.identity && IDENTITY_PATTERNS[rule.identity].test(c.name))
|
|
345
|
+
continue;
|
|
346
|
+
const expected = rule?.identity
|
|
347
|
+
? `${(rule.names ?? []).join(", ") || "(none)"}, or ${rule.identity} identity elements`
|
|
348
|
+
: (rule?.names ?? []).join(", ") || "(none)";
|
|
349
|
+
findings.push({
|
|
350
|
+
severity: "error",
|
|
351
|
+
rule: "element.unknown",
|
|
352
|
+
message: `element <${c.name}> is not part of the template contract under <${node.name}>; allowed: ${expected}`,
|
|
353
|
+
file,
|
|
354
|
+
line: c.line,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// END_BLOCK_CONTRACT_CHECKS
|
|
359
|
+
// START_BLOCK_LAYOUT_CHECKS
|
|
360
|
+
/** True when a file label sits under an archive/ directory. */
|
|
361
|
+
export function isSpecArchivePath(file) {
|
|
362
|
+
return /(^|\/)archive\//.test(file.replace(/\\/g, "/"));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Package layout checks applied when the label is a specs-package artifact
|
|
366
|
+
* (.vvoc/specs/<id>/<artifact>.xml or an equivalent specs/ segment). Foreign
|
|
367
|
+
* paths (plain filenames, temp fixtures without a specs segment) are skipped.
|
|
368
|
+
*/
|
|
369
|
+
function checkPackageLayout(file, findings) {
|
|
370
|
+
const segments = file.replace(/\\/g, "/").split("/");
|
|
371
|
+
const fileName = segments[segments.length - 1];
|
|
372
|
+
if (!["spec.xml", "plan.xml", "design-context.xml"].includes(fileName))
|
|
373
|
+
return;
|
|
374
|
+
const specsIndex = segments.lastIndexOf("specs");
|
|
375
|
+
if (specsIndex === -1)
|
|
376
|
+
return;
|
|
377
|
+
const dirSegments = segments.slice(specsIndex + 1, -1).filter((s) => s !== "archive");
|
|
378
|
+
if (dirSegments.length === 0)
|
|
379
|
+
return;
|
|
380
|
+
const packageId = dirSegments[dirSegments.length - 1];
|
|
381
|
+
if (dirSegments.length > 1)
|
|
382
|
+
return; // nested dirs inside a package: skip id checks
|
|
383
|
+
if (!PACKAGE_ID_PATTERN.test(packageId)) {
|
|
384
|
+
findings.push({
|
|
385
|
+
severity: "error",
|
|
386
|
+
rule: "layout.package_id",
|
|
387
|
+
message: `package directory "${packageId}" does not match the required date-prefixed id YYYY-MM-DD-<slug> with a lowercase slug`,
|
|
388
|
+
file,
|
|
389
|
+
line: 1,
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const slug = packageId.slice(11);
|
|
394
|
+
if (RESERVED_PACKAGE_SLUGS.has(slug)) {
|
|
395
|
+
findings.push({
|
|
396
|
+
severity: "error",
|
|
397
|
+
rule: "layout.reserved_slug",
|
|
398
|
+
message: `package slug "${slug}" is reserved`,
|
|
399
|
+
file,
|
|
400
|
+
line: 1,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// END_BLOCK_LAYOUT_CHECKS
|
|
405
|
+
// START_BLOCK_KIND_DETECTION
|
|
406
|
+
export function detectSpecArtifactKind(rootName) {
|
|
407
|
+
switch (rootName) {
|
|
408
|
+
case "spec":
|
|
409
|
+
return "spec";
|
|
410
|
+
case "plan":
|
|
411
|
+
return "plan";
|
|
412
|
+
case "design-context":
|
|
413
|
+
return "design-context";
|
|
414
|
+
default:
|
|
415
|
+
return "unknown";
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function lintSpec(root, file, parseFindings) {
|
|
419
|
+
const findings = [...parseFindings];
|
|
420
|
+
checkChildren(root, SPEC_CONTRACT.spec, file, findings);
|
|
421
|
+
const completeness = requiresCompleteness(root);
|
|
422
|
+
const statusNode = child(root, "status");
|
|
423
|
+
const status = textOf(statusNode);
|
|
424
|
+
if (!DOC_STATUSES.has(status)) {
|
|
425
|
+
findings.push({
|
|
426
|
+
severity: "error",
|
|
427
|
+
rule: "lifecycle.status",
|
|
428
|
+
message: `spec status "${status}" is not one of draft, approved, applied`,
|
|
429
|
+
file,
|
|
430
|
+
line: statusNode?.line ?? root.line,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const componentsNode = child(root, "components");
|
|
434
|
+
if (componentsNode) {
|
|
435
|
+
checkChildren(componentsNode, SPEC_CONTRACT.components, file, findings);
|
|
436
|
+
const seen = new Set();
|
|
437
|
+
const slugs = [];
|
|
438
|
+
for (const c of componentsNode.children) {
|
|
439
|
+
if (!IDENTITY_PATTERNS.component.test(c.name)) {
|
|
440
|
+
findings.push({
|
|
441
|
+
severity: "error",
|
|
442
|
+
rule: "identity.pattern",
|
|
443
|
+
message: `component element <${c.name}> does not match the COMPONENT-UPPER-SLUG pattern (uppercase alphanumeric and hyphens, derived from the display name)`,
|
|
444
|
+
file,
|
|
445
|
+
line: c.line,
|
|
446
|
+
});
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (seen.has(c.name)) {
|
|
450
|
+
findings.push({
|
|
451
|
+
severity: "error",
|
|
452
|
+
rule: "identity.duplicate",
|
|
453
|
+
message: `component ${c.name} is declared more than once`,
|
|
454
|
+
file,
|
|
455
|
+
line: c.line,
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
seen.add(c.name);
|
|
460
|
+
slugs.push(c.name);
|
|
461
|
+
checkChildren(c, { names: COMPONENT_CHILDREN }, file, findings);
|
|
462
|
+
}
|
|
463
|
+
for (const c of componentsNode.children) {
|
|
464
|
+
for (const dep of children(c, "depends_on")) {
|
|
465
|
+
const ref = textOf(dep);
|
|
466
|
+
if (!ref)
|
|
467
|
+
continue;
|
|
468
|
+
const target = `COMPONENT-${ref.startsWith("COMPONENT-") ? ref.slice("COMPONENT-".length) : ref}`;
|
|
469
|
+
if (!seen.has(target)) {
|
|
470
|
+
findings.push({
|
|
471
|
+
severity: "error",
|
|
472
|
+
rule: "ref.dangling",
|
|
473
|
+
message: `depends_on references "${ref}" which is not a declared component in this document`,
|
|
474
|
+
file,
|
|
475
|
+
line: dep.line,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (completeness) {
|
|
481
|
+
for (const c of componentsNode.children) {
|
|
482
|
+
if (!nonEmpty(child(c, "name"))) {
|
|
483
|
+
findings.push({
|
|
484
|
+
severity: "error",
|
|
485
|
+
rule: "lifecycle.required",
|
|
486
|
+
message: `component ${c.name} has an empty <name>; the spec is ${status} and must be complete`,
|
|
487
|
+
file,
|
|
488
|
+
line: c.line,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
if (!nonEmpty(child(c, "responsibility"))) {
|
|
492
|
+
findings.push({
|
|
493
|
+
severity: "error",
|
|
494
|
+
rule: "lifecycle.required",
|
|
495
|
+
message: `component ${c.name} has an empty <responsibility>; the spec is ${status} and must be complete`,
|
|
496
|
+
file,
|
|
497
|
+
line: c.line,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const testingNode = child(root, "testing");
|
|
504
|
+
if (testingNode)
|
|
505
|
+
checkChildren(testingNode, SPEC_CONTRACT.testing, file, findings);
|
|
506
|
+
const nonGoalsNode = child(root, "non_goals");
|
|
507
|
+
if (nonGoalsNode)
|
|
508
|
+
checkChildren(nonGoalsNode, SPEC_CONTRACT.non_goals, file, findings);
|
|
509
|
+
if (completeness) {
|
|
510
|
+
const required = [
|
|
511
|
+
["goal", child(root, "goal")],
|
|
512
|
+
["architecture", child(root, "architecture")],
|
|
513
|
+
["tech_stack", child(root, "tech_stack")],
|
|
514
|
+
["data_flow", child(root, "data_flow")],
|
|
515
|
+
["error_handling", child(root, "error_handling")],
|
|
516
|
+
["testing.strategy", testingNode ? child(testingNode, "strategy") : undefined],
|
|
517
|
+
["testing.coverage", testingNode ? child(testingNode, "coverage") : undefined],
|
|
518
|
+
];
|
|
519
|
+
for (const [label, node] of required) {
|
|
520
|
+
if (!nonEmpty(node)) {
|
|
521
|
+
findings.push({
|
|
522
|
+
severity: "error",
|
|
523
|
+
rule: "lifecycle.required",
|
|
524
|
+
message: `<${label}> is empty; the spec is ${status} and must be complete`,
|
|
525
|
+
file,
|
|
526
|
+
line: node?.line ?? root.line,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (componentsNode && componentsNode.children.length === 0) {
|
|
531
|
+
findings.push({
|
|
532
|
+
severity: "error",
|
|
533
|
+
rule: "lifecycle.required",
|
|
534
|
+
message: `<components> declares no components; the spec is ${status} and must be complete`,
|
|
535
|
+
file,
|
|
536
|
+
line: componentsNode.line,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
checkPackageLayout(file, findings);
|
|
541
|
+
return {
|
|
542
|
+
version: LINT_VERSION,
|
|
543
|
+
file,
|
|
544
|
+
kind: "spec",
|
|
545
|
+
ok: !findings.some((f) => f.severity === "error"),
|
|
546
|
+
findings,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
// END_BLOCK_SPEC_RULES
|
|
550
|
+
// START_BLOCK_PLAN_RULES
|
|
551
|
+
function lintPlan(root, file, parseFindings, specByLabel, options) {
|
|
552
|
+
const findings = [...parseFindings];
|
|
553
|
+
checkChildren(root, PLAN_CONTRACT.plan, file, findings);
|
|
554
|
+
const statusNode = child(root, "status");
|
|
555
|
+
const status = textOf(statusNode);
|
|
556
|
+
if (!DOC_STATUSES.has(status)) {
|
|
557
|
+
findings.push({
|
|
558
|
+
severity: "error",
|
|
559
|
+
rule: "lifecycle.status",
|
|
560
|
+
message: `plan status "${status}" is not one of draft, approved, applied`,
|
|
561
|
+
file,
|
|
562
|
+
line: statusNode?.line ?? root.line,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
const completeness = requiresCompleteness(root);
|
|
566
|
+
// Cross-file facts are resolved early so architecture depends_on may reference
|
|
567
|
+
// spec components that the plan does not touch.
|
|
568
|
+
const specPathNodeEarly = child(root, "spec");
|
|
569
|
+
const specFacts = options.skipCrossFile
|
|
570
|
+
? undefined
|
|
571
|
+
: resolveSpecFacts(textOf(specPathNodeEarly), specByLabel);
|
|
572
|
+
// Architecture: component identity elements mirroring the spec.
|
|
573
|
+
const architectureNode = child(root, "architecture");
|
|
574
|
+
const componentSlugs = [];
|
|
575
|
+
if (architectureNode) {
|
|
576
|
+
checkChildren(architectureNode, PLAN_CONTRACT.architecture, file, findings);
|
|
577
|
+
const seen = new Set();
|
|
578
|
+
for (const c of architectureNode.children) {
|
|
579
|
+
if (!IDENTITY_PATTERNS.component.test(c.name)) {
|
|
580
|
+
findings.push({
|
|
581
|
+
severity: "error",
|
|
582
|
+
rule: "identity.pattern",
|
|
583
|
+
message: `architecture element <${c.name}> does not match the COMPONENT-UPPER-SLUG pattern mirrored from spec.xml`,
|
|
584
|
+
file,
|
|
585
|
+
line: c.line,
|
|
586
|
+
});
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (seen.has(c.name)) {
|
|
590
|
+
findings.push({
|
|
591
|
+
severity: "error",
|
|
592
|
+
rule: "identity.duplicate",
|
|
593
|
+
message: `architecture component ${c.name} is declared more than once`,
|
|
594
|
+
file,
|
|
595
|
+
line: c.line,
|
|
596
|
+
});
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
seen.add(c.name);
|
|
600
|
+
componentSlugs.push(c.name);
|
|
601
|
+
checkChildren(c, { names: ["name", "purpose", "file", "contract", "depends_on"] }, file, findings);
|
|
602
|
+
const fileNode = child(c, "file");
|
|
603
|
+
if (fileNode)
|
|
604
|
+
checkChildren(fileNode, { names: PLAN_FILE_CHILDREN }, file, findings);
|
|
605
|
+
}
|
|
606
|
+
for (const c of architectureNode.children) {
|
|
607
|
+
for (const dep of children(c, "depends_on")) {
|
|
608
|
+
const ref = textOf(dep);
|
|
609
|
+
if (!ref)
|
|
610
|
+
continue;
|
|
611
|
+
const target = ref.startsWith("COMPONENT-") ? ref : `COMPONENT-${ref}`;
|
|
612
|
+
const knownLocally = seen.has(target);
|
|
613
|
+
const knownInSpec = specFacts?.componentSlugs.includes(target) ?? false;
|
|
614
|
+
// Without a resolved spec, non-local references stay unverifiable: the
|
|
615
|
+
// crossfile.spec_missing warning already reports the skipped checks.
|
|
616
|
+
if (specFacts && !knownLocally && !knownInSpec) {
|
|
617
|
+
findings.push({
|
|
618
|
+
severity: "error",
|
|
619
|
+
rule: "ref.dangling",
|
|
620
|
+
message: `architecture depends_on references "${ref}" which is declared neither in this plan's architecture nor in the linked spec`,
|
|
621
|
+
file,
|
|
622
|
+
line: dep.line,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Tasks: waves with TASK-T-NNN identity elements.
|
|
629
|
+
const tasksNode = child(root, "tasks");
|
|
630
|
+
const taskIds = new Set();
|
|
631
|
+
if (tasksNode) {
|
|
632
|
+
checkChildren(tasksNode, PLAN_CONTRACT.tasks, file, findings);
|
|
633
|
+
for (const wave of tasksNode.children) {
|
|
634
|
+
if (!IDENTITY_PATTERNS.wave.test(wave.name)) {
|
|
635
|
+
findings.push({
|
|
636
|
+
severity: "error",
|
|
637
|
+
rule: "identity.pattern",
|
|
638
|
+
message: `wave element <${wave.name}> does not match the WAVE-N pattern`,
|
|
639
|
+
file,
|
|
640
|
+
line: wave.line,
|
|
641
|
+
});
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
checkChildren(wave, { names: WAVE_CHILDREN, identity: "task" }, file, findings);
|
|
645
|
+
}
|
|
646
|
+
for (const wave of tasksNode.children) {
|
|
647
|
+
for (const task of wave.children) {
|
|
648
|
+
if (!IDENTITY_PATTERNS.task.test(task.name)) {
|
|
649
|
+
if (!WAVE_CHILDREN.includes(task.name)) {
|
|
650
|
+
findings.push({
|
|
651
|
+
severity: "error",
|
|
652
|
+
rule: "identity.pattern",
|
|
653
|
+
message: `task element <${task.name}> does not match the TASK-T-NNN pattern`,
|
|
654
|
+
file,
|
|
655
|
+
line: task.line,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (taskIds.has(task.name)) {
|
|
661
|
+
findings.push({
|
|
662
|
+
severity: "error",
|
|
663
|
+
rule: "identity.duplicate",
|
|
664
|
+
message: `task ${task.name} is declared more than once`,
|
|
665
|
+
file,
|
|
666
|
+
line: task.line,
|
|
667
|
+
});
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
taskIds.add(task.name);
|
|
671
|
+
checkChildren(task, { names: TASK_CHILDREN }, file, findings);
|
|
672
|
+
const dependsNode = child(task, "depends_on");
|
|
673
|
+
if (dependsNode)
|
|
674
|
+
checkChildren(dependsNode, { names: TASK_DEPENDS_CHILDREN }, file, findings);
|
|
675
|
+
const acceptanceNode = child(task, "acceptance");
|
|
676
|
+
if (acceptanceNode)
|
|
677
|
+
checkChildren(acceptanceNode, { names: ACCEPTANCE_CHILDREN }, file, findings);
|
|
678
|
+
const verificationNode = child(task, "verification");
|
|
679
|
+
if (verificationNode)
|
|
680
|
+
checkChildren(verificationNode, { names: VERIFICATION_CHILDREN }, file, findings);
|
|
681
|
+
const taskStatusNode = child(task, "status");
|
|
682
|
+
const taskStatus = textOf(taskStatusNode);
|
|
683
|
+
if (!TASK_STATUSES.has(taskStatus)) {
|
|
684
|
+
findings.push({
|
|
685
|
+
severity: "error",
|
|
686
|
+
rule: "lifecycle.task_status",
|
|
687
|
+
message: `task ${task.name} status "${taskStatus}" is not one of pending, in_progress, done, skipped`,
|
|
688
|
+
file,
|
|
689
|
+
line: taskStatusNode?.line ?? task.line,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
const snippetNode = child(task, "snippet");
|
|
693
|
+
if (snippetNode && snippetNode.text.trim() !== "" && snippetNode.cdataCount === 0) {
|
|
694
|
+
findings.push({
|
|
695
|
+
severity: "error",
|
|
696
|
+
rule: "snippet.cdata",
|
|
697
|
+
message: `task ${task.name} has a non-empty <snippet> that is not wrapped in CDATA`,
|
|
698
|
+
file,
|
|
699
|
+
line: snippetNode.line,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (completeness) {
|
|
703
|
+
if (!nonEmpty(child(task, "title"))) {
|
|
704
|
+
findings.push({
|
|
705
|
+
severity: "error",
|
|
706
|
+
rule: "lifecycle.required",
|
|
707
|
+
message: `task ${task.name} has an empty <title>; the plan is ${status} and must be complete`,
|
|
708
|
+
file,
|
|
709
|
+
line: task.line,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
if (!nonEmpty(child(task, "file"))) {
|
|
713
|
+
findings.push({
|
|
714
|
+
severity: "error",
|
|
715
|
+
rule: "lifecycle.required",
|
|
716
|
+
message: `task ${task.name} has an empty <file>; the plan is ${status} and must be complete`,
|
|
717
|
+
file,
|
|
718
|
+
line: task.line,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
if (!nonEmpty(child(task, "description"))) {
|
|
722
|
+
findings.push({
|
|
723
|
+
severity: "error",
|
|
724
|
+
rule: "lifecycle.required",
|
|
725
|
+
message: `task ${task.name} has an empty <description>; the plan is ${status} and must be complete`,
|
|
726
|
+
file,
|
|
727
|
+
line: task.line,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const acceptance = child(task, "acceptance");
|
|
731
|
+
if (!acceptance || children(acceptance, "criterion").length === 0) {
|
|
732
|
+
findings.push({
|
|
733
|
+
severity: "error",
|
|
734
|
+
rule: "lifecycle.required",
|
|
735
|
+
message: `task ${task.name} has no acceptance criteria; the plan is ${status} and must be complete`,
|
|
736
|
+
file,
|
|
737
|
+
line: acceptance?.line ?? task.line,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
const verification = child(task, "verification");
|
|
741
|
+
if (!verification || children(verification, "command").length === 0) {
|
|
742
|
+
findings.push({
|
|
743
|
+
severity: "error",
|
|
744
|
+
rule: "lifecycle.required",
|
|
745
|
+
message: `task ${task.name} has no verification command; the plan is ${status} and must be complete`,
|
|
746
|
+
file,
|
|
747
|
+
line: verification?.line ?? task.line,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const wave of tasksNode.children) {
|
|
754
|
+
for (const task of wave.children) {
|
|
755
|
+
const dependsNode = child(task, "depends_on");
|
|
756
|
+
if (!dependsNode)
|
|
757
|
+
continue;
|
|
758
|
+
for (const ref of children(dependsNode, "task_id")) {
|
|
759
|
+
const value = textOf(ref);
|
|
760
|
+
if (!value)
|
|
761
|
+
continue;
|
|
762
|
+
if (!TASK_ID_REF.test(value) || !taskIds.has(`TASK-${value}`)) {
|
|
763
|
+
findings.push({
|
|
764
|
+
severity: "error",
|
|
765
|
+
rule: "ref.dangling",
|
|
766
|
+
message: `task_id references "${value}" which is not a declared task in this plan`,
|
|
767
|
+
file,
|
|
768
|
+
line: ref.line,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (completeness) {
|
|
776
|
+
const metaNode = child(root, "meta");
|
|
777
|
+
const required = metaNode
|
|
778
|
+
? [
|
|
779
|
+
["meta.summary", child(metaNode, "summary")],
|
|
780
|
+
["meta.waves", child(metaNode, "waves")],
|
|
781
|
+
["meta.affected_modules", child(metaNode, "affected_modules")],
|
|
782
|
+
["meta.complexity", child(metaNode, "complexity")],
|
|
783
|
+
]
|
|
784
|
+
: [];
|
|
785
|
+
for (const [label, node] of required) {
|
|
786
|
+
if (!nonEmpty(node)) {
|
|
787
|
+
findings.push({
|
|
788
|
+
severity: "error",
|
|
789
|
+
rule: "lifecycle.required",
|
|
790
|
+
message: `<${label}> is empty; the plan is ${status} and must be complete`,
|
|
791
|
+
file,
|
|
792
|
+
line: node?.line ?? root.line,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (tasksNode && tasksNode.children.length === 0) {
|
|
797
|
+
findings.push({
|
|
798
|
+
severity: "error",
|
|
799
|
+
rule: "lifecycle.required",
|
|
800
|
+
message: `<tasks> declares no waves; the plan is ${status} and must be complete`,
|
|
801
|
+
file,
|
|
802
|
+
line: tasksNode.line,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Cross-file: plan components are a subset of spec components.
|
|
807
|
+
if (!options.skipCrossFile) {
|
|
808
|
+
const specPath = textOf(specPathNodeEarly);
|
|
809
|
+
if (!specFacts) {
|
|
810
|
+
findings.push({
|
|
811
|
+
severity: "warning",
|
|
812
|
+
rule: "crossfile.spec_missing",
|
|
813
|
+
message: `linked spec "${specPath || "(empty)"}" was not part of this lint run; plan-subset-of-spec checks were skipped`,
|
|
814
|
+
file,
|
|
815
|
+
line: specPathNodeEarly?.line ?? root.line,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const specSlugs = new Set(specFacts.componentSlugs);
|
|
820
|
+
for (const slug of componentSlugs) {
|
|
821
|
+
if (!specSlugs.has(slug)) {
|
|
822
|
+
const archNode = architectureNode?.children.find((c) => c.name === slug);
|
|
823
|
+
findings.push({
|
|
824
|
+
severity: "error",
|
|
825
|
+
rule: "crossfile.plan_component",
|
|
826
|
+
message: `plan architecture component ${slug} does not exist in the linked spec; plan components must be a subset of spec components`,
|
|
827
|
+
file,
|
|
828
|
+
line: archNode?.line ?? root.line,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
checkPackageLayout(file, findings);
|
|
835
|
+
return {
|
|
836
|
+
version: LINT_VERSION,
|
|
837
|
+
file,
|
|
838
|
+
kind: "plan",
|
|
839
|
+
ok: !findings.some((f) => f.severity === "error"),
|
|
840
|
+
findings,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
// END_BLOCK_PLAN_RULES
|
|
844
|
+
// START_BLOCK_SPEC_FACTS
|
|
845
|
+
function collectSpecFacts(root) {
|
|
846
|
+
const componentsNode = child(root, "components");
|
|
847
|
+
const componentSlugs = componentsNode
|
|
848
|
+
? componentsNode.children
|
|
849
|
+
.filter((c) => IDENTITY_PATTERNS.component.test(c.name))
|
|
850
|
+
.map((c) => c.name)
|
|
851
|
+
: [];
|
|
852
|
+
return { componentSlugs, status: textOf(child(root, "status")) };
|
|
853
|
+
}
|
|
854
|
+
function resolveSpecFacts(specPath, specByLabel) {
|
|
855
|
+
if (!specPath)
|
|
856
|
+
return undefined;
|
|
857
|
+
const normalized = specPath.replace(/\\/g, "/");
|
|
858
|
+
if (specByLabel.has(normalized))
|
|
859
|
+
return specByLabel.get(normalized);
|
|
860
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
861
|
+
for (const [label, facts] of specByLabel) {
|
|
862
|
+
const labelBase = label.replace(/\\/g, "/").split("/").pop() ?? label;
|
|
863
|
+
if (labelBase === base)
|
|
864
|
+
return facts;
|
|
865
|
+
}
|
|
866
|
+
return undefined;
|
|
867
|
+
}
|
|
868
|
+
// END_BLOCK_SPEC_FACTS
|
|
869
|
+
// START_BLOCK_DESIGN_CONTEXT_RULES
|
|
870
|
+
function lintDesignContext(root, file, parseFindings) {
|
|
871
|
+
const findings = [...parseFindings];
|
|
872
|
+
const contract = DESIGN_CONTEXT_CONTRACT;
|
|
873
|
+
checkChildren(root, contract["design-context"], file, findings);
|
|
874
|
+
const walk = (node) => {
|
|
875
|
+
const rule = contract[node.name];
|
|
876
|
+
if (rule)
|
|
877
|
+
checkChildren(node, rule, file, findings);
|
|
878
|
+
for (const c of node.children)
|
|
879
|
+
walk(c);
|
|
880
|
+
};
|
|
881
|
+
for (const c of root.children)
|
|
882
|
+
walk(c);
|
|
883
|
+
checkPackageLayout(file, findings);
|
|
884
|
+
return {
|
|
885
|
+
version: LINT_VERSION,
|
|
886
|
+
file,
|
|
887
|
+
kind: "design-context",
|
|
888
|
+
ok: !findings.some((f) => f.severity === "error"),
|
|
889
|
+
findings,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
// END_BLOCK_DESIGN_CONTEXT_RULES
|
|
893
|
+
// START_BLOCK_LINT_ENTRY
|
|
894
|
+
/**
|
|
895
|
+
* Lint a set of spec-package artifacts together. Plans resolve their linked
|
|
896
|
+
* spec among the provided inputs by path or basename for cross-file subset
|
|
897
|
+
* checks; a plan without its spec yields a warning, not an error.
|
|
898
|
+
*/
|
|
899
|
+
export function lintSpecArtifacts(inputs, options = {}) {
|
|
900
|
+
// Parse everything first so spec facts exist for plan cross-file rules.
|
|
901
|
+
const parsed = inputs.map((input) => ({ input, ...parseSpecXml(input.content, input.file) }));
|
|
902
|
+
const specByLabel = new Map();
|
|
903
|
+
for (const p of parsed) {
|
|
904
|
+
if (p.root && detectSpecArtifactKind(p.root.name) === "spec") {
|
|
905
|
+
specByLabel.set(p.input.file.replace(/\\/g, "/"), collectSpecFacts(p.root));
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const verdicts = [];
|
|
909
|
+
for (const p of parsed) {
|
|
910
|
+
if (!p.root) {
|
|
911
|
+
verdicts.push({
|
|
912
|
+
version: LINT_VERSION,
|
|
913
|
+
file: p.input.file,
|
|
914
|
+
kind: "unknown",
|
|
915
|
+
ok: false,
|
|
916
|
+
findings: p.findings.length
|
|
917
|
+
? p.findings
|
|
918
|
+
: [
|
|
919
|
+
{
|
|
920
|
+
severity: "error",
|
|
921
|
+
rule: "xml.empty",
|
|
922
|
+
message: "document has no root element",
|
|
923
|
+
file: p.input.file,
|
|
924
|
+
line: 1,
|
|
925
|
+
},
|
|
926
|
+
],
|
|
927
|
+
});
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const kind = detectSpecArtifactKind(p.root.name);
|
|
931
|
+
switch (kind) {
|
|
932
|
+
case "spec":
|
|
933
|
+
verdicts.push(lintSpec(p.root, p.input.file, p.findings));
|
|
934
|
+
break;
|
|
935
|
+
case "plan":
|
|
936
|
+
verdicts.push(lintPlan(p.root, p.input.file, p.findings, specByLabel, options));
|
|
937
|
+
break;
|
|
938
|
+
case "design-context":
|
|
939
|
+
verdicts.push(lintDesignContext(p.root, p.input.file, p.findings));
|
|
940
|
+
break;
|
|
941
|
+
default:
|
|
942
|
+
verdicts.push({
|
|
943
|
+
version: LINT_VERSION,
|
|
944
|
+
file: p.input.file,
|
|
945
|
+
kind: "unknown",
|
|
946
|
+
ok: false,
|
|
947
|
+
findings: [
|
|
948
|
+
...p.findings,
|
|
949
|
+
{
|
|
950
|
+
severity: "error",
|
|
951
|
+
rule: "element.root",
|
|
952
|
+
message: `root element <${p.root.name}> is not one of spec, plan, design-context`,
|
|
953
|
+
file: p.input.file,
|
|
954
|
+
line: p.root.line,
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return verdicts;
|
|
961
|
+
}
|
|
962
|
+
// END_BLOCK_LINT_ENTRY
|
|
963
|
+
//# sourceMappingURL=spec-lint.js.map
|