cans-spec 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,30 @@
1
1
  import type {
2
- CommandResult, CheckResult, Issue, InitResult, NewResult, DoneResult, StatusResult,
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, §22/§36): human output is scoped to the References
8
- * section (+ Rules + summary); JSON output is always the full result. */
9
- export function emit(result: CommandResult, json: boolean, refsOnly?: boolean): void {
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
- console.log(JSON.stringify(result, null, 2));
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
- const CATEGORY_ORDER: Array<Issue['category']> = ['structure', 'style', 'refs', 'redundancy', 'overflow'];
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
- function printCheckHuman(r: CheckResult, refsOnly?: boolean): void {
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 byCategory = new Map<string, Issue[]>();
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
- console.log('References');
235
- console.log(` ${r.refs.total} see: refs, ${r.refs.broken} broken, ${r.refs.deepHops} deep hops`);
236
- console.log(` back-pointers: ${r.backPointers.current}/${r.backPointers.total} current`);
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
- if (!refsOnly) {
240
- console.log('Redundancy');
241
- printIssues(byCategory.get('redundancy'));
242
- if (!byCategory.get('redundancy')?.length) {
243
- const none = r.issues.filter(i => i.category === 'redundancy').length === 0;
244
- if (none) console.log(' ✓ no redundancy detected');
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
- console.log('Overflow');
248
- if (!byCategory.get('overflow')?.length) {
249
- console.log(' ✓ no code blocks, tables, or oversized nodes');
250
- } else {
251
- printIssues(byCategory.get('overflow'));
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('Rules (_rules.yaml)');
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
- void CATEGORY_ORDER;
263
- console.log('');
264
- console.log(`${r.errorCount} errors, ${r.warningCount} warnings.`);
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
- function printIssues(issues: Issue[] | undefined): void {
268
- for (const i of issues ?? []) {
269
- const mark = i.level === 'error' ? '✗' : '⚠';
270
- // Avoid duplicating the file path when the message already carries it (parse errors)
271
- const msg = i.message.startsWith(`${i.file}:`) ? i.message.slice(i.file.length + 1) : i.message;
272
- const linePart = i.line > 0 ? `:${i.line}` : '';
273
- console.log(` ${mark} ${i.file}${linePart} — ${msg}`);
274
- if (i.suggestion) console.log(` ${i.suggestion}`);
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]
@@ -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
  }
@@ -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
  }
@@ -208,6 +211,7 @@ export function fuzzyDistance(
208
211
  file: a.file, line: a.line, level: 'warning', category: 'redundancy',
209
212
  message: `possible typo: "${a.text}" (${a.file}:${a.line}) ↔ "${b.text}" (${b.file}:${b.line}) — Levenshtein ${d}`,
210
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
211
215
  });
212
216
  }
213
217
  }
@@ -281,6 +285,7 @@ export function crossFileCanonicality(
281
285
  file: entry.first.file, line: entry.first.line, level: 'warning', category: 'redundancy',
282
286
  message: `"${concept}" at depth 0-1 in ${files.length}+ files without see: (${files.join(', ')})`,
283
287
  suggestion: `keep "${concept}" in one canonical file and see: it from the others`,
288
+ rule: 'redundancy.duplicate_home', // issue #41
284
289
  });
285
290
  }
286
291
  return issues;