@osovv/vv-opencode 1.4.5 → 1.5.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/CHANGELOG.md +14 -0
- package/README.md +8 -8
- package/dist/commands/patch-provider.d.ts +5 -5
- package/dist/commands/patch-provider.js +10 -6
- package/dist/commands/patch-provider.js.map +1 -1
- package/dist/lib/opencode/agent-registrations.d.ts +33 -0
- package/dist/lib/opencode/agent-registrations.js +469 -0
- package/dist/lib/opencode/agent-registrations.js.map +1 -0
- package/dist/lib/opencode/inspection.d.ts +84 -0
- package/dist/lib/opencode/inspection.js +367 -0
- package/dist/lib/opencode/inspection.js.map +1 -0
- package/dist/lib/opencode/model-overrides.d.ts +19 -0
- package/dist/lib/opencode/model-overrides.js +221 -0
- package/dist/lib/opencode/model-overrides.js.map +1 -0
- package/dist/lib/opencode/paths.d.ts +21 -0
- package/dist/lib/opencode/paths.js +62 -0
- package/dist/lib/opencode/paths.js.map +1 -0
- package/dist/lib/opencode/plugin-registration.d.ts +17 -0
- package/dist/lib/opencode/plugin-registration.js +210 -0
- package/dist/lib/opencode/plugin-registration.js.map +1 -0
- package/dist/lib/opencode/shared-utils.d.ts +30 -0
- package/dist/lib/opencode/shared-utils.js +183 -0
- package/dist/lib/opencode/shared-utils.js.map +1 -0
- package/dist/lib/opencode/vvoc-config-io.d.ts +9 -0
- package/dist/lib/opencode/vvoc-config-io.js +58 -0
- package/dist/lib/opencode/vvoc-config-io.js.map +1 -0
- package/dist/lib/opencode.d.ts +15 -174
- package/dist/lib/opencode.js +19 -1387
- package/dist/lib/opencode.js.map +1 -1
- package/dist/lib/spec-lint/cross-file-rules.d.ts +2 -0
- package/dist/lib/spec-lint/cross-file-rules.js +33 -0
- package/dist/lib/spec-lint/cross-file-rules.js.map +1 -0
- package/dist/lib/spec-lint/delegated-extraction.d.ts +8 -0
- package/dist/lib/spec-lint/delegated-extraction.js +112 -0
- package/dist/lib/spec-lint/delegated-extraction.js.map +1 -0
- package/dist/lib/spec-lint/lint.d.ts +7 -0
- package/dist/lib/spec-lint/lint.js +92 -0
- package/dist/lib/spec-lint/lint.js.map +1 -0
- package/dist/lib/spec-lint/parser.d.ts +101 -0
- package/dist/lib/spec-lint/parser.js +482 -0
- package/dist/lib/spec-lint/parser.js.map +1 -0
- package/dist/lib/spec-lint/plan-rules.d.ts +38 -0
- package/dist/lib/spec-lint/plan-rules.js +745 -0
- package/dist/lib/spec-lint/plan-rules.js.map +1 -0
- package/dist/lib/spec-lint/spec-rules.d.ts +8 -0
- package/dist/lib/spec-lint/spec-rules.js +188 -0
- package/dist/lib/spec-lint/spec-rules.js.map +1 -0
- package/dist/lib/spec-lint.d.ts +4 -63
- package/dist/lib/spec-lint.js +13 -1505
- package/dist/lib/spec-lint.js.map +1 -1
- package/dist/lib/vvoc-preset-registry.d.ts +7 -7
- package/dist/lib/vvoc-preset-registry.js +8 -8
- package/dist/lib/vvoc-preset-registry.js.map +1 -1
- package/dist/plugins/workflow/authorization.d.ts +30 -0
- package/dist/plugins/workflow/authorization.js +139 -0
- package/dist/plugins/workflow/authorization.js.map +1 -0
- package/dist/plugins/workflow/index.js +26 -329
- package/dist/plugins/workflow/index.js.map +1 -1
- package/dist/plugins/workflow/recovery.d.ts +17 -0
- package/dist/plugins/workflow/recovery.js +150 -0
- package/dist/plugins/workflow/recovery.js.map +1 -0
- package/dist/plugins/workflow/schemas.d.ts +208 -0
- package/dist/plugins/workflow/schemas.js +138 -0
- package/dist/plugins/workflow/schemas.js.map +1 -0
- package/dist/plugins/workflow/tooling.d.ts +10 -65
- package/dist/plugins/workflow/tooling.js +5 -5
- package/dist/plugins/workflow/tooling.js.map +1 -1
- package/package.json +1 -1
- package/schemas/vvoc/v3.json +1 -1
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
// FILE: src/lib/spec-lint/plan-rules.ts
|
|
2
|
+
// VERSION: 1.0.0
|
|
3
|
+
// START_MODULE_CONTRACT
|
|
4
|
+
// PURPOSE: Plan artifact rules: template compliance, task/wave identity, delegated execution-section and checkpoint validation, and delegated fact collection.
|
|
5
|
+
// SCOPE: lintPlan over the positioned tree (status, architecture mirror, waves and TASK-T-NNN tasks, dependencies, snippet CDATA, lifecycle completeness, cross-file subset hookup), collectDelegatedTaskFacts shared with delegated extraction, lintExecutionSection with write-scope, checkpoint identity/coverage/scope/reviewer, final-coverage and wave-barrier rules, and lintDelegatedWriteScopes obligations.
|
|
6
|
+
// DEPENDS: [src/lib/workflow-contract.ts, src/lib/spec-lint/parser.ts]
|
|
7
|
+
// LINKS: [M-SPEC-LINT, M-WORKFLOW-CONTRACT]
|
|
8
|
+
// ROLE: RUNTIME
|
|
9
|
+
// MAP_MODE: EXPORTS
|
|
10
|
+
// END_MODULE_CONTRACT
|
|
11
|
+
//
|
|
12
|
+
// START_MODULE_MAP
|
|
13
|
+
// DelegatedTaskFactsEntry - Per-task delegated facts collected for lint and extraction.
|
|
14
|
+
// DelegatedTaskFacts - Wave order plus task facts plus duplicate element list.
|
|
15
|
+
// collectDelegatedTaskFacts - Shared walk interpreting declared tasks and write scopes.
|
|
16
|
+
// lintDelegatedWriteScopes - Write-scope obligations for delegated tasks.
|
|
17
|
+
// lintExecutionSection - Execution mode and review-checkpoint validation.
|
|
18
|
+
// lintPlan - Plan template, identity, reference, and lifecycle rules.
|
|
19
|
+
// END_MODULE_MAP
|
|
20
|
+
//
|
|
21
|
+
// START_CHANGE_SUMMARY
|
|
22
|
+
// LAST_CHANGE: [C-SPEC-LINT-RULES-SPLIT-R1 - Extracted plan rules, delegated fact collection, and execution-section validation from the former src/lib/spec-lint.ts monolith into this zone module.]
|
|
23
|
+
// END_CHANGE_SUMMARY
|
|
24
|
+
import { normalizeDeclaredScopePath } from "../workflow-contract.js";
|
|
25
|
+
import { ACCEPTANCE_CHILDREN, CHECKPOINT_CHILDREN, CHECKPOINT_COVERS_CHILDREN, CHECKPOINT_KINDS, CHECKPOINT_REVIEWERS_CHILDREN, CHECKPOINT_SCOPE_CHILDREN, checkChildren, checkPackageLayout, child, children, DELEGATED_REVIEWERS, DOC_STATUSES, EXECUTION_MODES, IDENTITY_PATTERNS, LINT_VERSION, nonEmpty, PLAN_CONTRACT, PLAN_FILE_CHILDREN, requiresCompleteness, TASK_CHILDREN, TASK_DEPENDS_CHILDREN, TASK_ID_REF, TASK_STATUSES, TASK_WRITE_SCOPE_CHILDREN, textOf, VERIFICATION_CHILDREN, WAVE_CHILDREN, } from "./parser.js";
|
|
26
|
+
import { resolveSpecFacts } from "./cross-file-rules.js";
|
|
27
|
+
/**
|
|
28
|
+
* Collect wave order and per-task delegated facts with a standalone walk so
|
|
29
|
+
* lintPlan's main task validation and extractDelegatedPlanDefinition share one
|
|
30
|
+
* interpretation of the declared task/write-scope vocabulary.
|
|
31
|
+
*/
|
|
32
|
+
export function collectDelegatedTaskFacts(root) {
|
|
33
|
+
const waveOrder = [];
|
|
34
|
+
const tasks = [];
|
|
35
|
+
const duplicateTaskElements = [];
|
|
36
|
+
const seenTasks = new Set();
|
|
37
|
+
const tasksNode = child(root, "tasks");
|
|
38
|
+
if (!tasksNode)
|
|
39
|
+
return { waveOrder, tasks, duplicateTaskElements };
|
|
40
|
+
for (const wave of tasksNode.children) {
|
|
41
|
+
if (!IDENTITY_PATTERNS.wave.test(wave.name))
|
|
42
|
+
continue;
|
|
43
|
+
if (!waveOrder.includes(wave.name))
|
|
44
|
+
waveOrder.push(wave.name);
|
|
45
|
+
for (const task of wave.children) {
|
|
46
|
+
if (!IDENTITY_PATTERNS.task.test(task.name))
|
|
47
|
+
continue;
|
|
48
|
+
if (seenTasks.has(task.name)) {
|
|
49
|
+
duplicateTaskElements.push(task.name);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
seenTasks.add(task.name);
|
|
53
|
+
const writeScopeNode = child(task, "write_scope");
|
|
54
|
+
const writeScope = [];
|
|
55
|
+
const writeScopeDuplicates = [];
|
|
56
|
+
if (writeScopeNode) {
|
|
57
|
+
for (const fileNode of writeScopeNode.children) {
|
|
58
|
+
if (fileNode.name !== "file")
|
|
59
|
+
continue;
|
|
60
|
+
const normalized = normalizeDeclaredScopePath(textOf(fileNode));
|
|
61
|
+
if (!normalized.ok)
|
|
62
|
+
continue;
|
|
63
|
+
if (writeScope.includes(normalized.path)) {
|
|
64
|
+
// Duplicate write-scope entries are rejected by the lint rules;
|
|
65
|
+
// here the duplicate is recorded so the rule can point at it.
|
|
66
|
+
if (!writeScopeDuplicates.includes(normalized.path)) {
|
|
67
|
+
writeScopeDuplicates.push(normalized.path);
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
writeScope.push(normalized.path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
tasks.push({
|
|
75
|
+
taskElement: task.name,
|
|
76
|
+
taskId: task.name.slice("TASK-".length),
|
|
77
|
+
wave: wave.name,
|
|
78
|
+
line: task.line,
|
|
79
|
+
writeScope,
|
|
80
|
+
writeScopeDeclared: writeScopeNode !== undefined,
|
|
81
|
+
writeScopeDuplicates,
|
|
82
|
+
primaryFile: (() => {
|
|
83
|
+
const normalized = normalizeDeclaredScopePath(textOf(child(task, "file")));
|
|
84
|
+
return normalized.ok ? normalized.path : "";
|
|
85
|
+
})(),
|
|
86
|
+
title: textOf(child(task, "title")).trim(),
|
|
87
|
+
acceptance: (() => {
|
|
88
|
+
const acceptanceNode = child(task, "acceptance");
|
|
89
|
+
return acceptanceNode
|
|
90
|
+
? children(acceptanceNode, "criterion")
|
|
91
|
+
.map((entry) => textOf(entry).trim())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
: [];
|
|
94
|
+
})(),
|
|
95
|
+
verification: (() => {
|
|
96
|
+
const verificationNode = child(task, "verification");
|
|
97
|
+
return verificationNode
|
|
98
|
+
? children(verificationNode, "command")
|
|
99
|
+
.map((entry) => textOf(entry).trim())
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
: [];
|
|
102
|
+
})(),
|
|
103
|
+
dependsOn: (() => {
|
|
104
|
+
const dependsNode = child(task, "depends_on");
|
|
105
|
+
return dependsNode
|
|
106
|
+
? children(dependsNode, "task_id")
|
|
107
|
+
.map((entry) => textOf(entry).trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
: [];
|
|
110
|
+
})(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { waveOrder, tasks, duplicateTaskElements };
|
|
115
|
+
}
|
|
116
|
+
// END_BLOCK_DELEGATED_FACTS
|
|
117
|
+
// START_BLOCK_DELEGATED_VALIDATION
|
|
118
|
+
/** Emit write-scope obligations for every declared delegated task. */
|
|
119
|
+
export function lintDelegatedWriteScopes(facts, completeness, findings, file) {
|
|
120
|
+
for (const task of facts.tasks) {
|
|
121
|
+
if (task.writeScopeDuplicates.length > 0) {
|
|
122
|
+
findings.push({
|
|
123
|
+
severity: "error",
|
|
124
|
+
rule: "execution.scope_path",
|
|
125
|
+
message: `task ${task.taskId} declares write_scope path ${JSON.stringify(task.writeScopeDuplicates[0])} more than once; the runtime normalizer rejects duplicate scope entries`,
|
|
126
|
+
file,
|
|
127
|
+
line: task.line,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!task.writeScopeDeclared || task.writeScope.length === 0) {
|
|
132
|
+
findings.push({
|
|
133
|
+
severity: completeness ? "error" : "warning",
|
|
134
|
+
rule: "lifecycle.required",
|
|
135
|
+
message: `task ${task.taskId} declares no write_scope; delegated tasks must list their workspace-relative write scope`,
|
|
136
|
+
file,
|
|
137
|
+
line: task.line,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (task.primaryFile && !task.writeScope.includes(task.primaryFile)) {
|
|
142
|
+
findings.push({
|
|
143
|
+
severity: "error",
|
|
144
|
+
rule: "execution.write_scope",
|
|
145
|
+
message: `task ${task.taskId} write_scope does not include its primary file ${task.primaryFile}`,
|
|
146
|
+
file,
|
|
147
|
+
line: task.line,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Validate the optional <execution> section. Structural violations of declared
|
|
154
|
+
* content (unknown modes/children, duplicate identities, dangling references,
|
|
155
|
+
* future-wave coverage, multiple or misplaced finals, incomplete declared
|
|
156
|
+
* coverage) are always errors; presence obligations for approved/applied plans
|
|
157
|
+
* escalate from draft warnings.
|
|
158
|
+
*/
|
|
159
|
+
export function lintExecutionSection(root, file, facts, completeness, findings) {
|
|
160
|
+
const executionNode = child(root, "execution");
|
|
161
|
+
if (!executionNode)
|
|
162
|
+
return;
|
|
163
|
+
checkChildren(executionNode, PLAN_CONTRACT.execution, file, findings);
|
|
164
|
+
const mode = textOf(child(executionNode, "mode"));
|
|
165
|
+
if (!EXECUTION_MODES.has(mode)) {
|
|
166
|
+
findings.push({
|
|
167
|
+
severity: "error",
|
|
168
|
+
rule: "execution.mode",
|
|
169
|
+
message: `execution mode "${mode}" is not one of inline, classic, delegated`,
|
|
170
|
+
file,
|
|
171
|
+
line: child(executionNode, "mode")?.line ?? executionNode.line,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const reviewCheckpointsNode = child(executionNode, "review_checkpoints");
|
|
175
|
+
if (reviewCheckpointsNode && mode !== "delegated") {
|
|
176
|
+
findings.push({
|
|
177
|
+
severity: "error",
|
|
178
|
+
rule: "execution.checkpoints_mode",
|
|
179
|
+
message: `review_checkpoints are only valid with execution mode delegated, not "${mode}"`,
|
|
180
|
+
file,
|
|
181
|
+
line: reviewCheckpointsNode.line,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (mode !== "delegated")
|
|
186
|
+
return;
|
|
187
|
+
if (!reviewCheckpointsNode) {
|
|
188
|
+
findings.push({
|
|
189
|
+
severity: completeness ? "error" : "warning",
|
|
190
|
+
rule: "lifecycle.required",
|
|
191
|
+
message: `delegated execution declares no review_checkpoints; the plan is ${completeness ? "approved or applied" : "draft"} and must declare its checkpoint obligations`,
|
|
192
|
+
file,
|
|
193
|
+
line: executionNode.line,
|
|
194
|
+
});
|
|
195
|
+
// Task write-scope obligations still apply even without declared checkpoints.
|
|
196
|
+
lintDelegatedWriteScopes(facts, completeness, findings, file);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
checkChildren(reviewCheckpointsNode, PLAN_CONTRACT.review_checkpoints, file, findings);
|
|
200
|
+
const obligation = (message, line, rule = "lifecycle.required") => {
|
|
201
|
+
findings.push({
|
|
202
|
+
severity: completeness ? "error" : "warning",
|
|
203
|
+
rule,
|
|
204
|
+
message,
|
|
205
|
+
file,
|
|
206
|
+
line,
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
const waveIndex = new Map(facts.waveOrder.map((wave, index) => [wave, index]));
|
|
210
|
+
const taskById = new Map(facts.tasks.map((task) => [task.taskId, task]));
|
|
211
|
+
const seenCheckpoints = new Set();
|
|
212
|
+
const validCheckpoints = [];
|
|
213
|
+
for (const checkpoint of reviewCheckpointsNode.children) {
|
|
214
|
+
if (!IDENTITY_PATTERNS.checkpoint.test(checkpoint.name)) {
|
|
215
|
+
findings.push({
|
|
216
|
+
severity: "error",
|
|
217
|
+
rule: "identity.pattern",
|
|
218
|
+
message: `checkpoint element <${checkpoint.name}> does not match the CHECKPOINT-R-NNN pattern`,
|
|
219
|
+
file,
|
|
220
|
+
line: checkpoint.line,
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (seenCheckpoints.has(checkpoint.name)) {
|
|
225
|
+
findings.push({
|
|
226
|
+
severity: "error",
|
|
227
|
+
rule: "identity.duplicate",
|
|
228
|
+
message: `checkpoint ${checkpoint.name} is declared more than once`,
|
|
229
|
+
file,
|
|
230
|
+
line: checkpoint.line,
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
seenCheckpoints.add(checkpoint.name);
|
|
235
|
+
checkChildren(checkpoint, { names: CHECKPOINT_CHILDREN }, file, findings);
|
|
236
|
+
const kind = textOf(child(checkpoint, "kind"));
|
|
237
|
+
if (!CHECKPOINT_KINDS.has(kind)) {
|
|
238
|
+
findings.push({
|
|
239
|
+
severity: "error",
|
|
240
|
+
rule: "execution.checkpoint_kind",
|
|
241
|
+
message: `checkpoint ${checkpoint.name} kind "${kind}" is not one of milestone, final`,
|
|
242
|
+
file,
|
|
243
|
+
line: child(checkpoint, "kind")?.line ?? checkpoint.line,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const afterWaveNode = child(checkpoint, "after_wave");
|
|
247
|
+
const afterWave = textOf(afterWaveNode);
|
|
248
|
+
let afterWaveKnown = false;
|
|
249
|
+
if (!IDENTITY_PATTERNS.wave.test(afterWave) || !waveIndex.has(afterWave)) {
|
|
250
|
+
findings.push({
|
|
251
|
+
severity: "error",
|
|
252
|
+
rule: "ref.dangling",
|
|
253
|
+
message: `checkpoint ${checkpoint.name} after_wave "${afterWave}" is not a declared wave in this plan`,
|
|
254
|
+
file,
|
|
255
|
+
line: afterWaveNode?.line ?? checkpoint.line,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
afterWaveKnown = true;
|
|
260
|
+
}
|
|
261
|
+
const coversNode = child(checkpoint, "covers");
|
|
262
|
+
const covers = [];
|
|
263
|
+
if (coversNode) {
|
|
264
|
+
checkChildren(coversNode, { names: CHECKPOINT_COVERS_CHILDREN }, file, findings);
|
|
265
|
+
for (const taskRef of coversNode.children) {
|
|
266
|
+
if (taskRef.name !== "task_id")
|
|
267
|
+
continue;
|
|
268
|
+
const value = textOf(taskRef);
|
|
269
|
+
if (!TASK_ID_REF.test(value) || !taskById.has(value)) {
|
|
270
|
+
findings.push({
|
|
271
|
+
severity: "error",
|
|
272
|
+
rule: "ref.dangling",
|
|
273
|
+
message: `checkpoint ${checkpoint.name} covers task_id "${value}" which is not a declared task in this plan`,
|
|
274
|
+
file,
|
|
275
|
+
line: taskRef.line,
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
covers.push(value);
|
|
280
|
+
const coveredTask = taskById.get(value);
|
|
281
|
+
if (kind === "milestone" &&
|
|
282
|
+
afterWaveKnown &&
|
|
283
|
+
coveredTask &&
|
|
284
|
+
(waveIndex.get(coveredTask.wave) ?? -1) > (waveIndex.get(afterWave) ?? -1)) {
|
|
285
|
+
findings.push({
|
|
286
|
+
severity: "error",
|
|
287
|
+
rule: "execution.future_coverage",
|
|
288
|
+
message: `checkpoint ${checkpoint.name} (milestone after ${afterWave}) covers ${value} from a later wave ${coveredTask.wave}`,
|
|
289
|
+
file,
|
|
290
|
+
line: taskRef.line,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const scopeNode = child(checkpoint, "scope");
|
|
296
|
+
const scope = [];
|
|
297
|
+
if (scopeNode) {
|
|
298
|
+
checkChildren(scopeNode, { names: CHECKPOINT_SCOPE_CHILDREN }, file, findings);
|
|
299
|
+
for (const fileNode of scopeNode.children) {
|
|
300
|
+
if (fileNode.name !== "file")
|
|
301
|
+
continue;
|
|
302
|
+
const normalized = normalizeDeclaredScopePath(textOf(fileNode));
|
|
303
|
+
if (!normalized.ok) {
|
|
304
|
+
findings.push({
|
|
305
|
+
severity: "error",
|
|
306
|
+
rule: "execution.scope_path",
|
|
307
|
+
message: `checkpoint ${checkpoint.name} declares a malformed scope path (${normalized.reason}): ${JSON.stringify(textOf(fileNode))}`,
|
|
308
|
+
file,
|
|
309
|
+
line: fileNode.line,
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (scope.includes(normalized.path)) {
|
|
314
|
+
findings.push({
|
|
315
|
+
severity: "error",
|
|
316
|
+
rule: "execution.scope_path",
|
|
317
|
+
message: `checkpoint ${checkpoint.name} declares scope path ${JSON.stringify(normalized.path)} more than once; the runtime normalizer rejects duplicate scope entries`,
|
|
318
|
+
file,
|
|
319
|
+
line: fileNode.line,
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
scope.push(normalized.path);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const reviewersNode = child(checkpoint, "reviewers");
|
|
327
|
+
const reviewers = [];
|
|
328
|
+
if (reviewersNode) {
|
|
329
|
+
checkChildren(reviewersNode, { names: CHECKPOINT_REVIEWERS_CHILDREN }, file, findings);
|
|
330
|
+
for (const reviewerNode of reviewersNode.children) {
|
|
331
|
+
if (reviewerNode.name !== "reviewer")
|
|
332
|
+
continue;
|
|
333
|
+
const value = textOf(reviewerNode);
|
|
334
|
+
if (!DELEGATED_REVIEWERS.has(value)) {
|
|
335
|
+
findings.push({
|
|
336
|
+
severity: "error",
|
|
337
|
+
rule: "execution.reviewer",
|
|
338
|
+
message: `checkpoint ${checkpoint.name} reviewer "${value}" is not one of spec, code`,
|
|
339
|
+
file,
|
|
340
|
+
line: reviewerNode.line,
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (reviewers.includes(value)) {
|
|
345
|
+
findings.push({
|
|
346
|
+
severity: "error",
|
|
347
|
+
rule: "execution.reviewer_duplicate",
|
|
348
|
+
message: `checkpoint ${checkpoint.name} declares reviewer "${value}" more than once`,
|
|
349
|
+
file,
|
|
350
|
+
line: reviewerNode.line,
|
|
351
|
+
});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
reviewers.push(value);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const acceptanceNode = child(checkpoint, "acceptance");
|
|
358
|
+
if (acceptanceNode)
|
|
359
|
+
checkChildren(acceptanceNode, { names: ACCEPTANCE_CHILDREN }, file, findings);
|
|
360
|
+
const verificationNode = child(checkpoint, "verification");
|
|
361
|
+
if (verificationNode)
|
|
362
|
+
checkChildren(verificationNode, { names: VERIFICATION_CHILDREN }, file, findings);
|
|
363
|
+
if (covers.length === 0) {
|
|
364
|
+
obligation(`checkpoint ${checkpoint.name} covers no tasks; delegated checkpoints must list covered task_ids`, coversNode?.line ?? checkpoint.line);
|
|
365
|
+
}
|
|
366
|
+
if (scope.length === 0) {
|
|
367
|
+
obligation(`checkpoint ${checkpoint.name} declares an empty scope; delegated checkpoints must list reviewed files`, scopeNode?.line ?? checkpoint.line);
|
|
368
|
+
}
|
|
369
|
+
if (reviewers.length === 0) {
|
|
370
|
+
obligation(`checkpoint ${checkpoint.name} declares no reviewers; delegated checkpoints require a non-empty spec/code reviewer set`, reviewersNode?.line ?? checkpoint.line);
|
|
371
|
+
}
|
|
372
|
+
if (!acceptanceNode || children(acceptanceNode, "criterion").length === 0) {
|
|
373
|
+
obligation(`checkpoint ${checkpoint.name} has no acceptance criteria`, acceptanceNode?.line ?? checkpoint.line);
|
|
374
|
+
}
|
|
375
|
+
if (!verificationNode || children(verificationNode, "command").length === 0) {
|
|
376
|
+
obligation(`checkpoint ${checkpoint.name} has no verification commands`, verificationNode?.line ?? checkpoint.line);
|
|
377
|
+
}
|
|
378
|
+
validCheckpoints.push({
|
|
379
|
+
node: checkpoint,
|
|
380
|
+
id: checkpoint.name,
|
|
381
|
+
kind,
|
|
382
|
+
afterWave,
|
|
383
|
+
covers,
|
|
384
|
+
scope,
|
|
385
|
+
reviewers,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
// Delegated task write-scope obligations.
|
|
389
|
+
lintDelegatedWriteScopes(facts, completeness, findings, file);
|
|
390
|
+
const finals = validCheckpoints.filter((c) => c.kind === "final");
|
|
391
|
+
if (finals.length > 1) {
|
|
392
|
+
for (const final of finals.slice(1)) {
|
|
393
|
+
findings.push({
|
|
394
|
+
severity: "error",
|
|
395
|
+
rule: "execution.final_count",
|
|
396
|
+
message: `checkpoint ${final.id} is an additional final checkpoint; exactly one final checkpoint is allowed`,
|
|
397
|
+
file,
|
|
398
|
+
line: final.node.line,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const declaredCheckpointCount = reviewCheckpointsNode.children.filter((c) => IDENTITY_PATTERNS.checkpoint.test(c.name)).length;
|
|
403
|
+
if (finals.length === 0 && declaredCheckpointCount > 0) {
|
|
404
|
+
obligation("delegated execution declares no final checkpoint; exactly one final checkpoint is required", reviewCheckpointsNode.line, "execution.final_count");
|
|
405
|
+
}
|
|
406
|
+
if (finals.length === 1) {
|
|
407
|
+
const finalCheckpoint = finals[0];
|
|
408
|
+
const lastWave = facts.waveOrder[facts.waveOrder.length - 1];
|
|
409
|
+
if (lastWave &&
|
|
410
|
+
finalCheckpoint.afterWave !== lastWave &&
|
|
411
|
+
waveIndex.has(finalCheckpoint.afterWave)) {
|
|
412
|
+
findings.push({
|
|
413
|
+
severity: "error",
|
|
414
|
+
rule: "execution.final_wave",
|
|
415
|
+
message: `final checkpoint ${finalCheckpoint.id} must sit after the last declared wave ${lastWave}, not ${finalCheckpoint.afterWave}`,
|
|
416
|
+
file,
|
|
417
|
+
line: finalCheckpoint.node.line,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
for (const task of facts.tasks) {
|
|
421
|
+
if (!finalCheckpoint.covers.includes(task.taskId)) {
|
|
422
|
+
findings.push({
|
|
423
|
+
severity: "error",
|
|
424
|
+
rule: "execution.final_coverage",
|
|
425
|
+
message: `final checkpoint ${finalCheckpoint.id} does not cover task ${task.taskId}; the final checkpoint must cover every declared task`,
|
|
426
|
+
file,
|
|
427
|
+
line: finalCheckpoint.node.line,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const declaredWriteScopes = new Set(facts.tasks.flatMap((task) => task.writeScope));
|
|
432
|
+
for (const scopeFile of declaredWriteScopes) {
|
|
433
|
+
if (!finalCheckpoint.scope.includes(scopeFile)) {
|
|
434
|
+
findings.push({
|
|
435
|
+
severity: "error",
|
|
436
|
+
rule: "execution.final_scope",
|
|
437
|
+
message: `final checkpoint ${finalCheckpoint.id} scope does not cover declared task write scope file ${scopeFile}`,
|
|
438
|
+
file,
|
|
439
|
+
line: finalCheckpoint.node.line,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// END_BLOCK_DELEGATED_VALIDATION
|
|
446
|
+
// START_BLOCK_PLAN_RULES
|
|
447
|
+
export function lintPlan(root, file, parseFindings, specByLabel, options) {
|
|
448
|
+
const findings = [...parseFindings];
|
|
449
|
+
checkChildren(root, PLAN_CONTRACT.plan, file, findings);
|
|
450
|
+
const statusNode = child(root, "status");
|
|
451
|
+
const status = textOf(statusNode);
|
|
452
|
+
if (!DOC_STATUSES.has(status)) {
|
|
453
|
+
findings.push({
|
|
454
|
+
severity: "error",
|
|
455
|
+
rule: "lifecycle.status",
|
|
456
|
+
message: `plan status "${status}" is not one of draft, approved, applied`,
|
|
457
|
+
file,
|
|
458
|
+
line: statusNode?.line ?? root.line,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const completeness = requiresCompleteness(root);
|
|
462
|
+
// Cross-file facts are resolved early so architecture depends_on may reference
|
|
463
|
+
// spec components that the plan does not touch.
|
|
464
|
+
const specPathNodeEarly = child(root, "spec");
|
|
465
|
+
const specFacts = options.skipCrossFile
|
|
466
|
+
? undefined
|
|
467
|
+
: resolveSpecFacts(textOf(specPathNodeEarly), specByLabel);
|
|
468
|
+
// Architecture: component identity elements mirroring the spec.
|
|
469
|
+
const architectureNode = child(root, "architecture");
|
|
470
|
+
const componentSlugs = [];
|
|
471
|
+
if (architectureNode) {
|
|
472
|
+
checkChildren(architectureNode, PLAN_CONTRACT.architecture, file, findings);
|
|
473
|
+
const seen = new Set();
|
|
474
|
+
for (const c of architectureNode.children) {
|
|
475
|
+
if (!IDENTITY_PATTERNS.component.test(c.name)) {
|
|
476
|
+
findings.push({
|
|
477
|
+
severity: "error",
|
|
478
|
+
rule: "identity.pattern",
|
|
479
|
+
message: `architecture element <${c.name}> does not match the COMPONENT-UPPER-SLUG pattern mirrored from spec.xml`,
|
|
480
|
+
file,
|
|
481
|
+
line: c.line,
|
|
482
|
+
});
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (seen.has(c.name)) {
|
|
486
|
+
findings.push({
|
|
487
|
+
severity: "error",
|
|
488
|
+
rule: "identity.duplicate",
|
|
489
|
+
message: `architecture component ${c.name} is declared more than once`,
|
|
490
|
+
file,
|
|
491
|
+
line: c.line,
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
seen.add(c.name);
|
|
496
|
+
componentSlugs.push(c.name);
|
|
497
|
+
checkChildren(c, { names: ["name", "purpose", "file", "contract", "depends_on"] }, file, findings);
|
|
498
|
+
const fileNode = child(c, "file");
|
|
499
|
+
if (fileNode)
|
|
500
|
+
checkChildren(fileNode, { names: PLAN_FILE_CHILDREN }, file, findings);
|
|
501
|
+
}
|
|
502
|
+
for (const c of architectureNode.children) {
|
|
503
|
+
for (const dep of children(c, "depends_on")) {
|
|
504
|
+
const ref = textOf(dep);
|
|
505
|
+
if (!ref)
|
|
506
|
+
continue;
|
|
507
|
+
const target = ref.startsWith("COMPONENT-") ? ref : `COMPONENT-${ref}`;
|
|
508
|
+
const knownLocally = seen.has(target);
|
|
509
|
+
const knownInSpec = specFacts?.componentSlugs.includes(target) ?? false;
|
|
510
|
+
// Without a resolved spec, non-local references stay unverifiable: the
|
|
511
|
+
// crossfile.spec_missing warning already reports the skipped checks.
|
|
512
|
+
if (specFacts && !knownLocally && !knownInSpec) {
|
|
513
|
+
findings.push({
|
|
514
|
+
severity: "error",
|
|
515
|
+
rule: "ref.dangling",
|
|
516
|
+
message: `architecture depends_on references "${ref}" which is declared neither in this plan's architecture nor in the linked spec`,
|
|
517
|
+
file,
|
|
518
|
+
line: dep.line,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Tasks: waves with TASK-T-NNN identity elements.
|
|
525
|
+
const tasksNode = child(root, "tasks");
|
|
526
|
+
const taskIds = new Set();
|
|
527
|
+
if (tasksNode) {
|
|
528
|
+
checkChildren(tasksNode, PLAN_CONTRACT.tasks, file, findings);
|
|
529
|
+
for (const wave of tasksNode.children) {
|
|
530
|
+
if (!IDENTITY_PATTERNS.wave.test(wave.name)) {
|
|
531
|
+
findings.push({
|
|
532
|
+
severity: "error",
|
|
533
|
+
rule: "identity.pattern",
|
|
534
|
+
message: `wave element <${wave.name}> does not match the WAVE-N pattern`,
|
|
535
|
+
file,
|
|
536
|
+
line: wave.line,
|
|
537
|
+
});
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
checkChildren(wave, { names: WAVE_CHILDREN, identity: "task" }, file, findings);
|
|
541
|
+
}
|
|
542
|
+
for (const wave of tasksNode.children) {
|
|
543
|
+
for (const task of wave.children) {
|
|
544
|
+
if (!IDENTITY_PATTERNS.task.test(task.name)) {
|
|
545
|
+
if (!WAVE_CHILDREN.includes(task.name)) {
|
|
546
|
+
findings.push({
|
|
547
|
+
severity: "error",
|
|
548
|
+
rule: "identity.pattern",
|
|
549
|
+
message: `task element <${task.name}> does not match the TASK-T-NNN pattern`,
|
|
550
|
+
file,
|
|
551
|
+
line: task.line,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (taskIds.has(task.name)) {
|
|
557
|
+
findings.push({
|
|
558
|
+
severity: "error",
|
|
559
|
+
rule: "identity.duplicate",
|
|
560
|
+
message: `task ${task.name} is declared more than once`,
|
|
561
|
+
file,
|
|
562
|
+
line: task.line,
|
|
563
|
+
});
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
taskIds.add(task.name);
|
|
567
|
+
checkChildren(task, { names: TASK_CHILDREN }, file, findings);
|
|
568
|
+
const dependsNode = child(task, "depends_on");
|
|
569
|
+
if (dependsNode)
|
|
570
|
+
checkChildren(dependsNode, { names: TASK_DEPENDS_CHILDREN }, file, findings);
|
|
571
|
+
const acceptanceNode = child(task, "acceptance");
|
|
572
|
+
if (acceptanceNode)
|
|
573
|
+
checkChildren(acceptanceNode, { names: ACCEPTANCE_CHILDREN }, file, findings);
|
|
574
|
+
const verificationNode = child(task, "verification");
|
|
575
|
+
if (verificationNode)
|
|
576
|
+
checkChildren(verificationNode, { names: VERIFICATION_CHILDREN }, file, findings);
|
|
577
|
+
const writeScopeNode = child(task, "write_scope");
|
|
578
|
+
if (writeScopeNode)
|
|
579
|
+
checkChildren(writeScopeNode, { names: TASK_WRITE_SCOPE_CHILDREN }, file, findings);
|
|
580
|
+
const taskStatusNode = child(task, "status");
|
|
581
|
+
const taskStatus = textOf(taskStatusNode);
|
|
582
|
+
if (!TASK_STATUSES.has(taskStatus)) {
|
|
583
|
+
findings.push({
|
|
584
|
+
severity: "error",
|
|
585
|
+
rule: "lifecycle.task_status",
|
|
586
|
+
message: `task ${task.name} status "${taskStatus}" is not one of pending, in_progress, done, skipped`,
|
|
587
|
+
file,
|
|
588
|
+
line: taskStatusNode?.line ?? task.line,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
const snippetNode = child(task, "snippet");
|
|
592
|
+
if (snippetNode && snippetNode.text.trim() !== "" && snippetNode.cdataCount === 0) {
|
|
593
|
+
findings.push({
|
|
594
|
+
severity: "error",
|
|
595
|
+
rule: "snippet.cdata",
|
|
596
|
+
message: `task ${task.name} has a non-empty <snippet> that is not wrapped in CDATA`,
|
|
597
|
+
file,
|
|
598
|
+
line: snippetNode.line,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (completeness) {
|
|
602
|
+
if (!nonEmpty(child(task, "title"))) {
|
|
603
|
+
findings.push({
|
|
604
|
+
severity: "error",
|
|
605
|
+
rule: "lifecycle.required",
|
|
606
|
+
message: `task ${task.name} has an empty <title>; the plan is ${status} and must be complete`,
|
|
607
|
+
file,
|
|
608
|
+
line: task.line,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (!nonEmpty(child(task, "file"))) {
|
|
612
|
+
findings.push({
|
|
613
|
+
severity: "error",
|
|
614
|
+
rule: "lifecycle.required",
|
|
615
|
+
message: `task ${task.name} has an empty <file>; the plan is ${status} and must be complete`,
|
|
616
|
+
file,
|
|
617
|
+
line: task.line,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (!nonEmpty(child(task, "description"))) {
|
|
621
|
+
findings.push({
|
|
622
|
+
severity: "error",
|
|
623
|
+
rule: "lifecycle.required",
|
|
624
|
+
message: `task ${task.name} has an empty <description>; the plan is ${status} and must be complete`,
|
|
625
|
+
file,
|
|
626
|
+
line: task.line,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const acceptance = child(task, "acceptance");
|
|
630
|
+
if (!acceptance || children(acceptance, "criterion").length === 0) {
|
|
631
|
+
findings.push({
|
|
632
|
+
severity: "error",
|
|
633
|
+
rule: "lifecycle.required",
|
|
634
|
+
message: `task ${task.name} has no acceptance criteria; the plan is ${status} and must be complete`,
|
|
635
|
+
file,
|
|
636
|
+
line: acceptance?.line ?? task.line,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const verification = child(task, "verification");
|
|
640
|
+
if (!verification || children(verification, "command").length === 0) {
|
|
641
|
+
findings.push({
|
|
642
|
+
severity: "error",
|
|
643
|
+
rule: "lifecycle.required",
|
|
644
|
+
message: `task ${task.name} has no verification command; the plan is ${status} and must be complete`,
|
|
645
|
+
file,
|
|
646
|
+
line: verification?.line ?? task.line,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
for (const wave of tasksNode.children) {
|
|
653
|
+
for (const task of wave.children) {
|
|
654
|
+
const dependsNode = child(task, "depends_on");
|
|
655
|
+
if (!dependsNode)
|
|
656
|
+
continue;
|
|
657
|
+
for (const ref of children(dependsNode, "task_id")) {
|
|
658
|
+
const value = textOf(ref);
|
|
659
|
+
if (!value)
|
|
660
|
+
continue;
|
|
661
|
+
if (!TASK_ID_REF.test(value) || !taskIds.has(`TASK-${value}`)) {
|
|
662
|
+
findings.push({
|
|
663
|
+
severity: "error",
|
|
664
|
+
rule: "ref.dangling",
|
|
665
|
+
message: `task_id references "${value}" which is not a declared task in this plan`,
|
|
666
|
+
file,
|
|
667
|
+
line: ref.line,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (completeness) {
|
|
675
|
+
const metaNode = child(root, "meta");
|
|
676
|
+
const required = metaNode
|
|
677
|
+
? [
|
|
678
|
+
["meta.summary", child(metaNode, "summary")],
|
|
679
|
+
["meta.waves", child(metaNode, "waves")],
|
|
680
|
+
["meta.affected_modules", child(metaNode, "affected_modules")],
|
|
681
|
+
["meta.complexity", child(metaNode, "complexity")],
|
|
682
|
+
]
|
|
683
|
+
: [];
|
|
684
|
+
for (const [label, node] of required) {
|
|
685
|
+
if (!nonEmpty(node)) {
|
|
686
|
+
findings.push({
|
|
687
|
+
severity: "error",
|
|
688
|
+
rule: "lifecycle.required",
|
|
689
|
+
message: `<${label}> is empty; the plan is ${status} and must be complete`,
|
|
690
|
+
file,
|
|
691
|
+
line: node?.line ?? root.line,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (tasksNode && tasksNode.children.length === 0) {
|
|
696
|
+
findings.push({
|
|
697
|
+
severity: "error",
|
|
698
|
+
rule: "lifecycle.required",
|
|
699
|
+
message: `<tasks> declares no waves; the plan is ${status} and must be complete`,
|
|
700
|
+
file,
|
|
701
|
+
line: tasksNode.line,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Optional execution section: delegated/classic/inline intent and checkpoints.
|
|
706
|
+
lintExecutionSection(root, file, collectDelegatedTaskFacts(root), completeness, findings);
|
|
707
|
+
// Cross-file: plan components are a subset of spec components.
|
|
708
|
+
if (!options.skipCrossFile) {
|
|
709
|
+
const specPath = textOf(specPathNodeEarly);
|
|
710
|
+
if (!specFacts) {
|
|
711
|
+
findings.push({
|
|
712
|
+
severity: "warning",
|
|
713
|
+
rule: "crossfile.spec_missing",
|
|
714
|
+
message: `linked spec "${specPath || "(empty)"}" was not part of this lint run; plan-subset-of-spec checks were skipped`,
|
|
715
|
+
file,
|
|
716
|
+
line: specPathNodeEarly?.line ?? root.line,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
const specSlugs = new Set(specFacts.componentSlugs);
|
|
721
|
+
for (const slug of componentSlugs) {
|
|
722
|
+
if (!specSlugs.has(slug)) {
|
|
723
|
+
const archNode = architectureNode?.children.find((c) => c.name === slug);
|
|
724
|
+
findings.push({
|
|
725
|
+
severity: "error",
|
|
726
|
+
rule: "crossfile.plan_component",
|
|
727
|
+
message: `plan architecture component ${slug} does not exist in the linked spec; plan components must be a subset of spec components`,
|
|
728
|
+
file,
|
|
729
|
+
line: archNode?.line ?? root.line,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
checkPackageLayout(file, findings);
|
|
736
|
+
return {
|
|
737
|
+
version: LINT_VERSION,
|
|
738
|
+
file,
|
|
739
|
+
kind: "plan",
|
|
740
|
+
ok: !findings.some((f) => f.severity === "error"),
|
|
741
|
+
findings,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
// END_BLOCK_PLAN_RULES
|
|
745
|
+
//# sourceMappingURL=plan-rules.js.map
|