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/fs.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { statSync, readdirSync, existsSync, mkdirSync, type Stats } from 'fs';
|
|
2
|
+
import { join, relative, dirname, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
const SPEC_FILE_RE = /^\d{2}-.+\.md$/;
|
|
5
|
+
|
|
6
|
+
/** Tool-specific instruction artifacts (§21 `--tool <name>`, §32): emitted
|
|
7
|
+
* beside the specs as agent-facing instructions, never spec content — same
|
|
8
|
+
* exclusion class as AGENTS.md (QA-09 F4: CLAUDE.md must not be discovered
|
|
9
|
+
* as a spec file, counted by status, or exported). */
|
|
10
|
+
const TOOL_ARTIFACTS = new Set(['AGENTS.md', 'CLAUDE.md', '.cursorrules']);
|
|
11
|
+
|
|
12
|
+
export function exists(p: string): boolean {
|
|
13
|
+
return existsSync(p);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function dirExists(p: string): boolean {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(p).isDirectory();
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mkdirp(p: string): void {
|
|
25
|
+
mkdirSync(p, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function globFiles(dir: string, pattern: string): string[] {
|
|
29
|
+
if (!dirExists(dir)) return [];
|
|
30
|
+
const g = new Bun.Glob(pattern);
|
|
31
|
+
const out = [...g.scanSync({ cwd: dir, onlyFiles: true })] as string[];
|
|
32
|
+
return out.sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Spec files: root-level *.md (excluding _-prefixed, AGENTS.md and other tool
|
|
36
|
+
* artifacts) plus per-folder index.md. */
|
|
37
|
+
export function discoverSpecFiles(root: string): string[] {
|
|
38
|
+
const out: string[] = [];
|
|
39
|
+
if (!dirExists(root)) return out;
|
|
40
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
41
|
+
if (entry.name.startsWith('_') || TOOL_ARTIFACTS.has(entry.name)) continue;
|
|
42
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
43
|
+
out.push(entry.name);
|
|
44
|
+
} else if (entry.isDirectory()) {
|
|
45
|
+
const idx = join(entry.name, 'index.md');
|
|
46
|
+
if (exists(join(root, idx))) out.push(idx);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out.sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Detect flat-vs-folder conflicts: both `NN-name.md` AND `NN-name/index.md` exist.
|
|
53
|
+
* §8: "Flat wins over folder. If both exist, `cans check` flags error."
|
|
54
|
+
* Returns pairs of [flatRel, folderRel]. */
|
|
55
|
+
export function detectFlatFolderConflicts(root: string): Array<[string, string]> {
|
|
56
|
+
const conflicts: Array<[string, string]> = [];
|
|
57
|
+
if (!dirExists(root)) return conflicts;
|
|
58
|
+
|
|
59
|
+
const flatFiles = new Set<string>();
|
|
60
|
+
const folderDirs = new Set<string>();
|
|
61
|
+
|
|
62
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
63
|
+
if (entry.name.startsWith('_') || TOOL_ARTIFACTS.has(entry.name)) continue;
|
|
64
|
+
if (entry.isFile() && SPEC_FILE_RE.test(entry.name)) {
|
|
65
|
+
flatFiles.add(entry.name);
|
|
66
|
+
} else if (entry.isDirectory() && exists(join(root, entry.name, 'index.md'))) {
|
|
67
|
+
folderDirs.add(entry.name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const flat of flatFiles) {
|
|
72
|
+
const dirName = flat.replace(/\.md$/, '');
|
|
73
|
+
if (folderDirs.has(dirName)) {
|
|
74
|
+
conflicts.push([flat, `${dirName}/index.md`]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return conflicts.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Directories named like a spec file (e.g. `02-authentication.md/`) — malformed
|
|
82
|
+
* workspace entries that discovery would silently skip (§37: report what the
|
|
83
|
+
* user can fix instead of tolerating it). Returns the directory names. */
|
|
84
|
+
export function detectMalformedSpecDirs(root: string): string[] {
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
if (!dirExists(root)) return out;
|
|
87
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
88
|
+
if (entry.isDirectory() && SPEC_FILE_RE.test(entry.name)) {
|
|
89
|
+
out.push(entry.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out.sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Overflow target files: `.md` content files inside workspace subfolders
|
|
96
|
+
* (e.g. `04-api/request-schema.md`) that are not spec files themselves.
|
|
97
|
+
* §16: these must NOT contain their own `see:` refs (no chaining). */
|
|
98
|
+
export function discoverOverflowTargets(root: string): string[] {
|
|
99
|
+
const out: string[] = [];
|
|
100
|
+
if (!dirExists(root)) return out;
|
|
101
|
+
const walk = (dir: string, prefix: string): void => {
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.name.startsWith('_') || TOOL_ARTIFACTS.has(entry.name)) continue;
|
|
110
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
111
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
112
|
+
// `*/index.md` is a folder-mode spec file, not an overflow target.
|
|
113
|
+
if (prefix !== '' && entry.name !== 'index.md') out.push(rel);
|
|
114
|
+
} else if (entry.isDirectory()) {
|
|
115
|
+
walk(join(dir, entry.name), rel);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(root, '');
|
|
120
|
+
return out.sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function discoverActiveTasks(root: string): string[] {
|
|
124
|
+
return globFiles(join(root, '_tasks'), '*.md')
|
|
125
|
+
.map(p => join('_tasks', basename(p)))
|
|
126
|
+
.sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function discoverArchivedTasks(root: string): string[] {
|
|
130
|
+
return globFiles(join(root, '_tasks', '_archive'), '*.md')
|
|
131
|
+
.map(p => join('_tasks', '_archive', basename(p)))
|
|
132
|
+
.sort();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function discoverAdrs(root: string): string[] {
|
|
136
|
+
return globFiles(join(root, '_adr'), '*.md')
|
|
137
|
+
.map(p => join('_adr', basename(p)))
|
|
138
|
+
.filter(p => basename(p) !== '_template.md')
|
|
139
|
+
.sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolve a ref target: flat file wins, then folder index.md. null when neither exists. */
|
|
143
|
+
export function resolveSpecFile(root: string, name: string): string | null {
|
|
144
|
+
const direct = join(root, name);
|
|
145
|
+
if (exists(direct) && statSync(direct).isFile()) return direct;
|
|
146
|
+
if (name.endsWith('.md')) {
|
|
147
|
+
const folderIdx = join(root, name.slice(0, -3), 'index.md');
|
|
148
|
+
if (exists(folderIdx)) return folderIdx;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Workspace discovery ──
|
|
154
|
+
|
|
155
|
+
function looksLikeWorkspace(dir: string): boolean {
|
|
156
|
+
if (!dirExists(dir)) return false;
|
|
157
|
+
try {
|
|
158
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
159
|
+
if (entry.isFile() && SPEC_FILE_RE.test(entry.name)) return true;
|
|
160
|
+
if (entry.isDirectory() && entry.name === '_tasks') return true;
|
|
161
|
+
if (entry.isDirectory() && entry.name === '_adr') return true;
|
|
162
|
+
if (entry.isFile() && entry.name === '_rules.yaml') return true;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function listDirsRecursive(base: string, maxDepth: number): Array<{ dir: string; mtime: number; depth: number }> {
|
|
171
|
+
const out: Array<{ dir: string; mtime: number; depth: number }> = [];
|
|
172
|
+
const walk = (dir: string, depth: number): void => {
|
|
173
|
+
if (depth > maxDepth) return;
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const e of entries) {
|
|
181
|
+
if (!e.isDirectory()) continue;
|
|
182
|
+
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
183
|
+
const p = join(dir, e.name);
|
|
184
|
+
let mtime = 0;
|
|
185
|
+
try {
|
|
186
|
+
mtime = statSync(p).mtimeMs;
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
out.push({ dir: p, mtime, depth });
|
|
191
|
+
walk(p, depth + 1);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
walk(base, 1);
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** newest-mtime first; deeper path wins ties (most specific scratch target). */
|
|
199
|
+
function newestFirst(a: { dir: string; mtime: number; depth: number }, b: { dir: string; mtime: number; depth: number }): number {
|
|
200
|
+
if (b.mtime !== a.mtime) return b.mtime - a.mtime;
|
|
201
|
+
return b.depth - a.depth;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scratchRoot(cwd: string): string | null {
|
|
205
|
+
const tmp = join(cwd, '.tmp');
|
|
206
|
+
if (!dirExists(tmp)) return null;
|
|
207
|
+
const dirs = listDirsRecursive(tmp, 3);
|
|
208
|
+
if (dirs.length === 0) return null;
|
|
209
|
+
const workspaceLike = dirs.filter(d => looksLikeWorkspace(d.dir));
|
|
210
|
+
if (workspaceLike.length > 0) {
|
|
211
|
+
workspaceLike.sort(newestFirst);
|
|
212
|
+
return workspaceLike[0].dir;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The directory containing spec files.
|
|
218
|
+
* 1. CANS_ROOT env override.
|
|
219
|
+
* 2. Walk up from cwd for a `cans/` directory (production contract).
|
|
220
|
+
* 3. Scratch mode: newest workspace-like dir under <cwd>/.tmp.
|
|
221
|
+
* 4. null — caller reports "no cans workspace found". */
|
|
222
|
+
export function resolveWorkspaceRoot(): string | null {
|
|
223
|
+
const env = process.env.CANS_ROOT;
|
|
224
|
+
if (env && dirExists(env)) return env;
|
|
225
|
+
|
|
226
|
+
let dir = process.cwd();
|
|
227
|
+
for (let i = 0; i < 32; i++) {
|
|
228
|
+
const cans = join(dir, 'cans');
|
|
229
|
+
if (dirExists(cans)) return cans;
|
|
230
|
+
const parent = dirname(dir);
|
|
231
|
+
if (parent === dir) break;
|
|
232
|
+
dir = parent;
|
|
233
|
+
}
|
|
234
|
+
return scratchRoot(process.cwd());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Workspace for commands that may create their target (`new`).
|
|
238
|
+
* Falls back to the newest dir under .tmp, then <cwd>/cans. */
|
|
239
|
+
export function resolveWorkspaceOrCreate(): string {
|
|
240
|
+
const found = resolveWorkspaceRoot();
|
|
241
|
+
if (found) return found;
|
|
242
|
+
const tmp = join(process.cwd(), '.tmp');
|
|
243
|
+
if (dirExists(tmp)) {
|
|
244
|
+
const dirs = listDirsRecursive(tmp, 3);
|
|
245
|
+
if (dirs.length > 0) {
|
|
246
|
+
dirs.sort(newestFirst);
|
|
247
|
+
return dirs[0].dir;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return join(process.cwd(), 'cans');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Directory in which `cans init` creates the `cans/` workspace.
|
|
254
|
+
* 1. CANS_ROOT env.
|
|
255
|
+
* 2. Newest completely-empty dir under <cwd>/.tmp that is not nested inside
|
|
256
|
+
* an existing workspace (scratch/sandbox init — never re-inits into
|
|
257
|
+
* `cans/_adr`-style empty subdirs of a workspace).
|
|
258
|
+
* 3. cwd. */
|
|
259
|
+
export function resolveInitTarget(): string {
|
|
260
|
+
const env = process.env.CANS_ROOT;
|
|
261
|
+
if (env) return env;
|
|
262
|
+
const tmp = join(process.cwd(), '.tmp');
|
|
263
|
+
if (dirExists(tmp)) {
|
|
264
|
+
const dirs = listDirsRecursive(tmp, 3).filter(d => {
|
|
265
|
+
// never target a dir that lives inside an existing workspace
|
|
266
|
+
const rel = relative(tmp, d.dir).split('\\').join('/');
|
|
267
|
+
if (rel.split('/').includes('cans')) return false;
|
|
268
|
+
try {
|
|
269
|
+
return readdirSync(d.dir).length === 0;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
if (dirs.length > 0) {
|
|
275
|
+
dirs.sort(newestFirst);
|
|
276
|
+
return dirs[0].dir;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return process.cwd();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Is cwd inside an existing cans workspace? (init must refuse) */
|
|
283
|
+
export function insideWorkspace(): boolean {
|
|
284
|
+
let dir = process.cwd();
|
|
285
|
+
for (let i = 0; i < 32; i++) {
|
|
286
|
+
if (basename(dir) === 'cans' && dirExists(dir)) return true;
|
|
287
|
+
const cans = join(dir, 'cans');
|
|
288
|
+
if (dirExists(cans)) return true;
|
|
289
|
+
const parent = dirname(dir);
|
|
290
|
+
if (parent === dir) break;
|
|
291
|
+
dir = parent;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function toRelative(root: string, p: string): string {
|
|
297
|
+
const rel = relative(root, p);
|
|
298
|
+
return rel.split('\\').join('/');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function isFile(p: string): boolean {
|
|
302
|
+
try {
|
|
303
|
+
return statSync(p).isFile();
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export type { Stats };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './output';
|
|
2
|
+
export * from './fs';
|
|
3
|
+
export * from './outline';
|
|
4
|
+
export * from './refs';
|
|
5
|
+
export * from './structure';
|
|
6
|
+
export * from './style';
|
|
7
|
+
export * from './redundancy';
|
|
8
|
+
export * from './overflow';
|
|
9
|
+
export * from './rules';
|
|
10
|
+
export * from './token-budget';
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { OutlineNode, BackPointer, RefTarget } from '../types';
|
|
2
|
+
|
|
3
|
+
const BULLET_RE = /^(\s*)-\s+(.*)$/;
|
|
4
|
+
const CHECKBOX_RE = /^\[( |x|X)\]\s+/;
|
|
5
|
+
const OWNER_RE = /←\s*(@?\S+)/;
|
|
6
|
+
// §11: `see:` parsed via regex — accepts `see:TARGET` (no space), `see: TARGET`, `see TARGET`.
|
|
7
|
+
// The alternation keeps the no-colon form whitespace-required so words like "seed" never match.
|
|
8
|
+
const REF_RE = /see(?::\s*|\s+)([^\s#]+)(?:#([^\s#]+))?/g;
|
|
9
|
+
const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
|
|
10
|
+
const FENCE_RE = /^```/;
|
|
11
|
+
|
|
12
|
+
export interface ParseWarning {
|
|
13
|
+
line: number;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** §45: normalize line terminators at the read/split boundary — CRLF (Windows)
|
|
18
|
+
* and lone CR are line terminators just like LF. Root fix so every consumer
|
|
19
|
+
* (check/budget/status/export/done) sees the same nodes (QA-08 B5). */
|
|
20
|
+
function normalizeEol(source: string): string {
|
|
21
|
+
return source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse markdown bullet outline into an OutlineNode tree.
|
|
25
|
+
* Indentation unit: 2 spaces (hardcoded). Tabs rejected.
|
|
26
|
+
* Non-bullet lines ignored (prose, headings, blanks are for humans only).
|
|
27
|
+
* `warnings` (optional) collects non-fatal parse diagnostics, e.g. odd
|
|
28
|
+
* (non-2-multiple) indentation that silently re-parents nodes. */
|
|
29
|
+
export function parseOutline(source: string, file: string, warnings?: ParseWarning[]): OutlineNode[] {
|
|
30
|
+
const lines = normalizeEol(source).split('\n');
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
if (line.startsWith('\t')) {
|
|
33
|
+
throw new Error(`${file}: tab indentation rejected (use 2 spaces)`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const roots: OutlineNode[] = [];
|
|
38
|
+
const stack: OutlineNode[] = []; // stack[i] = most recent node at indent i
|
|
39
|
+
let lastNode: OutlineNode | null = null;
|
|
40
|
+
let fenceOpen = false;
|
|
41
|
+
let fenceStartLine = 0;
|
|
42
|
+
let fencePending = false; // saw a fence; mark next close
|
|
43
|
+
let tableRunOpen = false;
|
|
44
|
+
|
|
45
|
+
const makeNode = (text: string, line: number, indent: number): OutlineNode => ({
|
|
46
|
+
text,
|
|
47
|
+
line,
|
|
48
|
+
indent,
|
|
49
|
+
children: [],
|
|
50
|
+
file,
|
|
51
|
+
isTask: false,
|
|
52
|
+
isDone: false,
|
|
53
|
+
owner: null,
|
|
54
|
+
isHumanGate: false,
|
|
55
|
+
refs: [],
|
|
56
|
+
hasCodeFence: false,
|
|
57
|
+
hasTable: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const raw = lines[i];
|
|
62
|
+
const lineNo = i + 1;
|
|
63
|
+
|
|
64
|
+
// Code fence toggling: fence content is overflow, never bullets.
|
|
65
|
+
if (FENCE_RE.test(raw.trimEnd()) && raw.trim() !== '```' + '') {
|
|
66
|
+
// treat any line whose trimmed form starts with ``` as a fence marker
|
|
67
|
+
}
|
|
68
|
+
if (raw.trim().startsWith('```')) {
|
|
69
|
+
if (!fenceOpen) {
|
|
70
|
+
fenceOpen = true;
|
|
71
|
+
fenceStartLine = lineNo;
|
|
72
|
+
fencePending = true;
|
|
73
|
+
} else {
|
|
74
|
+
fenceOpen = false;
|
|
75
|
+
// attach fence flag to last node, or synthesize a node for fence-only files
|
|
76
|
+
if (lastNode) {
|
|
77
|
+
lastNode.hasCodeFence = true;
|
|
78
|
+
} else {
|
|
79
|
+
const n = makeNode('(code fence)', fenceStartLine, 0);
|
|
80
|
+
n.hasCodeFence = true;
|
|
81
|
+
roots.push(n);
|
|
82
|
+
stack.length = 0;
|
|
83
|
+
stack.push(n);
|
|
84
|
+
lastNode = n;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (fenceOpen) continue; // inside fence: ignore
|
|
90
|
+
|
|
91
|
+
// Table rows: runs of consecutive `| ... |` lines
|
|
92
|
+
if (raw.trim().startsWith('|')) {
|
|
93
|
+
if (!tableRunOpen) {
|
|
94
|
+
tableRunOpen = true;
|
|
95
|
+
if (lastNode) {
|
|
96
|
+
lastNode.hasTable = true;
|
|
97
|
+
} else {
|
|
98
|
+
const n = makeNode('(table)', lineNo, 0);
|
|
99
|
+
n.hasTable = true;
|
|
100
|
+
roots.push(n);
|
|
101
|
+
stack.length = 0;
|
|
102
|
+
stack.push(n);
|
|
103
|
+
lastNode = n;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
tableRunOpen = false;
|
|
109
|
+
|
|
110
|
+
const m = raw.match(BULLET_RE);
|
|
111
|
+
if (!m) continue; // prose / heading / blank — humans only
|
|
112
|
+
|
|
113
|
+
const leading = m[1];
|
|
114
|
+
// Warn on non-2-space-aligned indentation — it silently re-parents nodes.
|
|
115
|
+
if (leading.length % 2 !== 0 && warnings !== undefined) {
|
|
116
|
+
warnings.push({
|
|
117
|
+
line: lineNo,
|
|
118
|
+
message: `odd indentation (${leading.length} space${leading.length === 1 ? '' : 's'}) — nodes may be re-parented unexpectedly; use 2-space multiples`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const indent = Math.floor(leading.length / 2);
|
|
122
|
+
let rest = m[2];
|
|
123
|
+
|
|
124
|
+
// strip back-pointer comments from text (metadata, not spec)
|
|
125
|
+
rest = rest.replace(REF_BY_RE, '').trim();
|
|
126
|
+
|
|
127
|
+
// checkbox
|
|
128
|
+
let isTask = false;
|
|
129
|
+
let isDone = false;
|
|
130
|
+
const cb = rest.match(CHECKBOX_RE);
|
|
131
|
+
if (cb) {
|
|
132
|
+
isTask = true;
|
|
133
|
+
isDone = cb[1].toLowerCase() === 'x';
|
|
134
|
+
rest = rest.slice(cb[0].length).trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// owner arrow
|
|
138
|
+
const ownerMatch = rest.match(OWNER_RE);
|
|
139
|
+
const owner = ownerMatch ? ownerMatch[1] : null;
|
|
140
|
+
|
|
141
|
+
// see: references (both "see: X" and "see X" forms)
|
|
142
|
+
const refs: RefTarget[] = [];
|
|
143
|
+
let rm: RegExpExecArray | null;
|
|
144
|
+
REF_RE.lastIndex = 0;
|
|
145
|
+
while ((rm = REF_RE.exec(rest)) !== null) {
|
|
146
|
+
refs.push({ raw: rm[0], file: rm[1], anchor: rm[2] ?? null, line: lineNo });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const node = makeNode(rest, lineNo, indent);
|
|
150
|
+
node.isTask = isTask;
|
|
151
|
+
node.isDone = isDone;
|
|
152
|
+
node.owner = owner;
|
|
153
|
+
node.isHumanGate = owner === '@human';
|
|
154
|
+
node.refs = refs;
|
|
155
|
+
|
|
156
|
+
// stack-based attachment
|
|
157
|
+
if (stack.length === 0) {
|
|
158
|
+
roots.push(node);
|
|
159
|
+
stack.push(node);
|
|
160
|
+
} else {
|
|
161
|
+
const top = stack[stack.length - 1];
|
|
162
|
+
if (indent === top.indent) {
|
|
163
|
+
// sibling of top
|
|
164
|
+
stack.pop();
|
|
165
|
+
const parent = stack[stack.length - 1];
|
|
166
|
+
if (parent) parent.children.push(node);
|
|
167
|
+
else roots.push(node);
|
|
168
|
+
stack.push(node);
|
|
169
|
+
} else if (indent > top.indent) {
|
|
170
|
+
top.children.push(node);
|
|
171
|
+
stack.push(node);
|
|
172
|
+
} else {
|
|
173
|
+
// shallower: pop until we find the parent level
|
|
174
|
+
while (stack.length > 1 && stack[stack.length - 1].indent > indent) {
|
|
175
|
+
stack.pop();
|
|
176
|
+
}
|
|
177
|
+
const candidate = stack[stack.length - 1];
|
|
178
|
+
if (candidate.indent === indent) {
|
|
179
|
+
stack.pop();
|
|
180
|
+
const parent = stack[stack.length - 1];
|
|
181
|
+
if (parent) parent.children.push(node);
|
|
182
|
+
else roots.push(node);
|
|
183
|
+
stack.push(node);
|
|
184
|
+
} else {
|
|
185
|
+
// indented jump deeper than expected under candidate
|
|
186
|
+
candidate.children.push(node);
|
|
187
|
+
stack.push(node);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
lastNode = node;
|
|
192
|
+
}
|
|
193
|
+
void fencePending;
|
|
194
|
+
return roots;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function flattenNodes(nodes: OutlineNode[]): OutlineNode[] {
|
|
198
|
+
const out: OutlineNode[] = [];
|
|
199
|
+
const walk = (ns: OutlineNode[]): void => {
|
|
200
|
+
for (const n of ns) {
|
|
201
|
+
out.push(n);
|
|
202
|
+
walk(n.children);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
walk(nodes);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function extractBackPointers(source: string, file: string): BackPointer[] {
|
|
210
|
+
const out: BackPointer[] = [];
|
|
211
|
+
const lines = normalizeEol(source).split('\n');
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const m = lines[i].match(REF_BY_RE);
|
|
214
|
+
if (!m) continue;
|
|
215
|
+
const entries = m[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
216
|
+
for (const e of entries) {
|
|
217
|
+
out.push({ fromFile: e, fromLine: i + 1, toFile: file, toAnchor: null });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function countNodes(nodes: OutlineNode[]): number {
|
|
224
|
+
return flattenNodes(nodes).length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function maxDepth(nodes: OutlineNode[]): number {
|
|
228
|
+
let max = 0;
|
|
229
|
+
const walk = (ns: OutlineNode[]): void => {
|
|
230
|
+
for (const n of ns) {
|
|
231
|
+
if (n.indent > max) max = n.indent;
|
|
232
|
+
walk(n.children);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
walk(nodes);
|
|
236
|
+
return max;
|
|
237
|
+
}
|