dflow-sdd-ddd 0.13.0 → 0.14.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.
@@ -0,0 +1,178 @@
1
+ // PROPOSAL-058: pure, shippable drift-detection helpers for `dflow doctor`.
2
+ //
3
+ // The dev-only cross-ref resolver (scripts/check-cross-refs.mjs, PROPOSAL-055)
4
+ // is not part of the npm package, so the runtime checks reimplement the narrow
5
+ // subset doctor needs: fence-aware heading extraction, "<file> § Heading"
6
+ // reference extraction with soft-wrap joining, tolerant heading matching, and
7
+ // template-shape comparison. Everything here is I/O-free — callers read the
8
+ // files and pass contents — which keeps the checks unit-testable.
9
+
10
+ 'use strict';
11
+
12
+ // Machine-readable context lines. Shared with lib/init.js inference so the
13
+ // doctor "machine format" checks can never drift from what inference actually
14
+ // parses: inferGitPolicy / inferAiCommitMarker / inferProseLanguage read
15
+ // _conventions.md; inferTechStackSummary / inferMigrationContext read the
16
+ // `| Tech stack |` / `| Migration / legacy context |` rows of the guide's
17
+ // "## Project Context" table (PROPOSAL-076 — no packaged _overview.md template
18
+ // ever carried those rows).
19
+ const GIT_POLICY_LINE_RE = /Selected Git policy:\s*`([^`]+)`/;
20
+ const AI_COMMIT_MARKER_LINE_RE = /AI commit marker:\s*`([^`]+)`/;
21
+ const PROSE_LANGUAGE_LINE_RE = /Project prose language:\s*`([^`]+)`/;
22
+ // The row values accept Markdown-escaped pipes (`\|`) so a cell like
23
+ // `Node \| Express` is captured whole; parseContextLine unescapes them
24
+ // (PROPOSAL-076 gate G1 — a bare `[^|\n]` capture silently truncated at the
25
+ // escaped pipe while doctor still called the row machine-readable).
26
+ const TECH_STACK_ROW_RE = /\|\s*Tech stack\s*\|\s*((?:\\\||[^|\n])+?)\s*\|/i;
27
+ const MIGRATION_CONTEXT_ROW_RE = /\|\s*Migration \/ legacy context\s*\|\s*((?:\\\||[^|\n])+?)\s*\|/i;
28
+
29
+ const GIT_POLICY_VALUES = new Set(['gitflow', 'trunk']);
30
+ const AI_COMMIT_MARKER_VALUES = new Set(['none', 'co-authored-by', 'prefix']);
31
+
32
+ // Trimmed capture of a machine-readable context line, or null when the line is
33
+ // absent or its value is whitespace-only. Inference and the doctor checks must
34
+ // both parse through this helper: a value doctor accepts has to be the exact
35
+ // value configure-agents consumes (e.g. a stray space inside the backticks
36
+ // would otherwise pass doctor's trimmed validation yet miss strict comparisons
37
+ // like buildSubstitutionMap's `gitflow` check downstream).
38
+ function parseContextLine(content, re) {
39
+ const match = content.match(re);
40
+ if (!match) return null;
41
+ // Unescape Markdown-escaped pipes captured by the table-row patterns; the
42
+ // backtick context lines never contain `\|`, so this is a no-op for them.
43
+ const value = match[1].replace(/\\\|/g, '|').trim();
44
+ return value === '' ? null : value;
45
+ }
46
+
47
+ // Blank out fenced code blocks (``` / ~~~) line-by-line so headings and § refs
48
+ // inside examples are never extracted. Line positions are preserved. CommonMark
49
+ // close rules (PROPOSAL-076 gates G4/G6): a block closes only on the same fence
50
+ // character repeated at least the opening length, indented at most three
51
+ // spaces, with nothing but whitespace after — so a three-backtick line inside a
52
+ // four-backtick example, or an info-string line like ```js inside an open
53
+ // fence, is content and does not end the block early.
54
+ function blankFencedBlocks(content) {
55
+ const lines = content.split(/\r?\n/);
56
+ const out = [];
57
+ let fenceChar = null;
58
+ let fenceLen = 0;
59
+ for (const line of lines) {
60
+ if (fenceChar) {
61
+ out.push('');
62
+ const close = line.match(/^[ \t]{0,3}(```+|~~~+)[ \t]*$/);
63
+ if (close && close[1][0] === fenceChar && close[1].length >= fenceLen) {
64
+ fenceChar = null;
65
+ fenceLen = 0;
66
+ }
67
+ continue;
68
+ }
69
+ const open = line.match(/^[ \t]{0,3}(```+|~~~+)/);
70
+ if (open) {
71
+ fenceChar = open[1][0];
72
+ fenceLen = open[1].length;
73
+ out.push('');
74
+ continue;
75
+ }
76
+ out.push(line);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // All Markdown heading texts (any level), fence-aware.
82
+ function extractHeadings(content) {
83
+ const headings = [];
84
+ for (const line of blankFencedBlocks(content)) {
85
+ const m = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
86
+ if (m) headings.push(m[1].trim());
87
+ }
88
+ return headings;
89
+ }
90
+
91
+ // H2 ("## ") heading texts only, fence-aware — the section shape of a template.
92
+ function extractH2Headings(content) {
93
+ const headings = [];
94
+ for (const line of blankFencedBlocks(content)) {
95
+ const m = line.match(/^##\s+(.+?)\s*#*\s*$/);
96
+ if (m) headings.push(m[1].trim());
97
+ }
98
+ return headings;
99
+ }
100
+
101
+ // `<file>.md § Heading` references whose file basename is `targetBasename`.
102
+ // A soft-wrapped heading name is captured by joining the next line; a match
103
+ // anchored beyond the current line is left to that line's own iteration.
104
+ function extractSectionRefs(content, targetBasename) {
105
+ const lines = blankFencedBlocks(content);
106
+ const refs = [];
107
+ lines.forEach((line, i) => {
108
+ const firstLen = line.replace(/\s+$/, '').length;
109
+ const joined = line.replace(/\s+$/, '') + ' ' + (lines[i + 1] || '').replace(/^\s+/, '');
110
+ for (const m of joined.matchAll(/`?([A-Za-z0-9._/-]+\.md)`?\s*§\s*"?([^."`)\n]+)/g)) {
111
+ if (m.index > firstLen) continue;
112
+ const base = m[1].split('/').pop();
113
+ if (base !== targetBasename) continue;
114
+ refs.push({ line: i + 1, headingText: m[2].trim() });
115
+ }
116
+ });
117
+ return refs;
118
+ }
119
+
120
+ function normalizeHeading(text) {
121
+ return text.replace(/[`*_]/g, '').trim();
122
+ }
123
+
124
+ // Tolerant heading resolution: a reference resolves when it prefix-matches a
125
+ // real heading in either direction (covers soft wraps like "§ Workflow" for
126
+ // "Workflow Transparency" and shorthand references).
127
+ function headingResolves(referenceText, headings) {
128
+ const want = normalizeHeading(referenceText);
129
+ if (!want) return true;
130
+ return headings.some((heading) => {
131
+ const have = normalizeHeading(heading);
132
+ return have.startsWith(want) || want.startsWith(have);
133
+ });
134
+ }
135
+
136
+ // Which of the template's H2 sections are missing from a filled document — the
137
+ // "created from an older template shape" signal.
138
+ function missingTemplateSections(templateContent, documentContent) {
139
+ const have = new Set(extractH2Headings(documentContent).map(normalizeHeading));
140
+ return extractH2Headings(templateContent).filter((heading) => !have.has(normalizeHeading(heading)));
141
+ }
142
+
143
+ // True when `content` matches `templateContent` up to placeholder substitution:
144
+ // every single-line `{...}` placeholder in the template may match any text.
145
+ // Distinguishes "pristine current starter" from "edited or older starter"
146
+ // without knowing the values init substituted.
147
+ function matchesTemplateWithPlaceholders(content, templateContent) {
148
+ const normalize = (s) => s.replace(/\r\n/g, '\n').replace(/\s+$/, '');
149
+ const parts = normalize(templateContent).split(/\{[^}\n]*\}/);
150
+ const pattern = `^${parts.map(escapeRegExp).join('[\\s\\S]*?')}$`;
151
+ try {
152
+ return new RegExp(pattern).test(normalize(content));
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ function escapeRegExp(s) {
159
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
160
+ }
161
+
162
+ module.exports = {
163
+ GIT_POLICY_LINE_RE,
164
+ AI_COMMIT_MARKER_LINE_RE,
165
+ PROSE_LANGUAGE_LINE_RE,
166
+ TECH_STACK_ROW_RE,
167
+ MIGRATION_CONTEXT_ROW_RE,
168
+ GIT_POLICY_VALUES,
169
+ AI_COMMIT_MARKER_VALUES,
170
+ parseContextLine,
171
+ blankFencedBlocks,
172
+ extractHeadings,
173
+ extractH2Headings,
174
+ extractSectionRefs,
175
+ headingResolves,
176
+ missingTemplateSections,
177
+ matchesTemplateWithPlaceholders
178
+ };