@xenonbyte/da-vinci-workflow 0.2.5 → 0.2.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/lib/lint-spec.js CHANGED
@@ -1,9 +1,24 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
- const { pathExists } = require("./utils");
3
+ const { pathExists, readTextIfExists } = require("./utils");
4
4
  const { STATUS } = require("./workflow-contract");
5
5
  const { parseRuntimeSpecMarkdown } = require("./artifact-parsers");
6
- const { resolveChangeDir, detectSpecFiles } = require("./planning-parsers");
6
+ const {
7
+ normalizeText,
8
+ tokenizeNormalizedWords,
9
+ textContainsAnyNormalizedToken,
10
+ detectSpecFiles,
11
+ parseAmbiguityRecords,
12
+ readChangeArtifacts,
13
+ readArtifactTexts
14
+ } = require("./planning-parsers");
15
+ const { buildGateEnvelope, finalizeGateEnvelope, dedupe } = require("./gate-utils");
16
+ const {
17
+ buildStopWordSet,
18
+ buildBasePlanningResultEnvelope,
19
+ finalizePlanningResult,
20
+ resolveChangeWithFindings
21
+ } = require("./planning-quality-utils");
7
22
 
8
23
  const MAX_EXAMPLE_ITEMS = 3;
9
24
  const TESTABLE_ACCEPTANCE_PATTERN =
@@ -11,43 +26,7 @@ const TESTABLE_ACCEPTANCE_PATTERN =
11
26
  const VAGUE_ACCEPTANCE_PATTERN =
12
27
  /\b(good|nice|better|clean|intuitive|seamless|smooth|easy|simple|beautiful|fast)\b/i;
13
28
  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
- ]);
29
+ const STOP_WORDS = buildStopWordSet();
51
30
  const EXPLICIT_CONTRADICTORY_PAIRS = [
52
31
  ["enabled", "disabled"],
53
32
  ["active", "inactive"],
@@ -58,16 +37,66 @@ const EXPLICIT_CONTRADICTORY_PAIRS = [
58
37
  ["authenticated", "unauthenticated"],
59
38
  ["authorized", "unauthorized"]
60
39
  ];
40
+ const PLACEHOLDER_AMBIGUITY_PATTERN = /\b(TODO|TBD|unknown)\b/i;
41
+ const PRINCIPLE_DRIFT_PATTERNS = [
42
+ {
43
+ id: "spec_only_truth",
44
+ message: "artifact implies spec-only truth and conflicts with requirement/design/code truth boundaries",
45
+ pattern:
46
+ /\b(spec|specification)\b[\s\S]{0,60}\b(only|single|sole)\b[\s\S]{0,40}\b(truth|source of truth)\b/i
47
+ },
48
+ {
49
+ id: "design_truth_erasure",
50
+ message: "artifact implies design truth can be ignored in planning",
51
+ pattern: /\b(ignore|skip|bypass)\b[\s\S]{0,40}\b(design|pencil)\b[\s\S]{0,40}\btruth\b/i
52
+ },
53
+ {
54
+ id: "second_principle_source",
55
+ message: "artifact introduces a new constitution-style principle source",
56
+ pattern: /\b(constitution\.md|new constitution|principle manifesto)\b/i
57
+ }
58
+ ];
59
+ const PRINCIPLE_TRUTH_BOUNDARY_CONTRADICTION_PATTERNS = [
60
+ {
61
+ id: "design_controls_behavior",
62
+ message: "artifact assigns behavior authority to design/pencil instead of requirements",
63
+ pattern:
64
+ /\b(pencil|design)\b[\s\S]{0,48}\b(decide|decides|determine|determines|govern|governs|define|defines|control|controls|drive|drives)\b[\s\S]{0,48}\bbehavior\b/i
65
+ },
66
+ {
67
+ id: "requirements_controls_presentation",
68
+ message: "artifact assigns presentation authority to requirements/spec instead of design/pencil",
69
+ pattern:
70
+ /\b(requirement|requirements|spec|specification)\b[\s\S]{0,48}\b(decide|decides|determine|determines|govern|governs|define|defines|control|controls|drive|drives)\b[\s\S]{0,48}\b(presentation|visual|layout|ui)\b/i
71
+ },
72
+ {
73
+ id: "code_controls_planning_truth",
74
+ message: "artifact assigns planning truth authority to code instead of requirements/design boundaries",
75
+ pattern:
76
+ /\b(code|implementation)\b[\s\S]{0,48}\b(decide|decides|determine|determines|govern|governs|define|defines|control|controls|drive|drives)\b[\s\S]{0,48}\b(behavior|presentation|visual|layout|ui|requirements?)\b/i
77
+ }
78
+ ];
79
+ const ACCEPTED_AMBIGUITY_CLASSES = new Set([
80
+ "behavior",
81
+ "scope",
82
+ "page",
83
+ "surface",
84
+ "state",
85
+ "input",
86
+ "data",
87
+ "acceptance",
88
+ "design_dependency",
89
+ "design-dependency"
90
+ ]);
61
91
 
62
92
  function buildResultEnvelope(projectRoot, strict) {
63
93
  return {
64
- status: STATUS.PASS,
65
- failures: [],
66
- warnings: [],
67
- notes: [],
68
- projectRoot,
69
- changeId: null,
70
- strict,
94
+ ...buildBasePlanningResultEnvelope(projectRoot, strict),
95
+ gates: {
96
+ principleInheritance: null,
97
+ clarify: null,
98
+ scenarioQuality: null
99
+ },
71
100
  specs: [],
72
101
  summary: {
73
102
  checked: 0
@@ -75,45 +104,12 @@ function buildResultEnvelope(projectRoot, strict) {
75
104
  };
76
105
  }
77
106
 
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
107
  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
- );
108
+ return new Set(tokenizeNormalizedWords(value, { pattern: WORD_PATTERN, stopWords: STOP_WORDS }));
109
109
  }
110
110
 
111
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));
112
+ return textContainsAnyNormalizedToken(text, keywords);
117
113
  }
118
114
 
119
115
  function expandCoverageKeywords(keywords) {
@@ -139,8 +135,298 @@ function expandCoverageKeywords(keywords) {
139
135
  return dedupe(expanded);
140
136
  }
141
137
 
142
- function dedupe(values) {
143
- return Array.from(new Set((values || []).filter(Boolean)));
138
+ function collectPrincipleInheritanceGate(projectRoot, artifactEntries, strict = false) {
139
+ const gate = buildGateEnvelope("principleInheritance");
140
+ const repoPrinciplesPath = path.join(projectRoot, "openspec", "config.yaml");
141
+ const projectPrinciplesPath = path.join(projectRoot, "DA-VINCI.md");
142
+ const repoPrinciplesText = readTextIfExists(repoPrinciplesPath);
143
+ const projectPrinciplesText = readTextIfExists(projectPrinciplesPath);
144
+ if (!pathExists(repoPrinciplesPath)) {
145
+ gate.compatibility.push("repo-level principle source is missing (`openspec/config.yaml`).");
146
+ }
147
+ if (!pathExists(projectPrinciplesPath)) {
148
+ gate.compatibility.push("project-level principle source is missing (`DA-VINCI.md`); repo-level baseline only.");
149
+ } else if (projectPrinciplesText && /constitution\.md|principle manifesto|new constitution/i.test(projectPrinciplesText)) {
150
+ gate.blocking.push(
151
+ `DA-VINCI.md introduces a second constitution-style principle source and violates inheritance contract.`
152
+ );
153
+ gate.evidence.push("DA-VINCI.md");
154
+ }
155
+
156
+ const repoHasTruthBoundary =
157
+ /requirements.*behavior[\s\S]{0,80}pencil.*presentation[\s\S]{0,80}code/sim.test(
158
+ String(repoPrinciplesText || "")
159
+ ) || /requirements decide behavior/i.test(String(repoPrinciplesText || ""));
160
+ const collectTruthBoundaryContradictions = (text) => {
161
+ const findings = [];
162
+ const payload = String(text || "");
163
+ for (const pattern of PRINCIPLE_TRUTH_BOUNDARY_CONTRADICTION_PATTERNS) {
164
+ if (!pattern.pattern.test(payload)) {
165
+ continue;
166
+ }
167
+ findings.push(pattern.message);
168
+ }
169
+ return dedupe(findings);
170
+ };
171
+ if (repoHasTruthBoundary && projectPrinciplesText) {
172
+ for (const pattern of PRINCIPLE_DRIFT_PATTERNS) {
173
+ if (!pattern.pattern.test(projectPrinciplesText)) {
174
+ continue;
175
+ }
176
+ gate.blocking.push(
177
+ `DA-VINCI.md conflicts with repo-level principle baseline (\`openspec/config.yaml\`): ${pattern.message}.`
178
+ );
179
+ gate.evidence.push("openspec/config.yaml");
180
+ gate.evidence.push("DA-VINCI.md");
181
+ }
182
+ for (const message of collectTruthBoundaryContradictions(projectPrinciplesText)) {
183
+ gate.blocking.push(
184
+ `DA-VINCI.md conflicts with repo-level principle baseline (\`openspec/config.yaml\`): ${message}.`
185
+ );
186
+ gate.evidence.push("openspec/config.yaml");
187
+ gate.evidence.push("DA-VINCI.md");
188
+ }
189
+ }
190
+
191
+ for (const entry of artifactEntries) {
192
+ const text = String(entry.text || "");
193
+ for (const pattern of PRINCIPLE_DRIFT_PATTERNS) {
194
+ if (!pattern.pattern.test(text)) {
195
+ continue;
196
+ }
197
+ gate.blocking.push(
198
+ `${entry.path}: ${pattern.message} (violated source: openspec/config.yaml + DA-VINCI.md inheritance boundary).`
199
+ );
200
+ gate.evidence.push(entry.path);
201
+ }
202
+ if (repoHasTruthBoundary) {
203
+ for (const message of collectTruthBoundaryContradictions(text)) {
204
+ gate.blocking.push(
205
+ `${entry.path}: ${message} (violated source: openspec/config.yaml + DA-VINCI.md inheritance boundary).`
206
+ );
207
+ gate.evidence.push(entry.path);
208
+ }
209
+ }
210
+ }
211
+
212
+ return finalizeGateEnvelope(gate, {
213
+ strict,
214
+ // Missing project-level refinements are compatibility-only and must not become WARN by default.
215
+ warnOnCompatibility: false
216
+ });
217
+ }
218
+
219
+ function collectClarifyGate(artifactEntries, strict = false) {
220
+ const gate = buildGateEnvelope("clarify", { includeBounded: true });
221
+ let explicitRecordCount = 0;
222
+ for (const entry of artifactEntries) {
223
+ const parsed = parseAmbiguityRecords(entry.text);
224
+ const explicitRecordLines = new Set((parsed.records || []).map((record) => Number(record.line)));
225
+ for (const malformed of parsed.malformed) {
226
+ gate.advisory.push(
227
+ `${entry.path}:${malformed.line}: malformed ambiguity record (${malformed.reason}).`
228
+ );
229
+ gate.evidence.push(`${entry.path}:${malformed.line}`);
230
+ }
231
+ for (const record of parsed.records) {
232
+ explicitRecordCount += 1;
233
+ const metadata = record.metadata || {};
234
+ const summary = `${entry.path}:${record.line}: ${record.ambiguityClass} - ${record.summary}`;
235
+ gate.evidence.push(`${entry.path}:${record.line}`);
236
+ if (!ACCEPTED_AMBIGUITY_CLASSES.has(record.ambiguityClass)) {
237
+ gate.advisory.push(
238
+ `${entry.path}:${record.line}: ambiguity class "${record.ambiguityClass}" is non-canonical and should be normalized.`
239
+ );
240
+ }
241
+ if (record.severity === "blocking") {
242
+ gate.blocking.push(summary);
243
+ continue;
244
+ }
245
+ if (record.severity === "bounded") {
246
+ const missingKeys = ["owner", "impact", "next", "artifact"].filter((key) => !metadata[key]);
247
+ if (missingKeys.length > 0) {
248
+ gate.blocking.push(`${summary} (bounded record missing: ${missingKeys.join(", ")}).`);
249
+ } else {
250
+ gate.bounded.push(summary);
251
+ }
252
+ continue;
253
+ }
254
+ gate.advisory.push(summary);
255
+ }
256
+
257
+ const lines = String(entry.text || "").replace(/\r\n?/g, "\n").split("\n");
258
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
259
+ const line = String(lines[lineIndex] || "");
260
+ if (!PLACEHOLDER_AMBIGUITY_PATTERN.test(line)) {
261
+ continue;
262
+ }
263
+ if (explicitRecordLines.has(lineIndex + 1)) {
264
+ continue;
265
+ }
266
+ gate.blocking.push(
267
+ `${entry.path}:${lineIndex + 1}: unresolved placeholder-style ambiguity (${line.trim().slice(0, 120)}).`
268
+ );
269
+ gate.evidence.push(`${entry.path}:${lineIndex + 1}`);
270
+ }
271
+ }
272
+
273
+ if (explicitRecordCount === 0) {
274
+ gate.compatibility.push(
275
+ "no explicit ambiguity records found; fallback placeholder heuristics applied (legacy compatibility)."
276
+ );
277
+ }
278
+
279
+ return finalizeGateEnvelope(gate, {
280
+ strict,
281
+ includeBounded: true,
282
+ warnOnBounded: false
283
+ });
284
+ }
285
+
286
+ function isScenarioShapedAcceptance(itemText) {
287
+ const text = String(itemText || "");
288
+ const hasTrigger = /\b(when|if|on|after|before|while)\b/i.test(text);
289
+ const hasOutcomeVerb =
290
+ /\b(show|shows|render|renders|return|returns|emit|emits|display|displays|appear|appears|visible|hidden|error|success|retry|fallback|within)\b/i.test(
291
+ text
292
+ );
293
+ const hasMeasuredOutcome =
294
+ /\b(within|at\s+most|no\s+more\s+than|less\s+than|greater\s+than|at\s+least)\s+\d+\b/i.test(text) ||
295
+ /\b\d+(?:\.\d+)?\s*(ms|s|sec|secs|seconds|minutes?|hours?|px|%)\b/i.test(text);
296
+ const hasOutcome = hasOutcomeVerb || hasMeasuredOutcome;
297
+ return hasTrigger && hasOutcome;
298
+ }
299
+
300
+ function collectScenarioQualityGate(specRecords, strict = false) {
301
+ const gate = buildGateEnvelope("scenarioQuality");
302
+ for (const record of specRecords) {
303
+ const acceptanceItems =
304
+ record && record.parsed && record.parsed.sections && record.parsed.sections.acceptance
305
+ ? record.parsed.sections.acceptance.items
306
+ : [];
307
+ const behaviorItems =
308
+ record && record.parsed && record.parsed.sections && record.parsed.sections.behavior
309
+ ? record.parsed.sections.behavior.items
310
+ : [];
311
+ const stateItems =
312
+ record && record.parsed && record.parsed.sections && record.parsed.sections.states
313
+ ? record.parsed.sections.states.items
314
+ : [];
315
+ const inputItems =
316
+ record && record.parsed && record.parsed.sections && record.parsed.sections.inputs
317
+ ? record.parsed.sections.inputs.items
318
+ : [];
319
+ const outputItems =
320
+ record && record.parsed && record.parsed.sections && record.parsed.sections.outputs
321
+ ? record.parsed.sections.outputs.items
322
+ : [];
323
+ const edgeCaseItems =
324
+ record && record.parsed && record.parsed.sections && record.parsed.sections.edgeCases
325
+ ? record.parsed.sections.edgeCases.items
326
+ : [];
327
+
328
+ if (!Array.isArray(acceptanceItems) || acceptanceItems.length === 0) {
329
+ continue;
330
+ }
331
+ const shaped = acceptanceItems.filter((item) => isScenarioShapedAcceptance(item));
332
+ if (shaped.length === 0) {
333
+ gate.blocking.push(
334
+ `${record.path}: acceptance section is non-empty but has no scenario-shaped acceptance item.`
335
+ );
336
+ gate.evidence.push(`${record.path}:Acceptance`);
337
+ continue;
338
+ }
339
+
340
+ const hasScenarioIds = acceptanceItems.some((item) => /\bscenario[-_ ]?\d+\b/i.test(String(item || "")));
341
+ if (!hasScenarioIds) {
342
+ gate.compatibility.push(
343
+ `${record.path}: acceptance remains compatible without explicit scenario ids (legacy runtime schema).`
344
+ );
345
+ }
346
+
347
+ if (shaped.length < acceptanceItems.length) {
348
+ gate.advisory.push(
349
+ `${record.path}: ${acceptanceItems.length - shaped.length} acceptance item(s) remain weakly scenario-shaped.`
350
+ );
351
+ gate.evidence.push(`${record.path}:Acceptance`);
352
+ }
353
+
354
+ const behaviorText = behaviorItems.join(" ");
355
+ const acceptanceText = acceptanceItems.join(" ");
356
+ const behaviorAcceptanceText = `${behaviorText} ${acceptanceText}`.trim();
357
+ for (const stateItem of stateItems) {
358
+ const keywords = expandCoverageKeywords(Array.from(toKeywordSet(stateItem)));
359
+ if (keywords.length === 0) {
360
+ continue;
361
+ }
362
+ if (!containsAnyKeyword(behaviorAcceptanceText, keywords)) {
363
+ gate.advisory.push(
364
+ `${record.path}: state/behavior/acceptance coherence gap for state "${stateItem}".`
365
+ );
366
+ gate.evidence.push(`${record.path}:States`);
367
+ }
368
+ }
369
+
370
+ for (const inputItem of inputItems) {
371
+ const keywords = Array.from(toKeywordSet(inputItem));
372
+ if (keywords.length === 0) {
373
+ continue;
374
+ }
375
+ if (!containsAnyKeyword(`${behaviorText} ${acceptanceText}`, keywords)) {
376
+ gate.advisory.push(
377
+ `${record.path}: input linkage gap for "${inputItem}" (not connected to behavior/acceptance).`
378
+ );
379
+ gate.evidence.push(`${record.path}:Inputs`);
380
+ }
381
+ }
382
+ for (const outputItem of outputItems) {
383
+ const keywords = Array.from(toKeywordSet(outputItem));
384
+ if (keywords.length === 0) {
385
+ continue;
386
+ }
387
+ if (!containsAnyKeyword(`${behaviorText} ${acceptanceText}`, keywords)) {
388
+ gate.advisory.push(
389
+ `${record.path}: output linkage gap for "${outputItem}" (not connected to behavior/acceptance).`
390
+ );
391
+ gate.evidence.push(`${record.path}:Outputs`);
392
+ }
393
+ }
394
+ for (const edgeCaseItem of edgeCaseItems) {
395
+ const keywords = Array.from(toKeywordSet(edgeCaseItem));
396
+ if (keywords.length === 0) {
397
+ continue;
398
+ }
399
+ const sourceText = `${behaviorText} ${acceptanceText} ${stateItems.join(" ")} ${inputItems.join(" ")}`;
400
+ if (!containsAnyKeyword(sourceText, keywords)) {
401
+ gate.advisory.push(
402
+ `${record.path}: edge-case linkage gap for "${edgeCaseItem}" (not traceable to behavior/state/input/acceptance).`
403
+ );
404
+ gate.evidence.push(`${record.path}:Edge Cases`);
405
+ }
406
+ }
407
+ }
408
+ return finalizeGateEnvelope(gate, {
409
+ strict,
410
+ warnOnCompatibility: false
411
+ });
412
+ }
413
+
414
+ function attachGateFindings(result, gate) {
415
+ const gateId = gate && gate.id ? gate.id : "unknown";
416
+ for (const finding of gate.blocking || []) {
417
+ result.failures.push(`[gate:${gateId}] ${finding}`);
418
+ }
419
+ for (const finding of gate.advisory || []) {
420
+ result.warnings.push(`[gate:${gateId}] ${finding}`);
421
+ }
422
+ for (const finding of gate.compatibility || []) {
423
+ result.notes.push(`[gate:${gateId}] ${finding}`);
424
+ }
425
+ if (gateId === "clarify") {
426
+ for (const finding of gate.bounded || []) {
427
+ result.notes.push(`[gate:${gateId}] bounded ambiguity: ${finding}`);
428
+ }
429
+ }
144
430
  }
145
431
 
146
432
  function appendSectionPresenceFindings(relativePath, parsed, failures) {
@@ -232,24 +518,10 @@ function appendStateContradictionFindings(relativePath, parsed, warnings) {
232
518
 
233
519
  function appendCoverageFindings(relativePath, parsed, warnings) {
234
520
  const behaviorItems = parsed.sections.behavior ? parsed.sections.behavior.items : [];
235
- const stateItems = parsed.sections.states ? parsed.sections.states.items : [];
236
521
  const acceptanceItems = parsed.sections.acceptance ? parsed.sections.acceptance.items : [];
237
522
 
238
523
  const behaviorText = behaviorItems.join(" ");
239
524
  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
525
 
254
526
  const uncoveredBehaviorItems = [];
255
527
  for (const behaviorItem of behaviorItems) {
@@ -270,13 +542,6 @@ function appendCoverageFindings(relativePath, parsed, warnings) {
270
542
  }
271
543
  }
272
544
 
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
545
  function lintRuntimeSpecs(projectPathInput, options = {}) {
281
546
  const projectRoot = path.resolve(projectPathInput || process.cwd());
282
547
  const strict = options.strict === true;
@@ -286,13 +551,13 @@ function lintRuntimeSpecs(projectPathInput, options = {}) {
286
551
  if (!pathExists(projectRoot)) {
287
552
  result.failures.push(`Project path does not exist: ${projectRoot}`);
288
553
  result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
289
- return finalizeResult(result);
554
+ return finalizePlanningResult(result);
290
555
  }
291
556
 
292
- const changeDir = resolveChange(projectRoot, requestedChangeId, result.failures, result.notes);
557
+ const changeDir = resolveChangeWithFindings(projectRoot, requestedChangeId, result.failures, result.notes);
293
558
  if (!changeDir) {
294
559
  result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
295
- return finalizeResult(result);
560
+ return finalizePlanningResult(result);
296
561
  }
297
562
  result.changeId = path.basename(changeDir);
298
563
 
@@ -301,13 +566,19 @@ function lintRuntimeSpecs(projectPathInput, options = {}) {
301
566
  if (specFiles.length === 0) {
302
567
  result.failures.push("No runtime spec files found under `.da-vinci/changes/<change-id>/specs/*/spec.md`.");
303
568
  result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
304
- return finalizeResult(result);
569
+ return finalizePlanningResult(result);
305
570
  }
306
571
 
572
+ const specRecords = [];
307
573
  for (const specPath of specFiles) {
308
574
  const relativePath = path.relative(projectRoot, specPath) || specPath;
309
575
  const raw = fs.readFileSync(specPath, "utf8");
310
576
  const parsed = parseRuntimeSpecMarkdown(raw);
577
+ specRecords.push({
578
+ path: relativePath,
579
+ text: raw,
580
+ parsed
581
+ });
311
582
  const fileFailures = [];
312
583
  const fileWarnings = [];
313
584
  const fileNotes = [];
@@ -352,13 +623,36 @@ function lintRuntimeSpecs(projectPathInput, options = {}) {
352
623
  });
353
624
  }
354
625
 
626
+ const changeArtifacts = readChangeArtifacts(projectRoot, result.changeId);
627
+ const changeArtifactTexts = readArtifactTexts(changeArtifacts);
628
+ const artifactEntries = [
629
+ ...Object.entries(changeArtifactTexts)
630
+ .filter(([, text]) => String(text || "").trim())
631
+ .map(([key, text]) => ({
632
+ path: path.relative(projectRoot, changeArtifacts[`${key}Path`]) || changeArtifacts[`${key}Path`],
633
+ text
634
+ })),
635
+ ...specRecords.map((record) => ({
636
+ path: record.path,
637
+ text: record.text
638
+ }))
639
+ ];
640
+
641
+ result.gates.principleInheritance = collectPrincipleInheritanceGate(projectRoot, artifactEntries, strict);
642
+ result.gates.clarify = collectClarifyGate(artifactEntries, strict);
643
+ result.gates.scenarioQuality = collectScenarioQualityGate(specRecords, strict);
644
+
645
+ attachGateFindings(result, result.gates.principleInheritance);
646
+ attachGateFindings(result, result.gates.clarify);
647
+ attachGateFindings(result, result.gates.scenarioQuality);
648
+
355
649
  result.summary.checked = result.specs.length;
356
650
  result.failures = dedupe(result.failures);
357
651
  result.warnings = dedupe(result.warnings);
358
652
  result.notes = dedupe(result.notes);
359
653
  result.notes.push("lint-spec uses advisory mode by default; use `--strict` to block on findings.");
360
654
 
361
- return finalizeResult(result);
655
+ return finalizePlanningResult(result);
362
656
  }
363
657
 
364
658
  function formatLintSpecReport(result) {