cans-spec 0.2.0 → 0.5.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.
@@ -1,25 +1,30 @@
1
1
  import type {
2
- CommandResult, CheckResult, Issue, InitResult, NewResult, DoneResult, StatusResult,
2
+ CommandResult, CheckResult, InitResult, NewResult, DoneResult, StatusResult,
3
3
  BudgetReadResult, BudgetWriteResult, ImportResult, ExportResult, VersionResult,
4
4
  } from '../types.ts';
5
+ import {
6
+ buildReport, topGroups, checkReportJson,
7
+ type IssueGroup, type SectionReport,
8
+ } from './report.ts';
5
9
 
6
10
  /** 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 {
11
+ * `refsOnly` (check only): human output is scoped to the REFS section (+ Rules).
12
+ * `show` (check only, issue #41): sections rendered UNFOLDED by --show.
13
+ * --json: check emits the lossless structured wire shape (sections → arrays
14
+ * of {file, line, rule, detail}, issue #41); other commands the raw result. */
15
+ export function emit(result: CommandResult, json: boolean, refsOnly?: boolean, show?: Set<string>): void {
10
16
  if (json) {
11
- console.log(JSON.stringify(result, null, 2));
17
+ const body = result.command === 'check' ? checkReportJson(result as unknown as Parameters<typeof checkReportJson>[0]) : result;
18
+ console.log(JSON.stringify(body, null, 2));
12
19
  return;
13
20
  }
14
- printHuman(result, refsOnly);
21
+ printHuman(result, refsOnly, show);
15
22
  }
16
23
 
17
- const CATEGORY_ORDER: Array<Issue['category']> = ['structure', 'style', 'refs', 'redundancy', 'overflow'];
18
-
19
- export function printHuman(result: CommandResult, refsOnly?: boolean): void {
24
+ export function printHuman(result: CommandResult, refsOnly?: boolean, show?: Set<string>): void {
20
25
  switch (result.command) {
21
26
  case 'check':
22
- printCheckHuman(result as CheckResult, refsOnly);
27
+ printCheckHuman(result as CheckResult, refsOnly, show);
23
28
  break;
24
29
  case 'help':
25
30
  printHelp();
@@ -206,7 +211,23 @@ export function printHuman(result: CommandResult, refsOnly?: boolean): void {
206
211
  }
207
212
  }
208
213
 
209
- function printCheckHuman(r: CheckResult, refsOnly?: boolean): void {
214
+ // ── issue #41: aggregated check report ──
215
+ // One line per pattern (`61× <min children (2/3)`), grouped by root cause,
216
+ // top-N + fold with --show expansion, timing on the summary line, and a
217
+ // ≤500-token default budget for issue-scale projects.
218
+
219
+ const DISPLAY_NAMES: Record<string, string> = {
220
+ structure: 'STRUCTURE', style: 'STYLE', refs: 'REFS', redundancy: 'REDUNDANCY',
221
+ overflow: 'OVERFLOW', parse: 'PARSE', content: 'CONTENT', io: 'IO', other: 'OTHER',
222
+ };
223
+ const SECTION_PRINT_ORDER = ['structure', 'style', 'refs', 'redundancy', 'overflow', 'parse', 'content', 'io', 'other'];
224
+ const FOLD_TOP_GROUPS = 5;
225
+ const FOLD_KEYWORD_ITEMS = 5;
226
+ const FOLD_OVERLAP_ITEMS = 3;
227
+ const FOLD_TARGETS = 8;
228
+ const WRAP_COLS = 72;
229
+
230
+ function printCheckHuman(r: CheckResult, refsOnly?: boolean, show?: Set<string>): void {
210
231
  // §37: check-level failures (unknown flag, no cans workspace, invalid
211
232
  // _rules.yaml, unmatched file filter) carry their diagnosis in `error` —
212
233
  // print it standalone, never inside a report-shaped body.
@@ -216,63 +237,186 @@ function printCheckHuman(r: CheckResult, refsOnly?: boolean): void {
216
237
  return;
217
238
  }
218
239
 
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
- }
240
+ const expanded = (name: string): boolean => show !== undefined && (show.has('all') || show.has(name));
233
241
 
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'));
242
+ // issue #41: summary line first — severity mark, workspace shape, elapsed ms.
243
+ const mark = r.errorCount > 0 ? '✗' : r.warningCount > 0 ? '⚠' : '✓';
244
+ console.log(`${mark} ${r.files} files · ${r.nodes} nodes · depth ${r.maxDepth} · ${r.elapsedMs ?? 0}ms`);
238
245
 
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');
246
+ const report = buildReport(r.issues);
247
+ for (const name of SECTION_PRINT_ORDER) {
248
+ if (refsOnly && name !== 'refs') continue;
249
+ let section = report.sections[name];
250
+ // issue #11: a --fix run that rewrote back-pointer files always surfaces
251
+ // the REFS block — even when the refs engine has no findings of its own
252
+ // (the clean/healthy case), the run's writes stay visible there.
253
+ if (name === 'refs' && section === undefined && r.backPointersUpdatedFiles.length > 0) {
254
+ section = { name: 'refs', errorCount: 0, warningCount: 0, groups: [] };
245
255
  }
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'));
256
+ // Compact contract: STRUCTURE/STYLE/REDUNDANCY (and the small sections)
257
+ // print only when they carry findings; REFS and OVERFLOW always print
258
+ // (they carry the ✓ healthy state, as in the issue's expected output).
259
+ if (section === undefined) {
260
+ if (name === 'overflow' && !refsOnly) {
261
+ console.log('');
262
+ console.log('OVERFLOW ✓');
263
+ }
264
+ continue;
252
265
  }
266
+ console.log('');
267
+ if (name === 'refs') printRefsSection(r, section, expanded('refs'));
268
+ else printSection(section, expanded(name));
253
269
  }
254
270
 
255
- // §22: the fixed report order ends Structure → Style → References →
256
- // Redundancy → Overflow → Rules → Summary (QA-02 F17).
257
271
  if (r.rulesSummary !== undefined) {
258
- console.log('Rules (_rules.yaml)');
259
- console.log(` ✓ ${r.rulesSummary}`);
272
+ console.log('');
273
+ console.log(`RULES ✓ ${compactRules(r.rulesSummary)}`);
260
274
  }
275
+ }
276
+
277
+ function printSection(section: SectionReport, expanded: boolean): void {
278
+ const mark = section.errorCount > 0 ? '✗' : '⚠';
279
+ console.log(`${DISPLAY_NAMES[section.name] ?? section.name.toUpperCase()} ${mark} ${section.errorCount + section.warningCount}`);
280
+ const { shown, folded } = expanded ? { shown: section.groups, folded: 0 } : topGroups(section, FOLD_TOP_GROUPS);
281
+ for (const g of shown) printGroup(g, expanded);
282
+ if (folded > 0) console.log(` … ${folded} more → cans check --show ${section.name}`);
283
+ }
284
+
285
+ function printRefsSection(r: CheckResult, section: SectionReport, expanded: boolean): void {
286
+ // Header: per-root-cause counts (issue #41 example: `REFS ✗ 96 broken · ⚠ 8 stale · ⚠ 1 orphan`).
287
+ // Level-aware: named buckets subtract from the section totals, the residue
288
+ // prints as `✗ N` / `⚠ N` — no finding is ever hidden or double-counted.
289
+ const byRule = new Map<string, { err: number; warn: number }>();
290
+ for (const g of section.groups) {
291
+ const cur = byRule.get(g.rule) ?? { err: 0, warn: 0 };
292
+ if (g.level === 'error') cur.err += g.count;
293
+ else cur.warn += g.count;
294
+ byRule.set(g.rule, cur);
295
+ }
296
+ const named = ['refs.broken.file', 'refs.backpointer.stale', 'refs.orphan', 'refs.deep_hop'];
297
+ const namedErr = named.reduce((a, rl) => a + (byRule.get(rl)?.err ?? 0), 0);
298
+ const namedWarn = named.reduce((a, rl) => a + (byRule.get(rl)?.warn ?? 0), 0);
299
+ const parts: string[] = [];
300
+ const broken = byRule.get('refs.broken.file');
301
+ if (broken !== undefined && broken.err + broken.warn > 0) parts.push(`✗ ${broken.err + broken.warn} broken`);
302
+ const stale = byRule.get('refs.backpointer.stale');
303
+ if (stale !== undefined && stale.err + stale.warn > 0) parts.push(`⚠ ${stale.err + stale.warn} stale`);
304
+ const orphan = byRule.get('refs.orphan');
305
+ if (orphan !== undefined && orphan.err + orphan.warn > 0) parts.push(`⚠ ${orphan.err + orphan.warn} orphan`);
306
+ const hops = byRule.get('refs.deep_hop');
307
+ if (hops !== undefined && hops.err > 0) parts.push(`✗ ${hops.err} deep-hop`);
308
+ else if (hops !== undefined && hops.warn > 0) parts.push(`⚠ ${hops.warn} deep-hop`);
309
+ const errOther = section.errorCount - namedErr;
310
+ const warnOther = section.warningCount - namedWarn;
311
+ if (errOther > 0) parts.push(`✗ ${errOther}`);
312
+ if (warnOther > 0) parts.push(`⚠ ${warnOther}`);
313
+ if (parts.length > 0) {
314
+ console.log(`REFS ${parts.join(' · ')}`);
315
+ } else {
316
+ const bp = r.backPointers.total > 0 ? ` · ${r.backPointers.current}/${r.backPointers.total} back-ptrs` : '';
317
+ console.log(`REFS ✓ ${r.refs.total} refs${bp}`);
318
+ }
319
+ // Issue #11: name the files --fix actually rewrote (099e858 wording) —
320
+ // never printed as an empty list (plain checks show nothing here).
321
+ if (r.backPointersUpdatedFiles.length > 0) {
322
+ console.log(` --fix updated ref-by in: ${r.backPointersUpdatedFiles.join(', ')}`);
323
+ }
324
+
325
+ // issue #41 design rule 2: all missing-file refs coalesce into ONE block
326
+ // (91 broken refs → 4 targets with per-target counts, one fix hint).
327
+ const brokenGroups = section.groups.filter(g => g.rule === 'refs.broken.file');
328
+ const rest = section.groups.filter(g => g.rule !== 'refs.broken.file');
329
+ if (brokenGroups.length > 0) printBrokenFileBlock(brokenGroups);
330
+ const { shown, folded } = expanded ? { shown: rest, folded: 0 } : topGroups({ ...section, groups: rest }, FOLD_TOP_GROUPS);
331
+ for (const g of shown) printGroup(g, expanded);
332
+ if (folded > 0) console.log(` … ${folded} more → cans check --show refs`);
333
+ }
334
+
335
+ function printBrokenFileBlock(groups: IssueGroup[]): void {
336
+ const total = groups.reduce((a, g) => a + g.count, 0);
337
+ console.log(` ${String(total).padStart(2)}× missing file`);
338
+ const targets = groups
339
+ .filter(g => g.key !== undefined)
340
+ .map(g => ({ label: g.key!, count: g.count }))
341
+ .sort((a, b) => b.count - a.count || (a.label < b.label ? -1 : 1));
342
+ const shown = targets.slice(0, FOLD_TARGETS);
343
+ for (const line of wrapText(shown.map(t => `${t.label} (${t.count})`).join(' · '), WRAP_COLS, 6)) console.log(line);
344
+ if (targets.length > shown.length) console.log(` ↳ ${targets.length - shown.length} more targets → cans check --show refs`);
345
+ const hint = groups.map(g => g.suggestion).find(s => s !== undefined);
346
+ if (hint !== undefined) console.log(` ↳ ${hint}`);
347
+ }
261
348
 
262
- void CATEGORY_ORDER;
263
- console.log('');
264
- console.log(`${r.errorCount} errors, ${r.warningCount} warnings.`);
349
+ function printGroup(g: IssueGroup, expanded: boolean): void {
350
+ console.log(` ${String(g.count).padStart(2)}× ${g.pattern}`);
351
+ switch (g.rule) {
352
+ case 'redundancy.keyword': {
353
+ const items = g.items ?? [];
354
+ const shownItems = expanded ? items : items.slice(0, FOLD_KEYWORD_ITEMS);
355
+ // issue #41: `artifacts:105 db:74` — keyword:nodeCount, metric-ranked.
356
+ for (const line of wrapText(shownItems.map(it => `${it.label}:${it.metric ?? it.count}`).join(' '), WRAP_COLS, 6)) console.log(line);
357
+ const hidden = items.slice(FOLD_KEYWORD_ITEMS).reduce((a, it) => a + it.count, 0);
358
+ if (!expanded && hidden > 0) console.log(` ↳ ${hidden} more → cans check --show redundancy`);
359
+ else if (g.suggestion !== undefined) console.log(` ↳ ${g.suggestion}`);
360
+ return;
361
+ }
362
+ case 'redundancy.overlap.exact':
363
+ case 'redundancy.overlap.fuzzy': {
364
+ const items = g.items ?? [];
365
+ const shownItems = expanded ? items : items.slice(0, FOLD_OVERLAP_ITEMS);
366
+ if (shownItems.length > 0) {
367
+ for (const line of wrapText(`worst: ${shownItems.map(it => it.label).join(' · ')}`, WRAP_COLS, 6)) console.log(line);
368
+ }
369
+ const hidden = items.length - shownItems.length;
370
+ if (!expanded && hidden > 0) console.log(` ↳ ${hidden} more pairs → cans check --show redundancy`);
371
+ else if (g.suggestion !== undefined) console.log(` ↳ ${g.suggestion}`);
372
+ return;
373
+ }
374
+ case 'refs.backpointer.stale': {
375
+ // `budget:2 ← agent, effect, interface` — target lines + referrers.
376
+ const referrers = (g.items ?? []).map(it => it.label).join(', ');
377
+ const line = referrers !== '' ? `${g.locations.join(' ')} ← ${referrers}` : g.locations.join(' ');
378
+ for (const l of wrapText(line, WRAP_COLS, 6)) console.log(l);
379
+ break;
380
+ }
381
+ case 'refs.broken.anchor': {
382
+ const details = (expanded ? (g.items ?? []).map(it => it.label) : (g.detail !== undefined ? [g.detail] : (g.items ?? []).slice(0, FOLD_OVERLAP_ITEMS).map(it => it.label)));
383
+ for (const d of details) console.log(` ${d}`);
384
+ break;
385
+ }
386
+ default: {
387
+ for (const line of wrapText(g.locations.join(' '), WRAP_COLS, 6)) console.log(line);
388
+ }
389
+ }
390
+ if (g.suggestion !== undefined) console.log(` ↳ ${g.suggestion}`);
265
391
  }
266
392
 
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}`);
393
+ /** Greedy two-space-separator wrap (location lists), fixed indent. */
394
+ function wrapText(text: string, cols: number, indent: number): string[] {
395
+ const pad = ' '.repeat(indent);
396
+ const budget = Math.max(cols - indent, 20);
397
+ if (text.length <= budget) return text === '' ? [] : [pad + text];
398
+ const pieces = text.split(' ');
399
+ const lines: string[] = [];
400
+ let cur = '';
401
+ for (const p of pieces) {
402
+ const candidate = cur === '' ? p : `${cur} ${p}`;
403
+ if (cur === '' || candidate.length <= budget) cur = candidate;
404
+ else {
405
+ lines.push(pad + cur);
406
+ cur = p;
407
+ }
275
408
  }
409
+ if (cur !== '') lines.push(pad + cur);
410
+ return lines;
411
+ }
412
+
413
+ /** `node_length: 3–120 | siblings: 3–12 | depth: 5–7` → `len 3–120 · sib 3–12 · depth 5–7`. */
414
+ function compactRules(s: string): string {
415
+ return s
416
+ .replace('node_length: ', 'len ')
417
+ .replaceAll('siblings: ', 'sib ')
418
+ .replace('depth: ', 'depth ')
419
+ .replaceAll(' | ', ' · ');
276
420
  }
277
421
 
278
422
  function printHelp(): void {
@@ -282,7 +426,8 @@ Usage: cans <command> [args]
282
426
 
283
427
  Commands:
284
428
  init [--flat|--folders] [--bare] [--force] [--tool <name>]
285
- check [--fix] [--strict] [--refs-only] [--no-redundancy] [file] [--json]
429
+ check [--fix] [--strict] [--refs-only] [--no-redundancy] [--show <section>] [file] [--json]
430
+ --fix + [file] rewrites ref-by comments in matching files only
286
431
  new adr <title>
287
432
  new task <name>
288
433
  done <name> [--allow-incomplete] [--skip-check] [--json]
@@ -24,6 +24,7 @@ export function checkOverflow(
24
24
  level: 'error',
25
25
  category: 'overflow',
26
26
  message: 'code fence detected — extract to file and reference via see:',
27
+ rule: 'overflow.code_fence', // issue #41: machine-readable rule key
27
28
  });
28
29
  }
29
30
  if (node.hasTable && forceSet.has('table')) {
@@ -33,6 +34,7 @@ export function checkOverflow(
33
34
  level: 'error',
34
35
  category: 'overflow',
35
36
  message: 'table detected — extract to file and reference via see:',
37
+ rule: 'overflow.table', // issue #41
36
38
  });
37
39
  }
38
40
  if (rules.max_node_chars !== null && node.text.length > rules.max_node_chars) {
@@ -42,6 +44,7 @@ export function checkOverflow(
42
44
  level: 'error',
43
45
  category: 'overflow',
44
46
  message: `node exceeds max chars (${node.text.length} > ${rules.max_node_chars})`,
47
+ rule: 'overflow.node_chars', // issue #41
45
48
  });
46
49
  }
47
50
  walk(node.children);
@@ -67,6 +70,7 @@ export function checkNoChaining(targets: Map<string, OutlineNode[]>): Issue[] {
67
70
  category: 'overflow',
68
71
  message: `no chaining: overflow target ${file} must not contain its own see: refs (found see ${ref.file})`,
69
72
  suggestion: `remove the see: ref inside ${file} — overflow targets are leaf content, reference them from a spec file instead`,
73
+ rule: 'refs.chaining', // issue #41: §16 chaining is a refs-category rule
70
74
  });
71
75
  }
72
76
  }
@@ -1,5 +1,5 @@
1
1
  import type { OutlineNode, Issue, RedundancyRules } from '../types.ts';
2
- import { flattenNodes } from './outline.ts';
2
+ import { flattenNodes, isSyntheticNode } from './outline.ts';
3
3
 
4
4
  interface NodeRef {
5
5
  text: string;
@@ -93,6 +93,7 @@ export function wordFrequency(
93
93
  file: loc.file, line: loc.line, level: 'warning', category: 'redundancy',
94
94
  message: `"${word}" × ${n} nodes (threshold: ${threshold})`,
95
95
  suggestion: `pick one canonical home for "${word}" and see: it from the others`,
96
+ rule: 'redundancy.keyword', // issue #41: machine-readable rule key
96
97
  });
97
98
  }
98
99
  return issues;
@@ -126,6 +127,8 @@ export function phraseOverlap(
126
127
  file: a.node.file, line: a.node.line, level: 'warning', category: 'redundancy',
127
128
  message: `${pct}% overlap: ${a.node.file}:${a.node.line} ↔ ${b.node.file}:${b.node.line}`,
128
129
  suggestion: 'merge the duplicated bullets or see: the canonical one',
130
+ // issue #41: 100% overlap is an exact duplicate; below that is fuzzy.
131
+ rule: pct >= 100 ? 'redundancy.overlap.exact' : 'redundancy.overlap.fuzzy',
129
132
  });
130
133
  }
131
134
  }
@@ -133,21 +136,63 @@ export function phraseOverlap(
133
136
  return issues;
134
137
  }
135
138
 
139
+ /** Light English suffixes for the inflection check (issue #3), longest first —
140
+ * stripped iteratively from the end so "carries" → "carri" (→ i↔y → "carry"). */
141
+ const INFLECTION_SUFFIXES = ['ing', 'ers', 'er', 'ed', 'es', 'ly', 's'];
142
+
143
+ /** Reduce a word to a rough stem by iteratively stripping common English
144
+ * suffixes, then a trailing `e`, folding a trailing `i` onto `y` (so
145
+ * deny/denied → deny, approve/approved → approv). Deliberately NOT a real
146
+ * stemmer — only good enough to recognize pure suffix inflections. */
147
+ function lightStem(word: string): string {
148
+ let w = word;
149
+ for (;;) {
150
+ const suffix = INFLECTION_SUFFIXES.find(s => w.length > s.length && w.endsWith(s));
151
+ if (suffix === undefined) break;
152
+ w = w.slice(0, w.length - suffix.length);
153
+ }
154
+ if (w.endsWith('i')) w = `${w.slice(0, -1)}y`;
155
+ if (w.endsWith('e')) w = w.slice(0, -1);
156
+ return w;
157
+ }
158
+
159
+ /** Issue #3: is `a` a pure suffix-inflection of `b` (or vice versa)? Both words
160
+ * are reduced with lightStem; equal stems — or a stem equal to the other word's
161
+ * unstemmed form — mean the pair differs only by an English inflection
162
+ * (approve/approved, session/sessions, deny/denied), not by a typo. Stems
163
+ * shorter than 3 chars are treated as unsafe and the pair is NOT skipped
164
+ * (e.g. sing/singe stays a typo candidate). Genuine near-misses with no suffix
165
+ * relation (flavour/flavor, table/tabble) keep different stems → still flagged. */
166
+ export function isInflectionOf(a: string, b: string): boolean {
167
+ const sa = lightStem(a);
168
+ const sb = lightStem(b);
169
+ if (sa.length < 3 || sb.length < 3) return false;
170
+ return sa === sb || sa === b || sb === a;
171
+ }
172
+
136
173
  /** Layer 3 — near-miss word forms (Levenshtein <= 2, both words > 4 chars) → possible typo.
137
174
  * §13: "NOT ALREADY SYNONYM-MATCHED" — words are normalized with the rules'
138
175
  * synonym groups first, so members of the same group collapse to one word and
139
- * never pair up as typos. */
176
+ * never pair up as typos. Issue #3: the layer now applies the SAME collection
177
+ * filter as the other layers — the configured `redundancy.stopwords` and the
178
+ * §8/§13 ref-syntax tokens (`see`, `md`) are never collected — and skips
179
+ * candidate pairs that are pure suffix inflections of each other
180
+ * (isInflectionOf) before the Levenshtein comparison, so English inflections
181
+ * (approved/approve, sessions/session) are no longer reported as typos. */
140
182
  export function fuzzyDistance(
141
183
  nodes: NodeRef[],
142
184
  rules?: RedundancyRules,
143
185
  ): Issue[] {
144
186
  const synonyms = rules ? rules.synonyms : [];
187
+ const stopwords = rules ? rules.stopwords : [];
145
188
  const words: NodeRef[] = [];
146
189
  const seen = new Set<string>();
147
190
  for (const node of nodes) {
148
191
  for (const raw of tokenize(node.text)) {
149
192
  const w = normalizeWord(raw, synonyms);
150
193
  if (w.length === 0 || seen.has(w)) continue;
194
+ if (REF_SYNTAX_TOKENS.has(w)) continue;
195
+ if (stopwords.includes(w)) continue;
151
196
  seen.add(w);
152
197
  words.push({ text: w, file: node.file, line: node.line });
153
198
  }
@@ -159,12 +204,14 @@ export function fuzzyDistance(
159
204
  const b = words[j];
160
205
  if (a.text.length <= 4 || b.text.length <= 4) continue;
161
206
  if (Math.abs(a.text.length - b.text.length) > 2) continue;
207
+ if (isInflectionOf(a.text, b.text)) continue;
162
208
  const d = levenshtein(a.text, b.text);
163
209
  if (d <= 2) {
164
210
  issues.push({
165
211
  file: a.file, line: a.line, level: 'warning', category: 'redundancy',
166
212
  message: `possible typo: "${a.text}" (${a.file}:${a.line}) ↔ "${b.text}" (${b.file}:${b.line}) — Levenshtein ${d}`,
167
213
  suggestion: 'unify the spelling or map the variant as a synonym',
214
+ rule: 'redundancy.typo', // issue #41: word-form layer of the redundancy scheme
168
215
  });
169
216
  }
170
217
  }
@@ -208,6 +255,10 @@ export function crossFileCanonicality(
208
255
  const concepts = new Map<string, { files: Set<string>; first: NodeRef }>();
209
256
  for (const [key, nodes] of allFiles) {
210
257
  for (const node of flattenNodes(nodes)) {
258
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
259
+ // concepts — comparing them across files fabricated "canonical home"
260
+ // warnings with nonsense advice.
261
+ if (isSyntheticNode(node)) continue;
211
262
  if (node.indent > 1) continue;
212
263
  const text = node.text.trim().toLowerCase();
213
264
  if (text.length === 0) continue;
@@ -234,13 +285,16 @@ export function crossFileCanonicality(
234
285
  file: entry.first.file, line: entry.first.line, level: 'warning', category: 'redundancy',
235
286
  message: `"${concept}" at depth 0-1 in ${files.length}+ files without see: (${files.join(', ')})`,
236
287
  suggestion: `keep "${concept}" in one canonical file and see: it from the others`,
288
+ rule: 'redundancy.duplicate_home', // issue #41
237
289
  });
238
290
  }
239
291
  return issues;
240
292
  }
241
293
 
242
294
  /** All four redundancy layers over every loaded spec node.
243
- * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4. */
295
+ * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4.
296
+ * Issue #3: `redundancy.fuzzy` (§18 delete-key semantics: deleted → false)
297
+ * gates layer 3 independently of `enabled`, which still covers all layers. */
244
298
  export function checkRedundancy(
245
299
  allFiles: Map<string, OutlineNode[]>,
246
300
  rules: RedundancyRules,
@@ -249,13 +303,18 @@ export function checkRedundancy(
249
303
  const nodes: NodeRef[] = [];
250
304
  for (const [file, tree] of allFiles) {
251
305
  for (const node of flattenNodes(tree)) {
306
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
307
+ // content — excluded from all three text-comparison layers (they made
308
+ // any two table-opening files 100%-overlapping and inflated the
309
+ // "table"/"code"/"fence" word-frequency counts).
310
+ if (isSyntheticNode(node)) continue;
252
311
  nodes.push({ text: node.text, file, line: node.line });
253
312
  }
254
313
  }
255
314
  return [
256
315
  ...wordFrequency(nodes, rules),
257
316
  ...phraseOverlap(nodes, rules.phrase_overlap_threshold, rules),
258
- ...fuzzyDistance(nodes, rules),
317
+ ...(rules.fuzzy !== false ? fuzzyDistance(nodes, rules) : []),
259
318
  ...(duplicateHomeCheck ? crossFileCanonicality(allFiles, rules.cross_file_threshold) : []),
260
319
  ];
261
320
  }