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/commands/check.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
|
-
import type { CheckResult, Issue, OutlineNode } from '../types.ts';
|
|
2
|
+
import type { BackPointer, CheckResult, Issue, OutlineNode } from '../types.ts';
|
|
3
3
|
import { readText, writeText } from '../core/runtime.ts';
|
|
4
4
|
import {
|
|
5
5
|
discoverSpecFiles, discoverActiveTasks, discoverAdrs, resolveWorkspaceRoot,
|
|
@@ -16,7 +16,7 @@ import { checkOverflow, checkNoChaining } from '../core/overflow.ts';
|
|
|
16
16
|
import { checkRedundancy } from '../core/redundancy.ts';
|
|
17
17
|
import {
|
|
18
18
|
buildRefGraph, checkRefs, detectDeepHops, detectOrphans,
|
|
19
|
-
rebuildBackPointers, targetMatchesKey,
|
|
19
|
+
rebuildBackPointers, targetMatchesKey, anchorMatches, type RefByGroup, type RefGraph,
|
|
20
20
|
} from '../core/refs.ts';
|
|
21
21
|
import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args.ts';
|
|
22
22
|
|
|
@@ -27,6 +27,10 @@ export interface CheckArgs {
|
|
|
27
27
|
noRedundancy: boolean;
|
|
28
28
|
file: string | null;
|
|
29
29
|
json: boolean;
|
|
30
|
+
/** issue #41: sections to render unfolded (`--show <section[,section]>`).
|
|
31
|
+
* Emission-time concern only — the engine result is identical either way.
|
|
32
|
+
* Optional: internal callers (done.ts's ZERO_CHECK_ARGS) never set it. */
|
|
33
|
+
show?: string[];
|
|
30
34
|
/** §24 (done): the archiving task's parsed nodes, injected under their
|
|
31
35
|
* former `_tasks/<name>.md` identity so refs held by the archived task
|
|
32
36
|
* still count for the back-pointer rebuild. Never set by `check` itself. */
|
|
@@ -38,9 +42,15 @@ const CHECK_FLAGS: FlagSpec[] = [
|
|
|
38
42
|
{ name: 'strict', boolean: true },
|
|
39
43
|
{ name: 'refs-only', boolean: true },
|
|
40
44
|
{ name: 'no-redundancy', boolean: true },
|
|
45
|
+
{ name: 'show', boolean: false },
|
|
41
46
|
{ name: 'json', boolean: true },
|
|
42
47
|
];
|
|
43
48
|
|
|
49
|
+
/** issue #41: user-facing --show targets. `all` unfolds every section. */
|
|
50
|
+
const SHOW_SECTIONS = new Set([
|
|
51
|
+
'structure', 'style', 'refs', 'redundancy', 'overflow', 'parse', 'content', 'io', 'other', 'all',
|
|
52
|
+
]);
|
|
53
|
+
|
|
44
54
|
const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
|
|
45
55
|
// Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
|
|
46
56
|
// trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
|
|
@@ -56,6 +66,16 @@ function safeAdrs(root: string): string[] {
|
|
|
56
66
|
return dirExists(join(root, '_adr')) ? discoverAdrs(root) : [];
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/** issue #41: which sections `--show` unfolds — tolerant parse for the EMIT
|
|
70
|
+
* side (cli.ts). Strict validation lives in parseCheckArgs (usage errors); a
|
|
71
|
+
* failing run prints its diagnosis instead of a report, so the printer never
|
|
72
|
+
* needs the show set in that case. */
|
|
73
|
+
export function showSectionsFromArgs(args: string[]): Set<string> {
|
|
74
|
+
const raw = parseArgs(args, CHECK_FLAGS).flags.get('show');
|
|
75
|
+
if (typeof raw !== 'string') return new Set();
|
|
76
|
+
return new Set(raw.split(',').map(s => s.trim().toLowerCase()).filter(s => SHOW_SECTIONS.has(s)));
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
/** §20: route check's args through the shared parser — `--flag value` only,
|
|
60
80
|
* `[file]` is the sole positional. Unknown flags, short flags, `--flag=value`
|
|
61
81
|
* and extra positionals are user errors, never silently ignored. */
|
|
@@ -67,12 +87,28 @@ export function parseCheckArgs(args: string[]): CheckArgs & { errors: string[] }
|
|
|
67
87
|
if (positional.length > 1) {
|
|
68
88
|
errors.push(`unexpected argument "${positional[1]}" — check takes a single optional [file]`);
|
|
69
89
|
}
|
|
90
|
+
// issue #41: --show takes a comma-separated section list; unknown names are
|
|
91
|
+
// user errors, never silently ignored (§20 contract).
|
|
92
|
+
const show: string[] = [];
|
|
93
|
+
const rawShow = parsed.flags.get('show');
|
|
94
|
+
if (typeof rawShow === 'string') {
|
|
95
|
+
for (const part of rawShow.split(',')) {
|
|
96
|
+
const s = part.trim().toLowerCase();
|
|
97
|
+
if (s === '') continue;
|
|
98
|
+
if (!SHOW_SECTIONS.has(s)) {
|
|
99
|
+
errors.push(`unknown --show section "${s}" — use structure|style|refs|redundancy|overflow|all`);
|
|
100
|
+
} else {
|
|
101
|
+
show.push(s);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
70
105
|
return {
|
|
71
106
|
fix: parsed.flags.has('fix'),
|
|
72
107
|
strict: parsed.flags.has('strict'),
|
|
73
108
|
refsOnly: parsed.flags.has('refs-only'),
|
|
74
109
|
noRedundancy: parsed.flags.has('no-redundancy'),
|
|
75
110
|
json: parsed.flags.has('json'),
|
|
111
|
+
show,
|
|
76
112
|
file,
|
|
77
113
|
errors,
|
|
78
114
|
};
|
|
@@ -89,17 +125,20 @@ function zeroedCounts(): Omit<CheckResult, 'ok' | 'command' | 'exitCode'> {
|
|
|
89
125
|
errorCount: 0,
|
|
90
126
|
warningCount: 0,
|
|
91
127
|
backPointersUpdated: 0,
|
|
128
|
+
backPointersUpdatedFiles: [],
|
|
129
|
+
elapsedMs: 0, // issue #41: the static failure paths never ran a check
|
|
92
130
|
};
|
|
93
131
|
}
|
|
94
132
|
|
|
95
133
|
/** §37: check-level failure (no workspace, invalid rules, unknown flag, file
|
|
96
134
|
* filter matched nothing). The diagnosis rides in `error` so the human printer
|
|
97
|
-
* can show it standalone — never inside a report-shaped body.
|
|
135
|
+
* can show it standalone — never inside a report-shaped body.
|
|
136
|
+
* issue #41: the check could not run = error class → exitCode 2. */
|
|
98
137
|
function checkFail(message: string): CheckResult & { error: string } {
|
|
99
138
|
return {
|
|
100
139
|
ok: false,
|
|
101
140
|
command: 'check',
|
|
102
|
-
exitCode:
|
|
141
|
+
exitCode: 2, // issue #41: usage/no-workspace/invalid-rules are the error class
|
|
103
142
|
...zeroedCounts(),
|
|
104
143
|
issues: [{ file: '', line: 0, level: 'error', category: 'refs', message }],
|
|
105
144
|
errorCount: 1,
|
|
@@ -115,6 +154,38 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
|
|
|
115
154
|
return null;
|
|
116
155
|
}
|
|
117
156
|
|
|
157
|
+
/** Issue #19: is a `<!-- ref-by: ... -->` comment still earned?
|
|
158
|
+
* The comment's form (recorded by extractBackPointers in toAnchor) defines
|
|
159
|
+
* what it answers:
|
|
160
|
+
* - A STANDALONE comment (own line, toAnchor null) answers "who refs this
|
|
161
|
+
* file?" — current only while the referrer still holds a FILE-LEVEL ref
|
|
162
|
+
* here. An anchored ref no longer satisfies it: the mark must sit on the
|
|
163
|
+
* node the ref names.
|
|
164
|
+
* - An INLINE comment on node X (toAnchor = X's text) answers "who refs this
|
|
165
|
+
* node?" — current while the referrer's anchor resolves to X, or while it
|
|
166
|
+
* refs the file itself (a file-level ref satisfies any mark in the file:
|
|
167
|
+
* the mark is at least as precise as the ref, which keeps issue #6's
|
|
168
|
+
* replace-in-place contract convergent).
|
|
169
|
+
* Consequences pinned by issue #19: retargeting the anchor (#Sessions →
|
|
170
|
+
* #Passwords) makes the old mark stale; a broken anchor can never match any
|
|
171
|
+
* node, so it can never read as current. */
|
|
172
|
+
function backPointerIsCurrent(
|
|
173
|
+
bp: BackPointer,
|
|
174
|
+
rel: string,
|
|
175
|
+
allFiles: Map<string, OutlineNode[]>,
|
|
176
|
+
graph: RefGraph,
|
|
177
|
+
): boolean {
|
|
178
|
+
const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
|
|
179
|
+
if (fromKey === null) return false;
|
|
180
|
+
const fromRefs = graph.forward.get(fromKey);
|
|
181
|
+
if (fromRefs === undefined) return false;
|
|
182
|
+
return fromRefs.some(t => {
|
|
183
|
+
if (refTargetKey(t.file, allFiles.keys()) !== rel) return false;
|
|
184
|
+
if (bp.toAnchor === null) return t.anchor === null;
|
|
185
|
+
return t.anchor === null || anchorMatches(bp.toAnchor, t.anchor);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
118
189
|
/** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
|
|
119
190
|
* keeping the surrounding prose. Collapses the double space left where the
|
|
120
191
|
* comment sat (one seam space absorbed) and trims trailing whitespace. The
|
|
@@ -129,28 +200,122 @@ function stripRefByKeepProse(raw: string): string {
|
|
|
129
200
|
return out.replace(/[ \t]+$/, '');
|
|
130
201
|
}
|
|
131
202
|
|
|
203
|
+
/** Issue #19: one anchored ref-by placement — the comment body that belongs
|
|
204
|
+
* INLINE on the anchor node's bullet line (`line` is the node's 1-based
|
|
205
|
+
* source line from parseOutline; node lines are real bullets outside any
|
|
206
|
+
* fence by construction, so anchored marks can never land inside a fence). */
|
|
207
|
+
export interface RefByPlacement {
|
|
208
|
+
line: number;
|
|
209
|
+
body: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Issue #21: one source line, split off its terminator. `eol` records the
|
|
213
|
+
* bytes that ended the line ('\r\n' | '\n'); null only for a final line the
|
|
214
|
+
* file left unterminated (possibly the empty tail after a trailing newline).
|
|
215
|
+
* Splitting this way keeps every line's own terminator addressable so the
|
|
216
|
+
* rejoin is byte-preserving: replaced/stripped content never touches the
|
|
217
|
+
* terminator, dropped lines vanish with theirs, and only genuinely inserted
|
|
218
|
+
* lines mint a new one (the file's dominant EOL). Line indices are identical
|
|
219
|
+
* to normalizeEol + split('\n') — what parseOutline and extractBackPointers
|
|
220
|
+
* see — because '\r' is peeled off the same '\n' boundaries. */
|
|
221
|
+
interface SourceLine {
|
|
222
|
+
text: string;
|
|
223
|
+
eol: string | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function splitSourceLines(source: string): SourceLine[] {
|
|
227
|
+
const out: SourceLine[] = [];
|
|
228
|
+
let start = 0;
|
|
229
|
+
for (let i = 0; i < source.length; i++) {
|
|
230
|
+
if (source.charCodeAt(i) === 10 /* \n */) {
|
|
231
|
+
let end = i;
|
|
232
|
+
let eol = '\n';
|
|
233
|
+
if (end > start && source.charCodeAt(end - 1) === 13 /* \r */) {
|
|
234
|
+
end -= 1;
|
|
235
|
+
eol = '\r\n';
|
|
236
|
+
}
|
|
237
|
+
out.push({ text: source.slice(start, end), eol });
|
|
238
|
+
start = i + 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
out.push({ text: source.slice(start), eol: null }); // unterminated tail (may be '')
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function joinSourceLines(lines: SourceLine[]): string {
|
|
246
|
+
let out = '';
|
|
247
|
+
for (const l of lines) out += l.text + (l.eol ?? '');
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
132
251
|
/** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
|
|
133
|
-
* Fence-aware
|
|
252
|
+
* Fence-aware, prose-preserving, anchor-aware, EOL-preserving
|
|
253
|
+
* (issues #6 + #19 + #21):
|
|
134
254
|
* - Lines inside ``` fences (and the fence markers themselves) are preserved
|
|
135
255
|
* byte-for-byte: never scanned as hits, never rewritten, never dropped, and
|
|
136
|
-
* fenced "- fake bullets" are never insertion anchors.
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
|
|
256
|
+
* fenced "- fake bullets" are never insertion anchors. Anchored placements
|
|
257
|
+
* come from parsed outline nodes, which by construction never sit inside a
|
|
258
|
+
* fence — a fenced copy of the anchor node's text is never the anchor line.
|
|
259
|
+
* - Issue #19 placement: an ANCHORED ref's mark goes INLINE on the referenced
|
|
260
|
+
* node's bullet line (appended after one space, the §34 fixture convention
|
|
261
|
+
* `- Authentication <!-- ref-by: ... -->`); a FILE-LEVEL ref's mark keeps
|
|
262
|
+
* the issue #6 form — a standalone comment line right after the first root
|
|
263
|
+
* bullet outside any fence, appended at end when none exists — unless the
|
|
264
|
+
* file ends inside an unterminated fence, in which case the comment is
|
|
265
|
+
* inserted before the fence opener (never inside a fence).
|
|
266
|
+
* - Hits (comments outside fences) resolve per placement: an inline hit on an
|
|
267
|
+
* anchored placement line has its content REPLACED in place (form kept);
|
|
268
|
+
* the file-level body replaces the FIRST remaining hit's content wherever
|
|
269
|
+
* it sits (issue #6 contract); every other hit — stale, duplicate or
|
|
270
|
+
* misplaced — is stripped: bullets keep the bullet, non-bullet prose loses
|
|
271
|
+
* only the comment substring (the line is dropped only when bare).
|
|
272
|
+
* - Issue #21 byte preservation: content edits never touch a line's own
|
|
273
|
+
* terminator (a CRLF line stays CRLF through replace and strip; a dropped
|
|
274
|
+
* line disappears with its terminator). Only INSERTED lines mint a new
|
|
275
|
+
* terminator — the file's DOMINANT EOL — so a CRLF spec never acquires a
|
|
276
|
+
* bare-LF comment line, and an LF spec never acquires \r. Appending after
|
|
277
|
+
* an unterminated last line gives that line the dominant EOL as separator;
|
|
278
|
+
* the appended comment stays unterminated, exactly like a join would.
|
|
279
|
+
* Exported for regression tests (issues #6/#19/#21); behavior lives here. */
|
|
280
|
+
export function rewriteRefBy(source: string, body: string | null, anchored?: RefByPlacement[]): string {
|
|
281
|
+
const lines = splitSourceLines(source);
|
|
282
|
+
// Issue #21: the dominant terminator mints the EOL of inserted lines.
|
|
283
|
+
// Majority vote over the file's real terminators; a tie (or a file with no
|
|
284
|
+
// terminated lines) stays LF — the join default.
|
|
285
|
+
let crlf = 0;
|
|
286
|
+
let lf = 0;
|
|
287
|
+
for (const l of lines) {
|
|
288
|
+
if (l.eol === '\r\n') crlf++;
|
|
289
|
+
else if (l.eol === '\n') lf++;
|
|
290
|
+
}
|
|
291
|
+
const dominant = crlf > lf ? '\r\n' : '\n';
|
|
147
292
|
const comment = body !== null && body !== '' ? `<!-- ref-by: ${body} -->` : null;
|
|
293
|
+
// Merge anchored placements onto 0-based line indices (defensive: duplicate
|
|
294
|
+
// lines union their bodies). Out-of-range lines — pathological sources whose
|
|
295
|
+
// parse counted more lines than the raw split — clamp to the last line
|
|
296
|
+
// rather than silently dropping the mark.
|
|
297
|
+
const anchoredByLine = new Map<number, string[]>();
|
|
298
|
+
for (const p of anchored ?? []) {
|
|
299
|
+
if (p.body === '') continue;
|
|
300
|
+
const idx = Math.min(Math.max(p.line - 1, 0), lines.length - 1);
|
|
301
|
+
const list = anchoredByLine.get(idx) ?? [];
|
|
302
|
+
for (const entry of p.body.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
303
|
+
if (!list.includes(entry)) list.push(entry);
|
|
304
|
+
}
|
|
305
|
+
anchoredByLine.set(idx, list);
|
|
306
|
+
}
|
|
307
|
+
const anchoredBody = (idx: number): string | null => {
|
|
308
|
+
const list = anchoredByLine.get(idx);
|
|
309
|
+
return list !== undefined && list.length > 0 ? list.slice().sort().join(', ') : null;
|
|
310
|
+
};
|
|
311
|
+
|
|
148
312
|
const hits: number[] = [];
|
|
313
|
+
const dropped = new Set<number>(); // issue #6: bare non-bullet comment lines vanish
|
|
149
314
|
let fenceOpen = false;
|
|
150
315
|
let fenceOpener = -1;
|
|
151
316
|
const inFence: boolean[] = new Array(lines.length).fill(false);
|
|
152
317
|
for (let i = 0; i < lines.length; i++) {
|
|
153
|
-
const raw = lines[i]
|
|
318
|
+
const raw = lines[i]!.text;
|
|
154
319
|
if (FENCE_RE.test(raw.trim())) {
|
|
155
320
|
// Fence marker: toggles state; never a hit, never an anchor, never touched.
|
|
156
321
|
inFence[i] = true;
|
|
@@ -166,28 +331,65 @@ export function rewriteRefBy(source: string, body: string | null): string {
|
|
|
166
331
|
if (fenceOpen) continue;
|
|
167
332
|
if (REF_BY_RE.test(raw)) hits.push(i);
|
|
168
333
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
334
|
+
|
|
335
|
+
const replaced = new Set<number>(); // hits whose content was replaced in place
|
|
336
|
+
// 1. Anchored placements first: an inline hit ON an anchor node line is the
|
|
337
|
+
// mark for that node — replace its content, keep the inline form.
|
|
338
|
+
for (const i of hits) {
|
|
339
|
+
if (replaced.has(i)) continue;
|
|
340
|
+
const anchorBody = anchoredBody(i);
|
|
341
|
+
if (anchorBody !== null && /^\s*-\s/.test(lines[i]!.text)) {
|
|
342
|
+
lines[i]!.text = lines[i]!.text.replace(REF_BY_RE, `<!-- ref-by: ${anchorBody} -->`);
|
|
343
|
+
anchoredByLine.delete(i);
|
|
344
|
+
replaced.add(i);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// 2. File-level body: the first hit not claimed by an anchor placement keeps
|
|
348
|
+
// its position and form, content replaced (issue #6 contract).
|
|
349
|
+
let fileSlotUsed = false;
|
|
350
|
+
if (comment !== null) {
|
|
351
|
+
for (const i of hits) {
|
|
352
|
+
if (replaced.has(i)) continue;
|
|
353
|
+
lines[i]!.text = lines[i]!.text.replace(REF_BY_RE, comment);
|
|
354
|
+
replaced.add(i);
|
|
355
|
+
fileSlotUsed = true;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// 3. Every remaining hit is stale, duplicate or misplaced: strip it.
|
|
360
|
+
for (const i of hits) {
|
|
361
|
+
if (replaced.has(i)) continue;
|
|
362
|
+
const raw = lines[i]!.text;
|
|
363
|
+
const isBullet = /^\s*-\s/.test(raw);
|
|
364
|
+
if (isBullet) {
|
|
365
|
+
lines[i]!.text = raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '');
|
|
366
|
+
} else {
|
|
367
|
+
// Issue #6: never delete a prose line whole — strip the comment only.
|
|
368
|
+
const stripped = stripRefByKeepProse(raw);
|
|
369
|
+
if (stripped.trim() === '') dropped.add(i);
|
|
370
|
+
else lines[i]!.text = stripped;
|
|
185
371
|
}
|
|
186
|
-
}
|
|
372
|
+
}
|
|
373
|
+
// 4. Fresh anchored marks: append inline to the anchor node's bullet line
|
|
374
|
+
// (after one space). The line keeps its own terminator (issue #21).
|
|
375
|
+
for (const idx of [...anchoredByLine.keys()].sort((a, b) => a - b)) {
|
|
376
|
+
const anchorBody = anchoredBody(idx);
|
|
377
|
+
if (anchorBody === null) continue;
|
|
378
|
+
const target = lines[idx];
|
|
379
|
+
target.text = target.text === ''
|
|
380
|
+
? `<!-- ref-by: ${anchorBody} -->`
|
|
381
|
+
: `${target.text} <!-- ref-by: ${anchorBody} -->`;
|
|
382
|
+
}
|
|
383
|
+
// 5. Fresh file-level mark (only when no hit became the file-level slot):
|
|
384
|
+
// standalone line right after the first root bullet outside any fence,
|
|
385
|
+
// appended at end when none exists (issue #6) — never inside an
|
|
386
|
+
// unterminated fence. The inserted line is terminated with the file's
|
|
387
|
+
// dominant EOL (issue #21).
|
|
388
|
+
if (comment !== null && !fileSlotUsed) {
|
|
187
389
|
let insertAt = lines.length;
|
|
188
390
|
for (let i = 0; i < lines.length; i++) {
|
|
189
391
|
if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
|
|
190
|
-
if (/^- /.test(lines[i]
|
|
392
|
+
if (!dropped.has(i) && /^- /.test(lines[i]!.text)) {
|
|
191
393
|
insertAt = i + 1;
|
|
192
394
|
break;
|
|
193
395
|
}
|
|
@@ -195,13 +397,25 @@ export function rewriteRefBy(source: string, body: string | null): string {
|
|
|
195
397
|
// Issue #6: never insert inside an open fence — appending at EOF while a
|
|
196
398
|
// fence is unterminated would corrupt the fenced region.
|
|
197
399
|
if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
|
|
198
|
-
|
|
400
|
+
if (insertAt < lines.length) {
|
|
401
|
+
lines.splice(insertAt, 0, { text: comment, eol: dominant });
|
|
402
|
+
} else {
|
|
403
|
+
// Appending at EOF: the current last line (an unterminated tail) gains
|
|
404
|
+
// the dominant EOL as separator; the comment becomes the unterminated
|
|
405
|
+
// tail — byte-identical to what a plain join would produce, with the
|
|
406
|
+
// file's own dominant terminator instead of a hard-coded '\n'.
|
|
407
|
+
const last = lines[lines.length - 1]!;
|
|
408
|
+
if (last.eol === null) last.eol = dominant;
|
|
409
|
+
lines.push({ text: comment, eol: null });
|
|
410
|
+
}
|
|
199
411
|
}
|
|
200
|
-
|
|
412
|
+
const kept: SourceLine[] = lines.filter((_, i) => !dropped.has(i));
|
|
413
|
+
return joinSourceLines(kept);
|
|
201
414
|
}
|
|
202
415
|
|
|
203
416
|
/** The shared engine orchestrator used by `cans check` and `cans done`. */
|
|
204
417
|
export async function checkWorkspace(root: string, opts: CheckArgs): Promise<CheckResult> {
|
|
418
|
+
const t0 = performance.now(); // issue #41: elapsedMs timing (global in Bun + Node ≥16)
|
|
205
419
|
let rules;
|
|
206
420
|
try {
|
|
207
421
|
rules = loadRules(root);
|
|
@@ -218,6 +432,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
218
432
|
file: name, line: 0, level: 'warning', category: 'structure',
|
|
219
433
|
message: `malformed workspace entry: directory "${name}" looks like a spec file — rename it or use folder mode (${name.replace(/\.md$/, '')}/index.md)`,
|
|
220
434
|
suggestion: `remove or rename the directory cans/${name}`,
|
|
435
|
+
rule: 'structure.malformed_dir', // issue #41
|
|
221
436
|
});
|
|
222
437
|
}
|
|
223
438
|
|
|
@@ -227,6 +442,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
227
442
|
file: flat, line: 0, level: 'error', category: 'structure',
|
|
228
443
|
message: `duplicate home: both ${flat} and ${folder} exist — flat wins, remove the folder`,
|
|
229
444
|
suggestion: `delete ${folder} (or merge its content into ${flat})`,
|
|
445
|
+
rule: 'structure.duplicate_home', // issue #41
|
|
230
446
|
});
|
|
231
447
|
}
|
|
232
448
|
|
|
@@ -242,6 +458,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
242
458
|
issues.push({
|
|
243
459
|
file: rel, line: 0, level: 'error', category: 'structure',
|
|
244
460
|
message: `unreadable spec file: ${e instanceof Error ? e.message : String(e)}`,
|
|
461
|
+
rule: 'io.unreadable', // issue #41
|
|
245
462
|
});
|
|
246
463
|
continue;
|
|
247
464
|
}
|
|
@@ -253,6 +470,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
253
470
|
issues.push({
|
|
254
471
|
file: rel, line: 0, level: 'error', category: 'structure',
|
|
255
472
|
message: `parse error: ${e instanceof Error ? e.message : String(e)}`,
|
|
473
|
+
rule: 'parse.error', // issue #41
|
|
256
474
|
});
|
|
257
475
|
}
|
|
258
476
|
// Odd (non-2-multiple) indentation silently re-parents nodes — surface it.
|
|
@@ -260,6 +478,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
260
478
|
issues.push({
|
|
261
479
|
file: rel, line: pw.line, level: 'warning', category: 'structure',
|
|
262
480
|
message: pw.message,
|
|
481
|
+
rule: 'parse.indent', // issue #41
|
|
263
482
|
});
|
|
264
483
|
}
|
|
265
484
|
}
|
|
@@ -331,20 +550,15 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
331
550
|
for (const [rel, source] of specSources) {
|
|
332
551
|
for (const bp of extractBackPointers(source, rel)) {
|
|
333
552
|
bpTotal++;
|
|
334
|
-
|
|
335
|
-
const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
|
|
336
|
-
const isCurrent =
|
|
337
|
-
fromKey !== null &&
|
|
338
|
-
fromRefs !== undefined &&
|
|
339
|
-
fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
|
|
340
|
-
if (isCurrent) {
|
|
553
|
+
if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
|
|
341
554
|
bpCurrent++;
|
|
342
555
|
} else {
|
|
343
556
|
bpStale++;
|
|
344
557
|
issues.push({
|
|
345
558
|
file: rel, line: bp.fromLine, level: 'warning', category: 'refs',
|
|
346
|
-
message: `stale back-pointer: ${bp.fromFile} no longer refs ${rel}`,
|
|
559
|
+
message: `stale back-pointer: ${bp.fromFile} no longer refs ${rel}${bp.toAnchor !== null ? `#${bp.toAnchor}` : ''}`,
|
|
347
560
|
suggestion: 'remove the ref-by comment (or re-run cans check --fix)',
|
|
561
|
+
rule: 'refs.backpointer.stale', // issue #41
|
|
348
562
|
});
|
|
349
563
|
}
|
|
350
564
|
}
|
|
@@ -375,42 +589,69 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
375
589
|
// --fix: rewrite ref-by comments ONLY, in spec files ONLY.
|
|
376
590
|
// §18/§17: with the back-pointer check off (back_pointers false or deleted),
|
|
377
591
|
// --fix must not write anything — backPointersUpdated stays 0, no file touched.
|
|
592
|
+
// Issue #11 (round 6): a [file] filter scopes the WRITES to the filter-matched
|
|
593
|
+
// spec files. The desired-marks map is still computed from the GLOBAL ref
|
|
594
|
+
// graph (refs stay global by design) — only the writes are scoped, so a
|
|
595
|
+
// filtered run never mutates a file the user did not name. A referrer filter
|
|
596
|
+
// (e.g. 04-api.md) therefore leaves its TARGETS' marks untouched this run
|
|
597
|
+
// (targets are not filter-matched); the user re-runs with the target's
|
|
598
|
+
// filter, or unfiltered, to write them. `cans done` (file: null) and
|
|
599
|
+
// unfiltered runs rewrite every spec source, exactly as before.
|
|
378
600
|
let backPointersUpdated = 0;
|
|
601
|
+
const backPointersUpdatedFiles: string[] = [];
|
|
379
602
|
if (opts.fix && backPointersOn) {
|
|
380
603
|
const desired = rebuildBackPointers(allFiles, graph);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
604
|
+
// Issue #11: filtered run → only the checkable (filter-matched) spec
|
|
605
|
+
// files; unfiltered run (and `cans done`, which fixes with file: null) →
|
|
606
|
+
// every spec source.
|
|
607
|
+
const fixable: string[] = opts.file !== null ? checkable : [...specSources.keys()];
|
|
608
|
+
for (const rel of fixable) {
|
|
609
|
+
const source = specSources.get(rel);
|
|
610
|
+
if (source === undefined) continue;
|
|
611
|
+
// Issue #19: the desired marks are per (file, anchor). Anchored refs
|
|
612
|
+
// earn an INLINE mark on the anchor node's line; file-level refs keep
|
|
613
|
+
// the standalone after-first-root-bullet form (issue #6). Broken-anchor
|
|
614
|
+
// refs were dropped by rebuildBackPointers — nothing is written for
|
|
615
|
+
// them (they are already checkRefs errors).
|
|
616
|
+
const groups: RefByGroup[] = desired.get(rel) ?? [];
|
|
617
|
+
const fileBody = groups.find(g => g.node === null)?.fromFiles.join(', ') ?? null;
|
|
618
|
+
const anchored = groups
|
|
619
|
+
.filter(g => g.node !== null)
|
|
620
|
+
.map(g => ({ line: g.node!.line, body: g.fromFiles.join(', ') }));
|
|
621
|
+
const rewritten = rewriteRefBy(source, fileBody, anchored);
|
|
384
622
|
if (rewritten !== source) {
|
|
385
623
|
await writeText(join(root, rel), rewritten);
|
|
386
624
|
specSources.set(rel, rewritten);
|
|
387
625
|
backPointersUpdated++;
|
|
626
|
+
backPointersUpdatedFiles.push(rel);
|
|
388
627
|
}
|
|
389
628
|
}
|
|
390
629
|
|
|
391
630
|
// §35 check-fix.json reports the POST-fix state: recompute back-pointer
|
|
392
631
|
// counts from the rewritten sources and drop now-fixed stale issues.
|
|
632
|
+
// Issue #11: only a file the run ACTUALLY rewrote can have its stale
|
|
633
|
+
// warnings dropped — files outside a [file] filter keep theirs (the
|
|
634
|
+
// comment is still on disk, so it is still stale).
|
|
393
635
|
bpTotal = 0;
|
|
394
636
|
bpCurrent = 0;
|
|
395
637
|
bpStale = 0;
|
|
396
638
|
for (const [rel, source] of specSources) {
|
|
397
639
|
for (const bp of extractBackPointers(source, rel)) {
|
|
398
640
|
bpTotal++;
|
|
399
|
-
|
|
400
|
-
const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
|
|
401
|
-
const isCurrent =
|
|
402
|
-
fromKey !== null &&
|
|
403
|
-
fromRefs !== undefined &&
|
|
404
|
-
fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
|
|
405
|
-
if (isCurrent) {
|
|
641
|
+
if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
|
|
406
642
|
bpCurrent++;
|
|
407
643
|
} else {
|
|
408
644
|
bpStale++;
|
|
409
645
|
}
|
|
410
646
|
}
|
|
411
647
|
}
|
|
648
|
+
const rewrittenSet = new Set(backPointersUpdatedFiles);
|
|
412
649
|
for (let i = issues.length - 1; i >= 0; i--) {
|
|
413
|
-
if (
|
|
650
|
+
if (
|
|
651
|
+
issues[i]!.category === 'refs' &&
|
|
652
|
+
issues[i]!.message.startsWith('stale back-pointer:') &&
|
|
653
|
+
rewrittenSet.has(issues[i]!.file)
|
|
654
|
+
) {
|
|
414
655
|
issues.splice(i, 1);
|
|
415
656
|
}
|
|
416
657
|
}
|
|
@@ -429,8 +670,14 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
429
670
|
depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
|
|
430
671
|
}
|
|
431
672
|
const refsTotal = [...graph.forward.values()].reduce((a, ts) => a + ts.length, 0);
|
|
673
|
+
// Round 6 (QA-19 F40b): refs.broken is TRUTHFUL — broken ref targets AND
|
|
674
|
+
// broken anchors are both §12 broken-ref errors, so both count. (The old
|
|
675
|
+
// message-prefix filter left refs.broken: 0 beside a broken-anchor ERROR
|
|
676
|
+
// with errorCount 1 / ok:false — machine consumers filtered on the counter
|
|
677
|
+
// missed the error.) Rule keys are the stable vocabulary (issue #41).
|
|
432
678
|
const broken = issues.filter(
|
|
433
|
-
i => i.category === 'refs' && i.level === 'error'
|
|
679
|
+
i => i.category === 'refs' && i.level === 'error'
|
|
680
|
+
&& (i.rule === 'refs.broken.file' || i.rule === 'refs.broken.anchor'),
|
|
434
681
|
).length;
|
|
435
682
|
|
|
436
683
|
const errorCount = issues.filter(i => i.level === 'error').length;
|
|
@@ -440,7 +687,9 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
440
687
|
return {
|
|
441
688
|
ok,
|
|
442
689
|
command: 'check',
|
|
443
|
-
|
|
690
|
+
// issue #41: exit contract 0 clean · 1 warnings · 2 errors.
|
|
691
|
+
// strict affects `ok` only, never the exit code.
|
|
692
|
+
exitCode: errorCount > 0 ? 2 : (warningCount > 0 ? 1 : 0),
|
|
444
693
|
files: specFiles.size,
|
|
445
694
|
nodes: nodeCount,
|
|
446
695
|
// §35: maxDepth is 1-based (a 4-level chain reports 4); 0 for an empty workspace.
|
|
@@ -451,6 +700,12 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
451
700
|
errorCount,
|
|
452
701
|
warningCount,
|
|
453
702
|
backPointersUpdated,
|
|
703
|
+
// Issue #11: the report names the files --fix actually rewrote (sorted,
|
|
704
|
+
// spec-relative). Empty without --fix or when nothing needed a write;
|
|
705
|
+
// with a [file] filter only matching files can ever appear here.
|
|
706
|
+
backPointersUpdatedFiles: [...backPointersUpdatedFiles].sort(),
|
|
707
|
+
// issue #41: whole-ms wall-clock duration of the run (never negative).
|
|
708
|
+
elapsedMs: Math.max(0, Math.round(performance.now() - t0)),
|
|
454
709
|
// §22: fixed report order ends with a Rules section before the summary (QA-02 F17).
|
|
455
710
|
// §18 delete-key semantics: a deleted range key shows as "off", never a raw null.
|
|
456
711
|
rulesSummary:
|