cans-spec 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  import { join } from 'path';
2
- import type { CheckResult, Issue, OutlineNode } from '../types.ts';
2
+ import type { BackPointer, CheckResult, Issue, OutlineNode } from '../types.ts';
3
3
  import { readText, writeText } from '../core/runtime.ts';
4
4
  import {
5
5
  discoverSpecFiles, discoverActiveTasks, discoverAdrs, resolveWorkspaceRoot,
6
6
  dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
7
7
  } from '../core/fs.ts';
8
8
  import {
9
- parseOutline, extractBackPointers, flattenNodes, maxDepth as outlineMaxDepth,
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';
@@ -16,7 +16,7 @@ import { checkOverflow, checkNoChaining } from '../core/overflow.ts';
16
16
  import { checkRedundancy } from '../core/redundancy.ts';
17
17
  import {
18
18
  buildRefGraph, checkRefs, detectDeepHops, detectOrphans,
19
- rebuildBackPointers, targetMatchesKey,
19
+ rebuildBackPointers, targetMatchesKey, anchorMatches, type RefByGroup, type RefGraph,
20
20
  } from '../core/refs.ts';
21
21
  import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args.ts';
22
22
 
@@ -27,6 +27,10 @@ export interface CheckArgs {
27
27
  noRedundancy: boolean;
28
28
  file: string | null;
29
29
  json: boolean;
30
+ /** issue #41: sections to render unfolded (`--show <section[,section]>`).
31
+ * Emission-time concern only — the engine result is identical either way.
32
+ * Optional: internal callers (done.ts's ZERO_CHECK_ARGS) never set it. */
33
+ show?: string[];
30
34
  /** §24 (done): the archiving task's parsed nodes, injected under their
31
35
  * former `_tasks/<name>.md` identity so refs held by the archived task
32
36
  * still count for the back-pointer rebuild. Never set by `check` itself. */
@@ -38,10 +42,20 @@ const CHECK_FLAGS: FlagSpec[] = [
38
42
  { name: 'strict', boolean: true },
39
43
  { name: 'refs-only', boolean: true },
40
44
  { name: 'no-redundancy', boolean: true },
45
+ { name: 'show', boolean: false },
41
46
  { name: 'json', boolean: true },
42
47
  ];
43
48
 
49
+ /** issue #41: user-facing --show targets. `all` unfolds every section. */
50
+ const SHOW_SECTIONS = new Set([
51
+ 'structure', 'style', 'refs', 'redundancy', 'overflow', 'parse', 'content', 'io', 'other', 'all',
52
+ ]);
53
+
44
54
  const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
55
+ // Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
56
+ // trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
57
+ // the outline.ts FENCE_RE convention; the two must never diverge.
58
+ const FENCE_RE = /^```/;
45
59
 
46
60
  // globFiles throws ENOENT on missing dirs — guard the optional ones.
47
61
  function safeActiveTasks(root: string): string[] {
@@ -52,6 +66,16 @@ function safeAdrs(root: string): string[] {
52
66
  return dirExists(join(root, '_adr')) ? discoverAdrs(root) : [];
53
67
  }
54
68
 
69
+ /** issue #41: which sections `--show` unfolds — tolerant parse for the EMIT
70
+ * side (cli.ts). Strict validation lives in parseCheckArgs (usage errors); a
71
+ * failing run prints its diagnosis instead of a report, so the printer never
72
+ * needs the show set in that case. */
73
+ export function showSectionsFromArgs(args: string[]): Set<string> {
74
+ const raw = parseArgs(args, CHECK_FLAGS).flags.get('show');
75
+ if (typeof raw !== 'string') return new Set();
76
+ return new Set(raw.split(',').map(s => s.trim().toLowerCase()).filter(s => SHOW_SECTIONS.has(s)));
77
+ }
78
+
55
79
  /** §20: route check's args through the shared parser — `--flag value` only,
56
80
  * `[file]` is the sole positional. Unknown flags, short flags, `--flag=value`
57
81
  * and extra positionals are user errors, never silently ignored. */
@@ -63,12 +87,28 @@ export function parseCheckArgs(args: string[]): CheckArgs & { errors: string[] }
63
87
  if (positional.length > 1) {
64
88
  errors.push(`unexpected argument "${positional[1]}" — check takes a single optional [file]`);
65
89
  }
90
+ // issue #41: --show takes a comma-separated section list; unknown names are
91
+ // user errors, never silently ignored (§20 contract).
92
+ const show: string[] = [];
93
+ const rawShow = parsed.flags.get('show');
94
+ if (typeof rawShow === 'string') {
95
+ for (const part of rawShow.split(',')) {
96
+ const s = part.trim().toLowerCase();
97
+ if (s === '') continue;
98
+ if (!SHOW_SECTIONS.has(s)) {
99
+ errors.push(`unknown --show section "${s}" — use structure|style|refs|redundancy|overflow|all`);
100
+ } else {
101
+ show.push(s);
102
+ }
103
+ }
104
+ }
66
105
  return {
67
106
  fix: parsed.flags.has('fix'),
68
107
  strict: parsed.flags.has('strict'),
69
108
  refsOnly: parsed.flags.has('refs-only'),
70
109
  noRedundancy: parsed.flags.has('no-redundancy'),
71
110
  json: parsed.flags.has('json'),
111
+ show,
72
112
  file,
73
113
  errors,
74
114
  };
@@ -85,17 +125,20 @@ function zeroedCounts(): Omit<CheckResult, 'ok' | 'command' | 'exitCode'> {
85
125
  errorCount: 0,
86
126
  warningCount: 0,
87
127
  backPointersUpdated: 0,
128
+ backPointersUpdatedFiles: [],
129
+ elapsedMs: 0, // issue #41: the static failure paths never ran a check
88
130
  };
89
131
  }
90
132
 
91
133
  /** §37: check-level failure (no workspace, invalid rules, unknown flag, file
92
134
  * filter matched nothing). The diagnosis rides in `error` so the human printer
93
- * can show it standalone — never inside a report-shaped body. */
135
+ * can show it standalone — never inside a report-shaped body.
136
+ * issue #41: the check could not run = error class → exitCode 2. */
94
137
  function checkFail(message: string): CheckResult & { error: string } {
95
138
  return {
96
139
  ok: false,
97
140
  command: 'check',
98
- exitCode: 1,
141
+ exitCode: 2, // issue #41: usage/no-workspace/invalid-rules are the error class
99
142
  ...zeroedCounts(),
100
143
  issues: [{ file: '', line: 0, level: 'error', category: 'refs', message }],
101
144
  errorCount: 1,
@@ -111,42 +154,268 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
111
154
  return null;
112
155
  }
113
156
 
157
+ /** Issue #19: is a `<!-- ref-by: ... -->` comment still earned?
158
+ * The comment's form (recorded by extractBackPointers in toAnchor) defines
159
+ * what it answers:
160
+ * - A STANDALONE comment (own line, toAnchor null) answers "who refs this
161
+ * file?" — current only while the referrer still holds a FILE-LEVEL ref
162
+ * here. An anchored ref no longer satisfies it: the mark must sit on the
163
+ * node the ref names.
164
+ * - An INLINE comment on node X (toAnchor = X's text) answers "who refs this
165
+ * node?" — current while the referrer's anchor resolves to X, or while it
166
+ * refs the file itself (a file-level ref satisfies any mark in the file:
167
+ * the mark is at least as precise as the ref, which keeps issue #6's
168
+ * replace-in-place contract convergent).
169
+ * Consequences pinned by issue #19: retargeting the anchor (#Sessions →
170
+ * #Passwords) makes the old mark stale; a broken anchor can never match any
171
+ * node, so it can never read as current. */
172
+ function backPointerIsCurrent(
173
+ bp: BackPointer,
174
+ rel: string,
175
+ allFiles: Map<string, OutlineNode[]>,
176
+ graph: RefGraph,
177
+ ): boolean {
178
+ const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
179
+ if (fromKey === null) return false;
180
+ const fromRefs = graph.forward.get(fromKey);
181
+ if (fromRefs === undefined) return false;
182
+ return fromRefs.some(t => {
183
+ if (refTargetKey(t.file, allFiles.keys()) !== rel) return false;
184
+ if (bp.toAnchor === null) return t.anchor === null;
185
+ return t.anchor === null || anchorMatches(bp.toAnchor, t.anchor);
186
+ });
187
+ }
188
+
189
+ /** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
190
+ * keeping the surrounding prose. Collapses the double space left where the
191
+ * comment sat (one seam space absorbed) and trims trailing whitespace. The
192
+ * caller drops the line only when nothing but the comment remains. */
193
+ function stripRefByKeepProse(raw: string): string {
194
+ const m = raw.match(REF_BY_RE);
195
+ if (m === null || m.index === undefined) return raw;
196
+ let out = raw.slice(0, m.index) + raw.slice(m.index + m[0].length);
197
+ if (out.charAt(m.index - 1) === ' ' && out.charAt(m.index) === ' ') {
198
+ out = out.slice(0, m.index - 1) + out.slice(m.index);
199
+ }
200
+ return out.replace(/[ \t]+$/, '');
201
+ }
202
+
203
+ /** Issue #19: one anchored ref-by placement — the comment body that belongs
204
+ * INLINE on the anchor node's bullet line (`line` is the node's 1-based
205
+ * source line from parseOutline; node lines are real bullets outside any
206
+ * fence by construction, so anchored marks can never land inside a fence). */
207
+ export interface RefByPlacement {
208
+ line: number;
209
+ body: string;
210
+ }
211
+
212
+ /** Issue #21: one source line, split off its terminator. `eol` records the
213
+ * bytes that ended the line ('\r\n' | '\n'); null only for a final line the
214
+ * file left unterminated (possibly the empty tail after a trailing newline).
215
+ * Splitting this way keeps every line's own terminator addressable so the
216
+ * rejoin is byte-preserving: replaced/stripped content never touches the
217
+ * terminator, dropped lines vanish with theirs, and only genuinely inserted
218
+ * lines mint a new one (the file's dominant EOL). Line indices are identical
219
+ * to normalizeEol + split('\n') — what parseOutline and extractBackPointers
220
+ * see — because '\r' is peeled off the same '\n' boundaries. */
221
+ interface SourceLine {
222
+ text: string;
223
+ eol: string | null;
224
+ }
225
+
226
+ function splitSourceLines(source: string): SourceLine[] {
227
+ const out: SourceLine[] = [];
228
+ let start = 0;
229
+ for (let i = 0; i < source.length; i++) {
230
+ if (source.charCodeAt(i) === 10 /* \n */) {
231
+ let end = i;
232
+ let eol = '\n';
233
+ if (end > start && source.charCodeAt(end - 1) === 13 /* \r */) {
234
+ end -= 1;
235
+ eol = '\r\n';
236
+ }
237
+ out.push({ text: source.slice(start, end), eol });
238
+ start = i + 1;
239
+ }
240
+ }
241
+ out.push({ text: source.slice(start), eol: null }); // unterminated tail (may be '')
242
+ return out;
243
+ }
244
+
245
+ function joinSourceLines(lines: SourceLine[]): string {
246
+ let out = '';
247
+ for (const l of lines) out += l.text + (l.eol ?? '');
248
+ return out;
249
+ }
250
+
114
251
  /** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
115
- * Replaces the first existing comment's content, drops duplicates/stale ones,
116
- * or inserts a fresh comment line right after the first root bullet. */
117
- function rewriteRefBy(source: string, body: string | null): string {
118
- const lines: Array<string | null> = source.split('\n');
252
+ * Fence-aware, prose-preserving, anchor-aware, EOL-preserving
253
+ * (issues #6 + #19 + #21):
254
+ * - Lines inside ``` fences (and the fence markers themselves) are preserved
255
+ * byte-for-byte: never scanned as hits, never rewritten, never dropped, and
256
+ * fenced "- fake bullets" are never insertion anchors. Anchored placements
257
+ * come from parsed outline nodes, which by construction never sit inside a
258
+ * fence — a fenced copy of the anchor node's text is never the anchor line.
259
+ * - Issue #19 placement: an ANCHORED ref's mark goes INLINE on the referenced
260
+ * node's bullet line (appended after one space, the §34 fixture convention
261
+ * `- Authentication <!-- ref-by: ... -->`); a FILE-LEVEL ref's mark keeps
262
+ * the issue #6 form — a standalone comment line right after the first root
263
+ * bullet outside any fence, appended at end when none exists — unless the
264
+ * file ends inside an unterminated fence, in which case the comment is
265
+ * inserted before the fence opener (never inside a fence).
266
+ * - Hits (comments outside fences) resolve per placement: an inline hit on an
267
+ * anchored placement line has its content REPLACED in place (form kept);
268
+ * the file-level body replaces the FIRST remaining hit's content wherever
269
+ * it sits (issue #6 contract); every other hit — stale, duplicate or
270
+ * misplaced — is stripped: bullets keep the bullet, non-bullet prose loses
271
+ * only the comment substring (the line is dropped only when bare).
272
+ * - Issue #21 byte preservation: content edits never touch a line's own
273
+ * terminator (a CRLF line stays CRLF through replace and strip; a dropped
274
+ * line disappears with its terminator). Only INSERTED lines mint a new
275
+ * terminator — the file's DOMINANT EOL — so a CRLF spec never acquires a
276
+ * bare-LF comment line, and an LF spec never acquires \r. Appending after
277
+ * an unterminated last line gives that line the dominant EOL as separator;
278
+ * the appended comment stays unterminated, exactly like a join would.
279
+ * Exported for regression tests (issues #6/#19/#21); behavior lives here. */
280
+ export function rewriteRefBy(source: string, body: string | null, anchored?: RefByPlacement[]): string {
281
+ const lines = splitSourceLines(source);
282
+ // Issue #21: the dominant terminator mints the EOL of inserted lines.
283
+ // Majority vote over the file's real terminators; a tie (or a file with no
284
+ // terminated lines) stays LF — the join default.
285
+ let crlf = 0;
286
+ let lf = 0;
287
+ for (const l of lines) {
288
+ if (l.eol === '\r\n') crlf++;
289
+ else if (l.eol === '\n') lf++;
290
+ }
291
+ const dominant = crlf > lf ? '\r\n' : '\n';
119
292
  const comment = body !== null && body !== '' ? `<!-- ref-by: ${body} -->` : null;
293
+ // Merge anchored placements onto 0-based line indices (defensive: duplicate
294
+ // lines union their bodies). Out-of-range lines — pathological sources whose
295
+ // parse counted more lines than the raw split — clamp to the last line
296
+ // rather than silently dropping the mark.
297
+ const anchoredByLine = new Map<number, string[]>();
298
+ for (const p of anchored ?? []) {
299
+ if (p.body === '') continue;
300
+ const idx = Math.min(Math.max(p.line - 1, 0), lines.length - 1);
301
+ const list = anchoredByLine.get(idx) ?? [];
302
+ for (const entry of p.body.split(',').map(s => s.trim()).filter(Boolean)) {
303
+ if (!list.includes(entry)) list.push(entry);
304
+ }
305
+ anchoredByLine.set(idx, list);
306
+ }
307
+ const anchoredBody = (idx: number): string | null => {
308
+ const list = anchoredByLine.get(idx);
309
+ return list !== undefined && list.length > 0 ? list.slice().sort().join(', ') : null;
310
+ };
311
+
120
312
  const hits: number[] = [];
313
+ const dropped = new Set<number>(); // issue #6: bare non-bullet comment lines vanish
314
+ let fenceOpen = false;
315
+ let fenceOpener = -1;
316
+ const inFence: boolean[] = new Array(lines.length).fill(false);
121
317
  for (let i = 0; i < lines.length; i++) {
122
- if (lines[i] !== null && REF_BY_RE.test(lines[i]!)) hits.push(i);
123
- }
124
- if (hits.length > 0) {
125
- for (let j = 0; j < hits.length; j++) {
126
- const i = hits[j];
127
- const raw = lines[i]!;
128
- if (j === 0 && comment !== null) {
129
- lines[i] = raw.replace(REF_BY_RE, comment);
318
+ const raw = lines[i]!.text;
319
+ if (FENCE_RE.test(raw.trim())) {
320
+ // Fence marker: toggles state; never a hit, never an anchor, never touched.
321
+ inFence[i] = true;
322
+ if (fenceOpen) {
323
+ fenceOpen = false;
130
324
  } else {
131
- const isBullet = /^\s*-\s/.test(raw);
132
- lines[i] = isBullet ? raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '') : null;
325
+ fenceOpen = true;
326
+ fenceOpener = i;
133
327
  }
328
+ continue;
329
+ }
330
+ inFence[i] = fenceOpen;
331
+ if (fenceOpen) continue;
332
+ if (REF_BY_RE.test(raw)) hits.push(i);
333
+ }
334
+
335
+ const replaced = new Set<number>(); // hits whose content was replaced in place
336
+ // 1. Anchored placements first: an inline hit ON an anchor node line is the
337
+ // mark for that node — replace its content, keep the inline form.
338
+ for (const i of hits) {
339
+ if (replaced.has(i)) continue;
340
+ const anchorBody = anchoredBody(i);
341
+ if (anchorBody !== null && /^\s*-\s/.test(lines[i]!.text)) {
342
+ lines[i]!.text = lines[i]!.text.replace(REF_BY_RE, `<!-- ref-by: ${anchorBody} -->`);
343
+ anchoredByLine.delete(i);
344
+ replaced.add(i);
345
+ }
346
+ }
347
+ // 2. File-level body: the first hit not claimed by an anchor placement keeps
348
+ // its position and form, content replaced (issue #6 contract).
349
+ let fileSlotUsed = false;
350
+ if (comment !== null) {
351
+ for (const i of hits) {
352
+ if (replaced.has(i)) continue;
353
+ lines[i]!.text = lines[i]!.text.replace(REF_BY_RE, comment);
354
+ replaced.add(i);
355
+ fileSlotUsed = true;
356
+ break;
357
+ }
358
+ }
359
+ // 3. Every remaining hit is stale, duplicate or misplaced: strip it.
360
+ for (const i of hits) {
361
+ if (replaced.has(i)) continue;
362
+ const raw = lines[i]!.text;
363
+ const isBullet = /^\s*-\s/.test(raw);
364
+ if (isBullet) {
365
+ lines[i]!.text = raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '');
366
+ } else {
367
+ // Issue #6: never delete a prose line whole — strip the comment only.
368
+ const stripped = stripRefByKeepProse(raw);
369
+ if (stripped.trim() === '') dropped.add(i);
370
+ else lines[i]!.text = stripped;
134
371
  }
135
- } else if (comment !== null) {
372
+ }
373
+ // 4. Fresh anchored marks: append inline to the anchor node's bullet line
374
+ // (after one space). The line keeps its own terminator (issue #21).
375
+ for (const idx of [...anchoredByLine.keys()].sort((a, b) => a - b)) {
376
+ const anchorBody = anchoredBody(idx);
377
+ if (anchorBody === null) continue;
378
+ const target = lines[idx];
379
+ target.text = target.text === ''
380
+ ? `<!-- ref-by: ${anchorBody} -->`
381
+ : `${target.text} <!-- ref-by: ${anchorBody} -->`;
382
+ }
383
+ // 5. Fresh file-level mark (only when no hit became the file-level slot):
384
+ // standalone line right after the first root bullet outside any fence,
385
+ // appended at end when none exists (issue #6) — never inside an
386
+ // unterminated fence. The inserted line is terminated with the file's
387
+ // dominant EOL (issue #21).
388
+ if (comment !== null && !fileSlotUsed) {
136
389
  let insertAt = lines.length;
137
390
  for (let i = 0; i < lines.length; i++) {
138
- if (/^- /.test(lines[i]!)) {
391
+ if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
392
+ if (!dropped.has(i) && /^- /.test(lines[i]!.text)) {
139
393
  insertAt = i + 1;
140
394
  break;
141
395
  }
142
396
  }
143
- lines.splice(insertAt, 0, comment);
397
+ // Issue #6: never insert inside an open fence — appending at EOF while a
398
+ // fence is unterminated would corrupt the fenced region.
399
+ if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
400
+ if (insertAt < lines.length) {
401
+ lines.splice(insertAt, 0, { text: comment, eol: dominant });
402
+ } else {
403
+ // Appending at EOF: the current last line (an unterminated tail) gains
404
+ // the dominant EOL as separator; the comment becomes the unterminated
405
+ // tail — byte-identical to what a plain join would produce, with the
406
+ // file's own dominant terminator instead of a hard-coded '\n'.
407
+ const last = lines[lines.length - 1]!;
408
+ if (last.eol === null) last.eol = dominant;
409
+ lines.push({ text: comment, eol: null });
410
+ }
144
411
  }
145
- return lines.filter((l): l is string => l !== null).join('\n');
412
+ const kept: SourceLine[] = lines.filter((_, i) => !dropped.has(i));
413
+ return joinSourceLines(kept);
146
414
  }
147
415
 
148
416
  /** The shared engine orchestrator used by `cans check` and `cans done`. */
149
417
  export async function checkWorkspace(root: string, opts: CheckArgs): Promise<CheckResult> {
418
+ const t0 = performance.now(); // issue #41: elapsedMs timing (global in Bun + Node ≥16)
150
419
  let rules;
151
420
  try {
152
421
  rules = loadRules(root);
@@ -163,6 +432,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
163
432
  file: name, line: 0, level: 'warning', category: 'structure',
164
433
  message: `malformed workspace entry: directory "${name}" looks like a spec file — rename it or use folder mode (${name.replace(/\.md$/, '')}/index.md)`,
165
434
  suggestion: `remove or rename the directory cans/${name}`,
435
+ rule: 'structure.malformed_dir', // issue #41
166
436
  });
167
437
  }
168
438
 
@@ -172,6 +442,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
172
442
  file: flat, line: 0, level: 'error', category: 'structure',
173
443
  message: `duplicate home: both ${flat} and ${folder} exist — flat wins, remove the folder`,
174
444
  suggestion: `delete ${folder} (or merge its content into ${flat})`,
445
+ rule: 'structure.duplicate_home', // issue #41
175
446
  });
176
447
  }
177
448
 
@@ -187,6 +458,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
187
458
  issues.push({
188
459
  file: rel, line: 0, level: 'error', category: 'structure',
189
460
  message: `unreadable spec file: ${e instanceof Error ? e.message : String(e)}`,
461
+ rule: 'io.unreadable', // issue #41
190
462
  });
191
463
  continue;
192
464
  }
@@ -198,6 +470,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
198
470
  issues.push({
199
471
  file: rel, line: 0, level: 'error', category: 'structure',
200
472
  message: `parse error: ${e instanceof Error ? e.message : String(e)}`,
473
+ rule: 'parse.error', // issue #41
201
474
  });
202
475
  }
203
476
  // Odd (non-2-multiple) indentation silently re-parents nodes — surface it.
@@ -205,6 +478,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
205
478
  issues.push({
206
479
  file: rel, line: pw.line, level: 'warning', category: 'structure',
207
480
  message: pw.message,
481
+ rule: 'parse.indent', // issue #41
208
482
  });
209
483
  }
210
484
  }
@@ -276,20 +550,15 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
276
550
  for (const [rel, source] of specSources) {
277
551
  for (const bp of extractBackPointers(source, rel)) {
278
552
  bpTotal++;
279
- const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
280
- const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
281
- const isCurrent =
282
- fromKey !== null &&
283
- fromRefs !== undefined &&
284
- fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
285
- if (isCurrent) {
553
+ if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
286
554
  bpCurrent++;
287
555
  } else {
288
556
  bpStale++;
289
557
  issues.push({
290
558
  file: rel, line: bp.fromLine, level: 'warning', category: 'refs',
291
- message: `stale back-pointer: ${bp.fromFile} no longer refs ${rel}`,
559
+ message: `stale back-pointer: ${bp.fromFile} no longer refs ${rel}${bp.toAnchor !== null ? `#${bp.toAnchor}` : ''}`,
292
560
  suggestion: 'remove the ref-by comment (or re-run cans check --fix)',
561
+ rule: 'refs.backpointer.stale', // issue #41
293
562
  });
294
563
  }
295
564
  }
@@ -320,42 +589,69 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
320
589
  // --fix: rewrite ref-by comments ONLY, in spec files ONLY.
321
590
  // §18/§17: with the back-pointer check off (back_pointers false or deleted),
322
591
  // --fix must not write anything — backPointersUpdated stays 0, no file touched.
592
+ // Issue #11 (round 6): a [file] filter scopes the WRITES to the filter-matched
593
+ // spec files. The desired-marks map is still computed from the GLOBAL ref
594
+ // graph (refs stay global by design) — only the writes are scoped, so a
595
+ // filtered run never mutates a file the user did not name. A referrer filter
596
+ // (e.g. 04-api.md) therefore leaves its TARGETS' marks untouched this run
597
+ // (targets are not filter-matched); the user re-runs with the target's
598
+ // filter, or unfiltered, to write them. `cans done` (file: null) and
599
+ // unfiltered runs rewrite every spec source, exactly as before.
323
600
  let backPointersUpdated = 0;
601
+ const backPointersUpdatedFiles: string[] = [];
324
602
  if (opts.fix && backPointersOn) {
325
603
  const desired = rebuildBackPointers(allFiles, graph);
326
- for (const [rel, source] of specSources) {
327
- const body = desired.get(rel) ?? null;
328
- const rewritten = rewriteRefBy(source, body);
604
+ // Issue #11: filtered run → only the checkable (filter-matched) spec
605
+ // files; unfiltered run (and `cans done`, which fixes with file: null) →
606
+ // every spec source.
607
+ const fixable: string[] = opts.file !== null ? checkable : [...specSources.keys()];
608
+ for (const rel of fixable) {
609
+ const source = specSources.get(rel);
610
+ if (source === undefined) continue;
611
+ // Issue #19: the desired marks are per (file, anchor). Anchored refs
612
+ // earn an INLINE mark on the anchor node's line; file-level refs keep
613
+ // the standalone after-first-root-bullet form (issue #6). Broken-anchor
614
+ // refs were dropped by rebuildBackPointers — nothing is written for
615
+ // them (they are already checkRefs errors).
616
+ const groups: RefByGroup[] = desired.get(rel) ?? [];
617
+ const fileBody = groups.find(g => g.node === null)?.fromFiles.join(', ') ?? null;
618
+ const anchored = groups
619
+ .filter(g => g.node !== null)
620
+ .map(g => ({ line: g.node!.line, body: g.fromFiles.join(', ') }));
621
+ const rewritten = rewriteRefBy(source, fileBody, anchored);
329
622
  if (rewritten !== source) {
330
623
  await writeText(join(root, rel), rewritten);
331
624
  specSources.set(rel, rewritten);
332
625
  backPointersUpdated++;
626
+ backPointersUpdatedFiles.push(rel);
333
627
  }
334
628
  }
335
629
 
336
630
  // §35 check-fix.json reports the POST-fix state: recompute back-pointer
337
631
  // counts from the rewritten sources and drop now-fixed stale issues.
632
+ // Issue #11: only a file the run ACTUALLY rewrote can have its stale
633
+ // warnings dropped — files outside a [file] filter keep theirs (the
634
+ // comment is still on disk, so it is still stale).
338
635
  bpTotal = 0;
339
636
  bpCurrent = 0;
340
637
  bpStale = 0;
341
638
  for (const [rel, source] of specSources) {
342
639
  for (const bp of extractBackPointers(source, rel)) {
343
640
  bpTotal++;
344
- const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
345
- const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
346
- const isCurrent =
347
- fromKey !== null &&
348
- fromRefs !== undefined &&
349
- fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
350
- if (isCurrent) {
641
+ if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
351
642
  bpCurrent++;
352
643
  } else {
353
644
  bpStale++;
354
645
  }
355
646
  }
356
647
  }
648
+ const rewrittenSet = new Set(backPointersUpdatedFiles);
357
649
  for (let i = issues.length - 1; i >= 0; i--) {
358
- if (issues[i]!.category === 'refs' && issues[i]!.message.startsWith('stale back-pointer:')) {
650
+ if (
651
+ issues[i]!.category === 'refs' &&
652
+ issues[i]!.message.startsWith('stale back-pointer:') &&
653
+ rewrittenSet.has(issues[i]!.file)
654
+ ) {
359
655
  issues.splice(i, 1);
360
656
  }
361
657
  }
@@ -364,12 +660,24 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
364
660
  let nodeCount = 0;
365
661
  let depthMax = 0;
366
662
  for (const nodes of specFiles.values()) {
367
- nodeCount += flattenNodes(nodes).length;
663
+ // Issue #8: report REAL nodes only — synthetic "(table)"/"(code fence)"
664
+ // placeholders (leading table/fence before the first bullet) are not
665
+ // user content and inflated the header count and budget estimates.
666
+ nodeCount += realNodes(nodes).length;
667
+ // Depth keeps the full tree: a synthetic root sits at indent 0, so it can
668
+ // never raise depthMax; a workspace containing only phantoms now has
669
+ // nodeCount 0 and correctly reports maxDepth 0 (§35 empty-workspace case).
368
670
  depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
369
671
  }
370
672
  const refsTotal = [...graph.forward.values()].reduce((a, ts) => a + ts.length, 0);
673
+ // Round 6 (QA-19 F40b): refs.broken is TRUTHFUL — broken ref targets AND
674
+ // broken anchors are both §12 broken-ref errors, so both count. (The old
675
+ // message-prefix filter left refs.broken: 0 beside a broken-anchor ERROR
676
+ // with errorCount 1 / ok:false — machine consumers filtered on the counter
677
+ // missed the error.) Rule keys are the stable vocabulary (issue #41).
371
678
  const broken = issues.filter(
372
- i => i.category === 'refs' && i.level === 'error' && i.message.startsWith('broken ref:'),
679
+ i => i.category === 'refs' && i.level === 'error'
680
+ && (i.rule === 'refs.broken.file' || i.rule === 'refs.broken.anchor'),
373
681
  ).length;
374
682
 
375
683
  const errorCount = issues.filter(i => i.level === 'error').length;
@@ -379,7 +687,9 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
379
687
  return {
380
688
  ok,
381
689
  command: 'check',
382
- exitCode: ok ? 0 : 1,
690
+ // issue #41: exit contract 0 clean · 1 warnings · 2 errors.
691
+ // strict affects `ok` only, never the exit code.
692
+ exitCode: errorCount > 0 ? 2 : (warningCount > 0 ? 1 : 0),
383
693
  files: specFiles.size,
384
694
  nodes: nodeCount,
385
695
  // §35: maxDepth is 1-based (a 4-level chain reports 4); 0 for an empty workspace.
@@ -390,6 +700,12 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
390
700
  errorCount,
391
701
  warningCount,
392
702
  backPointersUpdated,
703
+ // Issue #11: the report names the files --fix actually rewrote (sorted,
704
+ // spec-relative). Empty without --fix or when nothing needed a write;
705
+ // with a [file] filter only matching files can ever appear here.
706
+ backPointersUpdatedFiles: [...backPointersUpdatedFiles].sort(),
707
+ // issue #41: whole-ms wall-clock duration of the run (never negative).
708
+ elapsedMs: Math.max(0, Math.round(performance.now() - t0)),
393
709
  // §22: fixed report order ends with a Rules section before the summary (QA-02 F17).
394
710
  // §18 delete-key semantics: a deleted range key shows as "off", never a raw null.
395
711
  rulesSummary: