cans-spec 0.1.2 → 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/src/core/refs.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
- import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types';
4
- import { flattenNodes, parseOutline } from './outline';
5
- import { resolveSpecFile, toRelative, isFile } from './fs';
3
+ import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types.ts';
4
+ import { flattenNodes, parseOutline } from './outline.ts';
5
+ import { resolveSpecFile, toRelative, isFile } from './fs.ts';
6
6
 
7
7
  export interface RefGraph {
8
8
  forward: Map<string, RefTarget[]>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import type { Rules } from '../types';
3
+ import type { Rules } from '../types.ts';
4
4
 
5
5
  interface YLine {
6
6
  lineNo: number;
@@ -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.
@@ -0,0 +1,110 @@
1
+ /** Runtime shim — the ONLY module allowed to touch `Bun.*` APIs (issue #12).
2
+ *
3
+ * Bun stays the primary runtime (fast path, behavior byte-identical to the
4
+ * pre-#12 code); Node.js ≥22.6 is the fallback (Termux / no-Bun harnesses).
5
+ * The Node path uses only `node:` builtins — the zero-dependency law holds on
6
+ * both runtimes. Anything outside this module that needs a runtime-sensitive
7
+ * operation MUST go through one of these exports; a repo self-audit test
8
+ * (test/node-fallback.test.ts) enforces `Bun\.` never appears elsewhere.
9
+ */
10
+ import { readdirSync, statSync } from 'node:fs';
11
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ /** Minimal structural type for the slice of the Bun global we use. Kept local
16
+ * so the shim also type-checks without relying on ambient `@types/bun`. */
17
+ interface BunGlobal {
18
+ file(path: string): { text(): Promise<string> };
19
+ write(path: string, data: string): Promise<number>;
20
+ Glob: new (pattern: string) => {
21
+ scanSync(opts: { cwd: string; onlyFiles: boolean }): Iterable<string>;
22
+ };
23
+ }
24
+
25
+ const BUN: BunGlobal | undefined = (globalThis as { Bun?: BunGlobal }).Bun;
26
+
27
+ /** Which runtime is executing: `'bun'` (primary) or `'node'` (fallback). */
28
+ export const RUNTIME: 'bun' | 'node' = BUN ? 'bun' : 'node';
29
+
30
+ /** CLI arguments, portable across runtimes (`Bun.argv` is an alias of
31
+ * `process.argv`, so the Node form works on both). */
32
+ export function argv(): string[] {
33
+ return process.argv.slice(2);
34
+ }
35
+
36
+ /** Directory of the module file that passed its `import.meta.url` — the
37
+ * portable replacement for Bun's `import.meta.dir` (Node has no equivalent
38
+ * property; both runtimes support `node:url.fileURLToPath`). */
39
+ export function dirFromUrl(moduleUrl: string): string {
40
+ return dirname(fileURLToPath(moduleUrl));
41
+ }
42
+
43
+ /** Read a UTF-8 text file. Bun: `Bun.file(path).text()` (unchanged fast path).
44
+ * Node: `fs/promises.readFile`. Rejects on missing files on both runtimes. */
45
+ export async function readText(path: string): Promise<string> {
46
+ if (BUN) return BUN.file(path).text();
47
+ return readFile(path, 'utf8');
48
+ }
49
+
50
+ /** Write a UTF-8 text file, creating missing parent directories — `Bun.write`
51
+ * auto-creates parents, so the Node path replicates that (plain
52
+ * `writeFile` would not) to keep behavior identical across runtimes. */
53
+ export async function writeText(path: string, data: string): Promise<void> {
54
+ if (BUN) {
55
+ await BUN.write(path, data);
56
+ return;
57
+ }
58
+ await mkdir(dirname(path), { recursive: true });
59
+ await writeFile(path, data, 'utf8');
60
+ }
61
+
62
+ function isDir(p: string): boolean {
63
+ try {
64
+ return statSync(p).isDirectory();
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** Glob relative file paths under `dir`, sorted lexicographically — portable
71
+ * replacement for `new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })`.
72
+ * Supported forms: single-segment (`*.md`) and recursive (a two-star prefix
73
+ * followed by a slash, e.g. "**" + "/" + "*.md") wildcard patterns; `*` never
74
+ * crosses `/` (same as Bun.Glob). Returns paths relative to `dir`, sorted.
75
+ * Empty array when `dir` does not exist (unchanged contract used by
76
+ * `_tasks`/`_adr` discovery). */
77
+ export function globFiles(dir: string, pattern: string): string[] {
78
+ if (!isDir(dir)) return [];
79
+ if (BUN) {
80
+ const g = new BUN.Glob(pattern);
81
+ return [...g.scanSync({ cwd: dir, onlyFiles: true }) as Iterable<string>].sort();
82
+ }
83
+ const recursive = pattern.startsWith('**/');
84
+ const base = recursive ? pattern.slice(3) : pattern;
85
+ const re = new RegExp(
86
+ `^(?:${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')})$`,
87
+ );
88
+ const out: string[] = [];
89
+ if (recursive) {
90
+ const walk = (d: string, prefix: string): void => {
91
+ let entries;
92
+ try {
93
+ entries = readdirSync(d, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ for (const e of entries) {
98
+ const rel = prefix === '' ? e.name : `${prefix}/${e.name}`;
99
+ if (e.isFile() && re.test(e.name)) out.push(rel);
100
+ else if (e.isDirectory()) walk(join(d, e.name), rel);
101
+ }
102
+ };
103
+ walk(dir, '');
104
+ } else {
105
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
106
+ if (e.isFile() && re.test(e.name)) out.push(e.name);
107
+ }
108
+ }
109
+ return out.sort();
110
+ }
@@ -1,7 +1,10 @@
1
- import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types';
2
- import { flattenNodes } from './outline';
1
+ import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types.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