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/commands/check.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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,
|
|
6
6
|
dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
|
|
7
7
|
} from '../core/fs.ts';
|
|
8
8
|
import {
|
|
9
|
-
parseOutline, extractBackPointers,
|
|
9
|
+
parseOutline, extractBackPointers, realNodes, maxDepth as outlineMaxDepth,
|
|
10
10
|
type ParseWarning,
|
|
11
11
|
} from '../core/outline.ts';
|
|
12
12
|
import { loadRules } from '../core/rules.ts';
|
|
@@ -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,10 +42,20 @@ 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*-->/;
|
|
55
|
+
// Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
|
|
56
|
+
// trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
|
|
57
|
+
// the outline.ts FENCE_RE convention; the two must never diverge.
|
|
58
|
+
const FENCE_RE = /^```/;
|
|
45
59
|
|
|
46
60
|
// globFiles throws ENOENT on missing dirs — guard the optional ones.
|
|
47
61
|
function safeActiveTasks(root: string): string[] {
|
|
@@ -52,6 +66,16 @@ function safeAdrs(root: string): string[] {
|
|
|
52
66
|
return dirExists(join(root, '_adr')) ? discoverAdrs(root) : [];
|
|
53
67
|
}
|
|
54
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
|
+
|
|
55
79
|
/** §20: route check's args through the shared parser — `--flag value` only,
|
|
56
80
|
* `[file]` is the sole positional. Unknown flags, short flags, `--flag=value`
|
|
57
81
|
* and extra positionals are user errors, never silently ignored. */
|
|
@@ -63,12 +87,28 @@ export function parseCheckArgs(args: string[]): CheckArgs & { errors: string[] }
|
|
|
63
87
|
if (positional.length > 1) {
|
|
64
88
|
errors.push(`unexpected argument "${positional[1]}" — check takes a single optional [file]`);
|
|
65
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
|
+
}
|
|
66
105
|
return {
|
|
67
106
|
fix: parsed.flags.has('fix'),
|
|
68
107
|
strict: parsed.flags.has('strict'),
|
|
69
108
|
refsOnly: parsed.flags.has('refs-only'),
|
|
70
109
|
noRedundancy: parsed.flags.has('no-redundancy'),
|
|
71
110
|
json: parsed.flags.has('json'),
|
|
111
|
+
show,
|
|
72
112
|
file,
|
|
73
113
|
errors,
|
|
74
114
|
};
|
|
@@ -85,17 +125,20 @@ function zeroedCounts(): Omit<CheckResult, 'ok' | 'command' | 'exitCode'> {
|
|
|
85
125
|
errorCount: 0,
|
|
86
126
|
warningCount: 0,
|
|
87
127
|
backPointersUpdated: 0,
|
|
128
|
+
backPointersUpdatedFiles: [],
|
|
129
|
+
elapsedMs: 0, // issue #41: the static failure paths never ran a check
|
|
88
130
|
};
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
/** §37: check-level failure (no workspace, invalid rules, unknown flag, file
|
|
92
134
|
* filter matched nothing). The diagnosis rides in `error` so the human printer
|
|
93
|
-
* 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. */
|
|
94
137
|
function checkFail(message: string): CheckResult & { error: string } {
|
|
95
138
|
return {
|
|
96
139
|
ok: false,
|
|
97
140
|
command: 'check',
|
|
98
|
-
exitCode:
|
|
141
|
+
exitCode: 2, // issue #41: usage/no-workspace/invalid-rules are the error class
|
|
99
142
|
...zeroedCounts(),
|
|
100
143
|
issues: [{ file: '', line: 0, level: 'error', category: 'refs', message }],
|
|
101
144
|
errorCount: 1,
|
|
@@ -111,42 +154,268 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
|
|
|
111
154
|
return null;
|
|
112
155
|
}
|
|
113
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
|
+
|
|
189
|
+
/** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
|
|
190
|
+
* keeping the surrounding prose. Collapses the double space left where the
|
|
191
|
+
* comment sat (one seam space absorbed) and trims trailing whitespace. The
|
|
192
|
+
* caller drops the line only when nothing but the comment remains. */
|
|
193
|
+
function stripRefByKeepProse(raw: string): string {
|
|
194
|
+
const m = raw.match(REF_BY_RE);
|
|
195
|
+
if (m === null || m.index === undefined) return raw;
|
|
196
|
+
let out = raw.slice(0, m.index) + raw.slice(m.index + m[0].length);
|
|
197
|
+
if (out.charAt(m.index - 1) === ' ' && out.charAt(m.index) === ' ') {
|
|
198
|
+
out = out.slice(0, m.index - 1) + out.slice(m.index);
|
|
199
|
+
}
|
|
200
|
+
return out.replace(/[ \t]+$/, '');
|
|
201
|
+
}
|
|
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
|
+
|
|
114
251
|
/** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
252
|
+
* Fence-aware, prose-preserving, anchor-aware, EOL-preserving
|
|
253
|
+
* (issues #6 + #19 + #21):
|
|
254
|
+
* - Lines inside ``` fences (and the fence markers themselves) are preserved
|
|
255
|
+
* byte-for-byte: never scanned as hits, never rewritten, never dropped, and
|
|
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';
|
|
119
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
|
+
|
|
120
312
|
const hits: number[] = [];
|
|
313
|
+
const dropped = new Set<number>(); // issue #6: bare non-bullet comment lines vanish
|
|
314
|
+
let fenceOpen = false;
|
|
315
|
+
let fenceOpener = -1;
|
|
316
|
+
const inFence: boolean[] = new Array(lines.length).fill(false);
|
|
121
317
|
for (let i = 0; i < lines.length; i++) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (j === 0 && comment !== null) {
|
|
129
|
-
lines[i] = raw.replace(REF_BY_RE, comment);
|
|
318
|
+
const raw = lines[i]!.text;
|
|
319
|
+
if (FENCE_RE.test(raw.trim())) {
|
|
320
|
+
// Fence marker: toggles state; never a hit, never an anchor, never touched.
|
|
321
|
+
inFence[i] = true;
|
|
322
|
+
if (fenceOpen) {
|
|
323
|
+
fenceOpen = false;
|
|
130
324
|
} else {
|
|
131
|
-
|
|
132
|
-
|
|
325
|
+
fenceOpen = true;
|
|
326
|
+
fenceOpener = i;
|
|
133
327
|
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
inFence[i] = fenceOpen;
|
|
331
|
+
if (fenceOpen) continue;
|
|
332
|
+
if (REF_BY_RE.test(raw)) hits.push(i);
|
|
333
|
+
}
|
|
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;
|
|
134
371
|
}
|
|
135
|
-
}
|
|
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) {
|
|
136
389
|
let insertAt = lines.length;
|
|
137
390
|
for (let i = 0; i < lines.length; i++) {
|
|
138
|
-
if (
|
|
391
|
+
if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
|
|
392
|
+
if (!dropped.has(i) && /^- /.test(lines[i]!.text)) {
|
|
139
393
|
insertAt = i + 1;
|
|
140
394
|
break;
|
|
141
395
|
}
|
|
142
396
|
}
|
|
143
|
-
|
|
397
|
+
// Issue #6: never insert inside an open fence — appending at EOF while a
|
|
398
|
+
// fence is unterminated would corrupt the fenced region.
|
|
399
|
+
if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
|
|
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
|
+
}
|
|
144
411
|
}
|
|
145
|
-
|
|
412
|
+
const kept: SourceLine[] = lines.filter((_, i) => !dropped.has(i));
|
|
413
|
+
return joinSourceLines(kept);
|
|
146
414
|
}
|
|
147
415
|
|
|
148
416
|
/** The shared engine orchestrator used by `cans check` and `cans done`. */
|
|
149
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)
|
|
150
419
|
let rules;
|
|
151
420
|
try {
|
|
152
421
|
rules = loadRules(root);
|
|
@@ -163,6 +432,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
163
432
|
file: name, line: 0, level: 'warning', category: 'structure',
|
|
164
433
|
message: `malformed workspace entry: directory "${name}" looks like a spec file — rename it or use folder mode (${name.replace(/\.md$/, '')}/index.md)`,
|
|
165
434
|
suggestion: `remove or rename the directory cans/${name}`,
|
|
435
|
+
rule: 'structure.malformed_dir', // issue #41
|
|
166
436
|
});
|
|
167
437
|
}
|
|
168
438
|
|
|
@@ -172,6 +442,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
172
442
|
file: flat, line: 0, level: 'error', category: 'structure',
|
|
173
443
|
message: `duplicate home: both ${flat} and ${folder} exist — flat wins, remove the folder`,
|
|
174
444
|
suggestion: `delete ${folder} (or merge its content into ${flat})`,
|
|
445
|
+
rule: 'structure.duplicate_home', // issue #41
|
|
175
446
|
});
|
|
176
447
|
}
|
|
177
448
|
|
|
@@ -187,6 +458,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
187
458
|
issues.push({
|
|
188
459
|
file: rel, line: 0, level: 'error', category: 'structure',
|
|
189
460
|
message: `unreadable spec file: ${e instanceof Error ? e.message : String(e)}`,
|
|
461
|
+
rule: 'io.unreadable', // issue #41
|
|
190
462
|
});
|
|
191
463
|
continue;
|
|
192
464
|
}
|
|
@@ -198,6 +470,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
198
470
|
issues.push({
|
|
199
471
|
file: rel, line: 0, level: 'error', category: 'structure',
|
|
200
472
|
message: `parse error: ${e instanceof Error ? e.message : String(e)}`,
|
|
473
|
+
rule: 'parse.error', // issue #41
|
|
201
474
|
});
|
|
202
475
|
}
|
|
203
476
|
// Odd (non-2-multiple) indentation silently re-parents nodes — surface it.
|
|
@@ -205,6 +478,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
205
478
|
issues.push({
|
|
206
479
|
file: rel, line: pw.line, level: 'warning', category: 'structure',
|
|
207
480
|
message: pw.message,
|
|
481
|
+
rule: 'parse.indent', // issue #41
|
|
208
482
|
});
|
|
209
483
|
}
|
|
210
484
|
}
|
|
@@ -276,20 +550,15 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
276
550
|
for (const [rel, source] of specSources) {
|
|
277
551
|
for (const bp of extractBackPointers(source, rel)) {
|
|
278
552
|
bpTotal++;
|
|
279
|
-
|
|
280
|
-
const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
|
|
281
|
-
const isCurrent =
|
|
282
|
-
fromKey !== null &&
|
|
283
|
-
fromRefs !== undefined &&
|
|
284
|
-
fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
|
|
285
|
-
if (isCurrent) {
|
|
553
|
+
if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
|
|
286
554
|
bpCurrent++;
|
|
287
555
|
} else {
|
|
288
556
|
bpStale++;
|
|
289
557
|
issues.push({
|
|
290
558
|
file: rel, line: bp.fromLine, level: 'warning', category: 'refs',
|
|
291
|
-
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}` : ''}`,
|
|
292
560
|
suggestion: 'remove the ref-by comment (or re-run cans check --fix)',
|
|
561
|
+
rule: 'refs.backpointer.stale', // issue #41
|
|
293
562
|
});
|
|
294
563
|
}
|
|
295
564
|
}
|
|
@@ -320,42 +589,69 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
320
589
|
// --fix: rewrite ref-by comments ONLY, in spec files ONLY.
|
|
321
590
|
// §18/§17: with the back-pointer check off (back_pointers false or deleted),
|
|
322
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.
|
|
323
600
|
let backPointersUpdated = 0;
|
|
601
|
+
const backPointersUpdatedFiles: string[] = [];
|
|
324
602
|
if (opts.fix && backPointersOn) {
|
|
325
603
|
const desired = rebuildBackPointers(allFiles, graph);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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);
|
|
329
622
|
if (rewritten !== source) {
|
|
330
623
|
await writeText(join(root, rel), rewritten);
|
|
331
624
|
specSources.set(rel, rewritten);
|
|
332
625
|
backPointersUpdated++;
|
|
626
|
+
backPointersUpdatedFiles.push(rel);
|
|
333
627
|
}
|
|
334
628
|
}
|
|
335
629
|
|
|
336
630
|
// §35 check-fix.json reports the POST-fix state: recompute back-pointer
|
|
337
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).
|
|
338
635
|
bpTotal = 0;
|
|
339
636
|
bpCurrent = 0;
|
|
340
637
|
bpStale = 0;
|
|
341
638
|
for (const [rel, source] of specSources) {
|
|
342
639
|
for (const bp of extractBackPointers(source, rel)) {
|
|
343
640
|
bpTotal++;
|
|
344
|
-
|
|
345
|
-
const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
|
|
346
|
-
const isCurrent =
|
|
347
|
-
fromKey !== null &&
|
|
348
|
-
fromRefs !== undefined &&
|
|
349
|
-
fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
|
|
350
|
-
if (isCurrent) {
|
|
641
|
+
if (backPointerIsCurrent(bp, rel, allFiles, graph)) {
|
|
351
642
|
bpCurrent++;
|
|
352
643
|
} else {
|
|
353
644
|
bpStale++;
|
|
354
645
|
}
|
|
355
646
|
}
|
|
356
647
|
}
|
|
648
|
+
const rewrittenSet = new Set(backPointersUpdatedFiles);
|
|
357
649
|
for (let i = issues.length - 1; i >= 0; i--) {
|
|
358
|
-
if (
|
|
650
|
+
if (
|
|
651
|
+
issues[i]!.category === 'refs' &&
|
|
652
|
+
issues[i]!.message.startsWith('stale back-pointer:') &&
|
|
653
|
+
rewrittenSet.has(issues[i]!.file)
|
|
654
|
+
) {
|
|
359
655
|
issues.splice(i, 1);
|
|
360
656
|
}
|
|
361
657
|
}
|
|
@@ -364,12 +660,24 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
364
660
|
let nodeCount = 0;
|
|
365
661
|
let depthMax = 0;
|
|
366
662
|
for (const nodes of specFiles.values()) {
|
|
367
|
-
|
|
663
|
+
// Issue #8: report REAL nodes only — synthetic "(table)"/"(code fence)"
|
|
664
|
+
// placeholders (leading table/fence before the first bullet) are not
|
|
665
|
+
// user content and inflated the header count and budget estimates.
|
|
666
|
+
nodeCount += realNodes(nodes).length;
|
|
667
|
+
// Depth keeps the full tree: a synthetic root sits at indent 0, so it can
|
|
668
|
+
// never raise depthMax; a workspace containing only phantoms now has
|
|
669
|
+
// nodeCount 0 and correctly reports maxDepth 0 (§35 empty-workspace case).
|
|
368
670
|
depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
|
|
369
671
|
}
|
|
370
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).
|
|
371
678
|
const broken = issues.filter(
|
|
372
|
-
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'),
|
|
373
681
|
).length;
|
|
374
682
|
|
|
375
683
|
const errorCount = issues.filter(i => i.level === 'error').length;
|
|
@@ -379,7 +687,9 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
379
687
|
return {
|
|
380
688
|
ok,
|
|
381
689
|
command: 'check',
|
|
382
|
-
|
|
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),
|
|
383
693
|
files: specFiles.size,
|
|
384
694
|
nodes: nodeCount,
|
|
385
695
|
// §35: maxDepth is 1-based (a 4-level chain reports 4); 0 for an empty workspace.
|
|
@@ -390,6 +700,12 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
390
700
|
errorCount,
|
|
391
701
|
warningCount,
|
|
392
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)),
|
|
393
709
|
// §22: fixed report order ends with a Rules section before the summary (QA-02 F17).
|
|
394
710
|
// §18 delete-key semantics: a deleted range key shows as "off", never a raw null.
|
|
395
711
|
rulesSummary:
|