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,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,135 @@ 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
+ rule: 'structure.node_length.max', // issue #41: machine-readable rule key
34
+ });
35
+ } else if (nl !== null && nl.min !== null && len < nl.min) {
36
+ issues.push({
37
+ file,
38
+ line: node.line,
39
+ level: 'warning',
40
+ category: 'structure',
41
+ message: `Node too short (${len} < ${nl.min}).`,
42
+ rule: 'structure.node_length.min', // issue #41: machine-readable rule key
43
+ });
44
+ }
36
45
 
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
- }
46
+ const depth = node.indent + 1;
47
+ const depthMax = rules.depth !== null ? rules.depth.max : null;
48
+ if (depthMax !== null && depth > depthMax) {
49
+ issues.push({
50
+ file,
51
+ line: node.line,
52
+ level: 'error',
53
+ category: 'structure',
54
+ message: `Depth ${depth} exceeds max ${depthMax}. Flatten.`,
55
+ rule: 'structure.depth.max', // issue #41: machine-readable rule key
56
+ });
57
+ }
48
58
 
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
- }
59
+ const count = node.children.length;
60
+ const siblingsMax = rules.siblings !== null ? rules.siblings.max : null;
61
+ if (siblingsMax !== null && count > siblingsMax) {
62
+ issues.push({
63
+ file,
64
+ line: node.line,
65
+ level: 'warning',
66
+ category: 'structure',
67
+ message: `"${node.text}" has ${count} children (max ${siblingsMax}).`,
68
+ rule: 'structure.siblings.max', // issue #41: machine-readable rule key
69
+ });
70
+ }
71
+ // Issue #1: enforce siblings.min — a parent with 0 < count < min children
72
+ // is under the configured fan-out. Warning level, consistent with the
73
+ // siblings.max side above. The single_child_collapse advisory below is a
74
+ // separate check and may fire for the same node — that is acceptable.
75
+ const siblingsMin = rules.siblings !== null ? rules.siblings.min : null;
76
+ if (siblingsMin !== null && count > 0 && count < siblingsMin) {
77
+ issues.push({
78
+ file,
79
+ line: node.line,
80
+ level: 'warning',
81
+ category: 'structure',
82
+ message: `"${node.text}" has ${count} children (min ${siblingsMin}).`,
83
+ rule: 'structure.siblings.min', // issue #41: machine-readable rule key
84
+ });
85
+ }
60
86
 
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
- });
87
+ if (rules.single_child_collapse && count === 1) {
88
+ issues.push({
89
+ file,
90
+ line: node.line,
91
+ level: 'warning',
92
+ category: 'structure',
93
+ message: `"${node.text}" has exactly 1 child. Collapse.`,
94
+ rule: 'structure.single_child', // issue #41: machine-readable rule key
95
+ });
96
+ }
97
+
98
+ if (rules.empty_nodes && node.text.trim() === '') {
99
+ issues.push({
100
+ file,
101
+ line: node.line,
102
+ level: 'warning',
103
+ category: 'structure',
104
+ message: 'Empty node.',
105
+ rule: 'structure.empty_node', // issue #41: machine-readable rule key
106
+ });
107
+ }
69
108
  }
70
109
 
71
- if (rules.empty_nodes && node.text.trim() === '') {
110
+ walk(node.children);
111
+ }
112
+ };
113
+
114
+ walk(nodes);
115
+
116
+ // Issue #1: enforce depth.min at file level — a file whose deepest node is
117
+ // above the configured minimum is under-specified. Attached to the first
118
+ // root node's line (there is always at least one node when the file has
119
+ // nodes); an empty tree skips the check entirely.
120
+ // LEVEL RATIONALE: depth.min is a warning — a too-shallow file is advisory
121
+ // (depth.max stays an error because it protects the token budget; making
122
+ // shallow files hard-fail would break legitimately shallow summary files).
123
+ // siblings.min is a warning for the same reason: it matches the warning
124
+ // level of the siblings.max side. Defaults (min: 1 both) can never fire —
125
+ // `0 < count < 1` is impossible and any non-empty file has max depth ≥ 1 —
126
+ // so default-rule workspaces stay clean.
127
+ if (nodes.length > 0) {
128
+ const depthMin = rules.depth !== null ? rules.depth.min : null;
129
+ if (depthMin !== null) {
130
+ let maxNodeDepth = 0;
131
+ for (const n of flattenNodes(nodes)) {
132
+ const d = n.indent + 1; // same convention as the per-node depth check above
133
+ if (d > maxNodeDepth) maxNodeDepth = d;
134
+ }
135
+ if (maxNodeDepth < depthMin) {
72
136
  issues.push({
73
137
  file,
74
- line: node.line,
138
+ line: nodes[0]!.line,
75
139
  level: 'warning',
76
140
  category: 'structure',
77
- message: 'Empty node.',
141
+ message: `Max depth ${maxNodeDepth} is below min ${depthMin}. Deepen the outline.`,
142
+ suggestion: `add nested sub-levels until the outline reaches depth ${depthMin}, or lower structure.depth.min in _rules.yaml`,
143
+ rule: 'structure.depth.min', // issue #41: machine-readable rule key
78
144
  });
79
145
  }
80
-
81
- walk(node.children);
82
146
  }
83
- };
147
+ }
84
148
 
85
- walk(nodes);
86
149
  return issues;
87
150
  }
88
151
 
@@ -106,6 +169,7 @@ export function checkTbdPolicy(
106
169
  level: 'warning',
107
170
  category: 'structure',
108
171
  message: 'TBD used but content.tbd_allowed is false',
172
+ rule: 'content.tbd.disallowed', // issue #41: machine-readable rule key
109
173
  suggestion: 'resolve the TBD nodes or set content.tbd_allowed: true',
110
174
  },
111
175
  ];
@@ -118,6 +182,7 @@ export function checkTbdPolicy(
118
182
  level: 'warning',
119
183
  category: 'structure',
120
184
  message: `${tbdNodes.length} TBD nodes exceed content.max_tbd_per_file (${rules.max_tbd_per_file})`,
185
+ rule: 'content.tbd.max', // issue #41: machine-readable rule key
121
186
  suggestion: 'resolve the TBD nodes or raise content.max_tbd_per_file',
122
187
  },
123
188
  ];
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,66 @@ 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
+ rule: 'style.prefix.shared', // issue #41: machine-readable rule key
70
+ });
71
+ }
41
72
  }
42
73
  }
43
- }
44
74
 
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
- });
75
+ // §14: "Parent with ≤ force_sibling_below leaf children → collapse to
76
+ // sibling style." `≤` semantics (exactly N is flagged). The file root is
77
+ // exempt — a root concept with few subtopics is the normal spec shape,
78
+ // not unnecessary nesting. A single child is reported by the structure
79
+ // engine ("exactly 1 child"); don't double-report it here.
80
+ const siblingBelow = rules.force_sibling_below;
81
+ // prefer: 'nested' declares a grouped-outline preference — the collapse
82
+ // hint contradicts it and is suppressed ('sibling'/null keep it firing).
83
+ if (
84
+ siblingBelow !== null &&
85
+ rules.prefer !== 'nested' &&
86
+ node.indent > 0 &&
87
+ children.length >= 2 &&
88
+ children.length <= siblingBelow &&
89
+ children.every((c) => c.children.length === 0)
90
+ ) {
91
+ issues.push({
92
+ file,
93
+ line: node.line,
94
+ level: 'warning',
95
+ category: 'style',
96
+ // QA-03 F17 pluralization contract (unreachable for 1 — the ≥2 guard
97
+ // above plus the structure engine owns the 1-child case).
98
+ message: `"${node.text}" has ${children.length} ${children.length === 1 ? 'child' : 'children'}. Collapse to sibling style.`,
99
+ rule: 'style.nesting.prefer', // issue #41: machine-readable rule key
100
+ });
101
+ }
67
102
  }
68
103
 
69
104
  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
  }
@@ -103,9 +108,22 @@ export function buildReadPlan(
103
108
  }
104
109
 
105
110
  const backRefFiles = new Set<string>();
111
+ // §26 step 3 forward-ref tier (QA-17 F28): the files the canonical home
112
+ // POINTS TO — targets of see: refs made from the home file — connect at 40.
113
+ // (Back-refs, the files that point AT the home, stay 60; a file that is
114
+ // both keeps the higher tier.)
115
+ const forwardFiles = new Set<string>();
106
116
  if (home !== null) {
107
117
  for (const bp of backPointers) {
108
118
  if (targetMatchesKey(bp.toFile, home.file)) backRefFiles.add(bp.fromFile);
119
+ if (bp.fromFile === home.file) {
120
+ for (const key of allFiles.keys()) {
121
+ if (targetMatchesKey(bp.toFile, key)) {
122
+ forwardFiles.add(key);
123
+ break;
124
+ }
125
+ }
126
+ }
109
127
  }
110
128
  }
111
129
  for (const key of allFiles.keys()) {
@@ -114,6 +132,10 @@ export function buildReadPlan(
114
132
  items.set(key, { file: key, anchor: null, reason: 'see: back-ref', score: 60, estTokens: tokens(key), rank: 2 });
115
133
  continue;
116
134
  }
135
+ if (forwardFiles.has(key)) {
136
+ items.set(key, { file: key, anchor: null, reason: 'forward ref', score: 40, estTokens: tokens(key), rank: 3 });
137
+ continue;
138
+ }
117
139
  const mentions = flattenNodes(allFiles.get(key)!).some(n => n.text.toLowerCase().includes(lc));
118
140
  if (mentions) {
119
141
  items.set(key, { file: key, anchor: null, reason: 'mentions concept', score: 20, estTokens: tokens(key), rank: 3 });
@@ -177,10 +199,14 @@ export function buildReadPlan(
177
199
  const plan: BudgetReadPlanItem[] = [];
178
200
  const skipped: string[] = [];
179
201
  let totalTokens = 0;
180
- let cut = false;
202
+ // §26 step 4 (issue #16): best-effort greedy packing. Items are walked in
203
+ // score order and each item that fits under the remaining budget is
204
+ // planned. An item that does not fit is skipped (listed in `skipped`) but
205
+ // does NOT cut the walk — cheaper lower-scored items that still fit are
206
+ // considered, so a limit below the canonical home (score 100) can still
207
+ // afford a cheaper back-ref (score 60) instead of yielding plan: [].
181
208
  for (const item of sorted) {
182
- if (cut || totalTokens + item.estTokens > budgetLimit) {
183
- cut = true;
209
+ if (totalTokens + item.estTokens > budgetLimit) {
184
210
  skipped.push(item.file);
185
211
  continue;
186
212
  }
@@ -193,6 +219,16 @@ export function buildReadPlan(
193
219
  for (const key of allFiles.keys()) {
194
220
  if (!items.has(key)) skipped.push(key);
195
221
  }
222
+ // §26 step 4 (QA-17 F25): skipped lists EVERY file not in the plan — active
223
+ // task files are in budget scope (§22), so a task file with no connection
224
+ // to the concept (never scored into `items`) is listed too, never
225
+ // invisible. Planned-but-unaffordable task files already landed in skipped
226
+ // via the packing loop above.
227
+ if (activeTaskPaths !== undefined) {
228
+ for (const taskPath of activeTaskPaths) {
229
+ if (!items.has(taskPath)) skipped.push(taskPath);
230
+ }
231
+ }
196
232
  skipped.sort();
197
233
 
198
234
  const usagePercent = budgetLimit > 0
package/src/types.ts CHANGED
@@ -20,12 +20,25 @@ 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 {
26
33
  fromFile: string;
27
34
  fromLine: number;
28
35
  toFile: string;
36
+ /** The anchor side of the back-pointer. For graph-built back-pointers
37
+ * (buildRefGraph): the ref's raw anchor token, null for file-level refs.
38
+ * For extracted `<!-- ref-by: ... -->` comments (extractBackPointers,
39
+ * issue #19): the text of the node whose bullet line carries the comment
40
+ * (INLINE form — a node mark), or null when the comment stands on its own
41
+ * line (STANDALONE form — a file-level mark). */
29
42
  toAnchor: string | null;
30
43
  }
31
44
 
@@ -41,6 +54,10 @@ export interface Issue {
41
54
  category: IssueCategory;
42
55
  message: string;
43
56
  suggestion?: string;
57
+ /** issue #41: machine-readable dotted rule key (e.g. "refs.broken.file",
58
+ * "structure.node_length.max") — stable vocabulary for report grouping and
59
+ * agent consumption. Optional so non-engine Issue constructors stay legal. */
60
+ rule?: string;
44
61
  }
45
62
 
46
63
  // ── Rules ──
@@ -63,7 +80,9 @@ export interface StructureRules {
63
80
  }
64
81
 
65
82
  export interface StyleRules {
66
- /** Deleted `prefer` disables prefer-driven style guidance (§18). */
83
+ /** Style-guide selector: `sibling` suppresses the nested-grouping hint,
84
+ * `nested` suppresses the sibling-collapse hint; null (deleted, §18) → no
85
+ * prefer-driven modulation — both base style hints fire unchanged. */
67
86
  prefer: 'sibling' | 'nested' | null;
68
87
  force_nested_above: number | null;
69
88
  force_sibling_below: number | null;
@@ -90,6 +109,8 @@ export interface RedundancyRules {
90
109
  word_frequency_threshold: number | null;
91
110
  phrase_overlap_threshold: number | null;
92
111
  cross_file_threshold: number | null;
112
+ /** Layer 3 switch: deleted → false (§18 delete-key semantics). */
113
+ fuzzy: boolean;
93
114
  /** Parameters (not checks): keep their defaults when deleted (§18). */
94
115
  stopwords: string[];
95
116
  synonyms: string[][];
@@ -149,6 +170,13 @@ export interface CheckResult extends CommandResult {
149
170
  errorCount: number;
150
171
  warningCount: number;
151
172
  backPointersUpdated: number;
173
+ /** Issue #11: spec-relative paths of the files --fix actually rewrote
174
+ * (sorted). Empty without --fix or when nothing needed a write; with a
175
+ * [file] filter only matching files can ever appear here. */
176
+ backPointersUpdatedFiles: string[];
177
+ /** issue #41: wall-clock duration of the whole checkWorkspace run,
178
+ * rounded to whole ms (0 for the static checkFail paths). */
179
+ elapsedMs: number;
152
180
  /** §22/§36: human-facing one-line summary of the active _rules.yaml limits (QA-02 F17). */
153
181
  rulesSummary?: string;
154
182
  }
@@ -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]