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/README.md +15 -6
- package/bin/cans.js +87 -14
- package/bin/ts-loader.mjs +9 -2
- package/package.json +1 -1
- package/src/cli.ts +6 -1
- package/src/commands/budget.ts +87 -16
- package/src/commands/check.ts +313 -58
- package/src/commands/import.ts +216 -6
- package/src/core/fs.ts +31 -11
- package/src/core/outline.ts +17 -1
- package/src/core/output.ts +202 -57
- package/src/core/overflow.ts +4 -0
- package/src/core/redundancy.ts +5 -0
- package/src/core/refs.ts +197 -25
- package/src/core/report.ts +575 -0
- package/src/core/structure.ts +10 -0
- package/src/core/style.ts +2 -0
- package/src/core/token-budget.ts +34 -3
- package/src/types.ts +17 -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
|
|
|
@@ -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
|
-
|
|
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
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
-
|
|
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,
|
|
395
|
-
const groups = new Map<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
|
|
406
|
-
if (
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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,
|
|
574
|
+
const out = new Map<string, RefByGroup[]>();
|
|
413
575
|
for (const key of [...groups.keys()].sort()) {
|
|
414
|
-
|
|
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
|
}
|