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/src/core/refs.ts CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types.ts';
4
4
  import { flattenNodes, parseOutline } from './outline.ts';
5
- import { resolveSpecFile, toRelative, isFile } from './fs.ts';
5
+ import { resolveSpecFile, toRelative, isFile, dirExists, exists } from './fs.ts';
6
6
 
7
7
  export interface RefGraph {
8
8
  forward: Map<string, RefTarget[]>;
@@ -10,9 +10,12 @@ export interface RefGraph {
10
10
  }
11
11
 
12
12
  /** Does a raw ref target `name` point at workspace file `key`?
13
- * Handles flat (`02-auth.md`) and folder (`02-auth/index.md`) layouts. */
13
+ * Handles flat (`02-auth.md`) and folder (`02-auth/index.md`) layouts.
14
+ * Issue #22: trailing-slash folder forms are equivalent spellings —
15
+ * `auth/`, `auth` and `auth/index.md` all name the same folder-layout
16
+ * target, so trailing slashes are trimmed before every comparison. */
14
17
  export function targetMatchesKey(name: string, key: string): boolean {
15
- const n = name.toLowerCase();
18
+ const n = name.toLowerCase().replace(/\/+$/, '');
16
19
  const k = key.toLowerCase();
17
20
  if (n === k) return true;
18
21
  const nBase = n.endsWith('.md') ? n.slice(0, -3) : n;
@@ -23,16 +26,37 @@ export function targetMatchesKey(name: string, key: string): boolean {
23
26
  return false;
24
27
  }
25
28
 
29
+ /** Issue #10 (round-6 port of 3adbc91): does a raw see: target escape the
30
+ * workspace — an absolute path or a `..` segment? Such targets can never
31
+ * name a workspace spec file, so their broken-ref message is workspace-scoped
32
+ * and their advice never proposes creating a path outside the workspace
33
+ * (no more `create /etc/hosts`). Conservative by design: ANY `..` segment
34
+ * flags — the label only affects messaging; actual resolution is decided by
35
+ * resolveSpecFile's containment guard, so interior `..` that normalizes back
36
+ * inside the root still resolves. */
37
+ export function refEscapesWorkspace(name: string): boolean {
38
+ return name.startsWith('/') || name.split('/').includes('..');
39
+ }
40
+
26
41
  /** Map a raw ref target to the loaded files-map key, if the target is loaded or resolvable on disk. */
27
42
  function loadedKeyFor(files: Map<string, OutlineNode[]>, root: string, name: string): string | null {
28
- if (files.has(name)) return name;
29
- if (name.endsWith('.md') && files.has(`${name.slice(0, -3)}/index.md`)) return `${name.slice(0, -3)}/index.md`;
30
- if (!name.endsWith('.md') && files.has(`${name}/index.md`)) return `${name}/index.md`;
31
- const p = resolveSpecFile(root, name);
43
+ // Issue #22: normalize the trailing-slash folder form up front so every
44
+ // probe below (`auth/`, `auth`, `auth/index.md`) agrees with targetMatchesKey.
45
+ const target = name.replace(/\/+$/, '');
46
+ if (target === '') return null;
47
+ if (files.has(target)) return target;
48
+ // Round 6 (QA-19 F51): §11 flat-first covers extensionless FLAT targets
49
+ // too — `see 02-b` resolves to `02-b.md` exactly like `see auth` resolves
50
+ // to `auth/index.md` (folders already had this). Probed BEFORE the folder
51
+ // index so flat wins when both spellings exist.
52
+ if (!target.endsWith('.md') && files.has(`${target}.md`)) return `${target}.md`;
53
+ if (target.endsWith('.md') && files.has(`${target.slice(0, -3)}/index.md`)) return `${target.slice(0, -3)}/index.md`;
54
+ if (!target.endsWith('.md') && files.has(`${target}/index.md`)) return `${target}/index.md`;
55
+ const p = resolveSpecFile(root, target);
32
56
  if (p === null) return null;
33
57
  const rel = toRelative(root, p);
34
58
  if (files.has(rel)) return rel;
35
- for (const key of files.keys()) if (targetMatchesKey(name, key)) return key;
59
+ for (const key of files.keys()) if (targetMatchesKey(target, key)) return key;
36
60
  return null; // exists on disk but is not part of the loaded set
37
61
  }
38
62
 
@@ -46,6 +70,25 @@ export function anchorMatches(nodeText: string, anchor: string): boolean {
46
70
  return norm(nodeText) === norm(anchor);
47
71
  }
48
72
 
73
+ /** Does an UNRESOLVED ref target look like an intended spec reference
74
+ * (broken-ref error territory) or like English prose that merely contains
75
+ * the word "see" (warning territory)? (issue #4)
76
+ *
77
+ * The §11 ref regex intentionally keeps minting refs from any `see <token>`
78
+ * prose ("see the runbook", "see below") so banner counts and deep-hop/orphan
79
+ * machinery stay centralized in the graph. Classification happens only at
80
+ * resolution time, in checkRefs: a target that itself looks spec-shaped keeps
81
+ * the documented broken-ref error; anything else is prose, not a dangling
82
+ * spec pointer. */
83
+ export function looksLikeSpecRef(target: string, hasAnchor: boolean): boolean {
84
+ if (hasAnchor) return true; // see X#anchor — explicit anchor intent
85
+ if (/\.md$/i.test(target)) return true; // explicit markdown target
86
+ if (/^_/.test(target)) return true; // workspace service dirs (_tasks/, _collab/, ...)
87
+ if (target.includes('/')) return true; // path-like
88
+ if (/^\d/.test(target)) return true; // numeric-prefix spec stems (02-auth)
89
+ return false;
90
+ }
91
+
49
92
  export function buildRefGraph(
50
93
  files: Map<string, OutlineNode[]>,
51
94
  root: string,
@@ -66,6 +109,43 @@ export function buildRefGraph(
66
109
  return { forward, back };
67
110
  }
68
111
 
112
+ /** Issue #22: broken-ref create-* advice must never propose creating a path
113
+ * that already exists (file or directory — same hygiene family as #10's
114
+ * `create /etc/hosts`), and (round 6, issue #10 port) never a path outside
115
+ * the workspace. A trailing-slash target (`see auth/`) reaches this branch
116
+ * only when no spec file sits behind it: if the bare folder exists without
117
+ * an index.md, the actionable creation target is the folder's spec file; if
118
+ * the folder is already complete (a deeper, non-spec index.md), the only fix
119
+ * is the ref target itself. Plain missing targets keep the exact legacy
120
+ * `create <target> or fix the ref target` advice — and (round 6, QA-19 F51)
121
+ * a missing extensionless stem proposes its §11 flat-first file `<stem>.md`,
122
+ * never an extensionless file. */
123
+ function brokenRefSuggestion(root: string, target: string): string {
124
+ // Defense in depth: checkRefs classifies escapes before calling here, but
125
+ // the contract holds for ANY caller — an escaping target never earns a
126
+ // create-* proposal.
127
+ if (refEscapesWorkspace(target)) {
128
+ return 'fix the ref target — see: targets must name spec files inside the workspace (no ../ or absolute paths)';
129
+ }
130
+ const clean = target.replace(/\/+$/, '');
131
+ if (clean === '') return 'fix the ref target';
132
+ if (dirExists(join(root, clean))) {
133
+ if (!exists(join(root, clean, 'index.md'))) {
134
+ return `create ${clean}/index.md or fix the ref target`;
135
+ }
136
+ return `fix the ref target — ${clean}/ is a folder, not a spec file`;
137
+ }
138
+ if (isFile(join(root, clean))) {
139
+ return `fix the ref target — ${clean} is not a spec file`;
140
+ }
141
+ if (clean.endsWith('.md')) {
142
+ return `create ${clean} or fix the ref target`;
143
+ }
144
+ return clean !== target
145
+ ? `create ${clean}/index.md or fix the ref target`
146
+ : `create ${clean}.md or fix the ref target`;
147
+ }
148
+
69
149
  export function checkRefs(
70
150
  files: Map<string, OutlineNode[]>,
71
151
  graph: RefGraph,
@@ -78,6 +158,7 @@ export function checkRefs(
78
158
  issues.push({
79
159
  file, line: ref.line, level: 'error', category: 'refs',
80
160
  message: `self-reference: ${file} → ${ref.file}`,
161
+ rule: 'refs.self', // issue #41: machine-readable rule key
81
162
  suggestion: 'remove the self-reference; point at the canonical file instead',
82
163
  });
83
164
  continue;
@@ -86,6 +167,7 @@ export function checkRefs(
86
167
  issues.push({
87
168
  file, line: ref.line, level: 'warning', category: 'refs',
88
169
  message: `transient ref: see ${ref.file} — _tasks/ files are transient, not spec`,
170
+ rule: 'refs.transient', // issue #41: machine-readable rule key
89
171
  suggestion: 're-point at a spec file when the task lands',
90
172
  });
91
173
  continue;
@@ -94,6 +176,7 @@ export function checkRefs(
94
176
  issues.push({
95
177
  file, line: ref.line, level: 'error', category: 'refs',
96
178
  message: `ref to _collab/: see ${ref.file} — collab notes are not spec`,
179
+ rule: 'refs.collab', // issue #41: machine-readable rule key
97
180
  suggestion: 'move the content into a spec file and ref that',
98
181
  });
99
182
  continue;
@@ -101,16 +184,51 @@ export function checkRefs(
101
184
 
102
185
  const key = loadedKeyFor(files, root, ref.file);
103
186
  if (key === null && resolveSpecFile(root, ref.file) === null) {
187
+ // Issue #10 (round-6 port of 3adbc91): ../ and absolute targets
188
+ // escape the workspace — say so, and never suggest creating a path
189
+ // outside it (`create /etc/hosts` proposed creating an EXISTING file
190
+ // beyond the root; `create ../escape` proposed a traversal path).
191
+ // Checked before the issue #4 prose exemption: an escaping target is
192
+ // never English prose. (Resolution itself is contained by
193
+ // resolveSpecFile's isInsideRoot guard — this is the reporting half.)
194
+ if (refEscapesWorkspace(ref.file)) {
195
+ issues.push({
196
+ file, line: ref.line, level: 'error', category: 'refs',
197
+ message: `broken ref: see ${ref.file} — file not found in workspace`,
198
+ rule: 'refs.broken.file', // issue #41: machine-readable rule key
199
+ suggestion: 'fix the ref target — see: targets must name spec files inside the workspace (no ../ or absolute paths)',
200
+ });
201
+ continue;
202
+ }
104
203
  // §12 edge cases: "File not found → Broken ref error." There is NO
105
204
  // span/direction exemption — forward or backward, inside or outside the
106
205
  // loaded numeric span, a missing file is always a level:error broken
107
206
  // ref. (The former "unwritten spec slot" backward in-span downgrade
108
207
  // violated §12 and masked real holes as warnings — removed.)
109
- issues.push({
110
- file, line: ref.line, level: 'error', category: 'refs',
111
- message: `broken ref: see ${ref.file} — file not found`,
112
- suggestion: `create ${ref.file} or fix the ref target`,
113
- });
208
+ //
209
+ // Issue #4 prose exemption: that error contract applies to targets that
210
+ // THEMSELVES look like intended spec references (looksLikeSpecRef —
211
+ // anchored, .md, workspace-service-dir, path-like, or numeric-prefix).
212
+ // English prose that merely contains the word "see" ("see the runbook",
213
+ // "see below") mints a ref target that resolves to nothing and is not
214
+ // spec-shaped — downgraded to a see-like-prose warning so natural
215
+ // language no longer fails the run. Genuinely malformed real refs
216
+ // (.md targets, anchors, paths, numeric stems) keep the exact error.
217
+ if (looksLikeSpecRef(ref.file, ref.anchor !== null)) {
218
+ issues.push({
219
+ file, line: ref.line, level: 'error', category: 'refs',
220
+ message: `broken ref: see ${ref.file} — file not found`,
221
+ rule: 'refs.broken.file', // issue #41: machine-readable rule key
222
+ suggestion: brokenRefSuggestion(root, ref.file),
223
+ });
224
+ } else {
225
+ issues.push({
226
+ file, line: ref.line, level: 'warning', category: 'refs',
227
+ message: `see-like prose: "see ${ref.file}" did not resolve to a spec file — rephrase or link explicitly`,
228
+ rule: 'refs.prose', // issue #41: machine-readable rule key
229
+ suggestion: 'use "see: <file>.md" (or "see: <file>.md#<anchor>") to link a spec file, or reword the sentence',
230
+ });
231
+ }
114
232
  continue;
115
233
  }
116
234
 
@@ -138,6 +256,7 @@ export function checkRefs(
138
256
  issues.push({
139
257
  file, line: ref.line, level: 'error', category: 'refs',
140
258
  message: `broken anchor: ${ref.file}#${anchor} — no node matches`,
259
+ rule: 'refs.broken.anchor', // issue #41: machine-readable rule key
141
260
  suggestion: `fix the anchor or add a "${anchor}" node to ${ref.file}`,
142
261
  });
143
262
  }
@@ -150,10 +269,49 @@ export function checkRefs(
150
269
 
151
270
  /** Deep-hop detection: a file that both receives refs and issues them extends
152
271
  * the ref chain. `maxHops` (§18 references.max_hops, default 1) is the number
153
- * of allowed hops: a chain whose hop count through `b` exceeds it is flagged.
154
- * Hop count for file `b` with outgoing refs = (longest incoming chain into b) + 1.
155
- * §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
156
- * skipped entirely. */
272
+ * of allowed hops.
273
+ *
274
+ * Semantics (issue #5):
275
+ * - Nodes are the loaded file keys; an edge a → b exists when a holds a see:
276
+ * ref resolving to loaded file b (targetMatchesKey, flat and folder
277
+ * layouts). Self-refs never form edges — checkRefs reports those.
278
+ * - Strongly-connected meshes (2-cycles, 3-cycles, any mutual back-reference
279
+ * cluster — the shape the engine's own redundancy guidance encourages) are
280
+ * collapsed via Tarjan's SCC. A ref from b back into b's own mesh is the
281
+ * documented back-reference pattern and is never a hop; only refs LEAVING
282
+ * b's mesh extend a chain, so a pure mesh can never be flagged.
283
+ * - For a file b with a mesh-exiting outgoing ref, the hop count is the
284
+ * longest chain of refs ending at b (counted over simple paths, so a mesh
285
+ * cannot poison the count) + 1 for b's outgoing edge. Chains are counted
286
+ * with a cycle-safe bounded search: nothing is memoized from a truncated
287
+ * traversal (the old depthOf cached values computed under its cycle guard,
288
+ * making symmetric graphs flag asymmetrically depending on iteration
289
+ * order); only confirmed saturations at maxHops are cached, and those hold
290
+ * for every caller. Hop count above maxHops flags b.
291
+ * - The suggested fix never recommends a ref the deepest direct referrer
292
+ * already holds (issue #22): `from`'s outgoing refs are resolved through
293
+ * the same resolveKey, and an existing equivalent spelling (see auth vs
294
+ * see: auth/index.md) flips the advice to "already refs … — remove the
295
+ * intermediate hop via <b>" — following the "add" advice would have
296
+ * appended a second see: to one node while leaving the hop in place.
297
+ * Round 6 (QA-19 F54/F55): the guard is ANCHOR-aware — two refs name the
298
+ * same target only when they resolve to the same target key AND the same
299
+ * anchor node (case-insensitive §12 anchorMatches), or when both are
300
+ * file-level. A same-file/different-node ref (`see auth#Passwords` vs the
301
+ * suggested `auth/index.md#Sessions`) and a file-level ref beside an
302
+ * anchored suggestion are NOT duplicates — the old file-key-only guard
303
+ * emitted false, self-contradictory "already refs" claims.
304
+ * The plain (no-existing-ref) advice likewise states the intermediate hop
305
+ * must be removed, not just the direct ref added: from ≠ b holds because
306
+ * incoming lists exclude self, and from = out would place from, b and out
307
+ * in one SCC — guarded defensively anyway, so the advice can never convert
308
+ * a deep-hop error into a checkRefs self-reference error.
309
+ * Round 6 (QA-19 F26): the removal names the EXACT edge — `from`'s raw
310
+ * ref to b with its line — so a multi-referrer workspace never leaves the
311
+ * user guessing which `see:` to delete ("remove the intermediate hop via
312
+ * X" alone is ambiguous when several files ref X).
313
+ * - §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
314
+ * skipped entirely. */
157
315
  export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Issue[] {
158
316
  if (maxHops === null) return [];
159
317
  const issues: Issue[] = [];
@@ -175,31 +333,109 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
175
333
  }
176
334
  }
177
335
 
178
- // depth(x) = length of the longest incoming chain ending at x (0 = no incoming).
179
- const depth = new Map<string, number>();
180
- const visiting = new Set<string>();
336
+ // Tarjan's SCC over exactly the edges `incoming` encodes (adjacency is
337
+ // derived from it, so classification and cycle detection cannot disagree).
338
+ // Recursive is fine: spec workspaces are tiny, and traversal depth is
339
+ // bounded by the file count either way.
340
+ const sccId = new Map<string, number>();
341
+ {
342
+ const adj = new Map<string, string[]>();
343
+ for (const [b, referrers] of incoming) {
344
+ for (const a of referrers) {
345
+ const list = adj.get(a) ?? [];
346
+ if (!list.includes(b)) list.push(b);
347
+ adj.set(a, list);
348
+ }
349
+ }
350
+ const index = new Map<string, number>();
351
+ const low = new Map<string, number>();
352
+ const onStack = new Set<string>();
353
+ const stack: string[] = [];
354
+ let counter = 0;
355
+ let components = 0;
356
+ const strongconnect = (v: string): void => {
357
+ index.set(v, counter);
358
+ low.set(v, counter);
359
+ counter += 1;
360
+ stack.push(v);
361
+ onStack.add(v);
362
+ for (const w of adj.get(v) ?? []) {
363
+ if (!index.has(w)) {
364
+ strongconnect(w);
365
+ low.set(v, Math.min(low.get(v)!, low.get(w)!));
366
+ } else if (onStack.has(w)) {
367
+ low.set(v, Math.min(low.get(v)!, index.get(w)!));
368
+ }
369
+ }
370
+ if (low.get(v) === index.get(v)) {
371
+ let w: string;
372
+ do {
373
+ w = stack.pop()!;
374
+ onStack.delete(w);
375
+ sccId.set(w, components);
376
+ } while (w !== v);
377
+ components += 1;
378
+ }
379
+ };
380
+ for (const v of [...keys].sort()) {
381
+ if (!index.has(v)) strongconnect(v);
382
+ }
383
+ }
384
+
385
+ // First loaded key a raw ref target matches — the same first-match loop the
386
+ // incoming map uses, so ref classification and edge building always agree.
387
+ const resolveKey = (name: string): string | null => {
388
+ for (const key of keys) {
389
+ if (targetMatchesKey(name, key)) return key;
390
+ }
391
+ return null;
392
+ };
393
+
394
+ // depth(x) = longest chain of refs ending at x, over simple paths (no file
395
+ // repeats), saturated at maxHops — the flag decision only ever needs to know
396
+ // whether the chain reaches maxHops. Values are computed per query with a
397
+ // fresh path-visited set; ONLY confirmed saturations are cached, and a
398
+ // saturation is a graph property that holds for every caller.
399
+ const saturated = new Set<string>();
400
+ const explore = (x: string, visited: Set<string>, len: number): number => {
401
+ let best = len;
402
+ if (best >= maxHops) return best;
403
+ for (const u of incoming.get(x) ?? []) {
404
+ if (visited.has(u)) continue;
405
+ visited.add(u);
406
+ best = Math.max(best, explore(u, visited, len + 1));
407
+ visited.delete(u);
408
+ if (best >= maxHops) return best;
409
+ }
410
+ return best;
411
+ };
181
412
  const depthOf = (x: string): number => {
182
- const memo = depth.get(x);
183
- if (memo !== undefined) return memo;
184
- if (visiting.has(x)) return 0; // cycle guard
185
- visiting.add(x);
186
- let d = 0;
187
- for (const a of incoming.get(x) ?? []) {
188
- if (a === x) continue;
189
- d = Math.max(d, depthOf(a) + 1);
413
+ if (saturated.has(x)) return maxHops;
414
+ const d = explore(x, new Set([x]), 0);
415
+ if (d >= maxHops) {
416
+ saturated.add(x);
417
+ return maxHops;
190
418
  }
191
- visiting.delete(x);
192
- depth.set(x, d);
193
419
  return d;
194
420
  };
195
421
 
196
422
  for (const [b, outTargets] of graph.forward) {
197
- const outgoing = outTargets.filter(r => r.file !== b);
423
+ const bScc = sccId.get(b);
424
+ if (bScc === undefined) continue; // unreachable: every key is a Tarjan node
425
+ const outgoing = outTargets.filter(r => {
426
+ if (r.file === b) return false; // self-reference, as before
427
+ const t = resolveKey(r.file);
428
+ if (t !== null && sccId.get(t) === bScc) return false; // back-ref into b's own mesh
429
+ return true;
430
+ });
198
431
  if (outgoing.length === 0) continue;
199
432
  if (depthOf(b) + 1 <= maxHops) continue;
433
+ // Deepest direct referrer names the chain; ties break by file key sort so
434
+ // the output never depends on map iteration order.
435
+ const referrers = (incoming.get(b) ?? []).slice().sort();
200
436
  let from: string | null = null;
201
437
  let best = -1;
202
- for (const a of incoming.get(b) ?? []) {
438
+ for (const a of referrers) {
203
439
  const d = depthOf(a);
204
440
  if (d > best) {
205
441
  best = d;
@@ -208,11 +444,46 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
208
444
  }
209
445
  if (from === null) continue;
210
446
  const out = outgoing[0];
447
+ // Defensive invariant (issue #5 defect 2): the suggested fix must never be
448
+ // a self-reference — checkRefs rejects those, so the advice would turn one
449
+ // error into another. from ≠ b holds by construction; from = out would
450
+ // put from, b and out in one SCC. Skip rather than emit a broken fix.
451
+ if (from === b || from === out.file) continue;
211
452
  const anchor = out.anchor !== null ? `#${out.anchor}` : '';
453
+ // Issue #22 defect 1: the advice must never recommend a ref `from`
454
+ // already holds. resolveKey maps equivalent spellings (auth, auth/,
455
+ // auth/index.md) onto one loaded key, so an existing match means the
456
+ // "add" advice would append a SECOND see: to the same target while the
457
+ // deep hop itself stays in place. Name the existing ref (verbatim raw
458
+ // spelling) and the hop to remove instead.
459
+ // Round 6 (QA-19 F54/F55): the file-key match alone is NOT enough — the
460
+ // two refs must also name the same ANCHOR NODE (case-insensitive §12
461
+ // anchorMatches), or both be file-level. Otherwise the referrer holds a
462
+ // ref to a DIFFERENT node of the same file (or a file-level ref beside an
463
+ // anchored suggestion): claiming "already refs" would be false and
464
+ // following its premise silently drops the linkage.
465
+ const outKey = resolveKey(out.file);
466
+ const sameAnchor = (r: RefTarget): boolean => {
467
+ if (out.anchor === null) return r.anchor === null; // both file-level
468
+ return r.anchor !== null && anchorMatches(r.anchor, out.anchor);
469
+ };
470
+ const existing = outKey !== null
471
+ ? (graph.forward.get(from) ?? []).find(r => resolveKey(r.file) === outKey && sameAnchor(r))
472
+ : undefined;
473
+ // Round 6 (QA-19 F26): name the EXACT edge that feeds the hop — from's
474
+ // ref to b (raw spelling + source line). b is a loaded key and from ∈
475
+ // incoming[b], so the ref exists; first match in document order.
476
+ const hopEdge = (graph.forward.get(from) ?? []).find(r => resolveKey(r.file) === b);
477
+ const edge = hopEdge !== undefined
478
+ ? `: delete ${from}'s "${hopEdge.raw}" (line ${hopEdge.line})`
479
+ : '';
212
480
  issues.push({
213
481
  file: b, line: out.line, level: 'error', category: 'refs',
214
482
  message: `DEEP HOP: ${from} → ${b} → ${out.file}`,
215
- suggestion: `add "see: ${out.file}${anchor}" directly to ${from}`,
483
+ rule: 'refs.deep_hop', // issue #41: machine-readable rule key
484
+ suggestion: outKey !== null && existing !== undefined
485
+ ? `${from} already refs ${outKey}${anchor} as "${existing.raw}" — remove the intermediate hop via ${b}${edge}`
486
+ : `add "see: ${out.file}${anchor}" directly to ${from} and remove the intermediate hop via ${b}${edge}`,
216
487
  });
217
488
  }
218
489
  return issues;
@@ -240,17 +511,34 @@ export function detectOrphans(
240
511
  issues.push({
241
512
  file: key, line: 0, level: 'warning', category: 'refs',
242
513
  message: `orphan: ${key} has no incoming or outgoing refs`,
514
+ rule: 'refs.orphan', // issue #41: machine-readable rule key
243
515
  suggestion: 'link it from a related spec file, or fold it into one',
244
516
  });
245
517
  }
246
518
  return issues;
247
519
  }
248
520
 
521
+ /** Issue #19: one desired ref-by comment — the referrers that point at one
522
+ * anchor node (node !== null) or at the file itself (node === null) of one
523
+ * target file. rebuildBackPointers groups incoming refs by (resolved target
524
+ * file, anchor node): a ref WITH an anchor earns its mark INLINE on the
525
+ * referenced node's bullet line, a plain file-level ref keeps the
526
+ * file-level mark (standalone line after the first root bullet). */
527
+ export interface RefByGroup {
528
+ /** Resolved target file key (workspace-relative). */
529
+ file: string;
530
+ /** The anchor node the refs point at (§12 anchorMatches resolution, first
531
+ * match in document order); null for file-level refs. */
532
+ node: OutlineNode | null;
533
+ /** Sorted unique referrer file names — the comment body. */
534
+ fromFiles: string[];
535
+ }
536
+
249
537
  export function rebuildBackPointers(
250
538
  files: Map<string, OutlineNode[]>,
251
539
  graph: RefGraph,
252
- ): Map<string, string> {
253
- const groups = new Map<string, Set<string>>();
540
+ ): Map<string, RefByGroup[]> {
541
+ const groups = new Map<string, RefByGroup[]>();
254
542
  for (const bp of graph.back) {
255
543
  let target: string | null = null;
256
544
  for (const key of files.keys()) {
@@ -260,16 +548,42 @@ export function rebuildBackPointers(
260
548
  }
261
549
  }
262
550
  const name = target ?? bp.toFile;
263
- let set = groups.get(name);
264
- if (set === undefined) {
265
- set = new Set<string>();
266
- groups.set(name, set);
551
+ let node: OutlineNode | null = null;
552
+ if (bp.toAnchor !== null) {
553
+ // Issue #19: the anchor is now part of the group key. Resolve it in the
554
+ // target file's outline (the same §12 anchorMatches resolution
555
+ // checkRefs applies). An anchor that resolves to NO node is a broken
556
+ // anchor — already a checkRefs error — and earns no mark: dropped here
557
+ // so --fix never writes it and it can never read as current.
558
+ if (target === null) continue; // target not loaded: anchor unresolvable
559
+ node = flattenNodes(files.get(target)!).find(n => anchorMatches(n.text, bp.toAnchor!)) ?? null;
560
+ if (node === null) continue; // broken anchor — earns nothing
561
+ }
562
+ let list = groups.get(name);
563
+ if (list === undefined) {
564
+ list = [];
565
+ groups.set(name, list);
267
566
  }
268
- set.add(bp.fromFile);
567
+ let group = list.find(g => g.node === node);
568
+ if (group === undefined) {
569
+ group = { file: name, node, fromFiles: [] };
570
+ list.push(group);
571
+ }
572
+ if (!group.fromFiles.includes(bp.fromFile)) group.fromFiles.push(bp.fromFile);
269
573
  }
270
- const out = new Map<string, string>();
574
+ const out = new Map<string, RefByGroup[]>();
271
575
  for (const key of [...groups.keys()].sort()) {
272
- out.set(key, [...groups.get(key)!].sort().join(', '));
576
+ const list = groups.get(key)!;
577
+ // Deterministic order: the file-level group first, then anchored groups by
578
+ // the anchor node's source line (document order).
579
+ list.sort((a, b) => {
580
+ if (a.node === null && b.node === null) return 0;
581
+ if (a.node === null) return -1;
582
+ if (b.node === null) return 1;
583
+ return a.node.line - b.node.line;
584
+ });
585
+ for (const g of list) g.fromFiles.sort();
586
+ out.set(key, list);
273
587
  }
274
588
  return out;
275
589
  }