cans-spec 0.1.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/LICENSE +21 -0
- package/README.md +339 -0
- package/package.json +38 -0
- package/src/cli.ts +48 -0
- package/src/commands/budget.ts +238 -0
- package/src/commands/check.ts +422 -0
- package/src/commands/done.ts +171 -0
- package/src/commands/export.ts +223 -0
- package/src/commands/import.ts +436 -0
- package/src/commands/init.ts +184 -0
- package/src/commands/new.ts +138 -0
- package/src/commands/status.ts +152 -0
- package/src/converters/index.ts +4 -0
- package/src/converters/logseq.ts +42 -0
- package/src/converters/obsidian.ts +95 -0
- package/src/converters/opml.ts +143 -0
- package/src/converters/shared.ts +268 -0
- package/src/core/args.ts +79 -0
- package/src/core/fs.ts +309 -0
- package/src/core/index.ts +10 -0
- package/src/core/outline.ts +237 -0
- package/src/core/output.ts +300 -0
- package/src/core/overflow.ts +75 -0
- package/src/core/redundancy.ts +261 -0
- package/src/core/refs.ts +275 -0
- package/src/core/rules.ts +483 -0
- package/src/core/structure.ts +86 -0
- package/src/core/style.ts +75 -0
- package/src/core/token-budget.ts +284 -0
- package/src/types.ts +284 -0
- package/templates/AGENTS.md +209 -0
- package/templates/_rules.yaml +46 -0
- package/templates/adr-template.md +28 -0
- package/templates/task-template.md +15 -0
package/src/core/refs.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
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';
|
|
6
|
+
|
|
7
|
+
export interface RefGraph {
|
|
8
|
+
forward: Map<string, RefTarget[]>;
|
|
9
|
+
back: BackPointer[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Does a raw ref target `name` point at workspace file `key`?
|
|
13
|
+
* Handles flat (`02-auth.md`) and folder (`02-auth/index.md`) layouts. */
|
|
14
|
+
export function targetMatchesKey(name: string, key: string): boolean {
|
|
15
|
+
const n = name.toLowerCase();
|
|
16
|
+
const k = key.toLowerCase();
|
|
17
|
+
if (n === k) return true;
|
|
18
|
+
const nBase = n.endsWith('.md') ? n.slice(0, -3) : n;
|
|
19
|
+
const kBase = k.endsWith('/index.md') ? k.slice(0, -9) : k.endsWith('.md') ? k.slice(0, -3) : k;
|
|
20
|
+
if (nBase === kBase) return true;
|
|
21
|
+
if (n.endsWith('.md') && k === `${nBase}/index.md`) return true;
|
|
22
|
+
if (k.endsWith('.md') && n === `${kBase}/index.md`) return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Map a raw ref target to the loaded files-map key, if the target is loaded or resolvable on disk. */
|
|
27
|
+
function loadedKeyFor(files: Map<string, OutlineNode[]>, root: string, name: string): string | null {
|
|
28
|
+
if (files.has(name)) return name;
|
|
29
|
+
if (name.endsWith('.md') && files.has(`${name.slice(0, -3)}/index.md`)) return `${name.slice(0, -3)}/index.md`;
|
|
30
|
+
if (!name.endsWith('.md') && files.has(`${name}/index.md`)) return `${name}/index.md`;
|
|
31
|
+
const p = resolveSpecFile(root, name);
|
|
32
|
+
if (p === null) return null;
|
|
33
|
+
const rel = toRelative(root, p);
|
|
34
|
+
if (files.has(rel)) return rel;
|
|
35
|
+
for (const key of files.keys()) if (targetMatchesKey(name, key)) return key;
|
|
36
|
+
return null; // exists on disk but is not part of the loaded set
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Anchor ↔ node-text equivalence (§12 docs' own `#Data-protection` convention):
|
|
40
|
+
* case-insensitive, hyphens/underscores ↔ spaces. Not fuzzy — exact after
|
|
41
|
+
* normalization. */
|
|
42
|
+
export function anchorMatches(nodeText: string, anchor: string): boolean {
|
|
43
|
+
if (nodeText === anchor) return true;
|
|
44
|
+
const norm = (s: string): string =>
|
|
45
|
+
s.toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
46
|
+
return norm(nodeText) === norm(anchor);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildRefGraph(
|
|
50
|
+
files: Map<string, OutlineNode[]>,
|
|
51
|
+
root: string,
|
|
52
|
+
): RefGraph {
|
|
53
|
+
void root; // graph is purely structural; root retained for signature stability
|
|
54
|
+
const forward = new Map<string, RefTarget[]>();
|
|
55
|
+
const back: BackPointer[] = [];
|
|
56
|
+
for (const [file, nodes] of files) {
|
|
57
|
+
const targets: RefTarget[] = [];
|
|
58
|
+
for (const node of flattenNodes(nodes)) {
|
|
59
|
+
for (const ref of node.refs) targets.push(ref);
|
|
60
|
+
}
|
|
61
|
+
forward.set(file, targets);
|
|
62
|
+
for (const ref of targets) {
|
|
63
|
+
back.push({ fromFile: file, fromLine: ref.line, toFile: ref.file, toAnchor: ref.anchor });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { forward, back };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function checkRefs(
|
|
70
|
+
files: Map<string, OutlineNode[]>,
|
|
71
|
+
graph: RefGraph,
|
|
72
|
+
root: string,
|
|
73
|
+
): Issue[] {
|
|
74
|
+
const issues: Issue[] = [];
|
|
75
|
+
for (const [file, targets] of graph.forward) {
|
|
76
|
+
for (const ref of targets) {
|
|
77
|
+
if (ref.file === file) {
|
|
78
|
+
issues.push({
|
|
79
|
+
file, line: ref.line, level: 'error', category: 'refs',
|
|
80
|
+
message: `self-reference: ${file} → ${ref.file}`,
|
|
81
|
+
suggestion: 'remove the self-reference; point at the canonical file instead',
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ref.file.startsWith('_tasks/')) {
|
|
86
|
+
issues.push({
|
|
87
|
+
file, line: ref.line, level: 'warning', category: 'refs',
|
|
88
|
+
message: `transient ref: see ${ref.file} — _tasks/ files are transient, not spec`,
|
|
89
|
+
suggestion: 're-point at a spec file when the task lands',
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ref.file.startsWith('_collab/')) {
|
|
94
|
+
issues.push({
|
|
95
|
+
file, line: ref.line, level: 'error', category: 'refs',
|
|
96
|
+
message: `ref to _collab/: see ${ref.file} — collab notes are not spec`,
|
|
97
|
+
suggestion: 'move the content into a spec file and ref that',
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const key = loadedKeyFor(files, root, ref.file);
|
|
103
|
+
if (key === null && resolveSpecFile(root, ref.file) === null) {
|
|
104
|
+
// §12 edge cases: "File not found → Broken ref error." There is NO
|
|
105
|
+
// span/direction exemption — forward or backward, inside or outside the
|
|
106
|
+
// loaded numeric span, a missing file is always a level:error broken
|
|
107
|
+
// ref. (The former "unwritten spec slot" backward in-span downgrade
|
|
108
|
+
// violated §12 and masked real holes as warnings — removed.)
|
|
109
|
+
issues.push({
|
|
110
|
+
file, line: ref.line, level: 'error', category: 'refs',
|
|
111
|
+
message: `broken ref: see ${ref.file} — file not found`,
|
|
112
|
+
suggestion: `create ${ref.file} or fix the ref target`,
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const anchor = ref.anchor;
|
|
118
|
+
if (anchor !== null) {
|
|
119
|
+
let nodes: OutlineNode[] | null = null;
|
|
120
|
+
if (key !== null) {
|
|
121
|
+
nodes = flattenNodes(files.get(key)!);
|
|
122
|
+
} else {
|
|
123
|
+
const p = resolveSpecFile(root, ref.file);
|
|
124
|
+
if (p !== null) {
|
|
125
|
+
try {
|
|
126
|
+
nodes = flattenNodes(parseOutline(readFileSync(p, 'utf-8'), p));
|
|
127
|
+
} catch {
|
|
128
|
+
nodes = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (nodes !== null) {
|
|
133
|
+
// §12: exact text match, then case-insensitive fallback; the docs' own
|
|
134
|
+
// anchor convention (`#Data-protection` for node "Data protection") also
|
|
135
|
+
// matches via hyphen/space normalization.
|
|
136
|
+
const hit = nodes.some(n => anchorMatches(n.text, anchor));
|
|
137
|
+
if (!hit) {
|
|
138
|
+
issues.push({
|
|
139
|
+
file, line: ref.line, level: 'error', category: 'refs',
|
|
140
|
+
message: `broken anchor: ${ref.file}#${anchor} — no node matches`,
|
|
141
|
+
suggestion: `fix the anchor or add a "${anchor}" node to ${ref.file}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return issues;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Deep-hop detection: a file that both receives refs and issues them extends
|
|
152
|
+
* the ref chain. `maxHops` (§18 references.max_hops, default 1) is the number
|
|
153
|
+
* of allowed hops: a chain whose hop count through `b` exceeds it is flagged.
|
|
154
|
+
* Hop count for file `b` with outgoing refs = (longest incoming chain into b) + 1.
|
|
155
|
+
* §18 delete-key semantics: maxHops null (key deleted) → the check is OFF —
|
|
156
|
+
* skipped entirely. */
|
|
157
|
+
export function detectDeepHops(graph: RefGraph, maxHops: number | null = 1): Issue[] {
|
|
158
|
+
if (maxHops === null) return [];
|
|
159
|
+
const issues: Issue[] = [];
|
|
160
|
+
const keys = [...graph.forward.keys()];
|
|
161
|
+
|
|
162
|
+
// Incoming edges among loaded files: b ← { a : a refs b }.
|
|
163
|
+
const incoming = new Map<string, string[]>();
|
|
164
|
+
for (const a of keys) {
|
|
165
|
+
for (const r of graph.forward.get(a) ?? []) {
|
|
166
|
+
for (const key of keys) {
|
|
167
|
+
if (key === a) continue;
|
|
168
|
+
if (targetMatchesKey(r.file, key)) {
|
|
169
|
+
const list = incoming.get(key) ?? [];
|
|
170
|
+
if (!list.includes(a)) list.push(a);
|
|
171
|
+
incoming.set(key, list);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// depth(x) = length of the longest incoming chain ending at x (0 = no incoming).
|
|
179
|
+
const depth = new Map<string, number>();
|
|
180
|
+
const visiting = new Set<string>();
|
|
181
|
+
const depthOf = (x: string): number => {
|
|
182
|
+
const memo = depth.get(x);
|
|
183
|
+
if (memo !== undefined) return memo;
|
|
184
|
+
if (visiting.has(x)) return 0; // cycle guard
|
|
185
|
+
visiting.add(x);
|
|
186
|
+
let d = 0;
|
|
187
|
+
for (const a of incoming.get(x) ?? []) {
|
|
188
|
+
if (a === x) continue;
|
|
189
|
+
d = Math.max(d, depthOf(a) + 1);
|
|
190
|
+
}
|
|
191
|
+
visiting.delete(x);
|
|
192
|
+
depth.set(x, d);
|
|
193
|
+
return d;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
for (const [b, outTargets] of graph.forward) {
|
|
197
|
+
const outgoing = outTargets.filter(r => r.file !== b);
|
|
198
|
+
if (outgoing.length === 0) continue;
|
|
199
|
+
if (depthOf(b) + 1 <= maxHops) continue;
|
|
200
|
+
let from: string | null = null;
|
|
201
|
+
let best = -1;
|
|
202
|
+
for (const a of incoming.get(b) ?? []) {
|
|
203
|
+
const d = depthOf(a);
|
|
204
|
+
if (d > best) {
|
|
205
|
+
best = d;
|
|
206
|
+
from = a;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (from === null) continue;
|
|
210
|
+
const out = outgoing[0];
|
|
211
|
+
const anchor = out.anchor !== null ? `#${out.anchor}` : '';
|
|
212
|
+
issues.push({
|
|
213
|
+
file: b, line: out.line, level: 'error', category: 'refs',
|
|
214
|
+
message: `DEEP HOP: ${from} → ${b} → ${out.file}`,
|
|
215
|
+
suggestion: `add "see: ${out.file}${anchor}" directly to ${from}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return issues;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function detectOrphans(
|
|
222
|
+
files: Map<string, OutlineNode[]>,
|
|
223
|
+
graph: RefGraph,
|
|
224
|
+
): Issue[] {
|
|
225
|
+
const issues: Issue[] = [];
|
|
226
|
+
for (const key of files.keys()) {
|
|
227
|
+
const flatKey = key.replace(/\/index\.md$/, '.md');
|
|
228
|
+
if (flatKey === '00-overview.md') continue;
|
|
229
|
+
const outgoing = (graph.forward.get(key) ?? []).some(r => r.file !== key);
|
|
230
|
+
if (outgoing) continue;
|
|
231
|
+
let incoming = false;
|
|
232
|
+
for (const [a, aTargets] of graph.forward) {
|
|
233
|
+
if (a === key) continue;
|
|
234
|
+
if (aTargets.some(r => targetMatchesKey(r.file, key))) {
|
|
235
|
+
incoming = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (incoming) continue;
|
|
240
|
+
issues.push({
|
|
241
|
+
file: key, line: 0, level: 'warning', category: 'refs',
|
|
242
|
+
message: `orphan: ${key} has no incoming or outgoing refs`,
|
|
243
|
+
suggestion: 'link it from a related spec file, or fold it into one',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function rebuildBackPointers(
|
|
250
|
+
files: Map<string, OutlineNode[]>,
|
|
251
|
+
graph: RefGraph,
|
|
252
|
+
): Map<string, string> {
|
|
253
|
+
const groups = new Map<string, Set<string>>();
|
|
254
|
+
for (const bp of graph.back) {
|
|
255
|
+
let target: string | null = null;
|
|
256
|
+
for (const key of files.keys()) {
|
|
257
|
+
if (targetMatchesKey(bp.toFile, key)) {
|
|
258
|
+
target = key;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const name = target ?? bp.toFile;
|
|
263
|
+
let set = groups.get(name);
|
|
264
|
+
if (set === undefined) {
|
|
265
|
+
set = new Set<string>();
|
|
266
|
+
groups.set(name, set);
|
|
267
|
+
}
|
|
268
|
+
set.add(bp.fromFile);
|
|
269
|
+
}
|
|
270
|
+
const out = new Map<string, string>();
|
|
271
|
+
for (const key of [...groups.keys()].sort()) {
|
|
272
|
+
out.set(key, [...groups.get(key)!].sort().join(', '));
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|