@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.
@@ -1,28 +1,125 @@
1
1
  const path = require("path");
2
2
  const { getMarkdownSection } = require("./audit-parsers");
3
3
  const { pathExists, readTextIfExists } = require("./utils");
4
- const { STATUS } = require("./workflow-contract");
5
4
  const {
5
+ normalizeText: normalizeWhitespace,
6
+ tokenizeNormalizedWords,
7
+ textContainsAnyNormalizedToken,
6
8
  parseListItems,
7
9
  unique,
8
- resolveChangeDir,
9
- parseRuntimeSpecs
10
+ parseRuntimeSpecs,
11
+ parseTasksArtifact,
12
+ parseBindingsArtifact
10
13
  } = require("./planning-parsers");
11
-
12
- const PAGE_NAME_PATTERN = /`([^`]+)`|([a-z0-9][a-z0-9 \-/]{1,80}?)\s+pages?\b/gi;
14
+ const {
15
+ loadAnalyzeEvidenceIndex,
16
+ resolveAnalyzeEvidenceRefs,
17
+ loadPlanningAnchorIndex,
18
+ resolvePlanningAnchorRefs
19
+ } = require("./sidecars");
20
+ const { buildGateEnvelope, finalizeGateEnvelope } = require("./gate-utils");
21
+ const {
22
+ buildStopWordSet,
23
+ buildBasePlanningResultEnvelope,
24
+ finalizePlanningResult,
25
+ resolveChangeWithFindings
26
+ } = require("./planning-quality-utils");
27
+
28
+ const PAGE_NAME_PATTERN = /`([^`]+)`|([a-z0-9][a-z0-9 \-/]{1,80}?)\s+pages?\b/i;
29
+ const ANALYZE_TOKEN_PATTERN = /[a-z0-9_-]{3,}/g;
13
30
  const STATE_SYNONYMS = {
14
31
  success: ["success", "succeed", "succeeds", "succeeded"],
15
32
  error: ["error", "fail", "fails", "failed", "failure"],
16
33
  loading: ["loading", "load", "spinner"]
17
34
  };
18
-
19
- function normalizeWhitespace(value) {
20
- return String(value || "")
21
- .toLowerCase()
22
- .replace(/[`*_~]/g, "")
23
- .replace(/\s+/g, " ")
24
- .trim();
25
- }
35
+ const ANALYZE_STOP_WORDS = buildStopWordSet(["pages"]);
36
+ const ANALYZE_GENERIC_TASK_TOKENS = buildStopWordSet([
37
+ "implement",
38
+ "implementation",
39
+ "verify",
40
+ "verification",
41
+ "run",
42
+ "test",
43
+ "tests",
44
+ "page",
45
+ "pages",
46
+ "screen",
47
+ "screens",
48
+ "section",
49
+ "sections",
50
+ "state",
51
+ "states",
52
+ "task",
53
+ "tasks",
54
+ "group",
55
+ "groups",
56
+ "coverage",
57
+ "build",
58
+ "update",
59
+ "create",
60
+ "add",
61
+ "review",
62
+ "check",
63
+ "loading",
64
+ "success",
65
+ "error",
66
+ "fallback",
67
+ "handling",
68
+ "placeholder",
69
+ "layout",
70
+ "copy",
71
+ "view",
72
+ "views",
73
+ "regression",
74
+ "npm",
75
+ "pnpm",
76
+ "yarn",
77
+ "npx",
78
+ "node",
79
+ "for",
80
+ "with",
81
+ "from",
82
+ "into",
83
+ "when",
84
+ "then",
85
+ "this",
86
+ "that",
87
+ "and",
88
+ "the",
89
+ "all",
90
+ "both"
91
+ ]);
92
+ const PAGE_ACTION_VERBS = new Set([
93
+ "implement",
94
+ "create",
95
+ "add",
96
+ "update",
97
+ "refactor",
98
+ "build",
99
+ "verify",
100
+ "test",
101
+ "run",
102
+ "check",
103
+ "document",
104
+ "wire",
105
+ "sync",
106
+ "review",
107
+ "finalize",
108
+ "ship",
109
+ "prepare"
110
+ ]);
111
+ const PAGE_COMMAND_HEADS = new Set([
112
+ "npm",
113
+ "pnpm",
114
+ "yarn",
115
+ "npx",
116
+ "node",
117
+ "da-vinci",
118
+ "git",
119
+ "bash",
120
+ "sh",
121
+ "zsh"
122
+ ]);
26
123
 
27
124
  function normalizePageKey(value) {
28
125
  return normalizeWhitespace(value)
@@ -48,12 +145,61 @@ function registerName(map, key, label) {
48
145
  }
49
146
  }
50
147
 
148
+ function normalizePageCandidate(value) {
149
+ let candidate = String(value || "")
150
+ .replace(/[`"'()]/g, "")
151
+ .replace(/\s+/g, " ")
152
+ .trim();
153
+ if (!candidate) {
154
+ return "";
155
+ }
156
+ const lowered = normalizeWhitespace(candidate);
157
+ if (!lowered) {
158
+ return "";
159
+ }
160
+ if (candidate.includes("/") || /\.(?:html?|tsx?|jsx?|vue|svelte|md)$/i.test(candidate)) {
161
+ return "";
162
+ }
163
+ if (/--|<|>/.test(candidate)) {
164
+ return "";
165
+ }
166
+
167
+ const words = lowered.split(" ").filter(Boolean);
168
+ if (words.length === 0) {
169
+ return "";
170
+ }
171
+ if (PAGE_COMMAND_HEADS.has(words[0])) {
172
+ return "";
173
+ }
174
+
175
+ let start = 0;
176
+ while (start < words.length && PAGE_ACTION_VERBS.has(words[start])) {
177
+ start += 1;
178
+ }
179
+ let end = words.length;
180
+ while (end > start && /^(?:page|pages|screen|screens|section|sections|layout|copy)$/.test(words[end - 1])) {
181
+ end -= 1;
182
+ }
183
+ const scoped = words.slice(start, end);
184
+ if (scoped.length === 0 || scoped.length > 4) {
185
+ return "";
186
+ }
187
+ if (scoped.every((token) => token.length <= 2)) {
188
+ return "";
189
+ }
190
+
191
+ return scoped
192
+ .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
193
+ .join(" ");
194
+ }
195
+
51
196
  function extractPageCandidatesFromItem(item) {
52
197
  const text = String(item || "");
53
198
  const pages = [];
199
+ const matcher = new RegExp(PAGE_NAME_PATTERN.source, "gi");
54
200
  let match;
55
- while ((match = PAGE_NAME_PATTERN.exec(text)) !== null) {
56
- const pageName = String(match[1] || match[2] || "").trim();
201
+ while ((match = matcher.exec(text)) !== null) {
202
+ const pageName = normalizePageCandidate(String(match[1] || match[2] || "").trim());
57
203
  if (pageName) {
58
204
  pages.push(pageName);
59
205
  }
@@ -143,13 +289,23 @@ function parseSpecs(changeDir, projectRoot) {
143
289
  const value = String(item || "").split(":")[0].trim();
144
290
  return value || item;
145
291
  });
292
+ const behaviorItems = parsed.sections.behavior.items || [];
293
+ const acceptanceItems = parsed.sections.acceptance.items || [];
294
+ const inputItems = parsed.sections.inputs.items || [];
295
+ const outputItems = parsed.sections.outputs.items || [];
296
+ const edgeCaseItems = parsed.sections.edgeCases.items || [];
146
297
  stateNames.push(...states);
147
298
  combinedTextChunks.push(text);
148
299
  records.push({
149
300
  path: relativePath,
150
301
  missingSections: parsed.missingSections,
151
302
  emptySections: parsed.emptySections,
152
- states
303
+ states,
304
+ behaviorItems,
305
+ acceptanceItems,
306
+ inputItems,
307
+ outputItems,
308
+ edgeCaseItems
153
309
  });
154
310
  }
155
311
 
@@ -196,30 +352,32 @@ function parsePencilDesign(text) {
196
352
  };
197
353
  }
198
354
 
355
+ function parseBindings(text) {
356
+ if (!String(text || "").trim()) {
357
+ return {
358
+ mappings: [],
359
+ designPages: [],
360
+ implementationPaths: []
361
+ };
362
+ }
363
+ const parsed = parseBindingsArtifact(text);
364
+ const mappings = Array.isArray(parsed.mappings) ? parsed.mappings : [];
365
+ return {
366
+ mappings,
367
+ designPages: unique(mappings.map((mapping) => String(mapping.designPage || "").trim()).filter(Boolean)),
368
+ implementationPaths: unique(
369
+ mappings.map((mapping) => String(mapping.implementation || "").replace(/`/g, "").trim()).filter(Boolean)
370
+ )
371
+ };
372
+ }
373
+
199
374
  function parseTasks(text) {
200
375
  const normalizedText = normalizeWhitespace(text);
201
- const taskGroups = [];
202
- const checklistItems = [];
203
-
204
- const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
205
- for (const line of lines) {
206
- const groupMatch = line.match(/^\s{0,3}##\s+(\d+(?:\.\d+)*)\.\s+(.+)$/);
207
- if (groupMatch) {
208
- taskGroups.push({
209
- id: groupMatch[1],
210
- title: String(groupMatch[2] || "").trim()
211
- });
212
- continue;
213
- }
214
-
215
- const checklistMatch = line.match(/^\s*-\s*\[[ xX]\]\s+(.+)$/);
216
- if (checklistMatch) {
217
- const item = String(checklistMatch[1] || "").trim();
218
- if (item) {
219
- checklistItems.push(item);
220
- }
221
- }
222
- }
376
+ const parsed = parseTasksArtifact(text);
377
+ const taskGroups = Array.isArray(parsed.taskGroups) ? parsed.taskGroups : [];
378
+ const checklistItems = Array.isArray(parsed.checklistItems)
379
+ ? parsed.checklistItems.map((item) => item.text)
380
+ : [];
223
381
 
224
382
  const mentionedPages = unique(checklistItems.flatMap((item) => extractPageCandidatesFromItem(item)));
225
383
 
@@ -245,15 +403,477 @@ function containsAnyToken(source, tokens) {
245
403
  return (tokens || []).some((token) => normalizedSource.includes(normalizeWhitespace(token)));
246
404
  }
247
405
 
248
- function buildScopeResultEnvelope(projectRoot, strict) {
406
+ function attachAnalyzeGateFindings(result, gate) {
407
+ for (const message of gate.blocking || []) {
408
+ result.failures.push(`[gate:analyze] ${message}`);
409
+ }
410
+ for (const message of gate.advisory || []) {
411
+ result.warnings.push(`[gate:analyze] ${message}`);
412
+ }
413
+ for (const message of gate.compatibility || []) {
414
+ result.notes.push(`[gate:analyze] ${message}`);
415
+ }
416
+ }
417
+
418
+ function addAnalyzeFinding(gate, evidenceIndex, severity, message, evidenceItems) {
419
+ const level = severity === "blocking" ? "blocking" : "advisory";
420
+ gate[level].push(message);
421
+ const resolvedEvidence = resolveAnalyzeEvidenceRefs(evidenceItems, {
422
+ index: evidenceIndex
423
+ });
424
+ gate.evidence.push(...resolvedEvidence.refs);
425
+ gate.compatibility.push(...resolvedEvidence.notes);
426
+ }
427
+
428
+ function collectScopedPageTokens(proposal = {}, pageMap = {}) {
429
+ const tokens = new Set();
430
+ for (const page of unique([...(proposal.scopePages || []), ...(pageMap.pages || [])])) {
431
+ const pageTokens = tokenizeNormalizedWords(page, {
432
+ pattern: ANALYZE_TOKEN_PATTERN,
433
+ stopWords: ANALYZE_STOP_WORDS
434
+ });
435
+ for (const token of pageTokens) {
436
+ tokens.add(token);
437
+ }
438
+ }
439
+ return tokens;
440
+ }
441
+
442
+ function evaluateTaskGroupUpstreamSupport(groupTokens, upstreamCorpus, scopedPageTokens) {
443
+ const matchedTokens = groupTokens.filter((token) => upstreamCorpus.includes(token));
444
+ const semanticTokens = groupTokens.filter(
445
+ (token) => !scopedPageTokens.has(token) && !ANALYZE_GENERIC_TASK_TOKENS.has(token)
446
+ );
447
+ const semanticMatches = semanticTokens.filter((token) => upstreamCorpus.includes(token));
448
+ if (semanticTokens.length === 0) {
449
+ return matchedTokens.length > 0;
450
+ }
451
+ return semanticMatches.length > 0;
452
+ }
453
+
454
+ function resolveAnalyzeAnchorArtifact(changeDir, projectRoot, reference) {
455
+ const ref = reference && typeof reference === "object" ? reference : {};
456
+ const artifactPath = String(ref.artifactPath || "").trim();
457
+ const artifactToken = String(ref.artifactToken || "").trim();
458
+ if (!artifactPath) {
459
+ return {
460
+ ok: false,
461
+ reason: "empty_artifact_path"
462
+ };
463
+ }
464
+
465
+ const candidates = [];
466
+ if (path.isAbsolute(artifactPath)) {
467
+ candidates.push(artifactPath);
468
+ } else {
469
+ candidates.push(path.join(changeDir, artifactPath));
470
+ candidates.push(path.join(projectRoot, artifactPath));
471
+ }
472
+ const absolutePath = candidates.find((candidate) => pathExists(candidate));
473
+ if (!absolutePath) {
474
+ return {
475
+ ok: false,
476
+ reason: "artifact_path_missing"
477
+ };
478
+ }
479
+
480
+ if (!artifactToken) {
481
+ return {
482
+ ok: true,
483
+ path: absolutePath
484
+ };
485
+ }
486
+
487
+ const content = readTextIfExists(absolutePath);
488
+ if (!content) {
489
+ return {
490
+ ok: false,
491
+ reason: "artifact_unreadable"
492
+ };
493
+ }
494
+ if (!String(content).toLowerCase().includes(artifactToken.toLowerCase())) {
495
+ return {
496
+ ok: false,
497
+ reason: "artifact_token_unmatched"
498
+ };
499
+ }
500
+ return {
501
+ ok: true,
502
+ path: absolutePath
503
+ };
504
+ }
505
+
506
+ function collectTaskGroupAnchorSignal(group, context = {}) {
507
+ const anchorRefs = Array.isArray(group && group.planningAnchors) ? group.planningAnchors : [];
508
+ const malformedAnchors = Array.isArray(group && group.malformedAnchors) ? group.malformedAnchors : [];
509
+ const hasAnchorSignal = anchorRefs.length > 0 || malformedAnchors.length > 0;
510
+ if (!hasAnchorSignal) {
511
+ return {
512
+ hasAnchorSignal: false,
513
+ supported: false,
514
+ advisory: null,
515
+ notes: []
516
+ };
517
+ }
518
+
519
+ const sidecarIndex = context.sidecarIndex || {
520
+ available: false,
521
+ index: {
522
+ behavior: new Set(),
523
+ acceptance: new Set(),
524
+ state: new Set(),
525
+ mapping: new Set()
526
+ }
527
+ };
528
+ const groupId = String((group && group.id) || "").trim() || "unknown";
529
+ const groupTitle = String((group && group.title) || "").trim();
530
+ const groupLabel = `${groupId}. ${groupTitle}`.trim();
531
+ const notes = [];
532
+
533
+ for (const malformed of malformedAnchors) {
534
+ const reason = String((malformed && malformed.reason) || "unsupported_anchor_format").trim();
535
+ notes.push(`${groupLabel} has malformed planning anchor syntax (${reason}).`);
536
+ }
537
+
538
+ const resolvedAnchors = resolvePlanningAnchorRefs(anchorRefs, {
539
+ sidecarIndex,
540
+ resolveArtifactRef: (reference) =>
541
+ resolveAnalyzeAnchorArtifact(context.changeDir || "", context.projectRoot || "", reference)
542
+ });
543
+ for (const note of resolvedAnchors.notes || []) {
544
+ notes.push(`${groupLabel} anchor resolution note: ${note}`);
545
+ }
546
+
547
+ if (resolvedAnchors.resolved.length > 0) {
548
+ return {
549
+ hasAnchorSignal: true,
550
+ supported: true,
551
+ advisory: null,
552
+ notes
553
+ };
554
+ }
555
+
556
+ const unresolvedReasons = unique(
557
+ (resolvedAnchors.unresolved || []).map((item) => String(item && item.reason ? item.reason : "").trim())
558
+ ).filter(Boolean);
559
+ const reasonSuffix = unresolvedReasons.length > 0 ? ` (${unresolvedReasons.join(", ")})` : "";
249
560
  return {
250
- status: STATUS.PASS,
251
- failures: [],
252
- warnings: [],
561
+ hasAnchorSignal: true,
562
+ supported: false,
563
+ advisory: `task group anchor coverage is unresolved; orphan detection is downgraded to advisory: ${groupLabel}${reasonSuffix}.`,
564
+ notes
565
+ };
566
+ }
567
+
568
+ function collectAnalyzeGate(context = {}) {
569
+ const gate = buildGateEnvelope("analyze");
570
+ const proposal = context.proposal || { scopeItems: [], nonGoalItems: [], scopePages: [] };
571
+ const pageMap = context.pageMap || { pages: [] };
572
+ const pencilDesign = context.pencilDesign || { pages: [] };
573
+ const specs = context.specs || { records: [], combinedText: "" };
574
+ const bindings = context.bindings || { mappings: [], designPages: [], implementationPaths: [] };
575
+ const tasks = context.tasks || { text: "", taskGroups: [] };
576
+ const verificationText = String(context.verificationText || "");
577
+ const evidenceIndex = context.evidenceIndex || {
578
+ available: false,
579
+ maps: {
580
+ behavior: new Map(),
581
+ acceptance: new Map(),
582
+ state: new Map(),
583
+ mappingByImplementation: new Map()
584
+ },
253
585
  notes: [],
254
- projectRoot,
255
- changeId: null,
256
- strict,
586
+ warnings: []
587
+ };
588
+ const paths = context.paths || {
589
+ proposalPath: "proposal.md",
590
+ pencilDesignPath: "pencil-design.md",
591
+ bindingsPath: "pencil-bindings.md",
592
+ tasksPath: "tasks.md",
593
+ verificationPath: "verification.md",
594
+ pageMapPath: "page-map.md"
595
+ };
596
+ const planningAnchorIndex = context.planningAnchorIndex || {
597
+ available: false,
598
+ index: {
599
+ behavior: new Set(),
600
+ acceptance: new Set(),
601
+ state: new Set(),
602
+ mapping: new Set()
603
+ },
604
+ notes: [],
605
+ warnings: []
606
+ };
607
+
608
+ gate.compatibility.push(...(evidenceIndex.notes || []));
609
+ gate.compatibility.push(...(evidenceIndex.warnings || []));
610
+ gate.compatibility.push(...(planningAnchorIndex.notes || []));
611
+ gate.compatibility.push(...(planningAnchorIndex.warnings || []));
612
+ if (!verificationText.trim()) {
613
+ gate.compatibility.push("Missing `verification.md`; analyze evidence uses tasks-only fallback.");
614
+ }
615
+ if (!Array.isArray(bindings.mappings) || bindings.mappings.length === 0) {
616
+ gate.compatibility.push("Missing or empty `pencil-bindings.md`; analyze mapping coverage is degraded.");
617
+ }
618
+
619
+ const downstreamText = `${tasks.text}\n${verificationText}`;
620
+ for (const record of specs.records || []) {
621
+ for (const behaviorItem of record.behaviorItems || []) {
622
+ const tokens = tokenizeNormalizedWords(behaviorItem, {
623
+ pattern: ANALYZE_TOKEN_PATTERN,
624
+ stopWords: ANALYZE_STOP_WORDS
625
+ });
626
+ if (tokens.length < 2) {
627
+ continue;
628
+ }
629
+ if (!textContainsAnyNormalizedToken(downstreamText, tokens)) {
630
+ addAnalyzeFinding(
631
+ gate,
632
+ evidenceIndex,
633
+ "blocking",
634
+ `orphan upstream behavior lacks downstream support: "${behaviorItem}" (${record.path}).`,
635
+ [
636
+ { artifactPath: record.path, itemText: behaviorItem, kind: "behavior" },
637
+ { artifactPath: paths.tasksPath, itemText: behaviorItem, kind: "behavior" },
638
+ { artifactPath: paths.verificationPath, itemText: behaviorItem, kind: "behavior" }
639
+ ]
640
+ );
641
+ }
642
+ }
643
+ for (const acceptanceItem of record.acceptanceItems || []) {
644
+ const tokens = tokenizeNormalizedWords(acceptanceItem, {
645
+ pattern: ANALYZE_TOKEN_PATTERN,
646
+ stopWords: ANALYZE_STOP_WORDS
647
+ });
648
+ if (tokens.length < 2) {
649
+ continue;
650
+ }
651
+ if (!textContainsAnyNormalizedToken(downstreamText, tokens)) {
652
+ addAnalyzeFinding(
653
+ gate,
654
+ evidenceIndex,
655
+ "blocking",
656
+ `acceptance outcome lacks tasks/verification support: "${acceptanceItem}" (${record.path}).`,
657
+ [
658
+ { artifactPath: record.path, itemText: acceptanceItem, kind: "acceptance" },
659
+ { artifactPath: paths.tasksPath, itemText: acceptanceItem, kind: "acceptance" },
660
+ { artifactPath: paths.verificationPath, itemText: acceptanceItem, kind: "acceptance" }
661
+ ]
662
+ );
663
+ }
664
+ }
665
+ }
666
+
667
+ const upstreamCorpus = normalizeWhitespace(
668
+ [
669
+ ...(proposal.scopeItems || []),
670
+ ...(proposal.scopePages || []),
671
+ ...(pageMap.pages || []),
672
+ ...((specs.records || []).flatMap((record) => [
673
+ ...(record.behaviorItems || []),
674
+ ...(record.acceptanceItems || [])
675
+ ]))
676
+ ].join(" ")
677
+ );
678
+ const scopedPageTokens = collectScopedPageTokens(proposal, pageMap);
679
+ for (const group of tasks.taskGroups || []) {
680
+ const groupText = normalizeWhitespace(
681
+ [
682
+ group.title,
683
+ ...(Array.isArray(group.checklistItems) ? group.checklistItems.map((item) => item.text || "") : [])
684
+ ].join(" ")
685
+ );
686
+ const groupTokens = tokenizeNormalizedWords(groupText, {
687
+ pattern: ANALYZE_TOKEN_PATTERN,
688
+ stopWords: ANALYZE_STOP_WORDS
689
+ });
690
+ if (groupTokens.length === 0) {
691
+ continue;
692
+ }
693
+ if (/verify|verification/i.test(String(group.title || ""))) {
694
+ continue;
695
+ }
696
+ const anchorSignal = collectTaskGroupAnchorSignal(group, {
697
+ sidecarIndex: planningAnchorIndex,
698
+ changeDir: context.changeDir || "",
699
+ projectRoot: context.projectRoot || ""
700
+ });
701
+ gate.compatibility.push(...(anchorSignal.notes || []));
702
+ if (anchorSignal.hasAnchorSignal) {
703
+ if (anchorSignal.supported) {
704
+ continue;
705
+ }
706
+ addAnalyzeFinding(
707
+ gate,
708
+ evidenceIndex,
709
+ "advisory",
710
+ anchorSignal.advisory,
711
+ [{ artifactPath: paths.tasksPath, itemText: `${group.id || "unknown"}. ${group.title || ""}`, kind: "behavior" }]
712
+ );
713
+ continue;
714
+ }
715
+ const supported = evaluateTaskGroupUpstreamSupport(groupTokens, upstreamCorpus, scopedPageTokens);
716
+ if (!supported) {
717
+ const groupId = String(group.id || "").trim() || "unknown";
718
+ addAnalyzeFinding(
719
+ gate,
720
+ evidenceIndex,
721
+ "blocking",
722
+ `orphan task group has no upstream planning support: ${groupId}. ${group.title}`,
723
+ [{ artifactPath: paths.tasksPath, itemText: `${groupId}. ${group.title}`, kind: "behavior" }]
724
+ );
725
+ }
726
+ }
727
+
728
+ const tasksText = normalizeWhitespace(tasks.text);
729
+ const specsText = normalizeWhitespace(specs.combinedText);
730
+ for (const nonGoal of proposal.nonGoalItems || []) {
731
+ const tokens = tokenizeNormalizedWords(nonGoal, {
732
+ pattern: ANALYZE_TOKEN_PATTERN,
733
+ stopWords: ANALYZE_STOP_WORDS
734
+ });
735
+ if (tokens.length === 0) {
736
+ continue;
737
+ }
738
+ const leakedToTasks = textContainsAnyNormalizedToken(tasksText, tokens);
739
+ const leakedToSpecs = textContainsAnyNormalizedToken(specsText, tokens);
740
+ const leakedToVerification = textContainsAnyNormalizedToken(verificationText, tokens);
741
+ if (leakedToTasks || leakedToSpecs || leakedToVerification) {
742
+ addAnalyzeFinding(
743
+ gate,
744
+ evidenceIndex,
745
+ "blocking",
746
+ `non-goal leakage detected for "${nonGoal}" across downstream planning artifacts.`,
747
+ [
748
+ { artifactPath: paths.proposalPath, itemText: nonGoal, kind: "behavior" },
749
+ { artifactPath: paths.tasksPath, itemText: nonGoal, kind: "behavior" },
750
+ { artifactPath: paths.verificationPath, itemText: nonGoal, kind: "behavior" }
751
+ ]
752
+ );
753
+ }
754
+ }
755
+
756
+ const proposalPageSet = new Set((proposal.scopePages || []).map((item) => normalizePageKey(item)));
757
+ const pageMapSet = new Set((pageMap.pages || []).map((item) => normalizePageKey(item)));
758
+ for (const page of pageMap.pages || []) {
759
+ const key = normalizePageKey(page);
760
+ if (!key || proposalPageSet.has(key)) {
761
+ continue;
762
+ }
763
+ addAnalyzeFinding(
764
+ gate,
765
+ evidenceIndex,
766
+ "blocking",
767
+ `material page/surface disagreement: page-map page "${page}" is outside proposal scope.`,
768
+ [
769
+ { artifactPath: paths.pageMapPath, itemText: page, kind: "behavior" },
770
+ { artifactPath: paths.proposalPath, itemText: page, kind: "behavior" }
771
+ ]
772
+ );
773
+ }
774
+ for (const page of proposal.scopePages || []) {
775
+ const key = normalizePageKey(page);
776
+ if (!key || pageMapSet.has(key)) {
777
+ continue;
778
+ }
779
+ addAnalyzeFinding(
780
+ gate,
781
+ evidenceIndex,
782
+ "blocking",
783
+ `material page/surface disagreement: proposal scope page "${page}" is missing from page-map.`,
784
+ [
785
+ { artifactPath: paths.proposalPath, itemText: page, kind: "behavior" },
786
+ { artifactPath: paths.pageMapPath, itemText: page, kind: "behavior" }
787
+ ]
788
+ );
789
+ }
790
+ const pencilSet = new Set((pencilDesign.pages || []).map((item) => normalizePageKey(item)));
791
+ const upstreamPageSet = new Set([...proposalPageSet, ...pageMapSet]);
792
+ for (const page of pencilDesign.pages || []) {
793
+ const key = normalizePageKey(page);
794
+ if (!key || upstreamPageSet.has(key)) {
795
+ continue;
796
+ }
797
+ addAnalyzeFinding(
798
+ gate,
799
+ evidenceIndex,
800
+ "blocking",
801
+ `material page/surface disagreement: pencil-design page "${page}" is outside proposal/page-map scope.`,
802
+ [
803
+ { artifactPath: paths.pencilDesignPath, itemText: page, kind: "behavior" },
804
+ { artifactPath: paths.proposalPath, itemText: page, kind: "behavior" },
805
+ { artifactPath: paths.pageMapPath, itemText: page, kind: "behavior" }
806
+ ]
807
+ );
808
+ }
809
+ if (pencilSet.size > 0) {
810
+ for (const page of unique([...(proposal.scopePages || []), ...(pageMap.pages || [])])) {
811
+ const key = normalizePageKey(page);
812
+ if (!key || pencilSet.has(key)) {
813
+ continue;
814
+ }
815
+ addAnalyzeFinding(
816
+ gate,
817
+ evidenceIndex,
818
+ "blocking",
819
+ `material page/surface disagreement: in-scope page "${page}" is missing from pencil-design.`,
820
+ [
821
+ { artifactPath: paths.proposalPath, itemText: page, kind: "behavior" },
822
+ { artifactPath: paths.pageMapPath, itemText: page, kind: "behavior" },
823
+ { artifactPath: paths.pencilDesignPath, itemText: page, kind: "behavior" }
824
+ ]
825
+ );
826
+ }
827
+ }
828
+
829
+ const bindingsPageSet = new Set((bindings.designPages || []).map((page) => normalizePageKey(page)));
830
+ for (const page of pageMap.pages || []) {
831
+ const key = normalizePageKey(page);
832
+ if (!key || bindingsPageSet.has(key)) {
833
+ continue;
834
+ }
835
+ addAnalyzeFinding(
836
+ gate,
837
+ evidenceIndex,
838
+ "blocking",
839
+ `binding mapping drift: in-scope page "${page}" is missing from pencil-bindings mappings.`,
840
+ [{ artifactPath: paths.bindingsPath, itemText: page, kind: "mapping" }]
841
+ );
842
+ }
843
+ for (const mapping of bindings.mappings || []) {
844
+ const designPage = String(mapping.designPage || "").trim();
845
+ const key = normalizePageKey(designPage);
846
+ if (!key) {
847
+ continue;
848
+ }
849
+ if (proposalPageSet.has(key) || pageMapSet.has(key)) {
850
+ continue;
851
+ }
852
+ addAnalyzeFinding(
853
+ gate,
854
+ evidenceIndex,
855
+ "blocking",
856
+ `binding mapping drift: mapping page "${designPage}" is outside proposal/page-map scope.`,
857
+ [
858
+ {
859
+ artifactPath: paths.bindingsPath,
860
+ itemText: designPage,
861
+ kind: "mapping",
862
+ implementationPath: mapping.implementation || ""
863
+ }
864
+ ]
865
+ );
866
+ }
867
+
868
+ return gate;
869
+ }
870
+
871
+ function buildScopeResultEnvelope(projectRoot, strict) {
872
+ return {
873
+ ...buildBasePlanningResultEnvelope(projectRoot, strict),
874
+ gates: {
875
+ analyze: null
876
+ },
257
877
  coverage: null,
258
878
  matrix: {
259
879
  pages: [],
@@ -266,30 +886,8 @@ function buildScopeResultEnvelope(projectRoot, strict) {
266
886
  };
267
887
  }
268
888
 
269
- function resolveChange(projectRoot, requestedChangeId, failures, notes) {
270
- const resolved = resolveChangeDir(projectRoot, requestedChangeId);
271
- failures.push(...resolved.failures);
272
- notes.push(...resolved.notes);
273
- return resolved.changeDir;
274
- }
275
-
276
- function finalizeResult(result) {
277
- const hasFindings = result.failures.length > 0 || result.warnings.length > 0;
278
- if (!hasFindings) {
279
- result.status = STATUS.PASS;
280
- return result;
281
- }
282
-
283
- if (result.strict) {
284
- result.status = STATUS.BLOCK;
285
- return result;
286
- }
287
-
288
- result.status = STATUS.WARN;
289
- return result;
290
- }
291
-
292
889
  function analyzeScopePropagation(result, proposal, pageMap, specs, pencilDesign, tasks) {
890
+ const propagationWarnings = [];
293
891
  const pageNames = new Map();
294
892
  const stateNames = new Map();
295
893
 
@@ -414,39 +1012,41 @@ function analyzeScopePropagation(result, proposal, pageMap, specs, pencilDesign,
414
1012
 
415
1013
  for (const row of result.matrix.pages) {
416
1014
  if (row.pageMap && !row.proposal) {
417
- result.warnings.push(
1015
+ propagationWarnings.push(
418
1016
  `Page propagation gap: \`${row.label}\` is in page-map but missing from proposal scope.`
419
1017
  );
420
1018
  }
421
1019
  if (row.proposal && !row.pageMap) {
422
- result.warnings.push(
1020
+ propagationWarnings.push(
423
1021
  `Page propagation gap: \`${row.label}\` is in proposal scope but missing from page-map.`
424
1022
  );
425
1023
  }
426
1024
  if (row.pageMap && !row.spec) {
427
- result.warnings.push(`Page propagation gap: \`${row.label}\` is in page-map but missing from specs.`);
1025
+ propagationWarnings.push(`Page propagation gap: \`${row.label}\` is in page-map but missing from specs.`);
428
1026
  }
429
1027
  if (row.pageMap && !row.tasks) {
430
- result.warnings.push(`Page propagation gap: \`${row.label}\` is in page-map but missing from tasks.`);
1028
+ propagationWarnings.push(`Page propagation gap: \`${row.label}\` is in page-map but missing from tasks.`);
431
1029
  }
432
1030
  if (row.tasks && !row.proposal && !row.pageMap) {
433
- result.warnings.push(`Overscoped task surface: \`${row.label}\` appears in tasks but is outside proposal/page-map scope.`);
1031
+ propagationWarnings.push(
1032
+ `Overscoped task surface: \`${row.label}\` appears in tasks but is outside proposal/page-map scope.`
1033
+ );
434
1034
  }
435
1035
  }
436
1036
 
437
1037
  for (const row of result.matrix.states) {
438
1038
  if (row.spec && !row.pageMap) {
439
- result.warnings.push(
1039
+ propagationWarnings.push(
440
1040
  `State propagation gap: \`${row.label}\` is in specs but missing from page-map states.`
441
1041
  );
442
1042
  }
443
1043
  if (row.spec && !row.pencil) {
444
- result.warnings.push(
1044
+ propagationWarnings.push(
445
1045
  `State propagation gap: \`${row.label}\` is in specs but missing from pencil-design states.`
446
1046
  );
447
1047
  }
448
1048
  if (row.spec && !row.tasks) {
449
- result.warnings.push(`State propagation gap: \`${row.label}\` is in specs but missing from tasks.`);
1049
+ propagationWarnings.push(`State propagation gap: \`${row.label}\` is in specs but missing from tasks.`);
450
1050
  }
451
1051
  }
452
1052
 
@@ -455,7 +1055,7 @@ function analyzeScopePropagation(result, proposal, pageMap, specs, pencilDesign,
455
1055
  .map((item) => normalizeWhitespace(item))
456
1056
  .filter((item) => scopeSet.has(item));
457
1057
  for (const value of unique(nonGoalOverlap)) {
458
- result.warnings.push(`Contradictory scope signal: \`${value}\` appears in both Scope and Non-Goals.`);
1058
+ propagationWarnings.push(`Contradictory scope signal: \`${value}\` appears in both Scope and Non-Goals.`);
459
1059
  }
460
1060
 
461
1061
  result.coverage = {
@@ -482,6 +1082,7 @@ function analyzeScopePropagation(result, proposal, pageMap, specs, pencilDesign,
482
1082
  pages: tasks.mentionedPages
483
1083
  }
484
1084
  };
1085
+ return propagationWarnings;
485
1086
  }
486
1087
 
487
1088
  function runScopeCheck(projectPathInput, options = {}) {
@@ -496,16 +1097,16 @@ function runScopeCheck(projectPathInput, options = {}) {
496
1097
  result.notes.push(
497
1098
  "Promotion criteria: turn on `--strict` in CI after scope-check warning baselines are stable and reviewed."
498
1099
  );
499
- return finalizeResult(result);
1100
+ return finalizePlanningResult(result);
500
1101
  }
501
1102
 
502
- const changeDir = resolveChange(projectRoot, requestedChangeId, result.failures, result.notes);
1103
+ const changeDir = resolveChangeWithFindings(projectRoot, requestedChangeId, result.failures, result.notes);
503
1104
  if (!changeDir) {
504
1105
  result.notes.push("scope-check defaults to advisory mode; pass `--strict` to block on findings.");
505
1106
  result.notes.push(
506
1107
  "Promotion criteria: turn on `--strict` in CI after scope-check warning baselines are stable and reviewed."
507
1108
  );
508
- return finalizeResult(result);
1109
+ return finalizePlanningResult(result);
509
1110
  }
510
1111
  result.changeId = path.basename(changeDir);
511
1112
 
@@ -513,12 +1114,16 @@ function runScopeCheck(projectPathInput, options = {}) {
513
1114
  const pageMapPath = path.join(projectRoot, ".da-vinci", "page-map.md");
514
1115
  const fallbackPageMapPath = path.join(changeDir, "page-map.md");
515
1116
  const pencilDesignPath = path.join(changeDir, "pencil-design.md");
1117
+ const bindingsPath = path.join(changeDir, "pencil-bindings.md");
516
1118
  const tasksPath = path.join(changeDir, "tasks.md");
1119
+ const verificationPath = path.join(changeDir, "verification.md");
517
1120
 
518
1121
  const proposalText = readTextIfExists(proposalPath);
519
1122
  const pageMapText = readTextIfExists(pageMapPath) || readTextIfExists(fallbackPageMapPath);
520
1123
  const pencilDesignText = readTextIfExists(pencilDesignPath);
1124
+ const bindingsText = readTextIfExists(bindingsPath);
521
1125
  const tasksText = readTextIfExists(tasksPath);
1126
+ const verificationText = readTextIfExists(verificationPath);
522
1127
 
523
1128
  if (!proposalText) {
524
1129
  result.failures.push("Missing `proposal.md` for scope-check.");
@@ -549,8 +1154,45 @@ function runScopeCheck(projectPathInput, options = {}) {
549
1154
  const proposal = parseProposal(proposalText);
550
1155
  const pageMap = parsePageMap(pageMapText);
551
1156
  const pencilDesign = parsePencilDesign(pencilDesignText);
1157
+ const bindings = parseBindings(bindingsText);
552
1158
  const tasks = parseTasks(tasksText);
553
- analyzeScopePropagation(result, proposal, pageMap, specs, pencilDesign, tasks);
1159
+ const evidenceIndex = loadAnalyzeEvidenceIndex(changeDir);
1160
+ const planningAnchorIndex = loadPlanningAnchorIndex(changeDir);
1161
+ const propagationWarnings = analyzeScopePropagation(result, proposal, pageMap, specs, pencilDesign, tasks);
1162
+ const analyzeGate = collectAnalyzeGate({
1163
+ projectRoot,
1164
+ changeDir,
1165
+ proposal,
1166
+ pageMap,
1167
+ pencilDesign,
1168
+ specs,
1169
+ bindings,
1170
+ tasks,
1171
+ verificationText,
1172
+ evidenceIndex,
1173
+ planningAnchorIndex,
1174
+ paths: {
1175
+ proposalPath: path.relative(projectRoot, proposalPath) || proposalPath,
1176
+ pencilDesignPath: path.relative(projectRoot, pencilDesignPath) || pencilDesignPath,
1177
+ bindingsPath: path.relative(projectRoot, bindingsPath) || bindingsPath,
1178
+ tasksPath: path.relative(projectRoot, tasksPath) || tasksPath,
1179
+ verificationPath: path.relative(projectRoot, verificationPath) || verificationPath,
1180
+ pageMapPath:
1181
+ path.relative(projectRoot, pageMapPath) || path.relative(projectRoot, fallbackPageMapPath) || pageMapPath
1182
+ }
1183
+ });
1184
+ if (propagationWarnings.length > 0) {
1185
+ analyzeGate.advisory = unique([
1186
+ ...(Array.isArray(analyzeGate.advisory) ? analyzeGate.advisory : []),
1187
+ ...propagationWarnings.map((item) => `scope propagation: ${item}`)
1188
+ ]);
1189
+ }
1190
+ result.gates.analyze = finalizeGateEnvelope(analyzeGate, {
1191
+ strict,
1192
+ // Compatibility findings (for example sidecar availability) must degrade gracefully.
1193
+ warnOnCompatibility: false
1194
+ });
1195
+ attachAnalyzeGateFindings(result, result.gates.analyze);
554
1196
  }
555
1197
 
556
1198
  result.warnings = unique(result.warnings);
@@ -560,10 +1202,13 @@ function runScopeCheck(projectPathInput, options = {}) {
560
1202
  "Promotion criteria: turn on `--strict` in CI after scope-check warning baselines are stable and reviewed."
561
1203
  );
562
1204
 
563
- return finalizeResult(result);
1205
+ return finalizePlanningResult(result);
564
1206
  }
565
1207
 
566
1208
  function formatScopeCheckReport(result) {
1209
+ const formatBool = (value) => (value ? "Y" : "N");
1210
+ const pageRows = Array.isArray(result.matrix && result.matrix.pages) ? result.matrix.pages : [];
1211
+ const stateRows = Array.isArray(result.matrix && result.matrix.states) ? result.matrix.states : [];
567
1212
  const lines = [
568
1213
  "Da Vinci scope-check",
569
1214
  `Project: ${result.projectRoot}`,
@@ -587,6 +1232,30 @@ function formatScopeCheckReport(result) {
587
1232
  }
588
1233
  }
589
1234
 
1235
+ if (pageRows.length > 0) {
1236
+ lines.push("", "Page Matrix (sample):");
1237
+ for (const row of pageRows.slice(0, 8)) {
1238
+ lines.push(
1239
+ `- ${row.label || row.key}: proposal=${formatBool(row.proposal)}, page-map=${formatBool(row.pageMap)}, spec=${formatBool(row.spec)}, pencil=${formatBool(row.pencil)}, tasks=${formatBool(row.tasks)}`
1240
+ );
1241
+ }
1242
+ if (pageRows.length > 8) {
1243
+ lines.push(`- ... ${pageRows.length - 8} more page row(s); use --json for full matrix.`);
1244
+ }
1245
+ }
1246
+
1247
+ if (stateRows.length > 0) {
1248
+ lines.push("", "State Matrix (sample):");
1249
+ for (const row of stateRows.slice(0, 8)) {
1250
+ lines.push(
1251
+ `- ${row.label || row.key}: spec=${formatBool(row.spec)}, page-map=${formatBool(row.pageMap)}, pencil=${formatBool(row.pencil)}, tasks=${formatBool(row.tasks)}`
1252
+ );
1253
+ }
1254
+ if (stateRows.length > 8) {
1255
+ lines.push(`- ... ${stateRows.length - 8} more state row(s); use --json for full matrix.`);
1256
+ }
1257
+ }
1258
+
590
1259
  if (result.notes.length > 0) {
591
1260
  lines.push("", "Notes:");
592
1261
  for (const note of result.notes) {