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.
- package/README.md +15 -6
- package/bin/cans.js +87 -14
- package/bin/ts-loader.mjs +9 -2
- package/package.json +2 -2
- package/src/cli.ts +6 -1
- package/src/commands/budget.ts +87 -16
- package/src/commands/check.ts +361 -45
- package/src/commands/import.ts +216 -6
- package/src/core/fs.ts +31 -11
- package/src/core/outline.ts +62 -8
- package/src/core/output.ts +202 -57
- package/src/core/overflow.ts +4 -0
- package/src/core/redundancy.ts +63 -4
- package/src/core/refs.ts +356 -42
- package/src/core/report.ts +575 -0
- package/src/core/rules.ts +32 -5
- package/src/core/structure.ts +122 -57
- package/src/core/style.ts +78 -43
- package/src/core/token-budget.ts +41 -5
- package/src/types.ts +29 -1
- package/templates/_rules.yaml +1 -0
package/src/core/output.ts
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
CommandResult, CheckResult,
|
|
2
|
+
CommandResult, CheckResult, InitResult, NewResult, DoneResult, StatusResult,
|
|
3
3
|
BudgetReadResult, BudgetWriteResult, ImportResult, ExportResult, VersionResult,
|
|
4
4
|
} from '../types.ts';
|
|
5
|
+
import {
|
|
6
|
+
buildReport, topGroups, checkReportJson,
|
|
7
|
+
type IssueGroup, type SectionReport,
|
|
8
|
+
} from './report.ts';
|
|
5
9
|
|
|
6
10
|
/** Single emission point. Commands never console.log or process.exit directly.
|
|
7
|
-
* `refsOnly` (check only
|
|
8
|
-
*
|
|
9
|
-
|
|
11
|
+
* `refsOnly` (check only): human output is scoped to the REFS section (+ Rules).
|
|
12
|
+
* `show` (check only, issue #41): sections rendered UNFOLDED by --show.
|
|
13
|
+
* --json: check emits the lossless structured wire shape (sections → arrays
|
|
14
|
+
* of {file, line, rule, detail}, issue #41); other commands the raw result. */
|
|
15
|
+
export function emit(result: CommandResult, json: boolean, refsOnly?: boolean, show?: Set<string>): void {
|
|
10
16
|
if (json) {
|
|
11
|
-
|
|
17
|
+
const body = result.command === 'check' ? checkReportJson(result as unknown as Parameters<typeof checkReportJson>[0]) : result;
|
|
18
|
+
console.log(JSON.stringify(body, null, 2));
|
|
12
19
|
return;
|
|
13
20
|
}
|
|
14
|
-
printHuman(result, refsOnly);
|
|
21
|
+
printHuman(result, refsOnly, show);
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export function printHuman(result: CommandResult, refsOnly?: boolean): void {
|
|
24
|
+
export function printHuman(result: CommandResult, refsOnly?: boolean, show?: Set<string>): void {
|
|
20
25
|
switch (result.command) {
|
|
21
26
|
case 'check':
|
|
22
|
-
printCheckHuman(result as CheckResult, refsOnly);
|
|
27
|
+
printCheckHuman(result as CheckResult, refsOnly, show);
|
|
23
28
|
break;
|
|
24
29
|
case 'help':
|
|
25
30
|
printHelp();
|
|
@@ -206,7 +211,23 @@ export function printHuman(result: CommandResult, refsOnly?: boolean): void {
|
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
|
|
209
|
-
|
|
214
|
+
// ── issue #41: aggregated check report ──
|
|
215
|
+
// One line per pattern (`61× <min children (2/3)`), grouped by root cause,
|
|
216
|
+
// top-N + fold with --show expansion, timing on the summary line, and a
|
|
217
|
+
// ≤500-token default budget for issue-scale projects.
|
|
218
|
+
|
|
219
|
+
const DISPLAY_NAMES: Record<string, string> = {
|
|
220
|
+
structure: 'STRUCTURE', style: 'STYLE', refs: 'REFS', redundancy: 'REDUNDANCY',
|
|
221
|
+
overflow: 'OVERFLOW', parse: 'PARSE', content: 'CONTENT', io: 'IO', other: 'OTHER',
|
|
222
|
+
};
|
|
223
|
+
const SECTION_PRINT_ORDER = ['structure', 'style', 'refs', 'redundancy', 'overflow', 'parse', 'content', 'io', 'other'];
|
|
224
|
+
const FOLD_TOP_GROUPS = 5;
|
|
225
|
+
const FOLD_KEYWORD_ITEMS = 5;
|
|
226
|
+
const FOLD_OVERLAP_ITEMS = 3;
|
|
227
|
+
const FOLD_TARGETS = 8;
|
|
228
|
+
const WRAP_COLS = 72;
|
|
229
|
+
|
|
230
|
+
function printCheckHuman(r: CheckResult, refsOnly?: boolean, show?: Set<string>): void {
|
|
210
231
|
// §37: check-level failures (unknown flag, no cans workspace, invalid
|
|
211
232
|
// _rules.yaml, unmatched file filter) carry their diagnosis in `error` —
|
|
212
233
|
// print it standalone, never inside a report-shaped body.
|
|
@@ -216,63 +237,186 @@ function printCheckHuman(r: CheckResult, refsOnly?: boolean): void {
|
|
|
216
237
|
return;
|
|
217
238
|
}
|
|
218
239
|
|
|
219
|
-
const
|
|
220
|
-
for (const i of r.issues) {
|
|
221
|
-
const list = byCategory.get(i.category) ?? [];
|
|
222
|
-
list.push(i);
|
|
223
|
-
byCategory.set(i.category, list);
|
|
224
|
-
}
|
|
225
|
-
if (!refsOnly) {
|
|
226
|
-
console.log('Structure');
|
|
227
|
-
console.log(` ${r.files} files, ${r.nodes} nodes, max depth ${r.maxDepth}`);
|
|
228
|
-
printIssues(byCategory.get('structure'));
|
|
229
|
-
|
|
230
|
-
console.log('Style');
|
|
231
|
-
printIssues(byCategory.get('style'));
|
|
232
|
-
}
|
|
240
|
+
const expanded = (name: string): boolean => show !== undefined && (show.has('all') || show.has(name));
|
|
233
241
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
console.log(
|
|
237
|
-
printIssues(byCategory.get('refs'));
|
|
242
|
+
// issue #41: summary line first — severity mark, workspace shape, elapsed ms.
|
|
243
|
+
const mark = r.errorCount > 0 ? '✗' : r.warningCount > 0 ? '⚠' : '✓';
|
|
244
|
+
console.log(`${mark} ${r.files} files · ${r.nodes} nodes · depth ${r.maxDepth} · ${r.elapsedMs ?? 0}ms`);
|
|
238
245
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
246
|
+
const report = buildReport(r.issues);
|
|
247
|
+
for (const name of SECTION_PRINT_ORDER) {
|
|
248
|
+
if (refsOnly && name !== 'refs') continue;
|
|
249
|
+
let section = report.sections[name];
|
|
250
|
+
// issue #11: a --fix run that rewrote back-pointer files always surfaces
|
|
251
|
+
// the REFS block — even when the refs engine has no findings of its own
|
|
252
|
+
// (the clean/healthy case), the run's writes stay visible there.
|
|
253
|
+
if (name === 'refs' && section === undefined && r.backPointersUpdatedFiles.length > 0) {
|
|
254
|
+
section = { name: 'refs', errorCount: 0, warningCount: 0, groups: [] };
|
|
245
255
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
256
|
+
// Compact contract: STRUCTURE/STYLE/REDUNDANCY (and the small sections)
|
|
257
|
+
// print only when they carry findings; REFS and OVERFLOW always print
|
|
258
|
+
// (they carry the ✓ healthy state, as in the issue's expected output).
|
|
259
|
+
if (section === undefined) {
|
|
260
|
+
if (name === 'overflow' && !refsOnly) {
|
|
261
|
+
console.log('');
|
|
262
|
+
console.log('OVERFLOW ✓');
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
252
265
|
}
|
|
266
|
+
console.log('');
|
|
267
|
+
if (name === 'refs') printRefsSection(r, section, expanded('refs'));
|
|
268
|
+
else printSection(section, expanded(name));
|
|
253
269
|
}
|
|
254
270
|
|
|
255
|
-
// §22: the fixed report order ends Structure → Style → References →
|
|
256
|
-
// Redundancy → Overflow → Rules → Summary (QA-02 F17).
|
|
257
271
|
if (r.rulesSummary !== undefined) {
|
|
258
|
-
console.log('
|
|
259
|
-
console.log(` ✓ ${r.rulesSummary}`);
|
|
272
|
+
console.log('');
|
|
273
|
+
console.log(`RULES ✓ ${compactRules(r.rulesSummary)}`);
|
|
260
274
|
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function printSection(section: SectionReport, expanded: boolean): void {
|
|
278
|
+
const mark = section.errorCount > 0 ? '✗' : '⚠';
|
|
279
|
+
console.log(`${DISPLAY_NAMES[section.name] ?? section.name.toUpperCase()} ${mark} ${section.errorCount + section.warningCount}`);
|
|
280
|
+
const { shown, folded } = expanded ? { shown: section.groups, folded: 0 } : topGroups(section, FOLD_TOP_GROUPS);
|
|
281
|
+
for (const g of shown) printGroup(g, expanded);
|
|
282
|
+
if (folded > 0) console.log(` … ${folded} more → cans check --show ${section.name}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function printRefsSection(r: CheckResult, section: SectionReport, expanded: boolean): void {
|
|
286
|
+
// Header: per-root-cause counts (issue #41 example: `REFS ✗ 96 broken · ⚠ 8 stale · ⚠ 1 orphan`).
|
|
287
|
+
// Level-aware: named buckets subtract from the section totals, the residue
|
|
288
|
+
// prints as `✗ N` / `⚠ N` — no finding is ever hidden or double-counted.
|
|
289
|
+
const byRule = new Map<string, { err: number; warn: number }>();
|
|
290
|
+
for (const g of section.groups) {
|
|
291
|
+
const cur = byRule.get(g.rule) ?? { err: 0, warn: 0 };
|
|
292
|
+
if (g.level === 'error') cur.err += g.count;
|
|
293
|
+
else cur.warn += g.count;
|
|
294
|
+
byRule.set(g.rule, cur);
|
|
295
|
+
}
|
|
296
|
+
const named = ['refs.broken.file', 'refs.backpointer.stale', 'refs.orphan', 'refs.deep_hop'];
|
|
297
|
+
const namedErr = named.reduce((a, rl) => a + (byRule.get(rl)?.err ?? 0), 0);
|
|
298
|
+
const namedWarn = named.reduce((a, rl) => a + (byRule.get(rl)?.warn ?? 0), 0);
|
|
299
|
+
const parts: string[] = [];
|
|
300
|
+
const broken = byRule.get('refs.broken.file');
|
|
301
|
+
if (broken !== undefined && broken.err + broken.warn > 0) parts.push(`✗ ${broken.err + broken.warn} broken`);
|
|
302
|
+
const stale = byRule.get('refs.backpointer.stale');
|
|
303
|
+
if (stale !== undefined && stale.err + stale.warn > 0) parts.push(`⚠ ${stale.err + stale.warn} stale`);
|
|
304
|
+
const orphan = byRule.get('refs.orphan');
|
|
305
|
+
if (orphan !== undefined && orphan.err + orphan.warn > 0) parts.push(`⚠ ${orphan.err + orphan.warn} orphan`);
|
|
306
|
+
const hops = byRule.get('refs.deep_hop');
|
|
307
|
+
if (hops !== undefined && hops.err > 0) parts.push(`✗ ${hops.err} deep-hop`);
|
|
308
|
+
else if (hops !== undefined && hops.warn > 0) parts.push(`⚠ ${hops.warn} deep-hop`);
|
|
309
|
+
const errOther = section.errorCount - namedErr;
|
|
310
|
+
const warnOther = section.warningCount - namedWarn;
|
|
311
|
+
if (errOther > 0) parts.push(`✗ ${errOther}`);
|
|
312
|
+
if (warnOther > 0) parts.push(`⚠ ${warnOther}`);
|
|
313
|
+
if (parts.length > 0) {
|
|
314
|
+
console.log(`REFS ${parts.join(' · ')}`);
|
|
315
|
+
} else {
|
|
316
|
+
const bp = r.backPointers.total > 0 ? ` · ${r.backPointers.current}/${r.backPointers.total} back-ptrs` : '';
|
|
317
|
+
console.log(`REFS ✓ ${r.refs.total} refs${bp}`);
|
|
318
|
+
}
|
|
319
|
+
// Issue #11: name the files --fix actually rewrote (099e858 wording) —
|
|
320
|
+
// never printed as an empty list (plain checks show nothing here).
|
|
321
|
+
if (r.backPointersUpdatedFiles.length > 0) {
|
|
322
|
+
console.log(` --fix updated ref-by in: ${r.backPointersUpdatedFiles.join(', ')}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// issue #41 design rule 2: all missing-file refs coalesce into ONE block
|
|
326
|
+
// (91 broken refs → 4 targets with per-target counts, one fix hint).
|
|
327
|
+
const brokenGroups = section.groups.filter(g => g.rule === 'refs.broken.file');
|
|
328
|
+
const rest = section.groups.filter(g => g.rule !== 'refs.broken.file');
|
|
329
|
+
if (brokenGroups.length > 0) printBrokenFileBlock(brokenGroups);
|
|
330
|
+
const { shown, folded } = expanded ? { shown: rest, folded: 0 } : topGroups({ ...section, groups: rest }, FOLD_TOP_GROUPS);
|
|
331
|
+
for (const g of shown) printGroup(g, expanded);
|
|
332
|
+
if (folded > 0) console.log(` … ${folded} more → cans check --show refs`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function printBrokenFileBlock(groups: IssueGroup[]): void {
|
|
336
|
+
const total = groups.reduce((a, g) => a + g.count, 0);
|
|
337
|
+
console.log(` ${String(total).padStart(2)}× missing file`);
|
|
338
|
+
const targets = groups
|
|
339
|
+
.filter(g => g.key !== undefined)
|
|
340
|
+
.map(g => ({ label: g.key!, count: g.count }))
|
|
341
|
+
.sort((a, b) => b.count - a.count || (a.label < b.label ? -1 : 1));
|
|
342
|
+
const shown = targets.slice(0, FOLD_TARGETS);
|
|
343
|
+
for (const line of wrapText(shown.map(t => `${t.label} (${t.count})`).join(' · '), WRAP_COLS, 6)) console.log(line);
|
|
344
|
+
if (targets.length > shown.length) console.log(` ↳ ${targets.length - shown.length} more targets → cans check --show refs`);
|
|
345
|
+
const hint = groups.map(g => g.suggestion).find(s => s !== undefined);
|
|
346
|
+
if (hint !== undefined) console.log(` ↳ ${hint}`);
|
|
347
|
+
}
|
|
261
348
|
|
|
262
|
-
|
|
263
|
-
console.log(
|
|
264
|
-
|
|
349
|
+
function printGroup(g: IssueGroup, expanded: boolean): void {
|
|
350
|
+
console.log(` ${String(g.count).padStart(2)}× ${g.pattern}`);
|
|
351
|
+
switch (g.rule) {
|
|
352
|
+
case 'redundancy.keyword': {
|
|
353
|
+
const items = g.items ?? [];
|
|
354
|
+
const shownItems = expanded ? items : items.slice(0, FOLD_KEYWORD_ITEMS);
|
|
355
|
+
// issue #41: `artifacts:105 db:74` — keyword:nodeCount, metric-ranked.
|
|
356
|
+
for (const line of wrapText(shownItems.map(it => `${it.label}:${it.metric ?? it.count}`).join(' '), WRAP_COLS, 6)) console.log(line);
|
|
357
|
+
const hidden = items.slice(FOLD_KEYWORD_ITEMS).reduce((a, it) => a + it.count, 0);
|
|
358
|
+
if (!expanded && hidden > 0) console.log(` ↳ ${hidden} more → cans check --show redundancy`);
|
|
359
|
+
else if (g.suggestion !== undefined) console.log(` ↳ ${g.suggestion}`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
case 'redundancy.overlap.exact':
|
|
363
|
+
case 'redundancy.overlap.fuzzy': {
|
|
364
|
+
const items = g.items ?? [];
|
|
365
|
+
const shownItems = expanded ? items : items.slice(0, FOLD_OVERLAP_ITEMS);
|
|
366
|
+
if (shownItems.length > 0) {
|
|
367
|
+
for (const line of wrapText(`worst: ${shownItems.map(it => it.label).join(' · ')}`, WRAP_COLS, 6)) console.log(line);
|
|
368
|
+
}
|
|
369
|
+
const hidden = items.length - shownItems.length;
|
|
370
|
+
if (!expanded && hidden > 0) console.log(` ↳ ${hidden} more pairs → cans check --show redundancy`);
|
|
371
|
+
else if (g.suggestion !== undefined) console.log(` ↳ ${g.suggestion}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
case 'refs.backpointer.stale': {
|
|
375
|
+
// `budget:2 ← agent, effect, interface` — target lines + referrers.
|
|
376
|
+
const referrers = (g.items ?? []).map(it => it.label).join(', ');
|
|
377
|
+
const line = referrers !== '' ? `${g.locations.join(' ')} ← ${referrers}` : g.locations.join(' ');
|
|
378
|
+
for (const l of wrapText(line, WRAP_COLS, 6)) console.log(l);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case 'refs.broken.anchor': {
|
|
382
|
+
const details = (expanded ? (g.items ?? []).map(it => it.label) : (g.detail !== undefined ? [g.detail] : (g.items ?? []).slice(0, FOLD_OVERLAP_ITEMS).map(it => it.label)));
|
|
383
|
+
for (const d of details) console.log(` ${d}`);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
default: {
|
|
387
|
+
for (const line of wrapText(g.locations.join(' '), WRAP_COLS, 6)) console.log(line);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (g.suggestion !== undefined) console.log(` ↳ ${g.suggestion}`);
|
|
265
391
|
}
|
|
266
392
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
393
|
+
/** Greedy two-space-separator wrap (location lists), fixed indent. */
|
|
394
|
+
function wrapText(text: string, cols: number, indent: number): string[] {
|
|
395
|
+
const pad = ' '.repeat(indent);
|
|
396
|
+
const budget = Math.max(cols - indent, 20);
|
|
397
|
+
if (text.length <= budget) return text === '' ? [] : [pad + text];
|
|
398
|
+
const pieces = text.split(' ');
|
|
399
|
+
const lines: string[] = [];
|
|
400
|
+
let cur = '';
|
|
401
|
+
for (const p of pieces) {
|
|
402
|
+
const candidate = cur === '' ? p : `${cur} ${p}`;
|
|
403
|
+
if (cur === '' || candidate.length <= budget) cur = candidate;
|
|
404
|
+
else {
|
|
405
|
+
lines.push(pad + cur);
|
|
406
|
+
cur = p;
|
|
407
|
+
}
|
|
275
408
|
}
|
|
409
|
+
if (cur !== '') lines.push(pad + cur);
|
|
410
|
+
return lines;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `node_length: 3–120 | siblings: 3–12 | depth: 5–7` → `len 3–120 · sib 3–12 · depth 5–7`. */
|
|
414
|
+
function compactRules(s: string): string {
|
|
415
|
+
return s
|
|
416
|
+
.replace('node_length: ', 'len ')
|
|
417
|
+
.replaceAll('siblings: ', 'sib ')
|
|
418
|
+
.replace('depth: ', 'depth ')
|
|
419
|
+
.replaceAll(' | ', ' · ');
|
|
276
420
|
}
|
|
277
421
|
|
|
278
422
|
function printHelp(): void {
|
|
@@ -282,7 +426,8 @@ Usage: cans <command> [args]
|
|
|
282
426
|
|
|
283
427
|
Commands:
|
|
284
428
|
init [--flat|--folders] [--bare] [--force] [--tool <name>]
|
|
285
|
-
check [--fix] [--strict] [--refs-only] [--no-redundancy] [file] [--json]
|
|
429
|
+
check [--fix] [--strict] [--refs-only] [--no-redundancy] [--show <section>] [file] [--json]
|
|
430
|
+
--fix + [file] rewrites ref-by comments in matching files only
|
|
286
431
|
new adr <title>
|
|
287
432
|
new task <name>
|
|
288
433
|
done <name> [--allow-incomplete] [--skip-check] [--json]
|
package/src/core/overflow.ts
CHANGED
|
@@ -24,6 +24,7 @@ export function checkOverflow(
|
|
|
24
24
|
level: 'error',
|
|
25
25
|
category: 'overflow',
|
|
26
26
|
message: 'code fence detected — extract to file and reference via see:',
|
|
27
|
+
rule: 'overflow.code_fence', // issue #41: machine-readable rule key
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
30
|
if (node.hasTable && forceSet.has('table')) {
|
|
@@ -33,6 +34,7 @@ export function checkOverflow(
|
|
|
33
34
|
level: 'error',
|
|
34
35
|
category: 'overflow',
|
|
35
36
|
message: 'table detected — extract to file and reference via see:',
|
|
37
|
+
rule: 'overflow.table', // issue #41
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
40
|
if (rules.max_node_chars !== null && node.text.length > rules.max_node_chars) {
|
|
@@ -42,6 +44,7 @@ export function checkOverflow(
|
|
|
42
44
|
level: 'error',
|
|
43
45
|
category: 'overflow',
|
|
44
46
|
message: `node exceeds max chars (${node.text.length} > ${rules.max_node_chars})`,
|
|
47
|
+
rule: 'overflow.node_chars', // issue #41
|
|
45
48
|
});
|
|
46
49
|
}
|
|
47
50
|
walk(node.children);
|
|
@@ -67,6 +70,7 @@ export function checkNoChaining(targets: Map<string, OutlineNode[]>): Issue[] {
|
|
|
67
70
|
category: 'overflow',
|
|
68
71
|
message: `no chaining: overflow target ${file} must not contain its own see: refs (found see ${ref.file})`,
|
|
69
72
|
suggestion: `remove the see: ref inside ${file} — overflow targets are leaf content, reference them from a spec file instead`,
|
|
73
|
+
rule: 'refs.chaining', // issue #41: §16 chaining is a refs-category rule
|
|
70
74
|
});
|
|
71
75
|
}
|
|
72
76
|
}
|
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;
|
|
@@ -93,6 +93,7 @@ export function wordFrequency(
|
|
|
93
93
|
file: loc.file, line: loc.line, level: 'warning', category: 'redundancy',
|
|
94
94
|
message: `"${word}" × ${n} nodes (threshold: ${threshold})`,
|
|
95
95
|
suggestion: `pick one canonical home for "${word}" and see: it from the others`,
|
|
96
|
+
rule: 'redundancy.keyword', // issue #41: machine-readable rule key
|
|
96
97
|
});
|
|
97
98
|
}
|
|
98
99
|
return issues;
|
|
@@ -126,6 +127,8 @@ export function phraseOverlap(
|
|
|
126
127
|
file: a.node.file, line: a.node.line, level: 'warning', category: 'redundancy',
|
|
127
128
|
message: `${pct}% overlap: ${a.node.file}:${a.node.line} ↔ ${b.node.file}:${b.node.line}`,
|
|
128
129
|
suggestion: 'merge the duplicated bullets or see: the canonical one',
|
|
130
|
+
// issue #41: 100% overlap is an exact duplicate; below that is fuzzy.
|
|
131
|
+
rule: pct >= 100 ? 'redundancy.overlap.exact' : 'redundancy.overlap.fuzzy',
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
}
|
|
@@ -133,21 +136,63 @@ export function phraseOverlap(
|
|
|
133
136
|
return issues;
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
/** Light English suffixes for the inflection check (issue #3), longest first —
|
|
140
|
+
* stripped iteratively from the end so "carries" → "carri" (→ i↔y → "carry"). */
|
|
141
|
+
const INFLECTION_SUFFIXES = ['ing', 'ers', 'er', 'ed', 'es', 'ly', 's'];
|
|
142
|
+
|
|
143
|
+
/** Reduce a word to a rough stem by iteratively stripping common English
|
|
144
|
+
* suffixes, then a trailing `e`, folding a trailing `i` onto `y` (so
|
|
145
|
+
* deny/denied → deny, approve/approved → approv). Deliberately NOT a real
|
|
146
|
+
* stemmer — only good enough to recognize pure suffix inflections. */
|
|
147
|
+
function lightStem(word: string): string {
|
|
148
|
+
let w = word;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const suffix = INFLECTION_SUFFIXES.find(s => w.length > s.length && w.endsWith(s));
|
|
151
|
+
if (suffix === undefined) break;
|
|
152
|
+
w = w.slice(0, w.length - suffix.length);
|
|
153
|
+
}
|
|
154
|
+
if (w.endsWith('i')) w = `${w.slice(0, -1)}y`;
|
|
155
|
+
if (w.endsWith('e')) w = w.slice(0, -1);
|
|
156
|
+
return w;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Issue #3: is `a` a pure suffix-inflection of `b` (or vice versa)? Both words
|
|
160
|
+
* are reduced with lightStem; equal stems — or a stem equal to the other word's
|
|
161
|
+
* unstemmed form — mean the pair differs only by an English inflection
|
|
162
|
+
* (approve/approved, session/sessions, deny/denied), not by a typo. Stems
|
|
163
|
+
* shorter than 3 chars are treated as unsafe and the pair is NOT skipped
|
|
164
|
+
* (e.g. sing/singe stays a typo candidate). Genuine near-misses with no suffix
|
|
165
|
+
* relation (flavour/flavor, table/tabble) keep different stems → still flagged. */
|
|
166
|
+
export function isInflectionOf(a: string, b: string): boolean {
|
|
167
|
+
const sa = lightStem(a);
|
|
168
|
+
const sb = lightStem(b);
|
|
169
|
+
if (sa.length < 3 || sb.length < 3) return false;
|
|
170
|
+
return sa === sb || sa === b || sb === a;
|
|
171
|
+
}
|
|
172
|
+
|
|
136
173
|
/** Layer 3 — near-miss word forms (Levenshtein <= 2, both words > 4 chars) → possible typo.
|
|
137
174
|
* §13: "NOT ALREADY SYNONYM-MATCHED" — words are normalized with the rules'
|
|
138
175
|
* synonym groups first, so members of the same group collapse to one word and
|
|
139
|
-
* never pair up as typos.
|
|
176
|
+
* never pair up as typos. Issue #3: the layer now applies the SAME collection
|
|
177
|
+
* filter as the other layers — the configured `redundancy.stopwords` and the
|
|
178
|
+
* §8/§13 ref-syntax tokens (`see`, `md`) are never collected — and skips
|
|
179
|
+
* candidate pairs that are pure suffix inflections of each other
|
|
180
|
+
* (isInflectionOf) before the Levenshtein comparison, so English inflections
|
|
181
|
+
* (approved/approve, sessions/session) are no longer reported as typos. */
|
|
140
182
|
export function fuzzyDistance(
|
|
141
183
|
nodes: NodeRef[],
|
|
142
184
|
rules?: RedundancyRules,
|
|
143
185
|
): Issue[] {
|
|
144
186
|
const synonyms = rules ? rules.synonyms : [];
|
|
187
|
+
const stopwords = rules ? rules.stopwords : [];
|
|
145
188
|
const words: NodeRef[] = [];
|
|
146
189
|
const seen = new Set<string>();
|
|
147
190
|
for (const node of nodes) {
|
|
148
191
|
for (const raw of tokenize(node.text)) {
|
|
149
192
|
const w = normalizeWord(raw, synonyms);
|
|
150
193
|
if (w.length === 0 || seen.has(w)) continue;
|
|
194
|
+
if (REF_SYNTAX_TOKENS.has(w)) continue;
|
|
195
|
+
if (stopwords.includes(w)) continue;
|
|
151
196
|
seen.add(w);
|
|
152
197
|
words.push({ text: w, file: node.file, line: node.line });
|
|
153
198
|
}
|
|
@@ -159,12 +204,14 @@ export function fuzzyDistance(
|
|
|
159
204
|
const b = words[j];
|
|
160
205
|
if (a.text.length <= 4 || b.text.length <= 4) continue;
|
|
161
206
|
if (Math.abs(a.text.length - b.text.length) > 2) continue;
|
|
207
|
+
if (isInflectionOf(a.text, b.text)) continue;
|
|
162
208
|
const d = levenshtein(a.text, b.text);
|
|
163
209
|
if (d <= 2) {
|
|
164
210
|
issues.push({
|
|
165
211
|
file: a.file, line: a.line, level: 'warning', category: 'redundancy',
|
|
166
212
|
message: `possible typo: "${a.text}" (${a.file}:${a.line}) ↔ "${b.text}" (${b.file}:${b.line}) — Levenshtein ${d}`,
|
|
167
213
|
suggestion: 'unify the spelling or map the variant as a synonym',
|
|
214
|
+
rule: 'redundancy.typo', // issue #41: word-form layer of the redundancy scheme
|
|
168
215
|
});
|
|
169
216
|
}
|
|
170
217
|
}
|
|
@@ -208,6 +255,10 @@ export function crossFileCanonicality(
|
|
|
208
255
|
const concepts = new Map<string, { files: Set<string>; first: NodeRef }>();
|
|
209
256
|
for (const [key, nodes] of allFiles) {
|
|
210
257
|
for (const node of flattenNodes(nodes)) {
|
|
258
|
+
// Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
|
|
259
|
+
// concepts — comparing them across files fabricated "canonical home"
|
|
260
|
+
// warnings with nonsense advice.
|
|
261
|
+
if (isSyntheticNode(node)) continue;
|
|
211
262
|
if (node.indent > 1) continue;
|
|
212
263
|
const text = node.text.trim().toLowerCase();
|
|
213
264
|
if (text.length === 0) continue;
|
|
@@ -234,13 +285,16 @@ export function crossFileCanonicality(
|
|
|
234
285
|
file: entry.first.file, line: entry.first.line, level: 'warning', category: 'redundancy',
|
|
235
286
|
message: `"${concept}" at depth 0-1 in ${files.length}+ files without see: (${files.join(', ')})`,
|
|
236
287
|
suggestion: `keep "${concept}" in one canonical file and see: it from the others`,
|
|
288
|
+
rule: 'redundancy.duplicate_home', // issue #41
|
|
237
289
|
});
|
|
238
290
|
}
|
|
239
291
|
return issues;
|
|
240
292
|
}
|
|
241
293
|
|
|
242
294
|
/** All four redundancy layers over every loaded spec node.
|
|
243
|
-
* `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4.
|
|
295
|
+
* `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4.
|
|
296
|
+
* Issue #3: `redundancy.fuzzy` (§18 delete-key semantics: deleted → false)
|
|
297
|
+
* gates layer 3 independently of `enabled`, which still covers all layers. */
|
|
244
298
|
export function checkRedundancy(
|
|
245
299
|
allFiles: Map<string, OutlineNode[]>,
|
|
246
300
|
rules: RedundancyRules,
|
|
@@ -249,13 +303,18 @@ export function checkRedundancy(
|
|
|
249
303
|
const nodes: NodeRef[] = [];
|
|
250
304
|
for (const [file, tree] of allFiles) {
|
|
251
305
|
for (const node of flattenNodes(tree)) {
|
|
306
|
+
// Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
|
|
307
|
+
// content — excluded from all three text-comparison layers (they made
|
|
308
|
+
// any two table-opening files 100%-overlapping and inflated the
|
|
309
|
+
// "table"/"code"/"fence" word-frequency counts).
|
|
310
|
+
if (isSyntheticNode(node)) continue;
|
|
252
311
|
nodes.push({ text: node.text, file, line: node.line });
|
|
253
312
|
}
|
|
254
313
|
}
|
|
255
314
|
return [
|
|
256
315
|
...wordFrequency(nodes, rules),
|
|
257
316
|
...phraseOverlap(nodes, rules.phrase_overlap_threshold, rules),
|
|
258
|
-
...fuzzyDistance(nodes, rules),
|
|
317
|
+
...(rules.fuzzy !== false ? fuzzyDistance(nodes, rules) : []),
|
|
259
318
|
...(duplicateHomeCheck ? crossFileCanonicality(allFiles, rules.cross_file_threshold) : []),
|
|
260
319
|
];
|
|
261
320
|
}
|