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 +2 -2
- package/src/commands/check.ts +68 -7
- package/src/core/outline.ts +45 -7
- package/src/core/redundancy.ts +58 -4
- package/src/core/refs.ts +166 -24
- package/src/core/rules.ts +32 -5
- package/src/core/structure.ts +112 -57
- package/src/core/style.ts +76 -43
- package/src/core/token-budget.ts +7 -2
- package/src/types.ts +12 -1
- package/templates/_rules.yaml +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cans-spec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Canonical Agent-Native Spec
|
|
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
|
},
|
package/src/commands/check.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
|
|
7
7
|
} from '../core/fs.ts';
|
|
8
8
|
import {
|
|
9
|
-
parseOutline, extractBackPointers,
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/core/outline.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
186
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/redundancy.ts
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
502
|
-
// they keep their defaults when omitted so the remaining layers
|
|
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.
|
package/src/core/structure.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
131
|
+
line: nodes[0]!.line,
|
|
75
132
|
level: 'warning',
|
|
76
133
|
category: 'structure',
|
|
77
|
-
message:
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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);
|
package/src/core/token-budget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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[][];
|
package/templates/_rules.yaml
CHANGED