@xenonbyte/da-vinci-workflow 0.1.26 → 0.2.2
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 +31 -0
- package/README.md +28 -65
- package/README.zh-CN.md +28 -65
- package/bin/da-vinci-tui.js +8 -0
- package/commands/claude/dv/continue.md +5 -0
- package/commands/codex/prompts/dv-continue.md +6 -1
- package/commands/gemini/dv/continue.toml +5 -0
- package/commands/templates/dv-continue.shared.md +33 -0
- package/docs/dv-command-reference.md +35 -0
- package/docs/execution-chain-migration.md +46 -0
- package/docs/execution-chain-plan.md +125 -0
- package/docs/prompt-entrypoints.md +8 -0
- package/docs/skill-usage.md +217 -0
- package/docs/workflow-examples.md +10 -0
- package/docs/workflow-overview.md +26 -0
- package/docs/zh-CN/dv-command-reference.md +35 -0
- package/docs/zh-CN/execution-chain-migration.md +46 -0
- package/docs/zh-CN/prompt-entrypoints.md +8 -0
- package/docs/zh-CN/skill-usage.md +217 -0
- package/docs/zh-CN/workflow-examples.md +10 -0
- package/docs/zh-CN/workflow-overview.md +26 -0
- package/lib/artifact-parsers.js +120 -0
- package/lib/audit.js +61 -0
- package/lib/cli.js +351 -13
- package/lib/diff-spec.js +242 -0
- package/lib/execution-signals.js +136 -0
- package/lib/lint-bindings.js +143 -0
- package/lib/lint-spec.js +408 -0
- package/lib/lint-tasks.js +176 -0
- package/lib/planning-parsers.js +567 -0
- package/lib/scaffold.js +193 -0
- package/lib/scope-check.js +603 -0
- package/lib/sidecars.js +369 -0
- package/lib/supervisor-review.js +28 -3
- package/lib/utils.js +10 -2
- package/lib/verify.js +652 -0
- package/lib/workflow-contract.js +107 -0
- package/lib/workflow-persisted-state.js +297 -0
- package/lib/workflow-state.js +785 -0
- package/package.json +13 -3
- package/references/artifact-templates.md +26 -0
- package/references/checkpoints.md +14 -0
- package/references/modes.md +10 -0
- package/tui/catalog.js +1190 -0
- package/tui/index.js +727 -0
package/lib/lint-spec.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { pathExists } = require("./utils");
|
|
4
|
+
const { STATUS } = require("./workflow-contract");
|
|
5
|
+
const { parseRuntimeSpecMarkdown } = require("./artifact-parsers");
|
|
6
|
+
const { resolveChangeDir, detectSpecFiles } = require("./planning-parsers");
|
|
7
|
+
|
|
8
|
+
const MAX_EXAMPLE_ITEMS = 3;
|
|
9
|
+
const TESTABLE_ACCEPTANCE_PATTERN =
|
|
10
|
+
/\b(must|shall|should|when|then|if|display|show|hide|render|return|emit|within|ms|seconds?|error|success|true|false)\b/i;
|
|
11
|
+
const VAGUE_ACCEPTANCE_PATTERN =
|
|
12
|
+
/\b(good|nice|better|clean|intuitive|seamless|smooth|easy|simple|beautiful|fast)\b/i;
|
|
13
|
+
const WORD_PATTERN = /[a-z][a-z0-9_-]{2,}/gi;
|
|
14
|
+
const STOP_WORDS = new Set([
|
|
15
|
+
"the",
|
|
16
|
+
"and",
|
|
17
|
+
"for",
|
|
18
|
+
"with",
|
|
19
|
+
"from",
|
|
20
|
+
"that",
|
|
21
|
+
"this",
|
|
22
|
+
"then",
|
|
23
|
+
"when",
|
|
24
|
+
"into",
|
|
25
|
+
"only",
|
|
26
|
+
"each",
|
|
27
|
+
"user",
|
|
28
|
+
"users",
|
|
29
|
+
"page",
|
|
30
|
+
"state",
|
|
31
|
+
"states",
|
|
32
|
+
"input",
|
|
33
|
+
"inputs",
|
|
34
|
+
"output",
|
|
35
|
+
"outputs",
|
|
36
|
+
"should",
|
|
37
|
+
"must",
|
|
38
|
+
"shall",
|
|
39
|
+
"able",
|
|
40
|
+
"about",
|
|
41
|
+
"after",
|
|
42
|
+
"before",
|
|
43
|
+
"during",
|
|
44
|
+
"while",
|
|
45
|
+
"where",
|
|
46
|
+
"there",
|
|
47
|
+
"have",
|
|
48
|
+
"has",
|
|
49
|
+
"into"
|
|
50
|
+
]);
|
|
51
|
+
const EXPLICIT_CONTRADICTORY_PAIRS = [
|
|
52
|
+
["enabled", "disabled"],
|
|
53
|
+
["active", "inactive"],
|
|
54
|
+
["online", "offline"],
|
|
55
|
+
["visible", "hidden"],
|
|
56
|
+
["open", "closed"],
|
|
57
|
+
["expanded", "collapsed"],
|
|
58
|
+
["authenticated", "unauthenticated"],
|
|
59
|
+
["authorized", "unauthorized"]
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function buildResultEnvelope(projectRoot, strict) {
|
|
63
|
+
return {
|
|
64
|
+
status: STATUS.PASS,
|
|
65
|
+
failures: [],
|
|
66
|
+
warnings: [],
|
|
67
|
+
notes: [],
|
|
68
|
+
projectRoot,
|
|
69
|
+
changeId: null,
|
|
70
|
+
strict,
|
|
71
|
+
specs: [],
|
|
72
|
+
summary: {
|
|
73
|
+
checked: 0
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function finalizeResult(result) {
|
|
79
|
+
const hasFindings = result.failures.length > 0 || result.warnings.length > 0;
|
|
80
|
+
if (!hasFindings) {
|
|
81
|
+
result.status = STATUS.PASS;
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (result.strict) {
|
|
86
|
+
result.status = STATUS.BLOCK;
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
result.status = STATUS.WARN;
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeText(value) {
|
|
95
|
+
return String(value || "")
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[`*_~]/g, "")
|
|
98
|
+
.replace(/\s+/g, " ")
|
|
99
|
+
.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toKeywordSet(value) {
|
|
103
|
+
const words = String(value || "").match(WORD_PATTERN) || [];
|
|
104
|
+
return new Set(
|
|
105
|
+
words
|
|
106
|
+
.map((word) => String(word || "").toLowerCase())
|
|
107
|
+
.filter((word) => word && !STOP_WORDS.has(word))
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function containsAnyKeyword(text, keywords) {
|
|
112
|
+
if (!Array.isArray(keywords) || keywords.length === 0) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const source = normalizeText(text);
|
|
116
|
+
return keywords.some((keyword) => source.includes(keyword));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function expandCoverageKeywords(keywords) {
|
|
120
|
+
const expanded = [];
|
|
121
|
+
for (const keyword of keywords || []) {
|
|
122
|
+
if (!keyword) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
expanded.push(keyword);
|
|
126
|
+
|
|
127
|
+
if (keyword === "success") {
|
|
128
|
+
expanded.push("succeed", "succeeds", "succeeded");
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (keyword === "error") {
|
|
132
|
+
expanded.push("fail", "fails", "failed", "failure");
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (keyword === "loading") {
|
|
136
|
+
expanded.push("load", "spinner");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return dedupe(expanded);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dedupe(values) {
|
|
143
|
+
return Array.from(new Set((values || []).filter(Boolean)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function appendSectionPresenceFindings(relativePath, parsed, failures) {
|
|
147
|
+
for (const heading of parsed.missingSections) {
|
|
148
|
+
failures.push(`${relativePath}: missing required section \`${heading}\`.`);
|
|
149
|
+
}
|
|
150
|
+
for (const heading of parsed.emptySections) {
|
|
151
|
+
failures.push(`${relativePath}: required section \`${heading}\` is empty.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const requiredItemSections = [
|
|
155
|
+
"behavior",
|
|
156
|
+
"states",
|
|
157
|
+
"inputs",
|
|
158
|
+
"outputs",
|
|
159
|
+
"acceptance",
|
|
160
|
+
"edgeCases"
|
|
161
|
+
];
|
|
162
|
+
for (const sectionId of requiredItemSections) {
|
|
163
|
+
const section = parsed.sections[sectionId];
|
|
164
|
+
if (!section || !section.present || section.empty) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (section.items.length === 0) {
|
|
168
|
+
failures.push(
|
|
169
|
+
`${relativePath}: section \`${section.heading}\` is present but no list or paragraph items were parsed.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function appendAcceptanceQualityFindings(relativePath, parsed, warnings) {
|
|
176
|
+
const acceptanceItems = parsed.sections.acceptance ? parsed.sections.acceptance.items : [];
|
|
177
|
+
for (const item of acceptanceItems) {
|
|
178
|
+
const normalized = String(item || "").trim();
|
|
179
|
+
if (!normalized) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hasTestableSignal = TESTABLE_ACCEPTANCE_PATTERN.test(normalized);
|
|
184
|
+
const looksVague = VAGUE_ACCEPTANCE_PATTERN.test(normalized);
|
|
185
|
+
const hasMetricHint = /\d/.test(normalized);
|
|
186
|
+
if (hasTestableSignal && (!looksVague || hasMetricHint)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
warnings.push(
|
|
191
|
+
`${relativePath}: acceptance item may be non-testable: "${normalized.slice(0, 120)}".`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function appendStateContradictionFindings(relativePath, parsed, warnings) {
|
|
197
|
+
const stateItems = parsed.sections.states ? parsed.sections.states.items : [];
|
|
198
|
+
const normalizedStates = stateItems.map((value) => normalizeText(value)).filter(Boolean);
|
|
199
|
+
const uniqueStates = new Set(normalizedStates);
|
|
200
|
+
|
|
201
|
+
if (uniqueStates.size !== normalizedStates.length) {
|
|
202
|
+
warnings.push(`${relativePath}: duplicate state names were detected.`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const [left, right] of EXPLICIT_CONTRADICTORY_PAIRS) {
|
|
206
|
+
if (uniqueStates.has(left) && uniqueStates.has(right)) {
|
|
207
|
+
warnings.push(`${relativePath}: contradictory state pair detected: "${left}" and "${right}".`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const prefixedNegationMap = new Map();
|
|
212
|
+
for (const state of uniqueStates) {
|
|
213
|
+
const match = state.match(/^(?:not|non)\s+(.+)$/);
|
|
214
|
+
if (!match) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const positive = String(match[1] || "").trim();
|
|
218
|
+
if (!positive) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
prefixedNegationMap.set(positive, state);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const [positive, negative] of prefixedNegationMap.entries()) {
|
|
225
|
+
if (uniqueStates.has(positive)) {
|
|
226
|
+
warnings.push(
|
|
227
|
+
`${relativePath}: contradictory state pair detected: "${positive}" and "${negative}".`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function appendCoverageFindings(relativePath, parsed, warnings) {
|
|
234
|
+
const behaviorItems = parsed.sections.behavior ? parsed.sections.behavior.items : [];
|
|
235
|
+
const stateItems = parsed.sections.states ? parsed.sections.states.items : [];
|
|
236
|
+
const acceptanceItems = parsed.sections.acceptance ? parsed.sections.acceptance.items : [];
|
|
237
|
+
|
|
238
|
+
const behaviorText = behaviorItems.join(" ");
|
|
239
|
+
const acceptanceText = acceptanceItems.join(" ");
|
|
240
|
+
const combinedCoverageText = `${behaviorText} ${acceptanceText}`.trim();
|
|
241
|
+
|
|
242
|
+
for (const stateItem of stateItems) {
|
|
243
|
+
const keywords = expandCoverageKeywords(Array.from(toKeywordSet(stateItem)));
|
|
244
|
+
if (keywords.length === 0) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (!containsAnyKeyword(combinedCoverageText, keywords)) {
|
|
248
|
+
warnings.push(
|
|
249
|
+
`${relativePath}: state coverage gap: "${stateItem}" does not appear in behavior or acceptance sections.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const uncoveredBehaviorItems = [];
|
|
255
|
+
for (const behaviorItem of behaviorItems) {
|
|
256
|
+
const keywords = Array.from(toKeywordSet(behaviorItem));
|
|
257
|
+
if (keywords.length === 0) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!containsAnyKeyword(acceptanceText, keywords)) {
|
|
261
|
+
uncoveredBehaviorItems.push(behaviorItem);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (uncoveredBehaviorItems.length > 0) {
|
|
266
|
+
const preview = uncoveredBehaviorItems.slice(0, MAX_EXAMPLE_ITEMS).join(" | ");
|
|
267
|
+
warnings.push(
|
|
268
|
+
`${relativePath}: acceptance coverage may miss behavior items (${uncoveredBehaviorItems.length}): ${preview}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveChange(projectRoot, requestedChangeId, failures, notes) {
|
|
274
|
+
const resolved = resolveChangeDir(projectRoot, requestedChangeId);
|
|
275
|
+
failures.push(...resolved.failures);
|
|
276
|
+
notes.push(...resolved.notes);
|
|
277
|
+
return resolved.changeDir;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function lintRuntimeSpecs(projectPathInput, options = {}) {
|
|
281
|
+
const projectRoot = path.resolve(projectPathInput || process.cwd());
|
|
282
|
+
const strict = options.strict === true;
|
|
283
|
+
const requestedChangeId = options.changeId ? String(options.changeId).trim() : "";
|
|
284
|
+
const result = buildResultEnvelope(projectRoot, strict);
|
|
285
|
+
|
|
286
|
+
if (!pathExists(projectRoot)) {
|
|
287
|
+
result.failures.push(`Project path does not exist: ${projectRoot}`);
|
|
288
|
+
result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
|
|
289
|
+
return finalizeResult(result);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const changeDir = resolveChange(projectRoot, requestedChangeId, result.failures, result.notes);
|
|
293
|
+
if (!changeDir) {
|
|
294
|
+
result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
|
|
295
|
+
return finalizeResult(result);
|
|
296
|
+
}
|
|
297
|
+
result.changeId = path.basename(changeDir);
|
|
298
|
+
|
|
299
|
+
const specsDir = path.join(changeDir, "specs");
|
|
300
|
+
const specFiles = detectSpecFiles(specsDir);
|
|
301
|
+
if (specFiles.length === 0) {
|
|
302
|
+
result.failures.push("No runtime spec files found under `.da-vinci/changes/<change-id>/specs/*/spec.md`.");
|
|
303
|
+
result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
|
|
304
|
+
return finalizeResult(result);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const specPath of specFiles) {
|
|
308
|
+
const relativePath = path.relative(projectRoot, specPath) || specPath;
|
|
309
|
+
const raw = fs.readFileSync(specPath, "utf8");
|
|
310
|
+
const parsed = parseRuntimeSpecMarkdown(raw);
|
|
311
|
+
const fileFailures = [];
|
|
312
|
+
const fileWarnings = [];
|
|
313
|
+
const fileNotes = [];
|
|
314
|
+
|
|
315
|
+
appendSectionPresenceFindings(relativePath, parsed, fileFailures);
|
|
316
|
+
appendAcceptanceQualityFindings(relativePath, parsed, fileWarnings);
|
|
317
|
+
appendStateContradictionFindings(relativePath, parsed, fileWarnings);
|
|
318
|
+
appendCoverageFindings(relativePath, parsed, fileWarnings);
|
|
319
|
+
|
|
320
|
+
if (/^\s*##\s+ADDED Requirements\b/im.test(raw)) {
|
|
321
|
+
fileNotes.push(
|
|
322
|
+
`${relativePath}: OpenSpec planning sections detected. Runtime lint still requires Da Vinci runtime headings.`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
result.failures.push(...fileFailures);
|
|
327
|
+
result.warnings.push(...fileWarnings);
|
|
328
|
+
result.notes.push(...fileNotes);
|
|
329
|
+
result.specs.push({
|
|
330
|
+
path: relativePath,
|
|
331
|
+
status:
|
|
332
|
+
fileFailures.length > 0 || fileWarnings.length > 0
|
|
333
|
+
? strict
|
|
334
|
+
? STATUS.BLOCK
|
|
335
|
+
: STATUS.WARN
|
|
336
|
+
: STATUS.PASS,
|
|
337
|
+
failures: fileFailures,
|
|
338
|
+
warnings: fileWarnings,
|
|
339
|
+
notes: fileNotes,
|
|
340
|
+
parsed: {
|
|
341
|
+
missingSections: parsed.missingSections,
|
|
342
|
+
emptySections: parsed.emptySections,
|
|
343
|
+
itemCounts: {
|
|
344
|
+
behavior: parsed.sections.behavior.items.length,
|
|
345
|
+
states: parsed.sections.states.items.length,
|
|
346
|
+
inputs: parsed.sections.inputs.items.length,
|
|
347
|
+
outputs: parsed.sections.outputs.items.length,
|
|
348
|
+
acceptance: parsed.sections.acceptance.items.length,
|
|
349
|
+
edgeCases: parsed.sections.edgeCases.items.length
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
result.summary.checked = result.specs.length;
|
|
356
|
+
result.failures = dedupe(result.failures);
|
|
357
|
+
result.warnings = dedupe(result.warnings);
|
|
358
|
+
result.notes = dedupe(result.notes);
|
|
359
|
+
result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
|
|
360
|
+
|
|
361
|
+
return finalizeResult(result);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formatLintSpecReport(result) {
|
|
365
|
+
const lines = [
|
|
366
|
+
"Da Vinci lint-spec",
|
|
367
|
+
`Project: ${result.projectRoot}`,
|
|
368
|
+
`Change: ${result.changeId || "(not selected)"}`,
|
|
369
|
+
`Strict mode: ${result.strict ? "yes" : "no"}`,
|
|
370
|
+
`Status: ${result.status}`,
|
|
371
|
+
`Spec files checked: ${result.summary.checked}`
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
if (result.specs.length > 0) {
|
|
375
|
+
lines.push("", "Spec results:");
|
|
376
|
+
for (const spec of result.specs) {
|
|
377
|
+
lines.push(`- ${spec.path}: ${spec.status}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (result.failures.length > 0) {
|
|
382
|
+
lines.push("", "Failures:");
|
|
383
|
+
for (const failure of result.failures) {
|
|
384
|
+
lines.push(`- ${failure}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (result.warnings.length > 0) {
|
|
389
|
+
lines.push("", "Warnings:");
|
|
390
|
+
for (const warning of result.warnings) {
|
|
391
|
+
lines.push(`- ${warning}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (result.notes.length > 0) {
|
|
396
|
+
lines.push("", "Notes:");
|
|
397
|
+
for (const note of result.notes) {
|
|
398
|
+
lines.push(`- ${note}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = {
|
|
406
|
+
lintRuntimeSpecs,
|
|
407
|
+
formatLintSpecReport
|
|
408
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const { STATUS } = require("./workflow-contract");
|
|
3
|
+
const {
|
|
4
|
+
normalizeText,
|
|
5
|
+
unique,
|
|
6
|
+
resolveChangeDir,
|
|
7
|
+
parseTasksArtifact,
|
|
8
|
+
parseRuntimeSpecs,
|
|
9
|
+
readChangeArtifacts,
|
|
10
|
+
readArtifactTexts
|
|
11
|
+
} = require("./planning-parsers");
|
|
12
|
+
|
|
13
|
+
function buildEnvelope(projectRoot, strict) {
|
|
14
|
+
return {
|
|
15
|
+
status: STATUS.PASS,
|
|
16
|
+
failures: [],
|
|
17
|
+
warnings: [],
|
|
18
|
+
notes: [],
|
|
19
|
+
projectRoot,
|
|
20
|
+
changeId: null,
|
|
21
|
+
strict,
|
|
22
|
+
summary: {
|
|
23
|
+
groups: 0,
|
|
24
|
+
checklistItems: 0
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function finalize(result) {
|
|
30
|
+
result.failures = unique(result.failures);
|
|
31
|
+
result.warnings = unique(result.warnings);
|
|
32
|
+
result.notes = unique(result.notes);
|
|
33
|
+
const hasFindings = result.failures.length > 0 || result.warnings.length > 0;
|
|
34
|
+
if (!hasFindings) {
|
|
35
|
+
result.status = STATUS.PASS;
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
result.status = result.strict ? STATUS.BLOCK : STATUS.WARN;
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findMissingTaskGroupSequence(taskGroups) {
|
|
43
|
+
const numeric = taskGroups
|
|
44
|
+
.map((group) => Number.parseInt(String(group.id || "").split(".")[0], 10))
|
|
45
|
+
.filter(Number.isFinite)
|
|
46
|
+
.sort((a, b) => a - b);
|
|
47
|
+
if (numeric.length === 0) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (let value = numeric[0]; value <= numeric[numeric.length - 1]; value += 1) {
|
|
52
|
+
if (!numeric.includes(value)) {
|
|
53
|
+
missing.push(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return missing;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lintTasks(projectPathInput, options = {}) {
|
|
60
|
+
const projectRoot = path.resolve(projectPathInput || process.cwd());
|
|
61
|
+
const strict = options.strict === true;
|
|
62
|
+
const requestedChangeId = options.changeId ? String(options.changeId).trim() : "";
|
|
63
|
+
const result = buildEnvelope(projectRoot, strict);
|
|
64
|
+
|
|
65
|
+
const resolved = resolveChangeDir(projectRoot, requestedChangeId);
|
|
66
|
+
result.failures.push(...resolved.failures);
|
|
67
|
+
result.notes.push(...resolved.notes);
|
|
68
|
+
if (!resolved.changeDir) {
|
|
69
|
+
result.notes.push("lint-tasks defaults to advisory mode; pass `--strict` to block on findings.");
|
|
70
|
+
return finalize(result);
|
|
71
|
+
}
|
|
72
|
+
result.changeId = resolved.changeId;
|
|
73
|
+
|
|
74
|
+
const artifactPaths = readChangeArtifacts(projectRoot, resolved.changeId);
|
|
75
|
+
const artifacts = readArtifactTexts(artifactPaths);
|
|
76
|
+
if (!artifacts.tasks) {
|
|
77
|
+
result.failures.push("Missing `tasks.md` for lint-tasks.");
|
|
78
|
+
result.notes.push("lint-tasks defaults to advisory mode; pass `--strict` to block on findings.");
|
|
79
|
+
return finalize(result);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parsedTasks = parseTasksArtifact(artifacts.tasks);
|
|
83
|
+
const specRecords = parseRuntimeSpecs(resolved.changeDir, projectRoot);
|
|
84
|
+
const taskText = normalizeText(artifacts.tasks);
|
|
85
|
+
|
|
86
|
+
result.summary.groups = parsedTasks.taskGroups.length;
|
|
87
|
+
result.summary.checklistItems = parsedTasks.checklistItems.length;
|
|
88
|
+
|
|
89
|
+
if (parsedTasks.taskGroups.length === 0) {
|
|
90
|
+
result.failures.push("`tasks.md` is missing top-level numbered task groups (for example `## 1. Setup`).");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const missingSequence = findMissingTaskGroupSequence(parsedTasks.taskGroups);
|
|
94
|
+
if (missingSequence.length > 0) {
|
|
95
|
+
result.warnings.push(
|
|
96
|
+
`Task-group numbering is non-contiguous. Missing top-level groups: ${missingSequence.join(", ")}.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const nonNumericGroups = parsedTasks.taskGroups.filter(
|
|
101
|
+
(group) => !/^\d+(?:\.\d+)*$/.test(String(group.id || ""))
|
|
102
|
+
);
|
|
103
|
+
if (nonNumericGroups.length > 0) {
|
|
104
|
+
result.warnings.push("Some task-group headings are not numeric and may be skipped by execution metadata.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (parsedTasks.checklistItems.length === 0) {
|
|
108
|
+
result.failures.push("`tasks.md` has no checklist items.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hasVerificationAction =
|
|
112
|
+
parsedTasks.taskGroups.some((group) => /verify|verification|coverage/i.test(group.title)) ||
|
|
113
|
+
parsedTasks.checklistItems.some((item) => /verify|verification|coverage/i.test(item.text));
|
|
114
|
+
if (!hasVerificationAction) {
|
|
115
|
+
result.warnings.push("Missing explicit verification actions in `tasks.md`.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const specRecord of specRecords) {
|
|
119
|
+
const behaviorItems = specRecord.parsed.sections.behavior.items || [];
|
|
120
|
+
for (const behaviorItem of behaviorItems) {
|
|
121
|
+
const normalizedBehavior = normalizeText(behaviorItem)
|
|
122
|
+
.split(" ")
|
|
123
|
+
.filter((token) => token.length >= 4)
|
|
124
|
+
.slice(0, 6);
|
|
125
|
+
if (normalizedBehavior.length === 0) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const covered = normalizedBehavior.some((token) => taskText.includes(token));
|
|
129
|
+
if (!covered) {
|
|
130
|
+
result.warnings.push(
|
|
131
|
+
`Planned behavior may be uncovered in tasks: "${behaviorItem}" (${specRecord.path}).`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
result.notes.push("lint-tasks defaults to advisory mode; pass `--strict` to block on findings.");
|
|
138
|
+
return finalize(result);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatLintTasksReport(result) {
|
|
142
|
+
const lines = [
|
|
143
|
+
"Da Vinci lint-tasks",
|
|
144
|
+
`Project: ${result.projectRoot}`,
|
|
145
|
+
`Change: ${result.changeId || "(not selected)"}`,
|
|
146
|
+
`Strict mode: ${result.strict ? "yes" : "no"}`,
|
|
147
|
+
`Status: ${result.status}`,
|
|
148
|
+
`Task groups: ${result.summary.groups}`,
|
|
149
|
+
`Checklist items: ${result.summary.checklistItems}`
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
if (result.failures.length > 0) {
|
|
153
|
+
lines.push("", "Failures:");
|
|
154
|
+
for (const failure of result.failures) {
|
|
155
|
+
lines.push(`- ${failure}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (result.warnings.length > 0) {
|
|
159
|
+
lines.push("", "Warnings:");
|
|
160
|
+
for (const warning of result.warnings) {
|
|
161
|
+
lines.push(`- ${warning}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (result.notes.length > 0) {
|
|
165
|
+
lines.push("", "Notes:");
|
|
166
|
+
for (const note of result.notes) {
|
|
167
|
+
lines.push(`- ${note}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
lintTasks,
|
|
175
|
+
formatLintTasksReport
|
|
176
|
+
};
|