cans-spec 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cans-spec",
3
- "version": "0.2.0",
4
- "description": "Canonical Agent-Native Spec \u2014 the outline is the spec, the state, and the task board",
3
+ "version": "0.3.0",
4
+ "description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
5
5
  "bin": {
6
6
  "cans": "./bin/cans.js"
7
7
  },
@@ -6,7 +6,7 @@ import {
6
6
  dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
7
7
  } from '../core/fs.ts';
8
8
  import {
9
- parseOutline, extractBackPointers, flattenNodes, maxDepth as outlineMaxDepth,
9
+ parseOutline, extractBackPointers, realNodes, maxDepth as outlineMaxDepth,
10
10
  type ParseWarning,
11
11
  } from '../core/outline.ts';
12
12
  import { loadRules } from '../core/rules.ts';
@@ -42,6 +42,10 @@ const CHECK_FLAGS: FlagSpec[] = [
42
42
  ];
43
43
 
44
44
  const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
45
+ // Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
46
+ // trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
47
+ // the outline.ts FENCE_RE convention; the two must never diverge.
48
+ const FENCE_RE = /^```/;
45
49
 
46
50
  // globFiles throws ENOENT on missing dirs — guard the optional ones.
47
51
  function safeActiveTasks(root: string): string[] {
@@ -111,15 +115,56 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
111
115
  return null;
112
116
  }
113
117
 
118
+ /** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
119
+ * keeping the surrounding prose. Collapses the double space left where the
120
+ * comment sat (one seam space absorbed) and trims trailing whitespace. The
121
+ * caller drops the line only when nothing but the comment remains. */
122
+ function stripRefByKeepProse(raw: string): string {
123
+ const m = raw.match(REF_BY_RE);
124
+ if (m === null || m.index === undefined) return raw;
125
+ let out = raw.slice(0, m.index) + raw.slice(m.index + m[0].length);
126
+ if (out.charAt(m.index - 1) === ' ' && out.charAt(m.index) === ' ') {
127
+ out = out.slice(0, m.index - 1) + out.slice(m.index);
128
+ }
129
+ return out.replace(/[ \t]+$/, '');
130
+ }
131
+
114
132
  /** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
115
- * Replaces the first existing comment's content, drops duplicates/stale ones,
116
- * or inserts a fresh comment line right after the first root bullet. */
117
- function rewriteRefBy(source: string, body: string | null): string {
133
+ * Fence-aware and prose-preserving (issue #6):
134
+ * - Lines inside ``` fences (and the fence markers themselves) are preserved
135
+ * byte-for-byte: never scanned as hits, never rewritten, never dropped, and
136
+ * fenced "- fake bullets" are never insertion anchors.
137
+ * - Replaces the first existing comment's content; duplicates/stale hits keep
138
+ * their line — bullets are stripped as before, non-bullet prose loses only
139
+ * the comment substring (the line is dropped only when bare).
140
+ * - With no hits and a desired body, inserts a fresh comment line right after
141
+ * the first root bullet outside any fence, appended at end when none exists
142
+ * — unless the file ends inside an unterminated fence, in which case the
143
+ * comment is inserted before the fence opener (never inside a fence).
144
+ * Exported for regression tests (issue #6); behavior lives in this module. */
145
+ export function rewriteRefBy(source: string, body: string | null): string {
118
146
  const lines: Array<string | null> = source.split('\n');
119
147
  const comment = body !== null && body !== '' ? `<!-- ref-by: ${body} -->` : null;
120
148
  const hits: number[] = [];
149
+ let fenceOpen = false;
150
+ let fenceOpener = -1;
151
+ const inFence: boolean[] = new Array(lines.length).fill(false);
121
152
  for (let i = 0; i < lines.length; i++) {
122
- if (lines[i] !== null && REF_BY_RE.test(lines[i]!)) hits.push(i);
153
+ const raw = lines[i]!;
154
+ if (FENCE_RE.test(raw.trim())) {
155
+ // Fence marker: toggles state; never a hit, never an anchor, never touched.
156
+ inFence[i] = true;
157
+ if (fenceOpen) {
158
+ fenceOpen = false;
159
+ } else {
160
+ fenceOpen = true;
161
+ fenceOpener = i;
162
+ }
163
+ continue;
164
+ }
165
+ inFence[i] = fenceOpen;
166
+ if (fenceOpen) continue;
167
+ if (REF_BY_RE.test(raw)) hits.push(i);
123
168
  }
124
169
  if (hits.length > 0) {
125
170
  for (let j = 0; j < hits.length; j++) {
@@ -129,17 +174,27 @@ function rewriteRefBy(source: string, body: string | null): string {
129
174
  lines[i] = raw.replace(REF_BY_RE, comment);
130
175
  } else {
131
176
  const isBullet = /^\s*-\s/.test(raw);
132
- lines[i] = isBullet ? raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '') : null;
177
+ if (isBullet) {
178
+ lines[i] = raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '');
179
+ } else {
180
+ // Issue #6: never delete a prose line whole — strip the comment only.
181
+ const stripped = stripRefByKeepProse(raw);
182
+ lines[i] = stripped.trim() === '' ? null : stripped;
183
+ }
133
184
  }
134
185
  }
135
186
  } else if (comment !== null) {
136
187
  let insertAt = lines.length;
137
188
  for (let i = 0; i < lines.length; i++) {
189
+ if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
138
190
  if (/^- /.test(lines[i]!)) {
139
191
  insertAt = i + 1;
140
192
  break;
141
193
  }
142
194
  }
195
+ // Issue #6: never insert inside an open fence — appending at EOF while a
196
+ // fence is unterminated would corrupt the fenced region.
197
+ if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
143
198
  lines.splice(insertAt, 0, comment);
144
199
  }
145
200
  return lines.filter((l): l is string => l !== null).join('\n');
@@ -364,7 +419,13 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
364
419
  let nodeCount = 0;
365
420
  let depthMax = 0;
366
421
  for (const nodes of specFiles.values()) {
367
- nodeCount += flattenNodes(nodes).length;
422
+ // Issue #8: report REAL nodes only — synthetic "(table)"/"(code fence)"
423
+ // placeholders (leading table/fence before the first bullet) are not
424
+ // user content and inflated the header count and budget estimates.
425
+ nodeCount += realNodes(nodes).length;
426
+ // Depth keeps the full tree: a synthetic root sits at indent 0, so it can
427
+ // never raise depthMax; a workspace containing only phantoms now has
428
+ // nodeCount 0 and correctly reports maxDepth 0 (§35 empty-workspace case).
368
429
  depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
369
430
  }
370
431
  const refsTotal = [...graph.forward.values()].reduce((a, ts) => a + ts.length, 0);
@@ -55,6 +55,9 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
55
55
  refs: [],
56
56
  hasCodeFence: false,
57
57
  hasTable: false,
58
+ // Issue #8: real bullets are never synthetic; the two placeholder
59
+ // synthesis sites below flip this to true after makeNode.
60
+ synthetic: false,
58
61
  });
59
62
 
60
63
  for (let i = 0; i < lines.length; i++) {
@@ -78,6 +81,7 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
78
81
  } else {
79
82
  const n = makeNode('(code fence)', fenceStartLine, 0);
80
83
  n.hasCodeFence = true;
84
+ n.synthetic = true; // issue #8: placeholder, not user content
81
85
  roots.push(n);
82
86
  stack.length = 0;
83
87
  stack.push(n);
@@ -97,6 +101,7 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
97
101
  } else {
98
102
  const n = makeNode('(table)', lineNo, 0);
99
103
  n.hasTable = true;
104
+ n.synthetic = true; // issue #8: placeholder, not user content
100
105
  roots.push(n);
101
106
  stack.length = 0;
102
107
  stack.push(n);
@@ -170,20 +175,29 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
170
175
  top.children.push(node);
171
176
  stack.push(node);
172
177
  } else {
173
- // shallower: pop until we find the parent level
174
- while (stack.length > 1 && stack[stack.length - 1].indent > indent) {
178
+ // shallower: pop until we find the parent level — all the way to an
179
+ // empty stack, not just down to stack[0] (issue #7). The stack bottom
180
+ // is only a real parent when the file's first bullet sits at column
181
+ // 0; when it opens indented (e.g. under a `#` heading) its indent
182
+ // must not swallow bullets that dedent past it, or the whole tree
183
+ // silently re-parents under that first node.
184
+ while (stack.length > 0 && stack[stack.length - 1].indent > indent) {
175
185
  stack.pop();
176
186
  }
177
- const candidate = stack[stack.length - 1];
178
- if (candidate.indent === indent) {
187
+ if (stack.length === 0) {
188
+ // dedented past every open node: a root sibling
189
+ roots.push(node);
190
+ stack.push(node);
191
+ } else if (stack[stack.length - 1].indent === indent) {
192
+ // sibling of the deepest open node at this level
179
193
  stack.pop();
180
194
  const parent = stack[stack.length - 1];
181
195
  if (parent) parent.children.push(node);
182
196
  else roots.push(node);
183
197
  stack.push(node);
184
198
  } else {
185
- // indented jump deeper than expected under candidate
186
- candidate.children.push(node);
199
+ // indented jump deeper than expected under that node
200
+ stack[stack.length - 1].children.push(node);
187
201
  stack.push(node);
188
202
  }
189
203
  }
@@ -194,6 +208,12 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
194
208
  return roots;
195
209
  }
196
210
 
211
+ /** Issue #8: true only for parser-created placeholder nodes ("(table)" /
212
+ * "(code fence)") representing a leading table/fence — never user content. */
213
+ export function isSyntheticNode(n: OutlineNode): boolean {
214
+ return n.synthetic === true;
215
+ }
216
+
197
217
  export function flattenNodes(nodes: OutlineNode[]): OutlineNode[] {
198
218
  const out: OutlineNode[] = [];
199
219
  const walk = (ns: OutlineNode[]): void => {
@@ -209,8 +229,18 @@ export function flattenNodes(nodes: OutlineNode[]): OutlineNode[] {
209
229
  export function extractBackPointers(source: string, file: string): BackPointer[] {
210
230
  const out: BackPointer[] = [];
211
231
  const lines = normalizeEol(source).split('\n');
232
+ // Issue #6: fence awareness — a `<!-- ref-by: ... -->` quoted inside a fenced
233
+ // example is documentation, not a real back-pointer. Same toggle rule as
234
+ // parseOutline (trimmed line starts with ```); the marker line itself is
235
+ // fence infrastructure and never carries a counted comment either.
236
+ let fenceOpen = false;
212
237
  for (let i = 0; i < lines.length; i++) {
213
- const m = lines[i].match(REF_BY_RE);
238
+ if (FENCE_RE.test(lines[i]!.trim())) {
239
+ fenceOpen = !fenceOpen;
240
+ continue;
241
+ }
242
+ if (fenceOpen) continue;
243
+ const m = lines[i]!.match(REF_BY_RE);
214
244
  if (!m) continue;
215
245
  const entries = m[1].split(',').map(s => s.trim()).filter(Boolean);
216
246
  for (const e of entries) {
@@ -220,6 +250,14 @@ export function extractBackPointers(source: string, file: string): BackPointer[]
220
250
  return out;
221
251
  }
222
252
 
253
+ /** Flattened tree WITHOUT synthetic placeholder nodes — the view every
254
+ * node-count and content-comparison consumer should use (issue #8).
255
+ * flattenNodes itself is unchanged: the tree shape keeps the placeholders
256
+ * (they carry hasTable/hasCodeFence for the overflow engine). */
257
+ export function realNodes(nodes: OutlineNode[]): OutlineNode[] {
258
+ return flattenNodes(nodes).filter(n => !isSyntheticNode(n));
259
+ }
260
+
223
261
  export function countNodes(nodes: OutlineNode[]): number {
224
262
  return flattenNodes(nodes).length;
225
263
  }
@@ -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;
@@ -133,21 +133,63 @@ export function phraseOverlap(
133
133
  return issues;
134
134
  }
135
135
 
136
+ /** Light English suffixes for the inflection check (issue #3), longest first —
137
+ * stripped iteratively from the end so "carries" → "carri" (→ i↔y → "carry"). */
138
+ const INFLECTION_SUFFIXES = ['ing', 'ers', 'er', 'ed', 'es', 'ly', 's'];
139
+
140
+ /** Reduce a word to a rough stem by iteratively stripping common English
141
+ * suffixes, then a trailing `e`, folding a trailing `i` onto `y` (so
142
+ * deny/denied → deny, approve/approved → approv). Deliberately NOT a real
143
+ * stemmer — only good enough to recognize pure suffix inflections. */
144
+ function lightStem(word: string): string {
145
+ let w = word;
146
+ for (;;) {
147
+ const suffix = INFLECTION_SUFFIXES.find(s => w.length > s.length && w.endsWith(s));
148
+ if (suffix === undefined) break;
149
+ w = w.slice(0, w.length - suffix.length);
150
+ }
151
+ if (w.endsWith('i')) w = `${w.slice(0, -1)}y`;
152
+ if (w.endsWith('e')) w = w.slice(0, -1);
153
+ return w;
154
+ }
155
+
156
+ /** Issue #3: is `a` a pure suffix-inflection of `b` (or vice versa)? Both words
157
+ * are reduced with lightStem; equal stems — or a stem equal to the other word's
158
+ * unstemmed form — mean the pair differs only by an English inflection
159
+ * (approve/approved, session/sessions, deny/denied), not by a typo. Stems
160
+ * shorter than 3 chars are treated as unsafe and the pair is NOT skipped
161
+ * (e.g. sing/singe stays a typo candidate). Genuine near-misses with no suffix
162
+ * relation (flavour/flavor, table/tabble) keep different stems → still flagged. */
163
+ export function isInflectionOf(a: string, b: string): boolean {
164
+ const sa = lightStem(a);
165
+ const sb = lightStem(b);
166
+ if (sa.length < 3 || sb.length < 3) return false;
167
+ return sa === sb || sa === b || sb === a;
168
+ }
169
+
136
170
  /** Layer 3 — near-miss word forms (Levenshtein <= 2, both words > 4 chars) → possible typo.
137
171
  * §13: "NOT ALREADY SYNONYM-MATCHED" — words are normalized with the rules'
138
172
  * synonym groups first, so members of the same group collapse to one word and
139
- * never pair up as typos. */
173
+ * never pair up as typos. Issue #3: the layer now applies the SAME collection
174
+ * filter as the other layers — the configured `redundancy.stopwords` and the
175
+ * §8/§13 ref-syntax tokens (`see`, `md`) are never collected — and skips
176
+ * candidate pairs that are pure suffix inflections of each other
177
+ * (isInflectionOf) before the Levenshtein comparison, so English inflections
178
+ * (approved/approve, sessions/session) are no longer reported as typos. */
140
179
  export function fuzzyDistance(
141
180
  nodes: NodeRef[],
142
181
  rules?: RedundancyRules,
143
182
  ): Issue[] {
144
183
  const synonyms = rules ? rules.synonyms : [];
184
+ const stopwords = rules ? rules.stopwords : [];
145
185
  const words: NodeRef[] = [];
146
186
  const seen = new Set<string>();
147
187
  for (const node of nodes) {
148
188
  for (const raw of tokenize(node.text)) {
149
189
  const w = normalizeWord(raw, synonyms);
150
190
  if (w.length === 0 || seen.has(w)) continue;
191
+ if (REF_SYNTAX_TOKENS.has(w)) continue;
192
+ if (stopwords.includes(w)) continue;
151
193
  seen.add(w);
152
194
  words.push({ text: w, file: node.file, line: node.line });
153
195
  }
@@ -159,6 +201,7 @@ export function fuzzyDistance(
159
201
  const b = words[j];
160
202
  if (a.text.length <= 4 || b.text.length <= 4) continue;
161
203
  if (Math.abs(a.text.length - b.text.length) > 2) continue;
204
+ if (isInflectionOf(a.text, b.text)) continue;
162
205
  const d = levenshtein(a.text, b.text);
163
206
  if (d <= 2) {
164
207
  issues.push({
@@ -208,6 +251,10 @@ export function crossFileCanonicality(
208
251
  const concepts = new Map<string, { files: Set<string>; first: NodeRef }>();
209
252
  for (const [key, nodes] of allFiles) {
210
253
  for (const node of flattenNodes(nodes)) {
254
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
255
+ // concepts — comparing them across files fabricated "canonical home"
256
+ // warnings with nonsense advice.
257
+ if (isSyntheticNode(node)) continue;
211
258
  if (node.indent > 1) continue;
212
259
  const text = node.text.trim().toLowerCase();
213
260
  if (text.length === 0) continue;
@@ -240,7 +287,9 @@ export function crossFileCanonicality(
240
287
  }
241
288
 
242
289
  /** All four redundancy layers over every loaded spec node.
243
- * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4. */
290
+ * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4.
291
+ * Issue #3: `redundancy.fuzzy` (§18 delete-key semantics: deleted → false)
292
+ * gates layer 3 independently of `enabled`, which still covers all layers. */
244
293
  export function checkRedundancy(
245
294
  allFiles: Map<string, OutlineNode[]>,
246
295
  rules: RedundancyRules,
@@ -249,13 +298,18 @@ export function checkRedundancy(
249
298
  const nodes: NodeRef[] = [];
250
299
  for (const [file, tree] of allFiles) {
251
300
  for (const node of flattenNodes(tree)) {
301
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
302
+ // content — excluded from all three text-comparison layers (they made
303
+ // any two table-opening files 100%-overlapping and inflated the
304
+ // "table"/"code"/"fence" word-frequency counts).
305
+ if (isSyntheticNode(node)) continue;
252
306
  nodes.push({ text: node.text, file, line: node.line });
253
307
  }
254
308
  }
255
309
  return [
256
310
  ...wordFrequency(nodes, rules),
257
311
  ...phraseOverlap(nodes, rules.phrase_overlap_threshold, rules),
258
- ...fuzzyDistance(nodes, rules),
312
+ ...(rules.fuzzy !== false ? fuzzyDistance(nodes, rules) : []),
259
313
  ...(duplicateHomeCheck ? crossFileCanonicality(allFiles, rules.cross_file_threshold) : []),
260
314
  ];
261
315
  }
package/src/core/refs.ts CHANGED
@@ -46,6 +46,25 @@ export function anchorMatches(nodeText: string, anchor: string): boolean {
46
46
  return norm(nodeText) === norm(anchor);
47
47
  }
48
48
 
49
+ /** Does an UNRESOLVED ref target look like an intended spec reference
50
+ * (broken-ref error territory) or like English prose that merely contains
51
+ * the word "see" (warning territory)? (issue #4)
52
+ *
53
+ * The §11 ref regex intentionally keeps minting refs from any `see <token>`
54
+ * prose ("see the runbook", "see below") so banner counts and deep-hop/orphan
55
+ * machinery stay centralized in the graph. Classification happens only at
56
+ * resolution time, in checkRefs: a target that itself looks spec-shaped keeps
57
+ * the documented broken-ref error; anything else is prose, not a dangling
58
+ * spec pointer. */
59
+ export function looksLikeSpecRef(target: string, hasAnchor: boolean): boolean {
60
+ if (hasAnchor) return true; // see X#anchor — explicit anchor intent
61
+ if (/\.md$/i.test(target)) return true; // explicit markdown target
62
+ if (/^_/.test(target)) return true; // workspace service dirs (_tasks/, _collab/, ...)
63
+ if (target.includes('/')) return true; // path-like
64
+ if (/^\d/.test(target)) return true; // numeric-prefix spec stems (02-auth)
65
+ return false;
66
+ }
67
+
49
68
  export function buildRefGraph(
50
69
  files: Map<string, OutlineNode[]>,
51
70
  root: string,
@@ -106,11 +125,28 @@ export function checkRefs(
106
125
  // loaded numeric span, a missing file is always a level:error broken
107
126
  // ref. (The former "unwritten spec slot" backward in-span downgrade
108
127
  // violated §12 and masked real holes as warnings — removed.)
109
- issues.push({
110
- file, line: ref.line, level: 'error', category: 'refs',
111
- message: `broken ref: see ${ref.file} — file not found`,
112
- suggestion: `create ${ref.file} or fix the ref target`,
113
- });
128
+ //
129
+ // Issue #4 prose exemption: that error contract applies to targets that
130
+ // THEMSELVES look like intended spec references (looksLikeSpecRef —
131
+ // anchored, .md, workspace-service-dir, path-like, or numeric-prefix).
132
+ // English prose that merely contains the word "see" ("see the runbook",
133
+ // "see below") mints a ref target that resolves to nothing and is not
134
+ // spec-shaped — downgraded to a see-like-prose warning so natural
135
+ // language no longer fails the run. Genuinely malformed real refs
136
+ // (.md targets, anchors, paths, numeric stems) keep the exact error.
137
+ if (looksLikeSpecRef(ref.file, ref.anchor !== null)) {
138
+ issues.push({
139
+ file, line: ref.line, level: 'error', category: 'refs',
140
+ message: `broken ref: see ${ref.file} — file not found`,
141
+ suggestion: `create ${ref.file} or fix the ref target`,
142
+ });
143
+ } else {
144
+ issues.push({
145
+ file, line: ref.line, level: 'warning', category: 'refs',
146
+ message: `see-like prose: "see ${ref.file}" did not resolve to a spec file — rephrase or link explicitly`,
147
+ suggestion: 'use "see: <file>.md" (or "see: <file>.md#<anchor>") to link a spec file, or reword the sentence',
148
+ });
149
+ }
114
150
  continue;
115
151
  }
116
152
 
@@ -150,10 +186,33 @@ export function checkRefs(
150
186
 
151
187
  /** Deep-hop detection: a file that both receives refs and issues them extends
152
188
  * the ref chain. `maxHops` (§18 references.max_hops, default 1) is the number
153
- * of allowed hops: a chain whose hop count through `b` exceeds it is flagged.
154
- * Hop count for file `b` with outgoing refs = (longest incoming chain into b) + 1.
155
- * §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
156
- * skipped entirely. */
189
+ * of allowed hops.
190
+ *
191
+ * Semantics (issue #5):
192
+ * - Nodes are the loaded file keys; an edge a → b exists when a holds a see:
193
+ * ref resolving to loaded file b (targetMatchesKey, flat and folder
194
+ * layouts). Self-refs never form edges — checkRefs reports those.
195
+ * - Strongly-connected meshes (2-cycles, 3-cycles, any mutual back-reference
196
+ * cluster — the shape the engine's own redundancy guidance encourages) are
197
+ * collapsed via Tarjan's SCC. A ref from b back into b's own mesh is the
198
+ * documented back-reference pattern and is never a hop; only refs LEAVING
199
+ * b's mesh extend a chain, so a pure mesh can never be flagged.
200
+ * - For a file b with a mesh-exiting outgoing ref, the hop count is the
201
+ * longest chain of refs ending at b (counted over simple paths, so a mesh
202
+ * cannot poison the count) + 1 for b's outgoing edge. Chains are counted
203
+ * with a cycle-safe bounded search: nothing is memoized from a truncated
204
+ * traversal (the old depthOf cached values computed under its cycle guard,
205
+ * making symmetric graphs flag asymmetrically depending on iteration
206
+ * order); only confirmed saturations at maxHops are cached, and those hold
207
+ * for every caller. Hop count above maxHops flags b.
208
+ * - The suggested fix (`add "see: <out>" directly to <from>`, where from is
209
+ * the deepest direct referrer of b, ties broken by file key sort so output
210
+ * is stable) is never a self-reference: from ≠ b holds because incoming
211
+ * lists exclude self, and from = out would place from, b and out in one
212
+ * SCC — guarded defensively anyway, so the advice can never convert a
213
+ * deep-hop error into a checkRefs self-reference error.
214
+ * - §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
215
+ * skipped entirely. */
157
216
  export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Issue[] {
158
217
  if (maxHops === null) return [];
159
218
  const issues: Issue[] = [];
@@ -175,31 +234,109 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
175
234
  }
176
235
  }
177
236
 
178
- // depth(x) = length of the longest incoming chain ending at x (0 = no incoming).
179
- const depth = new Map<string, number>();
180
- const visiting = new Set<string>();
237
+ // Tarjan's SCC over exactly the edges `incoming` encodes (adjacency is
238
+ // derived from it, so classification and cycle detection cannot disagree).
239
+ // Recursive is fine: spec workspaces are tiny, and traversal depth is
240
+ // bounded by the file count either way.
241
+ const sccId = new Map<string, number>();
242
+ {
243
+ const adj = new Map<string, string[]>();
244
+ for (const [b, referrers] of incoming) {
245
+ for (const a of referrers) {
246
+ const list = adj.get(a) ?? [];
247
+ if (!list.includes(b)) list.push(b);
248
+ adj.set(a, list);
249
+ }
250
+ }
251
+ const index = new Map<string, number>();
252
+ const low = new Map<string, number>();
253
+ const onStack = new Set<string>();
254
+ const stack: string[] = [];
255
+ let counter = 0;
256
+ let components = 0;
257
+ const strongconnect = (v: string): void => {
258
+ index.set(v, counter);
259
+ low.set(v, counter);
260
+ counter += 1;
261
+ stack.push(v);
262
+ onStack.add(v);
263
+ for (const w of adj.get(v) ?? []) {
264
+ if (!index.has(w)) {
265
+ strongconnect(w);
266
+ low.set(v, Math.min(low.get(v)!, low.get(w)!));
267
+ } else if (onStack.has(w)) {
268
+ low.set(v, Math.min(low.get(v)!, index.get(w)!));
269
+ }
270
+ }
271
+ if (low.get(v) === index.get(v)) {
272
+ let w: string;
273
+ do {
274
+ w = stack.pop()!;
275
+ onStack.delete(w);
276
+ sccId.set(w, components);
277
+ } while (w !== v);
278
+ components += 1;
279
+ }
280
+ };
281
+ for (const v of [...keys].sort()) {
282
+ if (!index.has(v)) strongconnect(v);
283
+ }
284
+ }
285
+
286
+ // First loaded key a raw ref target matches — the same first-match loop the
287
+ // incoming map uses, so ref classification and edge building always agree.
288
+ const resolveKey = (name: string): string | null => {
289
+ for (const key of keys) {
290
+ if (targetMatchesKey(name, key)) return key;
291
+ }
292
+ return null;
293
+ };
294
+
295
+ // depth(x) = longest chain of refs ending at x, over simple paths (no file
296
+ // repeats), saturated at maxHops — the flag decision only ever needs to know
297
+ // whether the chain reaches maxHops. Values are computed per query with a
298
+ // fresh path-visited set; ONLY confirmed saturations are cached, and a
299
+ // saturation is a graph property that holds for every caller.
300
+ const saturated = new Set<string>();
301
+ const explore = (x: string, visited: Set<string>, len: number): number => {
302
+ let best = len;
303
+ if (best >= maxHops) return best;
304
+ for (const u of incoming.get(x) ?? []) {
305
+ if (visited.has(u)) continue;
306
+ visited.add(u);
307
+ best = Math.max(best, explore(u, visited, len + 1));
308
+ visited.delete(u);
309
+ if (best >= maxHops) return best;
310
+ }
311
+ return best;
312
+ };
181
313
  const depthOf = (x: string): number => {
182
- const memo = depth.get(x);
183
- if (memo !== undefined) return memo;
184
- if (visiting.has(x)) return 0; // cycle guard
185
- visiting.add(x);
186
- let d = 0;
187
- for (const a of incoming.get(x) ?? []) {
188
- if (a === x) continue;
189
- d = Math.max(d, depthOf(a) + 1);
314
+ if (saturated.has(x)) return maxHops;
315
+ const d = explore(x, new Set([x]), 0);
316
+ if (d >= maxHops) {
317
+ saturated.add(x);
318
+ return maxHops;
190
319
  }
191
- visiting.delete(x);
192
- depth.set(x, d);
193
320
  return d;
194
321
  };
195
322
 
196
323
  for (const [b, outTargets] of graph.forward) {
197
- const outgoing = outTargets.filter(r => r.file !== b);
324
+ const bScc = sccId.get(b);
325
+ if (bScc === undefined) continue; // unreachable: every key is a Tarjan node
326
+ const outgoing = outTargets.filter(r => {
327
+ if (r.file === b) return false; // self-reference, as before
328
+ const t = resolveKey(r.file);
329
+ if (t !== null && sccId.get(t) === bScc) return false; // back-ref into b's own mesh
330
+ return true;
331
+ });
198
332
  if (outgoing.length === 0) continue;
199
333
  if (depthOf(b) + 1 <= maxHops) continue;
334
+ // Deepest direct referrer names the chain; ties break by file key sort so
335
+ // the output never depends on map iteration order.
336
+ const referrers = (incoming.get(b) ?? []).slice().sort();
200
337
  let from: string | null = null;
201
338
  let best = -1;
202
- for (const a of incoming.get(b) ?? []) {
339
+ for (const a of referrers) {
203
340
  const d = depthOf(a);
204
341
  if (d > best) {
205
342
  best = d;
@@ -208,6 +345,11 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
208
345
  }
209
346
  if (from === null) continue;
210
347
  const out = outgoing[0];
348
+ // Defensive invariant (issue #5 defect 2): the suggested fix must never be
349
+ // a self-reference — checkRefs rejects those, so the advice would turn one
350
+ // error into another. from ≠ b holds by construction; from = out would
351
+ // put from, b and out in one SCC. Skip rather than emit a broken fix.
352
+ if (from === b || from === out.file) continue;
211
353
  const anchor = out.anchor !== null ? `#${out.anchor}` : '';
212
354
  issues.push({
213
355
  file: b, line: out.line, level: 'error', category: 'refs',
package/src/core/rules.ts CHANGED
@@ -221,6 +221,7 @@ export function defaultRules(): Rules {
221
221
  word_frequency_threshold: 4,
222
222
  phrase_overlap_threshold: 0.7,
223
223
  cross_file_threshold: 2,
224
+ fuzzy: true,
224
225
  stopwords: ['the', 'a', 'an', 'of', 'to', 'in', 'for', 'and', 'or', 'with', 'must', 'shall', 'requires'],
225
226
  synonyms: [
226
227
  ['postgres', 'postgresql', 'pg'],
@@ -298,6 +299,21 @@ function validateRulesShape(merged: Record<string, unknown>, source: string): vo
298
299
  ) {
299
300
  throw new Error(`${at} — "${section}.${key}" must be a mapping like { min: 3, max: 120 }`);
300
301
  }
302
+ // Issue #2: style.prefer / references.mode are validated reserved values.
303
+ // The merged object always carries the (valid) defaults, so an invalid
304
+ // value can only come from the user's _rules.yaml. `null` (an empty
305
+ // `prefer:` / `mode:`) is accepted and means the documented default; a
306
+ // non-string (e.g. `prefer: 42`) fails the same got-value convention.
307
+ if (section === 'style' && key === 'prefer') {
308
+ if (v !== null && (typeof v !== 'string' || (v !== 'sibling' && v !== 'nested'))) {
309
+ throw new Error(`${at} — "style.prefer" must be "sibling" or "nested", got "${String(v)}"`);
310
+ }
311
+ }
312
+ if (section === 'references' && key === 'mode') {
313
+ if (v !== null && (typeof v !== 'string' || v !== 'pointer')) {
314
+ throw new Error(`${at} — "references.mode" must be "pointer", got "${String(v)}"`);
315
+ }
316
+ }
301
317
  }
302
318
  }
303
319
  }
@@ -307,7 +323,10 @@ function validateRulesShape(merged: Record<string, unknown>, source: string): vo
307
323
  * token_budget.enabled/default_limit/estimate_chars_per_token) are listed too:
308
324
  * they count towards a section's coverage but are never flipped OFF — omitted
309
325
  * parameters keep their documented defaults (§18 overrides only what the file
310
- * lists for them). */
326
+ * lists for them). references.mode is additionally a VALIDATED reserved
327
+ * parameter: validateRulesShape rejects any value other than 'pointer' (or a
328
+ * deleted/empty null) with a line-numbered error, and style.prefer is
329
+ * validated the same way against 'sibling' | 'nested'. */
311
330
  const SECTION_KEYS: Record<string, string[]> = {
312
331
  structure: ['node_length', 'siblings', 'depth', 'single_child_collapse', 'empty_nodes'],
313
332
  style: ['prefer', 'force_nested_above', 'force_sibling_below', 'shared_prefix_detection'],
@@ -318,6 +337,7 @@ const SECTION_KEYS: Record<string, string[]> = {
318
337
  'word_frequency_threshold',
319
338
  'phrase_overlap_threshold',
320
339
  'cross_file_threshold',
340
+ 'fuzzy',
321
341
  'stopwords',
322
342
  'synonyms',
323
343
  ],
@@ -387,7 +407,8 @@ export function loadRules(root: string): Rules {
387
407
  // from its deep-merged default to its OFF state:
388
408
  // boolean switch → false (single_child_collapse, empty_nodes, tbd_allowed,
389
409
  // shared_prefix_detection, back_pointers,
390
- // orphan_check, duplicate_home_check, redundancy.enabled;
410
+ // orphan_check, duplicate_home_check,
411
+ // redundancy.enabled, redundancy.fuzzy;
391
412
  // an explicit `false` stays false — same OFF result)
392
413
  // mapping/numeric → null (node_length, siblings, depth, max_tbd_per_file,
393
414
  // force_nested_above, force_sibling_below, max_hops,
@@ -498,9 +519,11 @@ export function loadRules(root: string): Rules {
498
519
  }
499
520
 
500
521
  // redundancy: enabled / word_frequency_threshold / phrase_overlap_threshold /
501
- // cross_file_threshold. `stopwords`/`synonyms` are parameters (§13 inputs) —
502
- // they keep their defaults when omitted so the remaining layers still
503
- // normalize text exactly as documented.
522
+ // cross_file_threshold / fuzzy. `stopwords`/`synonyms` are parameters (§13
523
+ // inputs) — they keep their defaults when omitted so the remaining layers
524
+ // still normalize text exactly as documented. `fuzzy` is layer 3's own check
525
+ // switch (issue #3): deleted → the near-miss layer alone turns OFF while
526
+ // layers 1/2/4 keep running.
504
527
  if (!listed('redundancy')) {
505
528
  if (deleteMode) {
506
529
  rules.redundancy = {
@@ -509,6 +532,7 @@ export function loadRules(root: string): Rules {
509
532
  word_frequency_threshold: null,
510
533
  phrase_overlap_threshold: null,
511
534
  cross_file_threshold: null,
535
+ fuzzy: false,
512
536
  };
513
537
  }
514
538
  } else {
@@ -524,6 +548,9 @@ export function loadRules(root: string): Rules {
524
548
  if (deleted('redundancy', 'cross_file_threshold')) {
525
549
  rules.redundancy = { ...rules.redundancy, cross_file_threshold: null };
526
550
  }
551
+ if (deleted('redundancy', 'fuzzy')) {
552
+ rules.redundancy = { ...rules.redundancy, fuzzy: false };
553
+ }
527
554
  }
528
555
 
529
556
  // token_budget: warn_threshold omitted (deleted) → null → no usage warning.
@@ -1,7 +1,10 @@
1
1
  import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types.ts';
2
- import { flattenNodes } from './outline.ts';
2
+ import { flattenNodes, isSyntheticNode } from './outline.ts';
3
3
 
4
4
  /** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
5
+ * Both sides of every range are enforced (issue #1 — siblings.min and depth.min
6
+ * were banner-checked but never enforced): node_length, siblings and depth each
7
+ * check their `min` (warning) as well as their `max`.
5
8
  * §18 delete-key semantics: a check whose rules key is null/false is OFF — the
6
9
  * check is skipped entirely (never compared against null, which would coerce
7
10
  * to 0 and flag everything). */
@@ -14,75 +17,127 @@ export function checkStructure(
14
17
 
15
18
  const walk = (list: OutlineNode[]): void => {
16
19
  for (const node of list) {
17
- const len = node.text.length;
18
- const nl = rules.node_length;
19
- if (nl !== null && nl.max !== null && len > nl.max) {
20
- issues.push({
21
- file,
22
- line: node.line,
23
- level: 'error',
24
- category: 'structure',
25
- message: `Node too long (${len} > ${nl.max}). Split or move to file.`,
26
- });
27
- } else if (nl !== null && nl.min !== null && len < nl.min) {
28
- issues.push({
29
- file,
30
- line: node.line,
31
- level: 'warning',
32
- category: 'structure',
33
- message: `Node too short (${len} < ${nl.min}).`,
34
- });
35
- }
20
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not user
21
+ // structure — never flagged themselves (their children, if any, still
22
+ // are: the walk recurses below regardless).
23
+ if (!isSyntheticNode(node)) {
24
+ const len = node.text.length;
25
+ const nl = rules.node_length;
26
+ if (nl !== null && nl.max !== null && len > nl.max) {
27
+ issues.push({
28
+ file,
29
+ line: node.line,
30
+ level: 'error',
31
+ category: 'structure',
32
+ message: `Node too long (${len} > ${nl.max}). Split or move to file.`,
33
+ });
34
+ } else if (nl !== null && nl.min !== null && len < nl.min) {
35
+ issues.push({
36
+ file,
37
+ line: node.line,
38
+ level: 'warning',
39
+ category: 'structure',
40
+ message: `Node too short (${len} < ${nl.min}).`,
41
+ });
42
+ }
36
43
 
37
- const depth = node.indent + 1;
38
- const depthMax = rules.depth !== null ? rules.depth.max : null;
39
- if (depthMax !== null && depth > depthMax) {
40
- issues.push({
41
- file,
42
- line: node.line,
43
- level: 'error',
44
- category: 'structure',
45
- message: `Depth ${depth} exceeds max ${depthMax}. Flatten.`,
46
- });
47
- }
44
+ const depth = node.indent + 1;
45
+ const depthMax = rules.depth !== null ? rules.depth.max : null;
46
+ if (depthMax !== null && depth > depthMax) {
47
+ issues.push({
48
+ file,
49
+ line: node.line,
50
+ level: 'error',
51
+ category: 'structure',
52
+ message: `Depth ${depth} exceeds max ${depthMax}. Flatten.`,
53
+ });
54
+ }
48
55
 
49
- const count = node.children.length;
50
- const siblingsMax = rules.siblings !== null ? rules.siblings.max : null;
51
- if (siblingsMax !== null && count > siblingsMax) {
52
- issues.push({
53
- file,
54
- line: node.line,
55
- level: 'warning',
56
- category: 'structure',
57
- message: `"${node.text}" has ${count} children (max ${siblingsMax}).`,
58
- });
59
- }
56
+ const count = node.children.length;
57
+ const siblingsMax = rules.siblings !== null ? rules.siblings.max : null;
58
+ if (siblingsMax !== null && count > siblingsMax) {
59
+ issues.push({
60
+ file,
61
+ line: node.line,
62
+ level: 'warning',
63
+ category: 'structure',
64
+ message: `"${node.text}" has ${count} children (max ${siblingsMax}).`,
65
+ });
66
+ }
67
+ // Issue #1: enforce siblings.min — a parent with 0 < count < min children
68
+ // is under the configured fan-out. Warning level, consistent with the
69
+ // siblings.max side above. The single_child_collapse advisory below is a
70
+ // separate check and may fire for the same node — that is acceptable.
71
+ const siblingsMin = rules.siblings !== null ? rules.siblings.min : null;
72
+ if (siblingsMin !== null && count > 0 && count < siblingsMin) {
73
+ issues.push({
74
+ file,
75
+ line: node.line,
76
+ level: 'warning',
77
+ category: 'structure',
78
+ message: `"${node.text}" has ${count} children (min ${siblingsMin}).`,
79
+ });
80
+ }
60
81
 
61
- if (rules.single_child_collapse && count === 1) {
62
- issues.push({
63
- file,
64
- line: node.line,
65
- level: 'warning',
66
- category: 'structure',
67
- message: `"${node.text}" has exactly 1 child. Collapse.`,
68
- });
82
+ if (rules.single_child_collapse && count === 1) {
83
+ issues.push({
84
+ file,
85
+ line: node.line,
86
+ level: 'warning',
87
+ category: 'structure',
88
+ message: `"${node.text}" has exactly 1 child. Collapse.`,
89
+ });
90
+ }
91
+
92
+ if (rules.empty_nodes && node.text.trim() === '') {
93
+ issues.push({
94
+ file,
95
+ line: node.line,
96
+ level: 'warning',
97
+ category: 'structure',
98
+ message: 'Empty node.',
99
+ });
100
+ }
69
101
  }
70
102
 
71
- if (rules.empty_nodes && node.text.trim() === '') {
103
+ walk(node.children);
104
+ }
105
+ };
106
+
107
+ walk(nodes);
108
+
109
+ // Issue #1: enforce depth.min at file level — a file whose deepest node is
110
+ // above the configured minimum is under-specified. Attached to the first
111
+ // root node's line (there is always at least one node when the file has
112
+ // nodes); an empty tree skips the check entirely.
113
+ // LEVEL RATIONALE: depth.min is a warning — a too-shallow file is advisory
114
+ // (depth.max stays an error because it protects the token budget; making
115
+ // shallow files hard-fail would break legitimately shallow summary files).
116
+ // siblings.min is a warning for the same reason: it matches the warning
117
+ // level of the siblings.max side. Defaults (min: 1 both) can never fire —
118
+ // `0 < count < 1` is impossible and any non-empty file has max depth ≥ 1 —
119
+ // so default-rule workspaces stay clean.
120
+ if (nodes.length > 0) {
121
+ const depthMin = rules.depth !== null ? rules.depth.min : null;
122
+ if (depthMin !== null) {
123
+ let maxNodeDepth = 0;
124
+ for (const n of flattenNodes(nodes)) {
125
+ const d = n.indent + 1; // same convention as the per-node depth check above
126
+ if (d > maxNodeDepth) maxNodeDepth = d;
127
+ }
128
+ if (maxNodeDepth < depthMin) {
72
129
  issues.push({
73
130
  file,
74
- line: node.line,
131
+ line: nodes[0]!.line,
75
132
  level: 'warning',
76
133
  category: 'structure',
77
- message: 'Empty node.',
134
+ message: `Max depth ${maxNodeDepth} is below min ${depthMin}. Deepen the outline.`,
135
+ suggestion: `add nested sub-levels until the outline reaches depth ${depthMin}, or lower structure.depth.min in _rules.yaml`,
78
136
  });
79
137
  }
80
-
81
- walk(node.children);
82
138
  }
83
- };
139
+ }
84
140
 
85
- walk(nodes);
86
141
  return issues;
87
142
  }
88
143
 
package/src/core/style.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import type { OutlineNode, Issue, StyleRules } from '../types.ts';
2
+ import { isSyntheticNode } from './outline.ts';
2
3
 
3
- /** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint.
4
+ /** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint,
5
+ * modulated by `style.prefer` (issue #2 — the key was parsed and reconciled but
6
+ * never read, so `prefer: nested` still advised "Collapse to sibling style.").
7
+ *
8
+ * Prefer semantics:
9
+ * - `prefer: 'sibling'` — the author prefers flat sibling lists: the
10
+ * shared-prefix "Group under nested style." hint is suppressed (it argues
11
+ * against the declared preference); the collapse-to-sibling hint still
12
+ * fires (it agrees with it).
13
+ * - `prefer: 'nested'` — the author prefers grouped outlines: the
14
+ * "Collapse to sibling style." hint is suppressed; the shared-prefix
15
+ * grouping hint still fires.
16
+ * - `prefer: null` (key deleted, §18) — NO prefer-driven modulation: both
17
+ * base hints fire exactly as they did before the prefer wiring existed.
18
+ * "Deleted `prefer` disables prefer-driven style guidance" therefore means
19
+ * the prefer-driven MODULATION is off, not that the base guidance is off.
20
+ *
4
21
  * SEVERITY NOTE (arbitration, same class as the refs-severity decision): §14/§36
5
22
  * show ✗ for style flags, but the frozen §35/§18 fixtures (flat-project,
6
23
  * folder-project, init templates) structurally trigger the ≤N-leaf rule and the
@@ -8,8 +25,9 @@ import type { OutlineNode, Issue, StyleRules } from '../types.ts';
8
25
  * findings stay `warning`-level. Changing test fixtures is out of bounds
9
26
  * (test/ is frozen).
10
27
  * §18 delete-key semantics: a style rule whose key is null/false no longer
11
- * fires (deleted force_nested_above / force_sibling_below / prefer or
12
- * shared_prefix_detection: false skip their rules entirely). */
28
+ * fires — deleted force_nested_above / force_sibling_below or
29
+ * shared_prefix_detection: false skip their rules entirely (deleted `prefer`
30
+ * is the modulation-off case documented above; it skips nothing). */
13
31
  export function checkStyle(
14
32
  nodes: OutlineNode[],
15
33
  file: string,
@@ -21,49 +39,64 @@ export function checkStyle(
21
39
  for (const node of list) {
22
40
  const children = node.children;
23
41
 
24
- const nestedAbove = rules.force_nested_above;
25
- if (rules.shared_prefix_detection && nestedAbove !== null && children.length >= nestedAbove) {
26
- const groups = new Map<string, number>();
27
- for (const child of children) {
28
- const word = child.text.split(/\s+/)[0] ?? '';
29
- if (word === '') continue;
30
- groups.set(word, (groups.get(word) ?? 0) + 1);
31
- }
32
- for (const [word, size] of groups) {
33
- if (size >= nestedAbove) {
34
- issues.push({
35
- file,
36
- line: node.line,
37
- level: 'warning',
38
- category: 'style',
39
- message: `${size} siblings share prefix "${word}". Group under nested style.`,
40
- });
42
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not user
43
+ // structure — never flagged themselves (their children, if any, still
44
+ // are: the walk recurses below regardless).
45
+ if (!isSyntheticNode(node)) {
46
+ const nestedAbove = rules.force_nested_above;
47
+ // prefer: 'sibling' declares a flat-list preference — the grouping hint
48
+ // contradicts it and is suppressed ('nested'/null keep it firing).
49
+ if (
50
+ rules.shared_prefix_detection &&
51
+ rules.prefer !== 'sibling' &&
52
+ nestedAbove !== null &&
53
+ children.length >= nestedAbove
54
+ ) {
55
+ const groups = new Map<string, number>();
56
+ for (const child of children) {
57
+ const word = child.text.split(/\s+/)[0] ?? '';
58
+ if (word === '') continue;
59
+ groups.set(word, (groups.get(word) ?? 0) + 1);
60
+ }
61
+ for (const [word, size] of groups) {
62
+ if (size >= nestedAbove) {
63
+ issues.push({
64
+ file,
65
+ line: node.line,
66
+ level: 'warning',
67
+ category: 'style',
68
+ message: `${size} siblings share prefix "${word}". Group under nested style.`,
69
+ });
70
+ }
41
71
  }
42
72
  }
43
- }
44
73
 
45
- // §14: "Parent with ≤ force_sibling_below leaf children → collapse to
46
- // sibling style." `≤` semantics (exactly N is flagged). The file root is
47
- // exempt — a root concept with few subtopics is the normal spec shape,
48
- // not unnecessary nesting. A single child is reported by the structure
49
- // engine ("exactly 1 child"); don't double-report it here.
50
- const siblingBelow = rules.force_sibling_below;
51
- if (
52
- siblingBelow !== null &&
53
- node.indent > 0 &&
54
- children.length >= 2 &&
55
- children.length <= siblingBelow &&
56
- children.every((c) => c.children.length === 0)
57
- ) {
58
- issues.push({
59
- file,
60
- line: node.line,
61
- level: 'warning',
62
- category: 'style',
63
- // QA-03 F17 pluralization contract (unreachable for 1 — the ≥2 guard
64
- // above plus the structure engine owns the 1-child case).
65
- message: `"${node.text}" has ${children.length} ${children.length === 1 ? 'child' : 'children'}. Collapse to sibling style.`,
66
- });
74
+ // §14: "Parent with ≤ force_sibling_below leaf children → collapse to
75
+ // sibling style." `≤` semantics (exactly N is flagged). The file root is
76
+ // exempt — a root concept with few subtopics is the normal spec shape,
77
+ // not unnecessary nesting. A single child is reported by the structure
78
+ // engine ("exactly 1 child"); don't double-report it here.
79
+ const siblingBelow = rules.force_sibling_below;
80
+ // prefer: 'nested' declares a grouped-outline preference — the collapse
81
+ // hint contradicts it and is suppressed ('sibling'/null keep it firing).
82
+ if (
83
+ siblingBelow !== null &&
84
+ rules.prefer !== 'nested' &&
85
+ node.indent > 0 &&
86
+ children.length >= 2 &&
87
+ children.length <= siblingBelow &&
88
+ children.every((c) => c.children.length === 0)
89
+ ) {
90
+ issues.push({
91
+ file,
92
+ line: node.line,
93
+ level: 'warning',
94
+ category: 'style',
95
+ // QA-03 F17 pluralization contract (unreachable for 1 — the ≥2 guard
96
+ // above plus the structure engine owns the 1-child case).
97
+ message: `"${node.text}" has ${children.length} ${children.length === 1 ? 'child' : 'children'}. Collapse to sibling style.`,
98
+ });
99
+ }
67
100
  }
68
101
 
69
102
  walk(children);
@@ -4,7 +4,7 @@ import type {
4
4
  OutlineNode, BackPointer, TokenBudgetRules,
5
5
  BudgetReadPlanItem, BudgetReadResult, BudgetWriteResult,
6
6
  } from '../types.ts';
7
- import { flattenNodes, parseOutline } from './outline.ts';
7
+ import { flattenNodes, parseOutline, isSyntheticNode } from './outline.ts';
8
8
  import { targetMatchesKey } from './refs.ts';
9
9
 
10
10
  export function estimateTokens(text: string, charsPerToken: number): number {
@@ -19,7 +19,10 @@ function relPath(file: string): string {
19
19
  }
20
20
 
21
21
  function serializedNodeText(nodes: OutlineNode[]): string {
22
- return flattenNodes(nodes).map(n => n.text).join('\n');
22
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not content —
23
+ // including them inflated every token estimate for files opening with a
24
+ // table/fence.
25
+ return flattenNodes(nodes).filter(n => !isSyntheticNode(n)).map(n => n.text).join('\n');
23
26
  }
24
27
 
25
28
  /** Token estimate for a workspace key (loaded nodes) or a raw file path (content read). */
@@ -53,6 +56,8 @@ export function findCanonicalHome(
53
56
  const candidates: Array<{ file: string; node: OutlineNode }> = [];
54
57
  for (const [file, nodes] of allFiles) {
55
58
  for (const node of flattenNodes(nodes)) {
59
+ // Issue #8: a synthetic placeholder must never be the canonical home.
60
+ if (isSyntheticNode(node)) continue;
56
61
  if (node.text.toLowerCase().includes(lc)) candidates.push({ file, node });
57
62
  }
58
63
  }
package/src/types.ts CHANGED
@@ -20,6 +20,13 @@ export interface OutlineNode {
20
20
  refs: RefTarget[];
21
21
  hasCodeFence: boolean;
22
22
  hasTable: boolean;
23
+ /** Issue #8: true ONLY for parser-created placeholder nodes (text "(table)"
24
+ * or "(code fence)") that represent a table/fence appearing BEFORE the
25
+ * first bullet of a file. They carry the hasTable/hasCodeFence signal but
26
+ * are not user content — consumers that count nodes or compare node text
27
+ * as content must exclude them (isSyntheticNode). Never set on nodes
28
+ * parsed from real bullets. */
29
+ synthetic?: boolean;
23
30
  }
24
31
 
25
32
  export interface BackPointer {
@@ -63,7 +70,9 @@ export interface StructureRules {
63
70
  }
64
71
 
65
72
  export interface StyleRules {
66
- /** Deleted `prefer` disables prefer-driven style guidance (§18). */
73
+ /** Style-guide selector: `sibling` suppresses the nested-grouping hint,
74
+ * `nested` suppresses the sibling-collapse hint; null (deleted, §18) → no
75
+ * prefer-driven modulation — both base style hints fire unchanged. */
67
76
  prefer: 'sibling' | 'nested' | null;
68
77
  force_nested_above: number | null;
69
78
  force_sibling_below: number | null;
@@ -90,6 +99,8 @@ export interface RedundancyRules {
90
99
  word_frequency_threshold: number | null;
91
100
  phrase_overlap_threshold: number | null;
92
101
  cross_file_threshold: number | null;
102
+ /** Layer 3 switch: deleted → false (§18 delete-key semantics). */
103
+ fuzzy: boolean;
93
104
  /** Parameters (not checks): keep their defaults when deleted (§18). */
94
105
  stopwords: string[];
95
106
  synonyms: string[][];
@@ -27,6 +27,7 @@ redundancy:
27
27
  word_frequency_threshold: 4
28
28
  phrase_overlap_threshold: 0.7
29
29
  cross_file_threshold: 2
30
+ fuzzy: true
30
31
  stopwords: [the, a, an, of, to, in, for, and, or, with, must, shall, requires]
31
32
  synonyms:
32
33
  - [postgres, postgresql, pg]