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,5 +1,5 @@
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,
@@ -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,9 +42,15 @@ 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*-->/;
45
55
  // Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
46
56
  // trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
@@ -56,6 +66,16 @@ function safeAdrs(root: string): string[] {
56
66
  return dirExists(join(root, '_adr')) ? discoverAdrs(root) : [];
57
67
  }
58
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
+
59
79
  /** §20: route check's args through the shared parser — `--flag value` only,
60
80
  * `[file]` is the sole positional. Unknown flags, short flags, `--flag=value`
61
81
  * and extra positionals are user errors, never silently ignored. */
@@ -67,12 +87,28 @@ export function parseCheckArgs(args: string[]): CheckArgs & { errors: string[] }
67
87
  if (positional.length > 1) {
68
88
  errors.push(`unexpected argument "${positional[1]}" — check takes a single optional [file]`);
69
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
+ }
70
105
  return {
71
106
  fix: parsed.flags.has('fix'),
72
107
  strict: parsed.flags.has('strict'),
73
108
  refsOnly: parsed.flags.has('refs-only'),
74
109
  noRedundancy: parsed.flags.has('no-redundancy'),
75
110
  json: parsed.flags.has('json'),
111
+ show,
76
112
  file,
77
113
  errors,
78
114
  };
@@ -89,17 +125,20 @@ function zeroedCounts(): Omit<CheckResult, 'ok' | 'command' | 'exitCode'> {
89
125
  errorCount: 0,
90
126
  warningCount: 0,
91
127
  backPointersUpdated: 0,
128
+ backPointersUpdatedFiles: [],
129
+ elapsedMs: 0, // issue #41: the static failure paths never ran a check
92
130
  };
93
131
  }
94
132
 
95
133
  /** §37: check-level failure (no workspace, invalid rules, unknown flag, file
96
134
  * filter matched nothing). The diagnosis rides in `error` so the human printer
97
- * 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. */
98
137
  function checkFail(message: string): CheckResult & { error: string } {
99
138
  return {
100
139
  ok: false,
101
140
  command: 'check',
102
- exitCode: 1,
141
+ exitCode: 2, // issue #41: usage/no-workspace/invalid-rules are the error class
103
142
  ...zeroedCounts(),
104
143
  issues: [{ file: '', line: 0, level: 'error', category: 'refs', message }],
105
144
  errorCount: 1,
@@ -115,6 +154,38 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
115
154
  return null;
116
155
  }
117
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
+
118
189
  /** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
119
190
  * keeping the surrounding prose. Collapses the double space left where the
120
191
  * comment sat (one seam space absorbed) and trims trailing whitespace. The
@@ -129,28 +200,122 @@ function stripRefByKeepProse(raw: string): string {
129
200
  return out.replace(/[ \t]+$/, '');
130
201
  }
131
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
+
132
251
  /** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
133
- * Fence-aware and prose-preserving (issue #6):
252
+ * Fence-aware, prose-preserving, anchor-aware, EOL-preserving
253
+ * (issues #6 + #19 + #21):
134
254
  * - Lines inside ``` fences (and the fence markers themselves) are preserved
135
255
  * byte-for-byte: never scanned as hits, never rewritten, never dropped, and
136
- * fenced "- fake bullets" are never insertion anchors.
137
- * - Replaces the first existing comment's content; duplicates/stale hits keep
138
- * their line — bullets are stripped as before, non-bullet prose loses only
139
- * the comment substring (the line is dropped only when bare).
140
- * - With no hits and a desired body, inserts a fresh comment line right after
141
- * the first root bullet outside any fence, appended at end when none exists
142
- * — unless the file ends inside an unterminated fence, in which case the
143
- * comment is inserted before the fence opener (never inside a fence).
144
- * Exported for regression tests (issue #6); behavior lives in this module. */
145
- export function rewriteRefBy(source: string, body: string | null): string {
146
- const lines: Array<string | null> = source.split('\n');
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';
147
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
+
148
312
  const hits: number[] = [];
313
+ const dropped = new Set<number>(); // issue #6: bare non-bullet comment lines vanish
149
314
  let fenceOpen = false;
150
315
  let fenceOpener = -1;
151
316
  const inFence: boolean[] = new Array(lines.length).fill(false);
152
317
  for (let i = 0; i < lines.length; i++) {
153
- const raw = lines[i]!;
318
+ const raw = lines[i]!.text;
154
319
  if (FENCE_RE.test(raw.trim())) {
155
320
  // Fence marker: toggles state; never a hit, never an anchor, never touched.
156
321
  inFence[i] = true;
@@ -166,28 +331,65 @@ export function rewriteRefBy(source: string, body: string | null): string {
166
331
  if (fenceOpen) continue;
167
332
  if (REF_BY_RE.test(raw)) hits.push(i);
168
333
  }
169
- if (hits.length > 0) {
170
- for (let j = 0; j < hits.length; j++) {
171
- const i = hits[j];
172
- const raw = lines[i]!;
173
- if (j === 0 && comment !== null) {
174
- lines[i] = raw.replace(REF_BY_RE, comment);
175
- } else {
176
- const isBullet = /^\s*-\s/.test(raw);
177
- if (isBullet) {
178
- lines[i] = raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '');
179
- } else {
180
- // Issue #6: never delete a prose line whole — strip the comment only.
181
- const stripped = stripRefByKeepProse(raw);
182
- lines[i] = stripped.trim() === '' ? null : stripped;
183
- }
184
- }
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;
185
371
  }
186
- } 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) {
187
389
  let insertAt = lines.length;
188
390
  for (let i = 0; i < lines.length; i++) {
189
391
  if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
190
- if (/^- /.test(lines[i]!)) {
392
+ if (!dropped.has(i) && /^- /.test(lines[i]!.text)) {
191
393
  insertAt = i + 1;
192
394
  break;
193
395
  }
@@ -195,13 +397,25 @@ export function rewriteRefBy(source: string, body: string | null): string {
195
397
  // Issue #6: never insert inside an open fence — appending at EOF while a
196
398
  // fence is unterminated would corrupt the fenced region.
197
399
  if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
198
- lines.splice(insertAt, 0, comment);
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
+ }
199
411
  }
200
- 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);
201
414
  }
202
415
 
203
416
  /** The shared engine orchestrator used by `cans check` and `cans done`. */
204
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)
205
419
  let rules;
206
420
  try {
207
421
  rules = loadRules(root);
@@ -218,6 +432,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
218
432
  file: name, line: 0, level: 'warning', category: 'structure',
219
433
  message: `malformed workspace entry: directory "${name}" looks like a spec file — rename it or use folder mode (${name.replace(/\.md$/, '')}/index.md)`,
220
434
  suggestion: `remove or rename the directory cans/${name}`,
435
+ rule: 'structure.malformed_dir', // issue #41
221
436
  });
222
437
  }
223
438
 
@@ -227,6 +442,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
227
442
  file: flat, line: 0, level: 'error', category: 'structure',
228
443
  message: `duplicate home: both ${flat} and ${folder} exist — flat wins, remove the folder`,
229
444
  suggestion: `delete ${folder} (or merge its content into ${flat})`,
445
+ rule: 'structure.duplicate_home', // issue #41
230
446
  });
231
447
  }
232
448
 
@@ -242,6 +458,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
242
458
  issues.push({
243
459
  file: rel, line: 0, level: 'error', category: 'structure',
244
460
  message: `unreadable spec file: ${e instanceof Error ? e.message : String(e)}`,
461
+ rule: 'io.unreadable', // issue #41
245
462
  });
246
463
  continue;
247
464
  }
@@ -253,6 +470,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
253
470
  issues.push({
254
471
  file: rel, line: 0, level: 'error', category: 'structure',
255
472
  message: `parse error: ${e instanceof Error ? e.message : String(e)}`,
473
+ rule: 'parse.error', // issue #41
256
474
  });
257
475
  }
258
476
  // Odd (non-2-multiple) indentation silently re-parents nodes — surface it.
@@ -260,6 +478,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
260
478
  issues.push({
261
479
  file: rel, line: pw.line, level: 'warning', category: 'structure',
262
480
  message: pw.message,
481
+ rule: 'parse.indent', // issue #41
263
482
  });
264
483
  }
265
484
  }
@@ -331,20 +550,15 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
331
550
  for (const [rel, source] of specSources) {
332
551
  for (const bp of extractBackPointers(source, rel)) {
333
552
  bpTotal++;
334
- const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
335
- const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
336
- const isCurrent =
337
- fromKey !== null &&
338
- fromRefs !== undefined &&
339
- fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
340
- if (isCurrent) {
553
+ if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
341
554
  bpCurrent++;
342
555
  } else {
343
556
  bpStale++;
344
557
  issues.push({
345
558
  file: rel, line: bp.fromLine, level: 'warning', category: 'refs',
346
- 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}` : ''}`,
347
560
  suggestion: 'remove the ref-by comment (or re-run cans check --fix)',
561
+ rule: 'refs.backpointer.stale', // issue #41
348
562
  });
349
563
  }
350
564
  }
@@ -375,42 +589,69 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
375
589
  // --fix: rewrite ref-by comments ONLY, in spec files ONLY.
376
590
  // §18/§17: with the back-pointer check off (back_pointers false or deleted),
377
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.
378
600
  let backPointersUpdated = 0;
601
+ const backPointersUpdatedFiles: string[] = [];
379
602
  if (opts.fix && backPointersOn) {
380
603
  const desired = rebuildBackPointers(allFiles, graph);
381
- for (const [rel, source] of specSources) {
382
- const body = desired.get(rel) ?? null;
383
- 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);
384
622
  if (rewritten !== source) {
385
623
  await writeText(join(root, rel), rewritten);
386
624
  specSources.set(rel, rewritten);
387
625
  backPointersUpdated++;
626
+ backPointersUpdatedFiles.push(rel);
388
627
  }
389
628
  }
390
629
 
391
630
  // §35 check-fix.json reports the POST-fix state: recompute back-pointer
392
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).
393
635
  bpTotal = 0;
394
636
  bpCurrent = 0;
395
637
  bpStale = 0;
396
638
  for (const [rel, source] of specSources) {
397
639
  for (const bp of extractBackPointers(source, rel)) {
398
640
  bpTotal++;
399
- const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
400
- const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
401
- const isCurrent =
402
- fromKey !== null &&
403
- fromRefs !== undefined &&
404
- fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
405
- if (isCurrent) {
641
+ if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
406
642
  bpCurrent++;
407
643
  } else {
408
644
  bpStale++;
409
645
  }
410
646
  }
411
647
  }
648
+ const rewrittenSet = new Set(backPointersUpdatedFiles);
412
649
  for (let i = issues.length - 1; i >= 0; i--) {
413
- 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
+ ) {
414
655
  issues.splice(i, 1);
415
656
  }
416
657
  }
@@ -429,8 +670,14 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
429
670
  depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
430
671
  }
431
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).
432
678
  const broken = issues.filter(
433
- 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'),
434
681
  ).length;
435
682
 
436
683
  const errorCount = issues.filter(i => i.level === 'error').length;
@@ -440,7 +687,9 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
440
687
  return {
441
688
  ok,
442
689
  command: 'check',
443
- 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),
444
693
  files: specFiles.size,
445
694
  nodes: nodeCount,
446
695
  // §35: maxDepth is 1-based (a 4-level chain reports 4); 0 for an empty workspace.
@@ -451,6 +700,12 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
451
700
  errorCount,
452
701
  warningCount,
453
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)),
454
709
  // §22: fixed report order ends with a Rules section before the summary (QA-02 F17).
455
710
  // §18 delete-key semantics: a deleted range key shows as "off", never a raw null.
456
711
  rulesSummary: