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/README.md +15 -6
- package/bin/cans.js +87 -14
- package/bin/ts-loader.mjs +9 -2
- package/package.json +2 -2
- package/src/cli.ts +6 -1
- package/src/commands/budget.ts +87 -16
- package/src/commands/check.ts +361 -45
- package/src/commands/import.ts +216 -6
- package/src/core/fs.ts +31 -11
- package/src/core/outline.ts +62 -8
- package/src/core/output.ts +202 -57
- package/src/core/overflow.ts +4 -0
- package/src/core/redundancy.ts +63 -4
- package/src/core/refs.ts +356 -42
- package/src/core/report.ts +575 -0
- package/src/core/rules.ts +32 -5
- package/src/core/structure.ts +122 -57
- package/src/core/style.ts +78 -43
- package/src/core/token-budget.ts +41 -5
- package/src/types.ts +29 -1
- package/templates/_rules.yaml +1 -0
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
253
|
-
const groups = new Map<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
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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,
|
|
574
|
+
const out = new Map<string, RefByGroup[]>();
|
|
271
575
|
for (const key of [...groups.keys()].sort()) {
|
|
272
|
-
|
|
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
|
}
|