cans-spec 0.1.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,300 @@
1
+ import type {
2
+ CommandResult, CheckResult, Issue, InitResult, NewResult, DoneResult, StatusResult,
3
+ BudgetReadResult, BudgetWriteResult, ImportResult, ExportResult, VersionResult,
4
+ } from '../types';
5
+
6
+ /** Single emission point. Commands never console.log or process.exit directly.
7
+ * `refsOnly` (check only, §22/§36): human output is scoped to the References
8
+ * section (+ Rules + summary); JSON output is always the full result. */
9
+ export function emit(result: CommandResult, json: boolean, refsOnly?: boolean): void {
10
+ if (json) {
11
+ console.log(JSON.stringify(result, null, 2));
12
+ return;
13
+ }
14
+ printHuman(result, refsOnly);
15
+ }
16
+
17
+ const CATEGORY_ORDER: Array<Issue['category']> = ['structure', 'style', 'refs', 'redundancy', 'overflow'];
18
+
19
+ export function printHuman(result: CommandResult, refsOnly?: boolean): void {
20
+ switch (result.command) {
21
+ case 'check':
22
+ printCheckHuman(result as CheckResult, refsOnly);
23
+ break;
24
+ case 'help':
25
+ printHelp();
26
+ break;
27
+ case 'version': {
28
+ const r = result as VersionResult;
29
+ console.log(`cans ${r.version}`);
30
+ break;
31
+ }
32
+ case 'init': {
33
+ const r = result as InitResult;
34
+ if (!r.ok) {
35
+ console.log(`✗ ${r.error ?? 'cannot init here'}`);
36
+ break;
37
+ }
38
+ console.log(`Workspace: ${r.root}`);
39
+ if (r.created?.length) {
40
+ for (const c of r.created) console.log(` + ${c}`);
41
+ }
42
+ if (r.skipped?.length) {
43
+ for (const s of r.skipped) console.log(` = ${s} (exists, skipped)`);
44
+ }
45
+ break;
46
+ }
47
+ case 'new': {
48
+ const r = result as NewResult;
49
+ if (r.ok) {
50
+ console.log(`Created ${r.file}`);
51
+ if (r.warning) console.log(` ⚠ ${r.warning}`);
52
+ } else {
53
+ console.log(`✗ ${r.error ?? 'failed to create'}`);
54
+ }
55
+ break;
56
+ }
57
+ case 'done': {
58
+ const r = result as DoneResult;
59
+ if (!r.ok) {
60
+ if (r.error) {
61
+ console.log(`✗ ${r.error}`);
62
+ } else if ((r.gates?.humanOpen ?? 0) > 0) {
63
+ console.log(`✗ BLOCKED: ${r.gates.humanOpen} unchecked ← @human gate${r.gates.humanOpen > 1 ? 's' : ''}`);
64
+ // §36: file:line — gate text
65
+ for (const g of r.gateDetails ?? []) {
66
+ if (g.text.includes('@human')) {
67
+ console.log(` ${g.file}:${g.line} — ${g.text}`);
68
+ }
69
+ }
70
+ console.log(' Check the gate, then re-run cans done.');
71
+ } else if ((r.gates?.tasksOpen ?? 0) > 0) {
72
+ console.log(`✗ BLOCKED: ${r.gates.tasksOpen} open task${r.gates.tasksOpen > 1 ? 's' : ''} (--allow-incomplete to override)`);
73
+ for (const g of r.gateDetails ?? []) {
74
+ if (!g.text.includes('@human')) {
75
+ console.log(` ${g.file}:${g.line} — ${g.text}`);
76
+ }
77
+ }
78
+ } else {
79
+ console.log(`✗ BLOCKED: cans check failed (--skip-check to override)`);
80
+ }
81
+ } else {
82
+ console.log(`✓ Archived ${r.archived}`);
83
+ }
84
+ break;
85
+ }
86
+ case 'status': {
87
+ const r = result as StatusResult;
88
+ if (!r.ok) {
89
+ // §37: say what actually happened. Arg/usage failures carry the real
90
+ // diagnosis in `error` — surface it verbatim (QA-10 M1: a rejected flag
91
+ // must never be re-diagnosed as a missing workspace). The genuine
92
+ // missing-workspace case also reports through `error`.
93
+ if (r.error) {
94
+ console.log(`✗ ${r.error}`);
95
+ } else {
96
+ console.log('✗ No cans workspace found.');
97
+ console.log(' Run `cans init` or cd into a project with a cans/ directory.');
98
+ }
99
+ break;
100
+ }
101
+ if (r.filter === 'owners') {
102
+ // Owners view: per-owner rollup, structurally distinct from the default report.
103
+ console.log('Owners view:');
104
+ const names = Object.keys(r.owners ?? {});
105
+ if (names.length === 0) console.log(' no owners assigned yet');
106
+ for (const name of names) {
107
+ const s = (r.owners ?? {})[name];
108
+ console.log(` ${name}: ${s.tasks} task(s), ${s.done} done`);
109
+ }
110
+ if (r.conflicts > 0) console.log(`Conflicts: ${r.conflicts} unresolved in _collab/conflicts.md`);
111
+ break;
112
+ }
113
+ console.log(`Files: ${r.specFiles} specs, ${r.activeTasks} tasks, ${r.archivedTasks} archived, ${r.adrCount} ADRs`);
114
+ console.log(`Tasks: ${r.tasks?.done ?? 0}/${r.tasks?.total ?? 0} done, ${r.tasks?.unclaimed ?? 0} unclaimed, ${r.tasks?.blocked ?? 0} blocked`);
115
+ if (r.owners && Object.keys(r.owners).length > 0) {
116
+ console.log(`Owners: ${Object.keys(r.owners).join(', ')}`);
117
+ }
118
+ let shown = r.taskFiles ?? [];
119
+ if (r.filter === 'unclaimed') {
120
+ // Only task files that still hold unclaimed items (§25 semantics).
121
+ shown = shown.filter(tf => (tf.unclaimed ?? Math.max(tf.tasksTotal - tf.tasksDone, 0)) > 0);
122
+ } else if (r.filter === 'blocked') {
123
+ shown = shown.filter(tf => tf.blocked);
124
+ }
125
+ // §36: multi-line per-task block
126
+ for (const tf of shown) {
127
+ console.log(` ${tf.name}:`);
128
+ console.log(` Tasks: ${tf.tasksDone}/${tf.tasksTotal}`);
129
+ console.log(` Gates: ${tf.gatesDone}/${tf.gatesTotal} ← @human`);
130
+ if (tf.blocked) console.log(' ⚠ BLOCKED');
131
+ }
132
+ if (r.conflicts > 0) console.log(`Conflicts: ${r.conflicts} unresolved in _collab/conflicts.md`);
133
+ break;
134
+ }
135
+ case 'budget-read': {
136
+ const r = result as BudgetReadResult;
137
+ if (!r.ok) {
138
+ console.log(`✗ ${r.error ?? `No files match concept "${r.concept}".`}`);
139
+ break;
140
+ }
141
+ console.log(`Reading plan for: ${r.concept}`);
142
+ let i = 1;
143
+ for (const item of r.plan ?? []) {
144
+ const anchor = item.anchor ? `#${item.anchor}` : '';
145
+ console.log(` ${i++}. ${item.file}${anchor} ← ${item.reason} (${item.estTokens} tok)`);
146
+ }
147
+ if (r.skipped?.length) {
148
+ console.log(`Skipped:`);
149
+ for (const s of r.skipped) console.log(` ${s}`);
150
+ }
151
+ console.log(`Budget: ${r.totalTokens} / ${r.budgetLimit} tokens (${r.usagePercent}%)`);
152
+ break;
153
+ }
154
+ case 'budget-write': {
155
+ const r = result as BudgetWriteResult;
156
+ if (!r.ok) {
157
+ console.log(`✗ ${r.error ?? `No files match concept "${r.concept}".`}`);
158
+ break;
159
+ }
160
+ console.log(`Writing scope for: ${r.concept}`);
161
+ console.log(`CAN edit:`);
162
+ for (const e of r.canEdit ?? []) console.log(` ${e.file} ← ${e.reason}`);
163
+ console.log(`MUST NOT edit:`);
164
+ for (const e of r.mustNotEdit ?? []) console.log(` ${e.file} ← ${e.reason}`);
165
+ break;
166
+ }
167
+ case 'import': {
168
+ const r = result as ImportResult;
169
+ if (!r.ok) {
170
+ console.log(`✗ ${r.error ?? 'import failed'}`);
171
+ break;
172
+ }
173
+ if (r.dryRun) {
174
+ console.log(`[dry-run] Would import ${r.format} from ${r.source}. No files written.`);
175
+ } else {
176
+ console.log(`Imported ${r.format} from ${r.source}`);
177
+ }
178
+ for (const f of r.newFiles ?? []) console.log(` + ${f}`);
179
+ for (const f of r.merged ?? []) console.log(` ~ ${f} (merged)`);
180
+ for (const c of r.conflicts ?? []) console.log(` ! ${c.file}:${c.line} ${c.resolution}`);
181
+ break;
182
+ }
183
+ case 'export': {
184
+ const r = result as ExportResult;
185
+ if (!r.ok) {
186
+ console.log(`✗ ${r.error ?? 'export failed'}`);
187
+ break;
188
+ }
189
+ if (r.dryRun) {
190
+ console.log(`[dry-run] Would export ${r.format} → ${r.outputDir} (${r.filesExported} files). No files written.`);
191
+ } else {
192
+ console.log(`Exported ${r.format} → ${r.outputDir} (${r.filesExported} files)`);
193
+ }
194
+ break;
195
+ }
196
+ default: {
197
+ // §37: unknown command — say what happened and how to fix it.
198
+ const err = (result as { error?: string }).error;
199
+ if (err) {
200
+ console.log(`✗ ${err}`);
201
+ } else {
202
+ console.log(`✗ Unknown command "${result.command}".`);
203
+ console.log(' Run `cans help` for available commands.');
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ function printCheckHuman(r: CheckResult, refsOnly?: boolean): void {
210
+ // §37: check-level failures (unknown flag, no cans workspace, invalid
211
+ // _rules.yaml, unmatched file filter) carry their diagnosis in `error` —
212
+ // print it standalone, never inside a report-shaped body.
213
+ const failure = (r as { error?: string }).error;
214
+ if (failure) {
215
+ console.log(`✗ ${failure}`);
216
+ return;
217
+ }
218
+
219
+ const byCategory = new Map<string, Issue[]>();
220
+ for (const i of r.issues) {
221
+ const list = byCategory.get(i.category) ?? [];
222
+ list.push(i);
223
+ byCategory.set(i.category, list);
224
+ }
225
+ if (!refsOnly) {
226
+ console.log('Structure');
227
+ console.log(` ${r.files} files, ${r.nodes} nodes, max depth ${r.maxDepth}`);
228
+ printIssues(byCategory.get('structure'));
229
+
230
+ console.log('Style');
231
+ printIssues(byCategory.get('style'));
232
+ }
233
+
234
+ console.log('References');
235
+ console.log(` ${r.refs.total} see: refs, ${r.refs.broken} broken, ${r.refs.deepHops} deep hops`);
236
+ console.log(` back-pointers: ${r.backPointers.current}/${r.backPointers.total} current`);
237
+ printIssues(byCategory.get('refs'));
238
+
239
+ if (!refsOnly) {
240
+ console.log('Redundancy');
241
+ printIssues(byCategory.get('redundancy'));
242
+ if (!byCategory.get('redundancy')?.length) {
243
+ const none = r.issues.filter(i => i.category === 'redundancy').length === 0;
244
+ if (none) console.log(' ✓ no redundancy detected');
245
+ }
246
+
247
+ console.log('Overflow');
248
+ if (!byCategory.get('overflow')?.length) {
249
+ console.log(' ✓ no code blocks, tables, or oversized nodes');
250
+ } else {
251
+ printIssues(byCategory.get('overflow'));
252
+ }
253
+ }
254
+
255
+ // §22: the fixed report order ends Structure → Style → References →
256
+ // Redundancy → Overflow → Rules → Summary (QA-02 F17).
257
+ if (r.rulesSummary !== undefined) {
258
+ console.log('Rules (_rules.yaml)');
259
+ console.log(` ✓ ${r.rulesSummary}`);
260
+ }
261
+
262
+ void CATEGORY_ORDER;
263
+ console.log('');
264
+ console.log(`${r.errorCount} errors, ${r.warningCount} warnings.`);
265
+ }
266
+
267
+ function printIssues(issues: Issue[] | undefined): void {
268
+ for (const i of issues ?? []) {
269
+ const mark = i.level === 'error' ? '✗' : '⚠';
270
+ // Avoid duplicating the file path when the message already carries it (parse errors)
271
+ const msg = i.message.startsWith(`${i.file}:`) ? i.message.slice(i.file.length + 1) : i.message;
272
+ const linePart = i.line > 0 ? `:${i.line}` : '';
273
+ console.log(` ${mark} ${i.file}${linePart} — ${msg}`);
274
+ if (i.suggestion) console.log(` ${i.suggestion}`);
275
+ }
276
+ }
277
+
278
+ function printHelp(): void {
279
+ console.log(`CANS — Canonical Agent-Native Spec
280
+
281
+ Usage: cans <command> [args]
282
+
283
+ Commands:
284
+ init [--flat|--folders] [--bare] [--force] [--tool <name>]
285
+ check [--fix] [--strict] [--refs-only] [--no-redundancy] [file] [--json]
286
+ new adr <title>
287
+ new task <name>
288
+ done <name> [--allow-incomplete] [--skip-check] [--json]
289
+ status [--unclaimed] [--blocked] [--owners] [--json]
290
+ budget read <concept> [--limit <tokens>] [--change <name>] [--json]
291
+ budget write <concept> [--json]
292
+ import <format> <path> [--out <path>] [--dry-run] [--merge-strategy <s>] [--json]
293
+ export <format> [--from <path>] [--include-tasks] [--vault <path>] [--dry-run] [--json]
294
+ help
295
+ version
296
+
297
+ Formats: opml, dynalist, logseq, obsidian
298
+ Config: cans/_rules.yaml
299
+ Agents: cans/AGENTS.md`);
300
+ }
@@ -0,0 +1,75 @@
1
+ import type { OutlineNode, Issue, OverflowRules } from '../types';
2
+ import { flattenNodes } from './outline';
3
+
4
+ /** Overflow checks: code fences, tables, over-long nodes. All errors.
5
+ * §18: `force_file_for` lists the content categories forced into files —
6
+ * `code_block` gates code-fence flags, `table` gates table flags. Empty list
7
+ * = nothing forced = no content-type flags; null (deleted key) = same.
8
+ * §18 delete-key semantics: `max_node_chars` null (deleted) → the char-length
9
+ * check is OFF — skipped entirely, never compared against null. */
10
+ export function checkOverflow(
11
+ nodes: OutlineNode[],
12
+ file: string,
13
+ rules: OverflowRules,
14
+ ): Issue[] {
15
+ const issues: Issue[] = [];
16
+ const forceSet = new Set(rules.force_file_for ?? []);
17
+
18
+ const walk = (list: OutlineNode[]): void => {
19
+ for (const node of list) {
20
+ if (node.hasCodeFence && forceSet.has('code_block')) {
21
+ issues.push({
22
+ file,
23
+ line: node.line,
24
+ level: 'error',
25
+ category: 'overflow',
26
+ message: 'code fence detected — extract to file and reference via see:',
27
+ });
28
+ }
29
+ if (node.hasTable && forceSet.has('table')) {
30
+ issues.push({
31
+ file,
32
+ line: node.line,
33
+ level: 'error',
34
+ category: 'overflow',
35
+ message: 'table detected — extract to file and reference via see:',
36
+ });
37
+ }
38
+ if (rules.max_node_chars !== null && node.text.length > rules.max_node_chars) {
39
+ issues.push({
40
+ file,
41
+ line: node.line,
42
+ level: 'error',
43
+ category: 'overflow',
44
+ message: `node exceeds max chars (${node.text.length} > ${rules.max_node_chars})`,
45
+ });
46
+ }
47
+ walk(node.children);
48
+ }
49
+ };
50
+
51
+ walk(nodes);
52
+ return issues;
53
+ }
54
+
55
+ /** §16 no-chaining: "Overflow target files must NOT contain their own see: refs."
56
+ * `targets` maps overflow target file → parsed nodes (files inside spec
57
+ * subfolders, e.g. `04-api/request-schema.md`). */
58
+ export function checkNoChaining(targets: Map<string, OutlineNode[]>): Issue[] {
59
+ const issues: Issue[] = [];
60
+ for (const [file, nodes] of targets) {
61
+ for (const node of flattenNodes(nodes)) {
62
+ for (const ref of node.refs) {
63
+ issues.push({
64
+ file,
65
+ line: ref.line,
66
+ level: 'error',
67
+ category: 'overflow',
68
+ message: `no chaining: overflow target ${file} must not contain its own see: refs (found see ${ref.file})`,
69
+ suggestion: `remove the see: ref inside ${file} — overflow targets are leaf content, reference them from a spec file instead`,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return issues;
75
+ }
@@ -0,0 +1,261 @@
1
+ import type { OutlineNode, Issue, RedundancyRules } from '../types';
2
+ import { flattenNodes } from './outline';
3
+
4
+ interface NodeRef {
5
+ text: string;
6
+ file: string;
7
+ line: number;
8
+ }
9
+
10
+ const EDGE_PUNCT_RE = /^[.,;:!?"']+|[.,;:!?"']+$/g;
11
+
12
+ /** §8/§13: ref syntax tokens (`see:`, `.md`) are structural pointers, not
13
+ * content words — excluded from every redundancy layer (QA-02 F3). */
14
+ const REF_SYNTAX_TOKENS = new Set(['see', 'md']);
15
+
16
+ /** lowercase → strip edge punctuation → synonym group (any member → first member) → bare word. */
17
+ export function normalizeWord(word: string, synonyms: string[][]): string {
18
+ const stripped = word.toLowerCase().replace(EDGE_PUNCT_RE, '');
19
+ for (const group of synonyms) {
20
+ if (group.includes(stripped)) return group[0];
21
+ }
22
+ return stripped;
23
+ }
24
+
25
+ function tokenize(text: string): string[] {
26
+ return text.split(/[^A-Za-z0-9]+/).filter(w => w.length > 0);
27
+ }
28
+
29
+ /** Normalized word set of a text; stopwords filtered when rules are given. */
30
+ function wordSet(text: string, rules?: RedundancyRules): Set<string> {
31
+ const out = new Set<string>();
32
+ const synonyms = rules ? rules.synonyms : [];
33
+ const stopwords = rules ? rules.stopwords : null;
34
+ for (const raw of tokenize(text)) {
35
+ const w = normalizeWord(raw, synonyms);
36
+ if (w.length === 0) continue;
37
+ if (REF_SYNTAX_TOKENS.has(w)) continue;
38
+ if (stopwords !== null && stopwords.includes(w)) continue;
39
+ out.add(w);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** Levenshtein edit distance (two-row DP). */
45
+ function levenshtein(a: string, b: string): number {
46
+ if (a === b) return 0;
47
+ const m = a.length;
48
+ const n = b.length;
49
+ if (m === 0) return n;
50
+ if (n === 0) return m;
51
+ let prev = new Array<number>(n + 1);
52
+ let cur = new Array<number>(n + 1);
53
+ for (let j = 0; j <= n; j++) prev[j] = j;
54
+ for (let i = 1; i <= m; i++) {
55
+ cur[0] = i;
56
+ for (let j = 1; j <= n; j++) {
57
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
58
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
59
+ }
60
+ const swap = prev;
61
+ prev = cur;
62
+ cur = swap;
63
+ }
64
+ return prev[n];
65
+ }
66
+
67
+ /** Layer 1 — words appearing in >= threshold nodes (node count, not occurrences).
68
+ * §18 delete-key semantics: `word_frequency_threshold` null (deleted) → the
69
+ * layer is OFF — skipped entirely, never compared against null. */
70
+ export function wordFrequency(
71
+ nodes: NodeRef[],
72
+ rules: RedundancyRules,
73
+ ): Issue[] {
74
+ const threshold = rules.word_frequency_threshold;
75
+ if (threshold === null) return [];
76
+ const counts = new Map<string, number>();
77
+ const firstLoc = new Map<string, NodeRef>();
78
+ for (const node of nodes) {
79
+ const seen = new Set<string>();
80
+ for (const w of wordSet(node.text, rules)) {
81
+ if (seen.has(w)) continue;
82
+ seen.add(w);
83
+ counts.set(w, (counts.get(w) ?? 0) + 1);
84
+ if (!firstLoc.has(w)) firstLoc.set(w, node);
85
+ }
86
+ }
87
+ const flagged = [...counts.entries()].filter(([, n]) => n >= threshold);
88
+ flagged.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
89
+ const issues: Issue[] = [];
90
+ for (const [word, n] of flagged) {
91
+ const loc = firstLoc.get(word)!;
92
+ issues.push({
93
+ file: loc.file, line: loc.line, level: 'warning', category: 'redundancy',
94
+ message: `"${word}" × ${n} nodes (threshold: ${threshold})`,
95
+ suggestion: `pick one canonical home for "${word}" and see: it from the others`,
96
+ });
97
+ }
98
+ return issues;
99
+ }
100
+
101
+ /** Layer 2 — pairwise word-set overlap of normalized word sets >= threshold.
102
+ * §13: "Normalized word set overlap ≥ 70% → flag." Overlap is measured
103
+ * against the LARGER of the two sets (|A∩B| / max(|A|,|B|)), after stopword
104
+ * and synonym normalization. §18: threshold null (deleted key) → layer OFF. */
105
+ export function phraseOverlap(
106
+ nodes: NodeRef[],
107
+ threshold: number | null,
108
+ rules?: RedundancyRules,
109
+ ): Issue[] {
110
+ if (threshold === null) return [];
111
+ const sets = nodes.map(n => ({ node: n, words: wordSet(n.text, rules) }));
112
+ const issues: Issue[] = [];
113
+ for (let i = 0; i < sets.length; i++) {
114
+ for (let j = i + 1; j < sets.length; j++) {
115
+ const a = sets[i];
116
+ const b = sets[j];
117
+ if (a.node.file === b.node.file && a.node.line === b.node.line) continue;
118
+ if (a.words.size === 0 || b.words.size === 0) continue;
119
+ let inter = 0;
120
+ for (const w of a.words) if (b.words.has(w)) inter++;
121
+ const larger = Math.max(a.words.size, b.words.size);
122
+ const similarity = larger === 0 ? 0 : inter / larger;
123
+ if (similarity >= threshold) {
124
+ const pct = Math.round(similarity * 100);
125
+ issues.push({
126
+ file: a.node.file, line: a.node.line, level: 'warning', category: 'redundancy',
127
+ message: `${pct}% overlap: ${a.node.file}:${a.node.line} ↔ ${b.node.file}:${b.node.line}`,
128
+ suggestion: 'merge the duplicated bullets or see: the canonical one',
129
+ });
130
+ }
131
+ }
132
+ }
133
+ return issues;
134
+ }
135
+
136
+ /** Layer 3 — near-miss word forms (Levenshtein <= 2, both words > 4 chars) → possible typo.
137
+ * §13: "NOT ALREADY SYNONYM-MATCHED" — words are normalized with the rules'
138
+ * synonym groups first, so members of the same group collapse to one word and
139
+ * never pair up as typos. */
140
+ export function fuzzyDistance(
141
+ nodes: NodeRef[],
142
+ rules?: RedundancyRules,
143
+ ): Issue[] {
144
+ const synonyms = rules ? rules.synonyms : [];
145
+ const words: NodeRef[] = [];
146
+ const seen = new Set<string>();
147
+ for (const node of nodes) {
148
+ for (const raw of tokenize(node.text)) {
149
+ const w = normalizeWord(raw, synonyms);
150
+ if (w.length === 0 || seen.has(w)) continue;
151
+ seen.add(w);
152
+ words.push({ text: w, file: node.file, line: node.line });
153
+ }
154
+ }
155
+ const issues: Issue[] = [];
156
+ for (let i = 0; i < words.length; i++) {
157
+ for (let j = i + 1; j < words.length; j++) {
158
+ const a = words[i];
159
+ const b = words[j];
160
+ if (a.text.length <= 4 || b.text.length <= 4) continue;
161
+ if (Math.abs(a.text.length - b.text.length) > 2) continue;
162
+ const d = levenshtein(a.text, b.text);
163
+ if (d <= 2) {
164
+ issues.push({
165
+ file: a.file, line: a.line, level: 'warning', category: 'redundancy',
166
+ message: `possible typo: "${a.text}" (${a.file}:${a.line}) ↔ "${b.text}" (${b.file}:${b.line}) — Levenshtein ${d}`,
167
+ suggestion: 'unify the spelling or map the variant as a synonym',
168
+ });
169
+ }
170
+ }
171
+ }
172
+ return issues;
173
+ }
174
+
175
+ /** Candidate ref-name spellings for a workspace key (flat and folder layouts). */
176
+ function refNamesForKey(key: string): string[] {
177
+ const names = new Set<string>([key]);
178
+ if (key.endsWith('.md')) names.add(key.slice(0, -3));
179
+ if (key.endsWith('/index.md')) {
180
+ const base = key.slice(0, -9);
181
+ names.add(base);
182
+ names.add(`${base}.md`);
183
+ }
184
+ return [...names];
185
+ }
186
+
187
+ function filesConnected(
188
+ allFiles: Map<string, OutlineNode[]>,
189
+ a: string,
190
+ b: string,
191
+ ): boolean {
192
+ const aNodes = flattenNodes(allFiles.get(a) ?? []);
193
+ const bNodes = flattenNodes(allFiles.get(b) ?? []);
194
+ const bNames = new Set(refNamesForKey(b));
195
+ const aNames = new Set(refNamesForKey(a));
196
+ const refsTo = (ns: OutlineNode[], names: Set<string>): boolean =>
197
+ ns.some(n => n.refs.some(r => names.has(r.file)));
198
+ return refsTo(aNodes, bNames) || refsTo(bNodes, aNames);
199
+ }
200
+
201
+ /** Layer 4 — identical node text at depth 0-1 in >= threshold files without see: linkage.
202
+ * §18 delete-key semantics: threshold null (deleted) → layer OFF. */
203
+ export function crossFileCanonicality(
204
+ allFiles: Map<string, OutlineNode[]>,
205
+ threshold: number | null,
206
+ ): Issue[] {
207
+ if (threshold === null) return [];
208
+ const concepts = new Map<string, { files: Set<string>; first: NodeRef }>();
209
+ for (const [key, nodes] of allFiles) {
210
+ for (const node of flattenNodes(nodes)) {
211
+ if (node.indent > 1) continue;
212
+ const text = node.text.trim().toLowerCase();
213
+ if (text.length === 0) continue;
214
+ let entry = concepts.get(text);
215
+ if (entry === undefined) {
216
+ entry = { files: new Set<string>(), first: { text, file: key, line: node.line } };
217
+ concepts.set(text, entry);
218
+ }
219
+ entry.files.add(key);
220
+ }
221
+ }
222
+ const issues: Issue[] = [];
223
+ for (const [concept, entry] of concepts) {
224
+ if (entry.files.size < threshold) continue;
225
+ const files = [...entry.files].sort();
226
+ let connected = false;
227
+ for (let i = 0; i < files.length && !connected; i++) {
228
+ for (let j = i + 1; j < files.length && !connected; j++) {
229
+ if (filesConnected(allFiles, files[i], files[j])) connected = true;
230
+ }
231
+ }
232
+ if (connected) continue;
233
+ issues.push({
234
+ file: entry.first.file, line: entry.first.line, level: 'warning', category: 'redundancy',
235
+ message: `"${concept}" at depth 0-1 in ${files.length}+ files without see: (${files.join(', ')})`,
236
+ suggestion: `keep "${concept}" in one canonical file and see: it from the others`,
237
+ });
238
+ }
239
+ return issues;
240
+ }
241
+
242
+ /** All four redundancy layers over every loaded spec node.
243
+ * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4. */
244
+ export function checkRedundancy(
245
+ allFiles: Map<string, OutlineNode[]>,
246
+ rules: RedundancyRules,
247
+ duplicateHomeCheck = true,
248
+ ): Issue[] {
249
+ const nodes: NodeRef[] = [];
250
+ for (const [file, tree] of allFiles) {
251
+ for (const node of flattenNodes(tree)) {
252
+ nodes.push({ text: node.text, file, line: node.line });
253
+ }
254
+ }
255
+ return [
256
+ ...wordFrequency(nodes, rules),
257
+ ...phraseOverlap(nodes, rules.phrase_overlap_threshold, rules),
258
+ ...fuzzyDistance(nodes, rules),
259
+ ...(duplicateHomeCheck ? crossFileCanonicality(allFiles, rules.cross_file_threshold) : []),
260
+ ];
261
+ }