cans-spec 0.1.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/LICENSE +21 -0
- package/README.md +339 -0
- package/package.json +38 -0
- package/src/cli.ts +48 -0
- package/src/commands/budget.ts +238 -0
- package/src/commands/check.ts +422 -0
- package/src/commands/done.ts +171 -0
- package/src/commands/export.ts +223 -0
- package/src/commands/import.ts +436 -0
- package/src/commands/init.ts +184 -0
- package/src/commands/new.ts +138 -0
- package/src/commands/status.ts +152 -0
- package/src/converters/index.ts +4 -0
- package/src/converters/logseq.ts +42 -0
- package/src/converters/obsidian.ts +95 -0
- package/src/converters/opml.ts +143 -0
- package/src/converters/shared.ts +268 -0
- package/src/core/args.ts +79 -0
- package/src/core/fs.ts +309 -0
- package/src/core/index.ts +10 -0
- package/src/core/outline.ts +237 -0
- package/src/core/output.ts +300 -0
- package/src/core/overflow.ts +75 -0
- package/src/core/redundancy.ts +261 -0
- package/src/core/refs.ts +275 -0
- package/src/core/rules.ts +483 -0
- package/src/core/structure.ts +86 -0
- package/src/core/style.ts +75 -0
- package/src/core/token-budget.ts +284 -0
- package/src/types.ts +284 -0
- package/templates/AGENTS.md +209 -0
- package/templates/_rules.yaml +46 -0
- package/templates/adr-template.md +28 -0
- package/templates/task-template.md +15 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Rules } from '../types';
|
|
4
|
+
|
|
5
|
+
interface YLine {
|
|
6
|
+
lineNo: number;
|
|
7
|
+
indent: number;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NUM_RE = /^[+-]?\d+(?:\.\d+)?$/;
|
|
12
|
+
|
|
13
|
+
function stripComment(raw: string): string {
|
|
14
|
+
let sq = false;
|
|
15
|
+
let dq = false;
|
|
16
|
+
for (let i = 0; i < raw.length; i++) {
|
|
17
|
+
const c = raw[i];
|
|
18
|
+
if (c === "'" && !dq) sq = !sq;
|
|
19
|
+
else if (c === '"' && !sq) dq = !dq;
|
|
20
|
+
else if (c === '#' && !sq && !dq && (i === 0 || raw[i - 1] === ' ' || raw[i - 1] === '\t')) {
|
|
21
|
+
return raw.slice(0, i);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function splitTopLevel(s: string, sep: string): string[] {
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
let depth = 0;
|
|
30
|
+
let sq = false;
|
|
31
|
+
let dq = false;
|
|
32
|
+
let cur = '';
|
|
33
|
+
for (const c of s) {
|
|
34
|
+
if (c === "'" && !dq) sq = !sq;
|
|
35
|
+
else if (c === '"' && !sq) dq = !dq;
|
|
36
|
+
else if (!sq && !dq) {
|
|
37
|
+
if (c === '{' || c === '[') depth++;
|
|
38
|
+
else if (c === '}' || c === ']') depth--;
|
|
39
|
+
else if (c === sep && depth === 0) {
|
|
40
|
+
parts.push(cur);
|
|
41
|
+
cur = '';
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
cur += c;
|
|
46
|
+
}
|
|
47
|
+
if (cur.trim() !== '') parts.push(cur);
|
|
48
|
+
return parts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scalar(s: string, lineNo: number): string | number | boolean {
|
|
52
|
+
const t = s.trim();
|
|
53
|
+
if (t === 'true') return true;
|
|
54
|
+
if (t === 'false') return false;
|
|
55
|
+
if (NUM_RE.test(t)) return Number(t);
|
|
56
|
+
if (t.length >= 2) {
|
|
57
|
+
const first = t[0];
|
|
58
|
+
const last = t[t.length - 1];
|
|
59
|
+
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
60
|
+
return t.slice(1, -1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (t.includes(':')) {
|
|
64
|
+
throw new Error(`line ${lineNo}: malformed value (unexpected ':'): ${t}`);
|
|
65
|
+
}
|
|
66
|
+
return t;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseInlineObject(s: string, lineNo: number): Record<string, unknown> {
|
|
70
|
+
const inner = s.trim().slice(1, -1);
|
|
71
|
+
const out: Record<string, unknown> = {};
|
|
72
|
+
for (const part of splitTopLevel(inner, ',')) {
|
|
73
|
+
if (part.trim() === '') continue;
|
|
74
|
+
const idx = part.indexOf(':');
|
|
75
|
+
if (idx < 1) throw new Error(`line ${lineNo}: malformed inline object entry: ${part.trim()}`);
|
|
76
|
+
const key = part.slice(0, idx).trim();
|
|
77
|
+
const rawVal = part.slice(idx + 1).trim();
|
|
78
|
+
if (rawVal === '') throw new Error(`line ${lineNo}: missing value for key '${key}'`);
|
|
79
|
+
out[key] = parseInlineValue(rawVal, lineNo);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseInlineArray(s: string, lineNo: number): unknown[] {
|
|
85
|
+
const inner = s.trim().slice(1, -1);
|
|
86
|
+
const out: unknown[] = [];
|
|
87
|
+
for (const part of splitTopLevel(inner, ',')) {
|
|
88
|
+
if (part.trim() === '') continue;
|
|
89
|
+
out.push(parseInlineValue(part.trim(), lineNo));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseInlineValue(s: string, lineNo: number): unknown {
|
|
95
|
+
const t = s.trim();
|
|
96
|
+
if (t.startsWith('{')) {
|
|
97
|
+
if (!t.endsWith('}')) throw new Error(`line ${lineNo}: unbalanced inline object: ${t}`);
|
|
98
|
+
return parseInlineObject(t, lineNo);
|
|
99
|
+
}
|
|
100
|
+
if (t.startsWith('[')) {
|
|
101
|
+
if (!t.endsWith(']')) throw new Error(`line ${lineNo}: unbalanced inline array: ${t}`);
|
|
102
|
+
return parseInlineArray(t, lineNo);
|
|
103
|
+
}
|
|
104
|
+
return scalar(t, lineNo);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isItem(text: string): boolean {
|
|
108
|
+
return text === '-' || text.startsWith('- ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Normalize CRLF (Windows) and lone-CR (classic Mac) line terminators to \n.
|
|
112
|
+
* Same read/split-boundary normalization as outline.ts — user-authored config
|
|
113
|
+
* must parse identically on every platform (§45). */
|
|
114
|
+
function normalizeEol(source: string): string {
|
|
115
|
+
return source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Minimal YAML subset parser: 2-space nested objects, inline objects/arrays,
|
|
119
|
+
* block arrays (`- item`, items may be inline arrays), scalars, comments. */
|
|
120
|
+
export function parseMinimalYaml(source: string): Record<string, unknown> {
|
|
121
|
+
const lines: YLine[] = [];
|
|
122
|
+
const rawLines = normalizeEol(source).split('\n');
|
|
123
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
124
|
+
// §18: invalid YAML = line-numbered error. YAML forbids tab indentation.
|
|
125
|
+
const leadWs = rawLines[i].match(/^[\t ]*/);
|
|
126
|
+
if (leadWs !== null && leadWs[0].includes('\t')) {
|
|
127
|
+
throw new Error(`line ${i + 1}: tab indentation (use 2 spaces)`);
|
|
128
|
+
}
|
|
129
|
+
const stripped = stripComment(rawLines[i]);
|
|
130
|
+
if (stripped.trim() === '') continue;
|
|
131
|
+
const indent = stripped.length - stripped.trimStart().length;
|
|
132
|
+
lines.push({ lineNo: i + 1, indent: Math.floor(indent / 2), text: stripped.trim() });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let pos = 0;
|
|
136
|
+
|
|
137
|
+
const parseMap = (indent: number): Record<string, unknown> => {
|
|
138
|
+
const out: Record<string, unknown> = {};
|
|
139
|
+
while (pos < lines.length && lines[pos].indent >= indent) {
|
|
140
|
+
const ln = lines[pos];
|
|
141
|
+
if (ln.indent > indent) throw new Error(`line ${ln.lineNo}: unexpected indentation`);
|
|
142
|
+
if (isItem(ln.text)) throw new Error(`line ${ln.lineNo}: list item outside array context`);
|
|
143
|
+
const m = ln.text.match(/^([^:\s]+):\s*(.*)$/);
|
|
144
|
+
if (!m) throw new Error(`line ${ln.lineNo}: malformed line: ${ln.text}`);
|
|
145
|
+
const key = m[1];
|
|
146
|
+
const rest = m[2].trim();
|
|
147
|
+
if (rest === '') {
|
|
148
|
+
pos++;
|
|
149
|
+
const next = pos < lines.length ? lines[pos] : undefined;
|
|
150
|
+
if (
|
|
151
|
+
next &&
|
|
152
|
+
(next.indent > indent || (next.indent === indent && isItem(next.text)))
|
|
153
|
+
) {
|
|
154
|
+
out[key] = isItem(next.text) ? parseArray(next.indent) : parseMap(next.indent);
|
|
155
|
+
} else {
|
|
156
|
+
out[key] = null;
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
out[key] = parseInlineValue(rest, ln.lineNo);
|
|
160
|
+
pos++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const parseArray = (indent: number): unknown[] => {
|
|
167
|
+
const out: unknown[] = [];
|
|
168
|
+
while (pos < lines.length && lines[pos].indent === indent && isItem(lines[pos].text)) {
|
|
169
|
+
const ln = lines[pos];
|
|
170
|
+
const item = ln.text === '-' ? '' : ln.text.slice(2).trim();
|
|
171
|
+
if (item === '') {
|
|
172
|
+
pos++;
|
|
173
|
+
const next = pos < lines.length ? lines[pos] : undefined;
|
|
174
|
+
if (next && next.indent > indent) {
|
|
175
|
+
out.push(isItem(next.text) ? parseArray(next.indent) : parseMap(next.indent));
|
|
176
|
+
} else {
|
|
177
|
+
out.push(null);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
out.push(parseInlineValue(item, ln.lineNo));
|
|
181
|
+
pos++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (lines.length === 0) return {};
|
|
188
|
+
if (lines[0].indent !== 0) throw new Error(`line ${lines[0].lineNo}: unexpected indentation`);
|
|
189
|
+
const result = parseMap(0);
|
|
190
|
+
if (pos < lines.length) {
|
|
191
|
+
throw new Error(`line ${lines[pos].lineNo}: unexpected content: ${lines[pos].text}`);
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function defaultRules(): Rules {
|
|
197
|
+
return {
|
|
198
|
+
structure: {
|
|
199
|
+
node_length: { min: 3, max: 120 },
|
|
200
|
+
siblings: { min: 1, max: 12 },
|
|
201
|
+
depth: { min: 1, max: 5 },
|
|
202
|
+
single_child_collapse: true,
|
|
203
|
+
empty_nodes: false,
|
|
204
|
+
},
|
|
205
|
+
style: {
|
|
206
|
+
prefer: 'sibling',
|
|
207
|
+
force_nested_above: 6,
|
|
208
|
+
force_sibling_below: 3,
|
|
209
|
+
shared_prefix_detection: true,
|
|
210
|
+
},
|
|
211
|
+
content: { tbd_allowed: true, max_tbd_per_file: 5 },
|
|
212
|
+
references: {
|
|
213
|
+
mode: 'pointer',
|
|
214
|
+
back_pointers: true,
|
|
215
|
+
max_hops: 1,
|
|
216
|
+
orphan_check: true,
|
|
217
|
+
duplicate_home_check: true,
|
|
218
|
+
},
|
|
219
|
+
redundancy: {
|
|
220
|
+
enabled: true,
|
|
221
|
+
word_frequency_threshold: 4,
|
|
222
|
+
phrase_overlap_threshold: 0.7,
|
|
223
|
+
cross_file_threshold: 2,
|
|
224
|
+
stopwords: ['the', 'a', 'an', 'of', 'to', 'in', 'for', 'and', 'or', 'with', 'must', 'shall', 'requires'],
|
|
225
|
+
synonyms: [
|
|
226
|
+
['postgres', 'postgresql', 'pg'],
|
|
227
|
+
['api', 'endpoint', 'route'],
|
|
228
|
+
['frontend', 'client', 'ui'],
|
|
229
|
+
['db', 'database', 'storage'],
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
token_budget: {
|
|
233
|
+
enabled: true,
|
|
234
|
+
default_limit: 4096,
|
|
235
|
+
estimate_chars_per_token: 3.5,
|
|
236
|
+
warn_threshold: 0.8,
|
|
237
|
+
},
|
|
238
|
+
overflow: { max_node_chars: 200, force_file_for: ['code_block', 'table', 'diagram'] },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
243
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function deepMerge(base: unknown, over: unknown): unknown {
|
|
247
|
+
if (isPlainObject(base) && isPlainObject(over)) {
|
|
248
|
+
const out: Record<string, unknown> = { ...base };
|
|
249
|
+
for (const [k, v] of Object.entries(over)) {
|
|
250
|
+
out[k] = deepMerge(base[k], v);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
return over === undefined ? base : over;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 1-based line number where a top-level `key:` is defined in the raw source (0 when absent). */
|
|
258
|
+
function topLevelKeyLine(source: string, key: string): number {
|
|
259
|
+
const rawLines = normalizeEol(source).split('\n');
|
|
260
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
261
|
+
const m = rawLines[i].match(/^([A-Za-z0-9_-]+):/);
|
|
262
|
+
if (m !== null && m[1] === key) return i + 1;
|
|
263
|
+
}
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Validate that the merged rules object has the expected top-level shape.
|
|
268
|
+
* Catches values like `structure: 42` or empty sections that parse without
|
|
269
|
+
* YAML syntax errors but produce type-inconsistent rules (§18: invalid config
|
|
270
|
+
* = line-numbered `_rules.yaml` error, exit 1 — never an internal crash). */
|
|
271
|
+
function validateRulesShape(merged: Record<string, unknown>, source: string): void {
|
|
272
|
+
const sections: Array<[string, string[]]> = [
|
|
273
|
+
['structure', ['node_length', 'siblings', 'depth']],
|
|
274
|
+
['style', ['prefer', 'force_nested_above', 'force_sibling_below']],
|
|
275
|
+
['content', ['tbd_allowed']],
|
|
276
|
+
['references', ['mode', 'max_hops', 'orphan_check']],
|
|
277
|
+
['redundancy', ['enabled', 'word_frequency_threshold']],
|
|
278
|
+
['token_budget', ['enabled', 'default_limit']],
|
|
279
|
+
['overflow', ['max_node_chars']],
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const [section, requiredKeys] of sections) {
|
|
283
|
+
const val = merged[section];
|
|
284
|
+
if (val === undefined) continue; // absent = section delete-key semantics apply instead
|
|
285
|
+
const line = topLevelKeyLine(source, section);
|
|
286
|
+
const at = line > 0 ? `line ${line}` : 'line 1';
|
|
287
|
+
if (typeof val !== 'object' || val === null || Array.isArray(val)) {
|
|
288
|
+
const got = val === null ? 'empty value (nothing under the key)' : typeof val;
|
|
289
|
+
throw new Error(`${at} — "${section}" must be a mapping, got ${got}`);
|
|
290
|
+
}
|
|
291
|
+
const obj = val as Record<string, unknown>;
|
|
292
|
+
for (const key of requiredKeys) {
|
|
293
|
+
if (!(key in obj)) continue;
|
|
294
|
+
const v = obj[key];
|
|
295
|
+
if (
|
|
296
|
+
(key === 'node_length' || key === 'siblings' || key === 'depth') &&
|
|
297
|
+
(typeof v !== 'object' || v === null || Array.isArray(v))
|
|
298
|
+
) {
|
|
299
|
+
throw new Error(`${at} — "${section}.${key}" must be a mapping like { min: 3, max: 120 }`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Load rules from `<root>/_rules.yaml` deep-merged over defaults.
|
|
306
|
+
* Missing file → all defaults.
|
|
307
|
+
* File exists → §18 "Delete a key = check turns off": sections/keys absent
|
|
308
|
+
* from the file disable their checks instead of keeping defaults.
|
|
309
|
+
* Invalid YAML or type-inconsistent shape → Error carrying the line number. */
|
|
310
|
+
export function loadRules(root: string): Rules {
|
|
311
|
+
const p = join(root, '_rules.yaml');
|
|
312
|
+
if (!existsSync(p)) return defaultRules();
|
|
313
|
+
const source = readFileSync(p, 'utf-8');
|
|
314
|
+
const parsed = parseMinimalYaml(source);
|
|
315
|
+
const merged = deepMerge(defaultRules(), parsed) as Record<string, unknown>;
|
|
316
|
+
validateRulesShape(merged, source);
|
|
317
|
+
|
|
318
|
+
const rules = merged as unknown as Rules;
|
|
319
|
+
const topLevel = new Set(Object.keys(parsed));
|
|
320
|
+
|
|
321
|
+
// §18 "Delete a key = check turns off" — applied AFTER validation, so every
|
|
322
|
+
// absent check-key is flipped from its deep-merged default to its OFF state:
|
|
323
|
+
// boolean switch → false (single_child_collapse, empty_nodes, tbd_allowed,
|
|
324
|
+
// shared_prefix_detection, back_pointers,
|
|
325
|
+
// orphan_check, duplicate_home_check, redundancy.enabled;
|
|
326
|
+
// an explicit `false` stays false — same OFF result)
|
|
327
|
+
// mapping/numeric → null (node_length, siblings, depth, max_tbd_per_file,
|
|
328
|
+
// force_nested_above, force_sibling_below, max_hops,
|
|
329
|
+
// word_frequency_threshold, phrase_overlap_threshold,
|
|
330
|
+
// cross_file_threshold, warn_threshold, max_node_chars,
|
|
331
|
+
// force_file_for, prefer)
|
|
332
|
+
// parameters (NOT checks — keep defaults when deleted, §18 overrides only
|
|
333
|
+
// what the file lists for these): mode, stopwords, synonyms,
|
|
334
|
+
// estimate_chars_per_token, default_limit; token_budget.enabled is a
|
|
335
|
+
// planning switch, not a check switch, so it too keeps its default.
|
|
336
|
+
// A FULL rules file (every key present) finds every key listed below and is
|
|
337
|
+
// returned byte-identical to the old deep-merge behavior; a MISSING file
|
|
338
|
+
// never reaches this pass (early return above).
|
|
339
|
+
const offRange = (): { min: null; max: null } => ({ min: null, max: null });
|
|
340
|
+
const has = (section: string, key: string): boolean => {
|
|
341
|
+
const s = parsed[section];
|
|
342
|
+
return isPlainObject(s) && key in s;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// structure: node_length / siblings / depth / single_child_collapse / empty_nodes
|
|
346
|
+
if (!topLevel.has('structure')) {
|
|
347
|
+
rules.structure = {
|
|
348
|
+
node_length: offRange(),
|
|
349
|
+
siblings: offRange(),
|
|
350
|
+
depth: offRange(),
|
|
351
|
+
single_child_collapse: false,
|
|
352
|
+
empty_nodes: false,
|
|
353
|
+
};
|
|
354
|
+
} else {
|
|
355
|
+
if (!has('structure', 'node_length')) {
|
|
356
|
+
rules.structure = { ...rules.structure, node_length: offRange() };
|
|
357
|
+
}
|
|
358
|
+
if (!has('structure', 'siblings')) {
|
|
359
|
+
rules.structure = { ...rules.structure, siblings: offRange() };
|
|
360
|
+
}
|
|
361
|
+
if (!has('structure', 'depth')) {
|
|
362
|
+
rules.structure = { ...rules.structure, depth: offRange() };
|
|
363
|
+
}
|
|
364
|
+
if (!has('structure', 'single_child_collapse')) {
|
|
365
|
+
rules.structure = { ...rules.structure, single_child_collapse: false };
|
|
366
|
+
}
|
|
367
|
+
if (!has('structure', 'empty_nodes')) {
|
|
368
|
+
rules.structure = { ...rules.structure, empty_nodes: false };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// style: prefer / force_nested_above / force_sibling_below / shared_prefix_detection
|
|
373
|
+
if (!topLevel.has('style')) {
|
|
374
|
+
rules.style = {
|
|
375
|
+
prefer: null,
|
|
376
|
+
force_nested_above: null,
|
|
377
|
+
force_sibling_below: null,
|
|
378
|
+
shared_prefix_detection: false,
|
|
379
|
+
};
|
|
380
|
+
} else {
|
|
381
|
+
if (!has('style', 'prefer')) {
|
|
382
|
+
rules.style = { ...rules.style, prefer: null };
|
|
383
|
+
}
|
|
384
|
+
if (!has('style', 'force_nested_above')) {
|
|
385
|
+
rules.style = { ...rules.style, force_nested_above: null };
|
|
386
|
+
}
|
|
387
|
+
if (!has('style', 'force_sibling_below')) {
|
|
388
|
+
rules.style = { ...rules.style, force_sibling_below: null };
|
|
389
|
+
}
|
|
390
|
+
if (!has('style', 'shared_prefix_detection')) {
|
|
391
|
+
rules.style = { ...rules.style, shared_prefix_detection: false };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// content: tbd_allowed / max_tbd_per_file
|
|
396
|
+
if (!topLevel.has('content')) {
|
|
397
|
+
rules.content = { tbd_allowed: false, max_tbd_per_file: null };
|
|
398
|
+
} else {
|
|
399
|
+
if (!has('content', 'tbd_allowed')) {
|
|
400
|
+
rules.content = { ...rules.content, tbd_allowed: false };
|
|
401
|
+
}
|
|
402
|
+
if (!has('content', 'max_tbd_per_file')) {
|
|
403
|
+
rules.content = { ...rules.content, max_tbd_per_file: null };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// references: back_pointers / max_hops / orphan_check / duplicate_home_check.
|
|
408
|
+
// `mode` is a parameter — keeps its default when deleted. max_hops deleted →
|
|
409
|
+
// null → the deep-hop check is skipped entirely (§18 strict: deleted = off;
|
|
410
|
+
// the old deleted → 1 (default) special case violated "delete = off").
|
|
411
|
+
if (!topLevel.has('references')) {
|
|
412
|
+
rules.references = {
|
|
413
|
+
mode: 'pointer',
|
|
414
|
+
back_pointers: false,
|
|
415
|
+
max_hops: null,
|
|
416
|
+
orphan_check: false,
|
|
417
|
+
duplicate_home_check: false,
|
|
418
|
+
};
|
|
419
|
+
} else {
|
|
420
|
+
if (!has('references', 'back_pointers')) {
|
|
421
|
+
rules.references = { ...rules.references, back_pointers: false };
|
|
422
|
+
}
|
|
423
|
+
if (!has('references', 'max_hops')) {
|
|
424
|
+
rules.references = { ...rules.references, max_hops: null };
|
|
425
|
+
}
|
|
426
|
+
if (!has('references', 'orphan_check')) {
|
|
427
|
+
rules.references = { ...rules.references, orphan_check: false };
|
|
428
|
+
}
|
|
429
|
+
if (!has('references', 'duplicate_home_check')) {
|
|
430
|
+
rules.references = { ...rules.references, duplicate_home_check: false };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// redundancy: enabled / word_frequency_threshold / phrase_overlap_threshold /
|
|
435
|
+
// cross_file_threshold. `stopwords`/`synonyms` are parameters (§13 inputs) —
|
|
436
|
+
// they keep their defaults when deleted so the remaining layers still
|
|
437
|
+
// normalize text exactly as documented.
|
|
438
|
+
if (!topLevel.has('redundancy')) {
|
|
439
|
+
rules.redundancy = {
|
|
440
|
+
...rules.redundancy,
|
|
441
|
+
enabled: false,
|
|
442
|
+
word_frequency_threshold: null,
|
|
443
|
+
phrase_overlap_threshold: null,
|
|
444
|
+
cross_file_threshold: null,
|
|
445
|
+
};
|
|
446
|
+
} else {
|
|
447
|
+
if (!has('redundancy', 'enabled')) {
|
|
448
|
+
rules.redundancy = { ...rules.redundancy, enabled: false };
|
|
449
|
+
}
|
|
450
|
+
if (!has('redundancy', 'word_frequency_threshold')) {
|
|
451
|
+
rules.redundancy = { ...rules.redundancy, word_frequency_threshold: null };
|
|
452
|
+
}
|
|
453
|
+
if (!has('redundancy', 'phrase_overlap_threshold')) {
|
|
454
|
+
rules.redundancy = { ...rules.redundancy, phrase_overlap_threshold: null };
|
|
455
|
+
}
|
|
456
|
+
if (!has('redundancy', 'cross_file_threshold')) {
|
|
457
|
+
rules.redundancy = { ...rules.redundancy, cross_file_threshold: null };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// token_budget: warn_threshold deleted → null → no usage warning. Planning
|
|
462
|
+
// parameters (enabled / default_limit / estimate_chars_per_token) keep their
|
|
463
|
+
// defaults when deleted — §18 budget planning must not change.
|
|
464
|
+
if (!topLevel.has('token_budget')) {
|
|
465
|
+
rules.token_budget = { ...rules.token_budget, warn_threshold: null };
|
|
466
|
+
} else if (!has('token_budget', 'warn_threshold')) {
|
|
467
|
+
rules.token_budget = { ...rules.token_budget, warn_threshold: null };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// overflow: max_node_chars / force_file_for — both are check keys.
|
|
471
|
+
if (!topLevel.has('overflow')) {
|
|
472
|
+
rules.overflow = { max_node_chars: null, force_file_for: null };
|
|
473
|
+
} else {
|
|
474
|
+
if (!has('overflow', 'max_node_chars')) {
|
|
475
|
+
rules.overflow = { ...rules.overflow, max_node_chars: null };
|
|
476
|
+
}
|
|
477
|
+
if (!has('overflow', 'force_file_for')) {
|
|
478
|
+
rules.overflow = { ...rules.overflow, force_file_for: null };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return rules;
|
|
483
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { OutlineNode, Issue, StructureRules } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
|
|
4
|
+
* §18 delete-key semantics: a check whose rules key is null/false is OFF — the
|
|
5
|
+
* check is skipped entirely (never compared against null, which would coerce
|
|
6
|
+
* to 0 and flag everything). */
|
|
7
|
+
export function checkStructure(
|
|
8
|
+
nodes: OutlineNode[],
|
|
9
|
+
file: string,
|
|
10
|
+
rules: StructureRules,
|
|
11
|
+
): Issue[] {
|
|
12
|
+
const issues: Issue[] = [];
|
|
13
|
+
|
|
14
|
+
const walk = (list: OutlineNode[]): void => {
|
|
15
|
+
for (const node of list) {
|
|
16
|
+
const len = node.text.length;
|
|
17
|
+
const nl = rules.node_length;
|
|
18
|
+
if (nl !== null && nl.max !== null && len > nl.max) {
|
|
19
|
+
issues.push({
|
|
20
|
+
file,
|
|
21
|
+
line: node.line,
|
|
22
|
+
level: 'error',
|
|
23
|
+
category: 'structure',
|
|
24
|
+
message: `Node too long (${len} > ${nl.max}). Split or move to file.`,
|
|
25
|
+
});
|
|
26
|
+
} else if (nl !== null && nl.min !== null && len < nl.min) {
|
|
27
|
+
issues.push({
|
|
28
|
+
file,
|
|
29
|
+
line: node.line,
|
|
30
|
+
level: 'warning',
|
|
31
|
+
category: 'structure',
|
|
32
|
+
message: `Node too short (${len} < ${nl.min}).`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const depth = node.indent + 1;
|
|
37
|
+
const depthMax = rules.depth !== null ? rules.depth.max : null;
|
|
38
|
+
if (depthMax !== null && depth > depthMax) {
|
|
39
|
+
issues.push({
|
|
40
|
+
file,
|
|
41
|
+
line: node.line,
|
|
42
|
+
level: 'error',
|
|
43
|
+
category: 'structure',
|
|
44
|
+
message: `Depth ${depth} exceeds max ${depthMax}. Flatten.`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const count = node.children.length;
|
|
49
|
+
const siblingsMax = rules.siblings !== null ? rules.siblings.max : null;
|
|
50
|
+
if (siblingsMax !== null && count > siblingsMax) {
|
|
51
|
+
issues.push({
|
|
52
|
+
file,
|
|
53
|
+
line: node.line,
|
|
54
|
+
level: 'warning',
|
|
55
|
+
category: 'structure',
|
|
56
|
+
message: `"${node.text}" has ${count} children (max ${siblingsMax}).`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (rules.single_child_collapse && count === 1) {
|
|
61
|
+
issues.push({
|
|
62
|
+
file,
|
|
63
|
+
line: node.line,
|
|
64
|
+
level: 'warning',
|
|
65
|
+
category: 'structure',
|
|
66
|
+
message: `"${node.text}" has exactly 1 child. Collapse.`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (rules.empty_nodes && node.text.trim() === '') {
|
|
71
|
+
issues.push({
|
|
72
|
+
file,
|
|
73
|
+
line: node.line,
|
|
74
|
+
level: 'warning',
|
|
75
|
+
category: 'structure',
|
|
76
|
+
message: 'Empty node.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
walk(node.children);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
walk(nodes);
|
|
85
|
+
return issues;
|
|
86
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { OutlineNode, Issue, StyleRules } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint.
|
|
4
|
+
* SEVERITY NOTE (arbitration, same class as the refs-severity decision): §14/§36
|
|
5
|
+
* show ✗ for style flags, but the frozen §35/§18 fixtures (flat-project,
|
|
6
|
+
* folder-project, init templates) structurally trigger the ≤N-leaf rule and the
|
|
7
|
+
* frozen baselines pin `errorCount === 0` / `ok === true` on them — so style
|
|
8
|
+
* findings stay `warning`-level. Changing test fixtures is out of bounds
|
|
9
|
+
* (test/ is frozen).
|
|
10
|
+
* §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). */
|
|
13
|
+
export function checkStyle(
|
|
14
|
+
nodes: OutlineNode[],
|
|
15
|
+
file: string,
|
|
16
|
+
rules: StyleRules,
|
|
17
|
+
): Issue[] {
|
|
18
|
+
const issues: Issue[] = [];
|
|
19
|
+
|
|
20
|
+
const walk = (list: OutlineNode[]): void => {
|
|
21
|
+
for (const node of list) {
|
|
22
|
+
const children = node.children;
|
|
23
|
+
|
|
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
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
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
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
walk(children);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
walk(nodes);
|
|
74
|
+
return issues;
|
|
75
|
+
}
|