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.
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
 
@@ -85,6 +109,43 @@ export function buildRefGraph(
85
109
  return { forward, back };
86
110
  }
87
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
+
88
149
  export function checkRefs(
89
150
  files: Map<string, OutlineNode[]>,
90
151
  graph: RefGraph,
@@ -97,6 +158,7 @@ export function checkRefs(
97
158
  issues.push({
98
159
  file, line: ref.line, level: 'error', category: 'refs',
99
160
  message: `self-reference: ${file} → ${ref.file}`,
161
+ rule: 'refs.self', // issue #41: machine-readable rule key
100
162
  suggestion: 'remove the self-reference; point at the canonical file instead',
101
163
  });
102
164
  continue;
@@ -105,6 +167,7 @@ export function checkRefs(
105
167
  issues.push({
106
168
  file, line: ref.line, level: 'warning', category: 'refs',
107
169
  message: `transient ref: see ${ref.file} — _tasks/ files are transient, not spec`,
170
+ rule: 'refs.transient', // issue #41: machine-readable rule key
108
171
  suggestion: 're-point at a spec file when the task lands',
109
172
  });
110
173
  continue;
@@ -113,6 +176,7 @@ export function checkRefs(
113
176
  issues.push({
114
177
  file, line: ref.line, level: 'error', category: 'refs',
115
178
  message: `ref to _collab/: see ${ref.file} — collab notes are not spec`,
179
+ rule: 'refs.collab', // issue #41: machine-readable rule key
116
180
  suggestion: 'move the content into a spec file and ref that',
117
181
  });
118
182
  continue;
@@ -120,6 +184,22 @@ export function checkRefs(
120
184
 
121
185
  const key = loadedKeyFor(files, root, ref.file);
122
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
+ }
123
203
  // §12 edge cases: "File not found → Broken ref error." There is NO
124
204
  // span/direction exemption — forward or backward, inside or outside the
125
205
  // loaded numeric span, a missing file is always a level:error broken
@@ -138,12 +218,14 @@ export function checkRefs(
138
218
  issues.push({
139
219
  file, line: ref.line, level: 'error', category: 'refs',
140
220
  message: `broken ref: see ${ref.file} — file not found`,
141
- suggestion: `create ${ref.file} or fix the ref target`,
221
+ rule: 'refs.broken.file', // issue #41: machine-readable rule key
222
+ suggestion: brokenRefSuggestion(root, ref.file),
142
223
  });
143
224
  } else {
144
225
  issues.push({
145
226
  file, line: ref.line, level: 'warning', category: 'refs',
146
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
147
229
  suggestion: 'use "see: <file>.md" (or "see: <file>.md#<anchor>") to link a spec file, or reword the sentence',
148
230
  });
149
231
  }
@@ -174,6 +256,7 @@ export function checkRefs(
174
256
  issues.push({
175
257
  file, line: ref.line, level: 'error', category: 'refs',
176
258
  message: `broken anchor: ${ref.file}#${anchor} — no node matches`,
259
+ rule: 'refs.broken.anchor', // issue #41: machine-readable rule key
177
260
  suggestion: `fix the anchor or add a "${anchor}" node to ${ref.file}`,
178
261
  });
179
262
  }
@@ -205,12 +288,28 @@ export function checkRefs(
205
288
  * making symmetric graphs flag asymmetrically depending on iteration
206
289
  * order); only confirmed saturations at maxHops are cached, and those hold
207
290
  * for every caller. Hop count above maxHops flags b.
208
- * - The suggested fix (`add "see: <out>" directly to <from>`, where from is
209
- * the deepest direct referrer of b, ties broken by file key sort so output
210
- * is stable) is never a self-reference: from ≠ b holds because incoming
211
- * lists exclude self, and from = out would place from, b and out in one
212
- * SCC — guarded defensively anyway, so the advice can never convert a
213
- * deep-hop error into a checkRefs self-reference error.
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).
214
313
  * - §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
215
314
  * skipped entirely. */
216
315
  export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Issue[] {
@@ -351,10 +450,40 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
351
450
  // put from, b and out in one SCC. Skip rather than emit a broken fix.
352
451
  if (from === b || from === out.file) continue;
353
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
+ : '';
354
480
  issues.push({
355
481
  file: b, line: out.line, level: 'error', category: 'refs',
356
482
  message: `DEEP HOP: ${from} → ${b} → ${out.file}`,
357
- 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}`,
358
487
  });
359
488
  }
360
489
  return issues;
@@ -382,17 +511,34 @@ export function detectOrphans(
382
511
  issues.push({
383
512
  file: key, line: 0, level: 'warning', category: 'refs',
384
513
  message: `orphan: ${key} has no incoming or outgoing refs`,
514
+ rule: 'refs.orphan', // issue #41: machine-readable rule key
385
515
  suggestion: 'link it from a related spec file, or fold it into one',
386
516
  });
387
517
  }
388
518
  return issues;
389
519
  }
390
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
+
391
537
  export function rebuildBackPointers(
392
538
  files: Map<string, OutlineNode[]>,
393
539
  graph: RefGraph,
394
- ): Map<string, string> {
395
- const groups = new Map<string, Set<string>>();
540
+ ): Map<string, RefByGroup[]> {
541
+ const groups = new Map<string, RefByGroup[]>();
396
542
  for (const bp of graph.back) {
397
543
  let target: string | null = null;
398
544
  for (const key of files.keys()) {
@@ -402,16 +548,42 @@ export function rebuildBackPointers(
402
548
  }
403
549
  }
404
550
  const name = target ?? bp.toFile;
405
- let set = groups.get(name);
406
- if (set === undefined) {
407
- set = new Set<string>();
408
- 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);
409
566
  }
410
- 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);
411
573
  }
412
- const out = new Map<string, string>();
574
+ const out = new Map<string, RefByGroup[]>();
413
575
  for (const key of [...groups.keys()].sort()) {
414
- 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);
415
587
  }
416
588
  return out;
417
589
  }