cans-spec 0.1.2 → 0.3.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 +17 -5
- package/bin/cans.js +76 -0
- package/bin/ts-loader.mjs +34 -0
- package/package.json +8 -5
- package/src/cli.ts +19 -15
- package/src/commands/budget.ts +8 -7
- package/src/commands/check.ts +83 -21
- package/src/commands/done.ts +7 -6
- package/src/commands/export.ts +9 -8
- package/src/commands/import.ts +14 -13
- package/src/commands/init.ts +7 -6
- package/src/commands/new.ts +9 -8
- package/src/commands/status.ts +6 -5
- package/src/converters/index.ts +4 -4
- package/src/converters/logseq.ts +2 -2
- package/src/converters/obsidian.ts +2 -2
- package/src/converters/opml.ts +1 -1
- package/src/converters/shared.ts +1 -1
- package/src/core/fs.ts +3 -4
- package/src/core/index.ts +10 -10
- package/src/core/outline.ts +46 -8
- package/src/core/output.ts +1 -1
- package/src/core/overflow.ts +2 -2
- package/src/core/redundancy.ts +59 -5
- package/src/core/refs.ts +169 -27
- package/src/core/rules.ts +33 -6
- package/src/core/runtime.ts +110 -0
- package/src/core/structure.ts +113 -58
- package/src/core/style.ts +77 -44
- package/src/core/token-budget.ts +9 -4
- package/src/types.ts +12 -1
- package/templates/_rules.yaml +1 -0
package/src/core/refs.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types';
|
|
4
|
-
import { flattenNodes, parseOutline } from './outline';
|
|
5
|
-
import { resolveSpecFile, toRelative, isFile } from './fs';
|
|
3
|
+
import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types.ts';
|
|
4
|
+
import { flattenNodes, parseOutline } from './outline.ts';
|
|
5
|
+
import { resolveSpecFile, toRelative, isFile } from './fs.ts';
|
|
6
6
|
|
|
7
7
|
export interface RefGraph {
|
|
8
8
|
forward: Map<string, RefTarget[]>;
|
|
@@ -46,6 +46,25 @@ export function anchorMatches(nodeText: string, anchor: string): boolean {
|
|
|
46
46
|
return norm(nodeText) === norm(anchor);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Does an UNRESOLVED ref target look like an intended spec reference
|
|
50
|
+
* (broken-ref error territory) or like English prose that merely contains
|
|
51
|
+
* the word "see" (warning territory)? (issue #4)
|
|
52
|
+
*
|
|
53
|
+
* The §11 ref regex intentionally keeps minting refs from any `see <token>`
|
|
54
|
+
* prose ("see the runbook", "see below") so banner counts and deep-hop/orphan
|
|
55
|
+
* machinery stay centralized in the graph. Classification happens only at
|
|
56
|
+
* resolution time, in checkRefs: a target that itself looks spec-shaped keeps
|
|
57
|
+
* the documented broken-ref error; anything else is prose, not a dangling
|
|
58
|
+
* spec pointer. */
|
|
59
|
+
export function looksLikeSpecRef(target: string, hasAnchor: boolean): boolean {
|
|
60
|
+
if (hasAnchor) return true; // see X#anchor — explicit anchor intent
|
|
61
|
+
if (/\.md$/i.test(target)) return true; // explicit markdown target
|
|
62
|
+
if (/^_/.test(target)) return true; // workspace service dirs (_tasks/, _collab/, ...)
|
|
63
|
+
if (target.includes('/')) return true; // path-like
|
|
64
|
+
if (/^\d/.test(target)) return true; // numeric-prefix spec stems (02-auth)
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
export function buildRefGraph(
|
|
50
69
|
files: Map<string, OutlineNode[]>,
|
|
51
70
|
root: string,
|
|
@@ -106,11 +125,28 @@ export function checkRefs(
|
|
|
106
125
|
// loaded numeric span, a missing file is always a level:error broken
|
|
107
126
|
// ref. (The former "unwritten spec slot" backward in-span downgrade
|
|
108
127
|
// violated §12 and masked real holes as warnings — removed.)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
//
|
|
129
|
+
// Issue #4 prose exemption: that error contract applies to targets that
|
|
130
|
+
// THEMSELVES look like intended spec references (looksLikeSpecRef —
|
|
131
|
+
// anchored, .md, workspace-service-dir, path-like, or numeric-prefix).
|
|
132
|
+
// English prose that merely contains the word "see" ("see the runbook",
|
|
133
|
+
// "see below") mints a ref target that resolves to nothing and is not
|
|
134
|
+
// spec-shaped — downgraded to a see-like-prose warning so natural
|
|
135
|
+
// language no longer fails the run. Genuinely malformed real refs
|
|
136
|
+
// (.md targets, anchors, paths, numeric stems) keep the exact error.
|
|
137
|
+
if (looksLikeSpecRef(ref.file, ref.anchor !== null)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
file, line: ref.line, level: 'error', category: 'refs',
|
|
140
|
+
message: `broken ref: see ${ref.file} — file not found`,
|
|
141
|
+
suggestion: `create ${ref.file} or fix the ref target`,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
issues.push({
|
|
145
|
+
file, line: ref.line, level: 'warning', category: 'refs',
|
|
146
|
+
message: `see-like prose: "see ${ref.file}" did not resolve to a spec file — rephrase or link explicitly`,
|
|
147
|
+
suggestion: 'use "see: <file>.md" (or "see: <file>.md#<anchor>") to link a spec file, or reword the sentence',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
114
150
|
continue;
|
|
115
151
|
}
|
|
116
152
|
|
|
@@ -150,10 +186,33 @@ export function checkRefs(
|
|
|
150
186
|
|
|
151
187
|
/** Deep-hop detection: a file that both receives refs and issues them extends
|
|
152
188
|
* the ref chain. `maxHops` (§18 references.max_hops, default 1) is the number
|
|
153
|
-
* of allowed hops
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
189
|
+
* of allowed hops.
|
|
190
|
+
*
|
|
191
|
+
* Semantics (issue #5):
|
|
192
|
+
* - Nodes are the loaded file keys; an edge a → b exists when a holds a see:
|
|
193
|
+
* ref resolving to loaded file b (targetMatchesKey, flat and folder
|
|
194
|
+
* layouts). Self-refs never form edges — checkRefs reports those.
|
|
195
|
+
* - Strongly-connected meshes (2-cycles, 3-cycles, any mutual back-reference
|
|
196
|
+
* cluster — the shape the engine's own redundancy guidance encourages) are
|
|
197
|
+
* collapsed via Tarjan's SCC. A ref from b back into b's own mesh is the
|
|
198
|
+
* documented back-reference pattern and is never a hop; only refs LEAVING
|
|
199
|
+
* b's mesh extend a chain, so a pure mesh can never be flagged.
|
|
200
|
+
* - For a file b with a mesh-exiting outgoing ref, the hop count is the
|
|
201
|
+
* longest chain of refs ending at b (counted over simple paths, so a mesh
|
|
202
|
+
* cannot poison the count) + 1 for b's outgoing edge. Chains are counted
|
|
203
|
+
* with a cycle-safe bounded search: nothing is memoized from a truncated
|
|
204
|
+
* traversal (the old depthOf cached values computed under its cycle guard,
|
|
205
|
+
* making symmetric graphs flag asymmetrically depending on iteration
|
|
206
|
+
* order); only confirmed saturations at maxHops are cached, and those hold
|
|
207
|
+
* for every caller. Hop count above maxHops flags b.
|
|
208
|
+
* - The suggested fix (`add "see: <out>" directly to <from>`, where from is
|
|
209
|
+
* the deepest direct referrer of b, ties broken by file key sort so output
|
|
210
|
+
* is stable) is never a self-reference: from ≠ b holds because incoming
|
|
211
|
+
* lists exclude self, and from = out would place from, b and out in one
|
|
212
|
+
* SCC — guarded defensively anyway, so the advice can never convert a
|
|
213
|
+
* deep-hop error into a checkRefs self-reference error.
|
|
214
|
+
* - §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
|
|
215
|
+
* skipped entirely. */
|
|
157
216
|
export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Issue[] {
|
|
158
217
|
if (maxHops === null) return [];
|
|
159
218
|
const issues: Issue[] = [];
|
|
@@ -175,31 +234,109 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
|
|
|
175
234
|
}
|
|
176
235
|
}
|
|
177
236
|
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
237
|
+
// Tarjan's SCC over exactly the edges `incoming` encodes (adjacency is
|
|
238
|
+
// derived from it, so classification and cycle detection cannot disagree).
|
|
239
|
+
// Recursive is fine: spec workspaces are tiny, and traversal depth is
|
|
240
|
+
// bounded by the file count either way.
|
|
241
|
+
const sccId = new Map<string, number>();
|
|
242
|
+
{
|
|
243
|
+
const adj = new Map<string, string[]>();
|
|
244
|
+
for (const [b, referrers] of incoming) {
|
|
245
|
+
for (const a of referrers) {
|
|
246
|
+
const list = adj.get(a) ?? [];
|
|
247
|
+
if (!list.includes(b)) list.push(b);
|
|
248
|
+
adj.set(a, list);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const index = new Map<string, number>();
|
|
252
|
+
const low = new Map<string, number>();
|
|
253
|
+
const onStack = new Set<string>();
|
|
254
|
+
const stack: string[] = [];
|
|
255
|
+
let counter = 0;
|
|
256
|
+
let components = 0;
|
|
257
|
+
const strongconnect = (v: string): void => {
|
|
258
|
+
index.set(v, counter);
|
|
259
|
+
low.set(v, counter);
|
|
260
|
+
counter += 1;
|
|
261
|
+
stack.push(v);
|
|
262
|
+
onStack.add(v);
|
|
263
|
+
for (const w of adj.get(v) ?? []) {
|
|
264
|
+
if (!index.has(w)) {
|
|
265
|
+
strongconnect(w);
|
|
266
|
+
low.set(v, Math.min(low.get(v)!, low.get(w)!));
|
|
267
|
+
} else if (onStack.has(w)) {
|
|
268
|
+
low.set(v, Math.min(low.get(v)!, index.get(w)!));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (low.get(v) === index.get(v)) {
|
|
272
|
+
let w: string;
|
|
273
|
+
do {
|
|
274
|
+
w = stack.pop()!;
|
|
275
|
+
onStack.delete(w);
|
|
276
|
+
sccId.set(w, components);
|
|
277
|
+
} while (w !== v);
|
|
278
|
+
components += 1;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
for (const v of [...keys].sort()) {
|
|
282
|
+
if (!index.has(v)) strongconnect(v);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// First loaded key a raw ref target matches — the same first-match loop the
|
|
287
|
+
// incoming map uses, so ref classification and edge building always agree.
|
|
288
|
+
const resolveKey = (name: string): string | null => {
|
|
289
|
+
for (const key of keys) {
|
|
290
|
+
if (targetMatchesKey(name, key)) return key;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// depth(x) = longest chain of refs ending at x, over simple paths (no file
|
|
296
|
+
// repeats), saturated at maxHops — the flag decision only ever needs to know
|
|
297
|
+
// whether the chain reaches maxHops. Values are computed per query with a
|
|
298
|
+
// fresh path-visited set; ONLY confirmed saturations are cached, and a
|
|
299
|
+
// saturation is a graph property that holds for every caller.
|
|
300
|
+
const saturated = new Set<string>();
|
|
301
|
+
const explore = (x: string, visited: Set<string>, len: number): number => {
|
|
302
|
+
let best = len;
|
|
303
|
+
if (best >= maxHops) return best;
|
|
304
|
+
for (const u of incoming.get(x) ?? []) {
|
|
305
|
+
if (visited.has(u)) continue;
|
|
306
|
+
visited.add(u);
|
|
307
|
+
best = Math.max(best, explore(u, visited, len + 1));
|
|
308
|
+
visited.delete(u);
|
|
309
|
+
if (best >= maxHops) return best;
|
|
310
|
+
}
|
|
311
|
+
return best;
|
|
312
|
+
};
|
|
181
313
|
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);
|
|
314
|
+
if (saturated.has(x)) return maxHops;
|
|
315
|
+
const d = explore(x, new Set([x]), 0);
|
|
316
|
+
if (d >= maxHops) {
|
|
317
|
+
saturated.add(x);
|
|
318
|
+
return maxHops;
|
|
190
319
|
}
|
|
191
|
-
visiting.delete(x);
|
|
192
|
-
depth.set(x, d);
|
|
193
320
|
return d;
|
|
194
321
|
};
|
|
195
322
|
|
|
196
323
|
for (const [b, outTargets] of graph.forward) {
|
|
197
|
-
const
|
|
324
|
+
const bScc = sccId.get(b);
|
|
325
|
+
if (bScc === undefined) continue; // unreachable: every key is a Tarjan node
|
|
326
|
+
const outgoing = outTargets.filter(r => {
|
|
327
|
+
if (r.file === b) return false; // self-reference, as before
|
|
328
|
+
const t = resolveKey(r.file);
|
|
329
|
+
if (t !== null && sccId.get(t) === bScc) return false; // back-ref into b's own mesh
|
|
330
|
+
return true;
|
|
331
|
+
});
|
|
198
332
|
if (outgoing.length === 0) continue;
|
|
199
333
|
if (depthOf(b) + 1 <= maxHops) continue;
|
|
334
|
+
// Deepest direct referrer names the chain; ties break by file key sort so
|
|
335
|
+
// the output never depends on map iteration order.
|
|
336
|
+
const referrers = (incoming.get(b) ?? []).slice().sort();
|
|
200
337
|
let from: string | null = null;
|
|
201
338
|
let best = -1;
|
|
202
|
-
for (const a of
|
|
339
|
+
for (const a of referrers) {
|
|
203
340
|
const d = depthOf(a);
|
|
204
341
|
if (d > best) {
|
|
205
342
|
best = d;
|
|
@@ -208,6 +345,11 @@ export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Iss
|
|
|
208
345
|
}
|
|
209
346
|
if (from === null) continue;
|
|
210
347
|
const out = outgoing[0];
|
|
348
|
+
// Defensive invariant (issue #5 defect 2): the suggested fix must never be
|
|
349
|
+
// a self-reference — checkRefs rejects those, so the advice would turn one
|
|
350
|
+
// error into another. from ≠ b holds by construction; from = out would
|
|
351
|
+
// put from, b and out in one SCC. Skip rather than emit a broken fix.
|
|
352
|
+
if (from === b || from === out.file) continue;
|
|
211
353
|
const anchor = out.anchor !== null ? `#${out.anchor}` : '';
|
|
212
354
|
issues.push({
|
|
213
355
|
file: b, line: out.line, level: 'error', category: 'refs',
|
package/src/core/rules.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import type { Rules } from '../types';
|
|
3
|
+
import type { Rules } from '../types.ts';
|
|
4
4
|
|
|
5
5
|
interface YLine {
|
|
6
6
|
lineNo: number;
|
|
@@ -221,6 +221,7 @@ export function defaultRules(): Rules {
|
|
|
221
221
|
word_frequency_threshold: 4,
|
|
222
222
|
phrase_overlap_threshold: 0.7,
|
|
223
223
|
cross_file_threshold: 2,
|
|
224
|
+
fuzzy: true,
|
|
224
225
|
stopwords: ['the', 'a', 'an', 'of', 'to', 'in', 'for', 'and', 'or', 'with', 'must', 'shall', 'requires'],
|
|
225
226
|
synonyms: [
|
|
226
227
|
['postgres', 'postgresql', 'pg'],
|
|
@@ -298,6 +299,21 @@ function validateRulesShape(merged: Record<string, unknown>, source: string): vo
|
|
|
298
299
|
) {
|
|
299
300
|
throw new Error(`${at} — "${section}.${key}" must be a mapping like { min: 3, max: 120 }`);
|
|
300
301
|
}
|
|
302
|
+
// Issue #2: style.prefer / references.mode are validated reserved values.
|
|
303
|
+
// The merged object always carries the (valid) defaults, so an invalid
|
|
304
|
+
// value can only come from the user's _rules.yaml. `null` (an empty
|
|
305
|
+
// `prefer:` / `mode:`) is accepted and means the documented default; a
|
|
306
|
+
// non-string (e.g. `prefer: 42`) fails the same got-value convention.
|
|
307
|
+
if (section === 'style' && key === 'prefer') {
|
|
308
|
+
if (v !== null && (typeof v !== 'string' || (v !== 'sibling' && v !== 'nested'))) {
|
|
309
|
+
throw new Error(`${at} — "style.prefer" must be "sibling" or "nested", got "${String(v)}"`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (section === 'references' && key === 'mode') {
|
|
313
|
+
if (v !== null && (typeof v !== 'string' || v !== 'pointer')) {
|
|
314
|
+
throw new Error(`${at} — "references.mode" must be "pointer", got "${String(v)}"`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
301
317
|
}
|
|
302
318
|
}
|
|
303
319
|
}
|
|
@@ -307,7 +323,10 @@ function validateRulesShape(merged: Record<string, unknown>, source: string): vo
|
|
|
307
323
|
* token_budget.enabled/default_limit/estimate_chars_per_token) are listed too:
|
|
308
324
|
* they count towards a section's coverage but are never flipped OFF — omitted
|
|
309
325
|
* parameters keep their documented defaults (§18 overrides only what the file
|
|
310
|
-
* lists for them).
|
|
326
|
+
* lists for them). references.mode is additionally a VALIDATED reserved
|
|
327
|
+
* parameter: validateRulesShape rejects any value other than 'pointer' (or a
|
|
328
|
+
* deleted/empty null) with a line-numbered error, and style.prefer is
|
|
329
|
+
* validated the same way against 'sibling' | 'nested'. */
|
|
311
330
|
const SECTION_KEYS: Record<string, string[]> = {
|
|
312
331
|
structure: ['node_length', 'siblings', 'depth', 'single_child_collapse', 'empty_nodes'],
|
|
313
332
|
style: ['prefer', 'force_nested_above', 'force_sibling_below', 'shared_prefix_detection'],
|
|
@@ -318,6 +337,7 @@ const SECTION_KEYS: Record<string, string[]> = {
|
|
|
318
337
|
'word_frequency_threshold',
|
|
319
338
|
'phrase_overlap_threshold',
|
|
320
339
|
'cross_file_threshold',
|
|
340
|
+
'fuzzy',
|
|
321
341
|
'stopwords',
|
|
322
342
|
'synonyms',
|
|
323
343
|
],
|
|
@@ -387,7 +407,8 @@ export function loadRules(root: string): Rules {
|
|
|
387
407
|
// from its deep-merged default to its OFF state:
|
|
388
408
|
// boolean switch → false (single_child_collapse, empty_nodes, tbd_allowed,
|
|
389
409
|
// shared_prefix_detection, back_pointers,
|
|
390
|
-
// orphan_check, duplicate_home_check,
|
|
410
|
+
// orphan_check, duplicate_home_check,
|
|
411
|
+
// redundancy.enabled, redundancy.fuzzy;
|
|
391
412
|
// an explicit `false` stays false — same OFF result)
|
|
392
413
|
// mapping/numeric → null (node_length, siblings, depth, max_tbd_per_file,
|
|
393
414
|
// force_nested_above, force_sibling_below, max_hops,
|
|
@@ -498,9 +519,11 @@ export function loadRules(root: string): Rules {
|
|
|
498
519
|
}
|
|
499
520
|
|
|
500
521
|
// redundancy: enabled / word_frequency_threshold / phrase_overlap_threshold /
|
|
501
|
-
// cross_file_threshold. `stopwords`/`synonyms` are parameters (§13
|
|
502
|
-
// they keep their defaults when omitted so the remaining layers
|
|
503
|
-
// normalize text exactly as documented.
|
|
522
|
+
// cross_file_threshold / fuzzy. `stopwords`/`synonyms` are parameters (§13
|
|
523
|
+
// inputs) — they keep their defaults when omitted so the remaining layers
|
|
524
|
+
// still normalize text exactly as documented. `fuzzy` is layer 3's own check
|
|
525
|
+
// switch (issue #3): deleted → the near-miss layer alone turns OFF while
|
|
526
|
+
// layers 1/2/4 keep running.
|
|
504
527
|
if (!listed('redundancy')) {
|
|
505
528
|
if (deleteMode) {
|
|
506
529
|
rules.redundancy = {
|
|
@@ -509,6 +532,7 @@ export function loadRules(root: string): Rules {
|
|
|
509
532
|
word_frequency_threshold: null,
|
|
510
533
|
phrase_overlap_threshold: null,
|
|
511
534
|
cross_file_threshold: null,
|
|
535
|
+
fuzzy: false,
|
|
512
536
|
};
|
|
513
537
|
}
|
|
514
538
|
} else {
|
|
@@ -524,6 +548,9 @@ export function loadRules(root: string): Rules {
|
|
|
524
548
|
if (deleted('redundancy', 'cross_file_threshold')) {
|
|
525
549
|
rules.redundancy = { ...rules.redundancy, cross_file_threshold: null };
|
|
526
550
|
}
|
|
551
|
+
if (deleted('redundancy', 'fuzzy')) {
|
|
552
|
+
rules.redundancy = { ...rules.redundancy, fuzzy: false };
|
|
553
|
+
}
|
|
527
554
|
}
|
|
528
555
|
|
|
529
556
|
// token_budget: warn_threshold omitted (deleted) → null → no usage warning.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Runtime shim — the ONLY module allowed to touch `Bun.*` APIs (issue #12).
|
|
2
|
+
*
|
|
3
|
+
* Bun stays the primary runtime (fast path, behavior byte-identical to the
|
|
4
|
+
* pre-#12 code); Node.js ≥22.6 is the fallback (Termux / no-Bun harnesses).
|
|
5
|
+
* The Node path uses only `node:` builtins — the zero-dependency law holds on
|
|
6
|
+
* both runtimes. Anything outside this module that needs a runtime-sensitive
|
|
7
|
+
* operation MUST go through one of these exports; a repo self-audit test
|
|
8
|
+
* (test/node-fallback.test.ts) enforces `Bun\.` never appears elsewhere.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
/** Minimal structural type for the slice of the Bun global we use. Kept local
|
|
16
|
+
* so the shim also type-checks without relying on ambient `@types/bun`. */
|
|
17
|
+
interface BunGlobal {
|
|
18
|
+
file(path: string): { text(): Promise<string> };
|
|
19
|
+
write(path: string, data: string): Promise<number>;
|
|
20
|
+
Glob: new (pattern: string) => {
|
|
21
|
+
scanSync(opts: { cwd: string; onlyFiles: boolean }): Iterable<string>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BUN: BunGlobal | undefined = (globalThis as { Bun?: BunGlobal }).Bun;
|
|
26
|
+
|
|
27
|
+
/** Which runtime is executing: `'bun'` (primary) or `'node'` (fallback). */
|
|
28
|
+
export const RUNTIME: 'bun' | 'node' = BUN ? 'bun' : 'node';
|
|
29
|
+
|
|
30
|
+
/** CLI arguments, portable across runtimes (`Bun.argv` is an alias of
|
|
31
|
+
* `process.argv`, so the Node form works on both). */
|
|
32
|
+
export function argv(): string[] {
|
|
33
|
+
return process.argv.slice(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Directory of the module file that passed its `import.meta.url` — the
|
|
37
|
+
* portable replacement for Bun's `import.meta.dir` (Node has no equivalent
|
|
38
|
+
* property; both runtimes support `node:url.fileURLToPath`). */
|
|
39
|
+
export function dirFromUrl(moduleUrl: string): string {
|
|
40
|
+
return dirname(fileURLToPath(moduleUrl));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a UTF-8 text file. Bun: `Bun.file(path).text()` (unchanged fast path).
|
|
44
|
+
* Node: `fs/promises.readFile`. Rejects on missing files on both runtimes. */
|
|
45
|
+
export async function readText(path: string): Promise<string> {
|
|
46
|
+
if (BUN) return BUN.file(path).text();
|
|
47
|
+
return readFile(path, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write a UTF-8 text file, creating missing parent directories — `Bun.write`
|
|
51
|
+
* auto-creates parents, so the Node path replicates that (plain
|
|
52
|
+
* `writeFile` would not) to keep behavior identical across runtimes. */
|
|
53
|
+
export async function writeText(path: string, data: string): Promise<void> {
|
|
54
|
+
if (BUN) {
|
|
55
|
+
await BUN.write(path, data);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
await writeFile(path, data, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isDir(p: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
return statSync(p).isDirectory();
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Glob relative file paths under `dir`, sorted lexicographically — portable
|
|
71
|
+
* replacement for `new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })`.
|
|
72
|
+
* Supported forms: single-segment (`*.md`) and recursive (a two-star prefix
|
|
73
|
+
* followed by a slash, e.g. "**" + "/" + "*.md") wildcard patterns; `*` never
|
|
74
|
+
* crosses `/` (same as Bun.Glob). Returns paths relative to `dir`, sorted.
|
|
75
|
+
* Empty array when `dir` does not exist (unchanged contract used by
|
|
76
|
+
* `_tasks`/`_adr` discovery). */
|
|
77
|
+
export function globFiles(dir: string, pattern: string): string[] {
|
|
78
|
+
if (!isDir(dir)) return [];
|
|
79
|
+
if (BUN) {
|
|
80
|
+
const g = new BUN.Glob(pattern);
|
|
81
|
+
return [...g.scanSync({ cwd: dir, onlyFiles: true }) as Iterable<string>].sort();
|
|
82
|
+
}
|
|
83
|
+
const recursive = pattern.startsWith('**/');
|
|
84
|
+
const base = recursive ? pattern.slice(3) : pattern;
|
|
85
|
+
const re = new RegExp(
|
|
86
|
+
`^(?:${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')})$`,
|
|
87
|
+
);
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
if (recursive) {
|
|
90
|
+
const walk = (d: string, prefix: string): void => {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
const rel = prefix === '' ? e.name : `${prefix}/${e.name}`;
|
|
99
|
+
if (e.isFile() && re.test(e.name)) out.push(rel);
|
|
100
|
+
else if (e.isDirectory()) walk(join(d, e.name), rel);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
walk(dir, '');
|
|
104
|
+
} else {
|
|
105
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
106
|
+
if (e.isFile() && re.test(e.name)) out.push(e.name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out.sort();
|
|
110
|
+
}
|
package/src/core/structure.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types';
|
|
2
|
-
import { flattenNodes } from './outline';
|
|
1
|
+
import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types.ts';
|
|
2
|
+
import { flattenNodes, isSyntheticNode } from './outline.ts';
|
|
3
3
|
|
|
4
4
|
/** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
|
|
5
|
+
* Both sides of every range are enforced (issue #1 — siblings.min and depth.min
|
|
6
|
+
* were banner-checked but never enforced): node_length, siblings and depth each
|
|
7
|
+
* check their `min` (warning) as well as their `max`.
|
|
5
8
|
* §18 delete-key semantics: a check whose rules key is null/false is OFF — the
|
|
6
9
|
* check is skipped entirely (never compared against null, which would coerce
|
|
7
10
|
* to 0 and flag everything). */
|
|
@@ -14,75 +17,127 @@ export function checkStructure(
|
|
|
14
17
|
|
|
15
18
|
const walk = (list: OutlineNode[]): void => {
|
|
16
19
|
for (const node of list) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
20
|
+
// Issue #8: synthetic "(table)"/"(code fence)" placeholders are not user
|
|
21
|
+
// structure — never flagged themselves (their children, if any, still
|
|
22
|
+
// are: the walk recurses below regardless).
|
|
23
|
+
if (!isSyntheticNode(node)) {
|
|
24
|
+
const len = node.text.length;
|
|
25
|
+
const nl = rules.node_length;
|
|
26
|
+
if (nl !== null && nl.max !== null && len > nl.max) {
|
|
27
|
+
issues.push({
|
|
28
|
+
file,
|
|
29
|
+
line: node.line,
|
|
30
|
+
level: 'error',
|
|
31
|
+
category: 'structure',
|
|
32
|
+
message: `Node too long (${len} > ${nl.max}). Split or move to file.`,
|
|
33
|
+
});
|
|
34
|
+
} else if (nl !== null && nl.min !== null && len < nl.min) {
|
|
35
|
+
issues.push({
|
|
36
|
+
file,
|
|
37
|
+
line: node.line,
|
|
38
|
+
level: 'warning',
|
|
39
|
+
category: 'structure',
|
|
40
|
+
message: `Node too short (${len} < ${nl.min}).`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const depth = node.indent + 1;
|
|
45
|
+
const depthMax = rules.depth !== null ? rules.depth.max : null;
|
|
46
|
+
if (depthMax !== null && depth > depthMax) {
|
|
47
|
+
issues.push({
|
|
48
|
+
file,
|
|
49
|
+
line: node.line,
|
|
50
|
+
level: 'error',
|
|
51
|
+
category: 'structure',
|
|
52
|
+
message: `Depth ${depth} exceeds max ${depthMax}. Flatten.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
const count = node.children.length;
|
|
57
|
+
const siblingsMax = rules.siblings !== null ? rules.siblings.max : null;
|
|
58
|
+
if (siblingsMax !== null && count > siblingsMax) {
|
|
59
|
+
issues.push({
|
|
60
|
+
file,
|
|
61
|
+
line: node.line,
|
|
62
|
+
level: 'warning',
|
|
63
|
+
category: 'structure',
|
|
64
|
+
message: `"${node.text}" has ${count} children (max ${siblingsMax}).`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Issue #1: enforce siblings.min — a parent with 0 < count < min children
|
|
68
|
+
// is under the configured fan-out. Warning level, consistent with the
|
|
69
|
+
// siblings.max side above. The single_child_collapse advisory below is a
|
|
70
|
+
// separate check and may fire for the same node — that is acceptable.
|
|
71
|
+
const siblingsMin = rules.siblings !== null ? rules.siblings.min : null;
|
|
72
|
+
if (siblingsMin !== null && count > 0 && count < siblingsMin) {
|
|
73
|
+
issues.push({
|
|
74
|
+
file,
|
|
75
|
+
line: node.line,
|
|
76
|
+
level: 'warning',
|
|
77
|
+
category: 'structure',
|
|
78
|
+
message: `"${node.text}" has ${count} children (min ${siblingsMin}).`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
60
81
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
if (rules.single_child_collapse && count === 1) {
|
|
83
|
+
issues.push({
|
|
84
|
+
file,
|
|
85
|
+
line: node.line,
|
|
86
|
+
level: 'warning',
|
|
87
|
+
category: 'structure',
|
|
88
|
+
message: `"${node.text}" has exactly 1 child. Collapse.`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (rules.empty_nodes && node.text.trim() === '') {
|
|
93
|
+
issues.push({
|
|
94
|
+
file,
|
|
95
|
+
line: node.line,
|
|
96
|
+
level: 'warning',
|
|
97
|
+
category: 'structure',
|
|
98
|
+
message: 'Empty node.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
69
101
|
}
|
|
70
102
|
|
|
71
|
-
|
|
103
|
+
walk(node.children);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
walk(nodes);
|
|
108
|
+
|
|
109
|
+
// Issue #1: enforce depth.min at file level — a file whose deepest node is
|
|
110
|
+
// above the configured minimum is under-specified. Attached to the first
|
|
111
|
+
// root node's line (there is always at least one node when the file has
|
|
112
|
+
// nodes); an empty tree skips the check entirely.
|
|
113
|
+
// LEVEL RATIONALE: depth.min is a warning — a too-shallow file is advisory
|
|
114
|
+
// (depth.max stays an error because it protects the token budget; making
|
|
115
|
+
// shallow files hard-fail would break legitimately shallow summary files).
|
|
116
|
+
// siblings.min is a warning for the same reason: it matches the warning
|
|
117
|
+
// level of the siblings.max side. Defaults (min: 1 both) can never fire —
|
|
118
|
+
// `0 < count < 1` is impossible and any non-empty file has max depth ≥ 1 —
|
|
119
|
+
// so default-rule workspaces stay clean.
|
|
120
|
+
if (nodes.length > 0) {
|
|
121
|
+
const depthMin = rules.depth !== null ? rules.depth.min : null;
|
|
122
|
+
if (depthMin !== null) {
|
|
123
|
+
let maxNodeDepth = 0;
|
|
124
|
+
for (const n of flattenNodes(nodes)) {
|
|
125
|
+
const d = n.indent + 1; // same convention as the per-node depth check above
|
|
126
|
+
if (d > maxNodeDepth) maxNodeDepth = d;
|
|
127
|
+
}
|
|
128
|
+
if (maxNodeDepth < depthMin) {
|
|
72
129
|
issues.push({
|
|
73
130
|
file,
|
|
74
|
-
line:
|
|
131
|
+
line: nodes[0]!.line,
|
|
75
132
|
level: 'warning',
|
|
76
133
|
category: 'structure',
|
|
77
|
-
message:
|
|
134
|
+
message: `Max depth ${maxNodeDepth} is below min ${depthMin}. Deepen the outline.`,
|
|
135
|
+
suggestion: `add nested sub-levels until the outline reaches depth ${depthMin}, or lower structure.depth.min in _rules.yaml`,
|
|
78
136
|
});
|
|
79
137
|
}
|
|
80
|
-
|
|
81
|
-
walk(node.children);
|
|
82
138
|
}
|
|
83
|
-
}
|
|
139
|
+
}
|
|
84
140
|
|
|
85
|
-
walk(nodes);
|
|
86
141
|
return issues;
|
|
87
142
|
}
|
|
88
143
|
|