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.
@@ -0,0 +1,223 @@
1
+ import { join, basename, relative } from 'path';
2
+ import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types';
3
+ import {
4
+ resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, discoverAdrs,
5
+ mkdirp, dirExists, exists,
6
+ } from '../core/fs';
7
+ import { parseOutline } from '../core/outline';
8
+ import { serializeOpml } from '../converters/opml';
9
+ import { serializeLogseq } from '../converters/logseq';
10
+ import { serializeObsidian } from '../converters/obsidian';
11
+
12
+ export interface ExportArgs {
13
+ format: ExportFormat;
14
+ from: string | null;
15
+ vault: string | null;
16
+ includeTasks: boolean;
17
+ dryRun: boolean;
18
+ json: boolean;
19
+ }
20
+
21
+ const FORMATS: readonly string[] = ['opml', 'dynalist', 'logseq', 'obsidian', 'all'];
22
+
23
+ export function parseExportArgs(args: string[]): ExportArgs {
24
+ let format = '';
25
+ let from: string | null = null;
26
+ let vault: string | null = null;
27
+ let includeTasks = false;
28
+ let dryRun = false;
29
+ let json = false;
30
+ const positional: string[] = [];
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i];
33
+ if (a === '--from') {
34
+ from = args[i + 1] ?? null;
35
+ } else if (a === '--vault') {
36
+ vault = args[i + 1] ?? null;
37
+ } else if (a === '--include-tasks') {
38
+ includeTasks = true;
39
+ } else if (a === '--dry-run') {
40
+ dryRun = true;
41
+ } else if (a === '--json') {
42
+ json = true;
43
+ } else if (!a.startsWith('--')) {
44
+ positional.push(a);
45
+ }
46
+ }
47
+ format = positional[0] ?? '';
48
+ return { format: format as ExportFormat, from, vault, includeTasks, dryRun, json };
49
+ }
50
+
51
+ /** see: X#Y → '→ X#Y' (opml/dynalist) | '[[X/Y]]' (logseq) | '[[X#Y]]' (obsidian). */
52
+ function transformRefs(text: string, fmt: string): string {
53
+ if (fmt === 'opml' || fmt === 'dynalist') {
54
+ return text.replace(
55
+ /\bsee:?\s+([^\s#]+)(?:#([^\s#]+))?/g,
56
+ (_m, file: string, anchor?: string) => `→ ${file}${anchor !== undefined ? `#${anchor}` : ''}`,
57
+ );
58
+ }
59
+ if (fmt === 'logseq') {
60
+ return text.replace(
61
+ /\bsee:?\s+([^\s#]+)(?:#([^\s#]+))?/g,
62
+ (_m, file: string, anchor?: string) => {
63
+ const base = file.replace(/\.md$/, '');
64
+ return anchor !== undefined ? `[[${base}/${anchor}]]` : `[[${base}]]`;
65
+ },
66
+ );
67
+ }
68
+ if (fmt === 'obsidian') {
69
+ return text.replace(
70
+ /\bsee:?\s+([^\s#]+)(?:#([^\s#]+))?/g,
71
+ (_m, file: string, anchor?: string) => {
72
+ const base = file.replace(/\.md$/, '');
73
+ return anchor !== undefined ? `[[${base}#${anchor}]]` : `[[${base}]]`;
74
+ },
75
+ );
76
+ }
77
+ return text;
78
+ }
79
+
80
+ /** ← @human → '⏳ Human'; ← agent-1 → '[agent-1]' / 'agent-1:: assigned' / '🤖 agent-1'. */
81
+ function transformOwner(text: string, fmt: string): string {
82
+ let s = text.replace(/←\s*@human\b/g, '⏳ Human');
83
+ if (fmt === 'opml' || fmt === 'dynalist') {
84
+ s = s.replace(/←\s*(\S+)/g, (_m, owner: string) => `[${owner}]`);
85
+ } else if (fmt === 'logseq') {
86
+ s = s.replace(/←\s*(\S+)/g, (_m, owner: string) => `${owner}:: assigned`);
87
+ } else if (fmt === 'obsidian') {
88
+ s = s.replace(/←\s*(\S+)/g, (_m, owner: string) => `🤖 ${owner}`);
89
+ }
90
+ return s;
91
+ }
92
+
93
+ function toExternal(node: OutlineNode, fmt: string): ExternalNode {
94
+ return {
95
+ text: transformOwner(transformRefs(node.text, fmt), fmt),
96
+ indent: node.indent,
97
+ isTask: node.isTask,
98
+ isDone: node.isDone,
99
+ children: node.children.map(c => toExternal(c, fmt)),
100
+ metadata: {},
101
+ };
102
+ }
103
+
104
+ function serializeFor(nodes: ExternalNode[], fmt: string, title: string): string {
105
+ if (fmt === 'opml' || fmt === 'dynalist') return serializeOpml(nodes, title);
106
+ if (fmt === 'logseq') return serializeLogseq(nodes);
107
+ if (fmt === 'obsidian') return serializeObsidian(nodes);
108
+ return '';
109
+ }
110
+
111
+ function outputFileName(specRel: string, fmt: string): string {
112
+ const base = basename(specRel);
113
+ if (fmt === 'opml' || fmt === 'dynalist') return `${base.slice(0, -3)}.opml`;
114
+ return base;
115
+ }
116
+
117
+ export async function run(args: string[]): Promise<ExportResult> {
118
+ const opts = parseExportArgs(args);
119
+ const fmt: string = opts.format.toLowerCase(); // §28 formats are lowercase; accept OPML/All casing
120
+
121
+ if (!FORMATS.includes(fmt)) {
122
+ return {
123
+ ok: false, command: 'export', exitCode: 1,
124
+ format: fmt, outputDir: '', filesExported: 0,
125
+ error: fmt === ''
126
+ ? 'usage: cans export <format>\n Formats: opml, dynalist, logseq, obsidian, all'
127
+ : `unknown format "${opts.format}" — valid: opml, dynalist, logseq, obsidian, all`,
128
+ };
129
+ }
130
+
131
+ // §37/§19 (QA-05 F15 / QA-10 #9): a --from that does not name an existing
132
+ // directory is user-correctable — fail with a stated reason, never
133
+ // success-shaped nothing (ok:true, filesExported: 0).
134
+ if (opts.from !== null && !dirExists(opts.from)) {
135
+ return {
136
+ ok: false, command: 'export', exitCode: 1,
137
+ format: fmt, outputDir: '', filesExported: 0,
138
+ error: `--from directory not found: ${opts.from}\n Check the path (it must be an existing directory) and try again.`,
139
+ };
140
+ }
141
+
142
+ const workspace = opts.from ?? resolveWorkspaceRoot();
143
+ if (workspace === null) {
144
+ return {
145
+ ok: false, command: 'export', exitCode: 1,
146
+ format: fmt, outputDir: '', filesExported: 0,
147
+ error: 'no cans workspace found — run `cans init` first',
148
+ };
149
+ }
150
+
151
+ const sources = discoverSpecFiles(workspace);
152
+ // §28: exports exclude ONLY `_collab/`, `_adr/_archive/`, `_rules.yaml`,
153
+ // `AGENTS.md` — active `_adr/` records are spec surface and must be exported
154
+ // (QA-05 F18). discoverAdrs is [] without _adr/, skips `_template.md`, and its
155
+ // flat `_adr/*.md` glob never matches `_archive/` subdir files.
156
+ sources.push(...discoverAdrs(workspace));
157
+ if (opts.includeTasks && dirExists(join(workspace, '_tasks'))) {
158
+ sources.push(...discoverActiveTasks(workspace));
159
+ }
160
+
161
+ const formats = fmt === 'all'
162
+ ? ['opml', 'dynalist', 'logseq', 'obsidian']
163
+ : [fmt];
164
+
165
+ const baseDir = opts.vault ?? join(process.cwd(), 'cans-export');
166
+ const outputDir = fmt === 'all' ? baseDir : join(baseDir, fmt);
167
+
168
+ // §37/§19 (QA-08 E14/E15): an output path occupied by a FILE is
169
+ // user-correctable — ✗ exit 1 with a fix hint, never a raw ENOTDIR
170
+ // internal error. Pre-check with stat; the write loop below also maps
171
+ // ENOTDIR/EEXIST from mkdir/write as a belt-and-braces net (EACCES and
172
+ // other unexpected errnos stay internal per QA-10 D2).
173
+ const notDirError = (p: string): ExportResult => ({
174
+ ok: false, command: 'export', exitCode: 1,
175
+ format: fmt, outputDir: '', filesExported: 0,
176
+ error: `${p} exists and is not a directory — remove/rename it or choose another output path`,
177
+ });
178
+ if (exists(baseDir) && !dirExists(baseDir)) return notDirError(baseDir);
179
+ if (fmt !== 'all' && exists(outputDir) && !dirExists(outputDir)) return notDirError(outputDir);
180
+
181
+ let filesExported = 0;
182
+ for (const f of formats) {
183
+ const fmtDir = fmt === 'all' ? join(baseDir, f) : outputDir;
184
+ if (exists(fmtDir) && !dirExists(fmtDir)) return notDirError(fmtDir);
185
+ for (const rel of sources) {
186
+ let text = '';
187
+ try {
188
+ text = await Bun.file(join(workspace, rel)).text();
189
+ } catch {
190
+ continue;
191
+ }
192
+ let tree: OutlineNode[] = [];
193
+ try {
194
+ tree = parseOutline(text, rel);
195
+ } catch {
196
+ continue;
197
+ }
198
+ const external = tree.map(n => toExternal(n, f));
199
+ if (external.length === 0) continue;
200
+ const content = serializeFor(external, f, basename(rel));
201
+ if (content === '') continue;
202
+ if (!opts.dryRun) {
203
+ try {
204
+ mkdirp(fmtDir);
205
+ await Bun.write(join(fmtDir, outputFileName(rel, f)), content);
206
+ } catch (e) {
207
+ const code = (e as NodeJS.ErrnoException | null)?.code;
208
+ if (code === 'ENOTDIR' || code === 'EEXIST') return notDirError(fmtDir);
209
+ throw e; // EACCES etc. remain internal (§37: unexpected failures only)
210
+ }
211
+ }
212
+ filesExported++;
213
+ }
214
+ }
215
+
216
+ return {
217
+ ok: true, command: 'export', exitCode: 0,
218
+ format: fmt,
219
+ outputDir: relative(process.cwd(), outputDir) || '.', // §35 fixture: relative to cwd
220
+ filesExported,
221
+ dryRun: opts.dryRun || undefined,
222
+ };
223
+ }
@@ -0,0 +1,436 @@
1
+ import { join, basename, dirname } from 'path';
2
+ import { readdirSync } from 'fs';
3
+ import type {
4
+ ImportResult, ImportFormat, ImportConflict, MergeStrategy, ExternalNode,
5
+ } from '../types';
6
+ import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs';
7
+ import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml';
8
+ import { parseLogseq } from '../converters/logseq';
9
+ import { parseObsidian, stripFrontmatter } from '../converters/obsidian';
10
+ import {
11
+ serializeToCans, parseFromCans, stripMetadata, parseCheckbox,
12
+ extractOverflowContent, type OverflowExtraction,
13
+ } from '../converters/shared';
14
+
15
+ export interface ImportArgs {
16
+ format: ImportFormat;
17
+ path: string;
18
+ out: string | null;
19
+ dryRun: boolean;
20
+ mergeStrategy: MergeStrategy;
21
+ /** Raw `--merge-strategy` value as given, so invalid enums can be rejected (QA-05 F10). */
22
+ mergeStrategyRaw: string | null;
23
+ json: boolean;
24
+ }
25
+
26
+ const FORMATS: readonly string[] = ['opml', 'dynalist', 'logseq', 'obsidian'];
27
+ const STRATEGIES: readonly MergeStrategy[] = ['cans-wins', 'import-wins', 'ask'];
28
+
29
+ export function parseImportArgs(args: string[]): ImportArgs {
30
+ const positional: string[] = [];
31
+ let out: string | null = null;
32
+ let dryRun = false;
33
+ let mergeStrategy: MergeStrategy = 'cans-wins';
34
+ let mergeStrategyRaw: string | null = null;
35
+ let json = false;
36
+ for (let i = 0; i < args.length; i++) {
37
+ const a = args[i];
38
+ if (a === '--out') {
39
+ out = args[i + 1] ?? null;
40
+ } else if (a === '--dry-run') {
41
+ dryRun = true;
42
+ } else if (a === '--merge-strategy') {
43
+ const s = args[i + 1] ?? null;
44
+ mergeStrategyRaw = s;
45
+ if (s !== null && (STRATEGIES as readonly string[]).includes(s)) mergeStrategy = s as MergeStrategy;
46
+ } else if (a === '--json') {
47
+ json = true;
48
+ } else if (!a.startsWith('--')) {
49
+ positional.push(a);
50
+ }
51
+ }
52
+ return {
53
+ format: (positional[0] ?? '') as ImportFormat,
54
+ path: positional[1] ?? '',
55
+ out,
56
+ dryRun,
57
+ mergeStrategy,
58
+ mergeStrategyRaw,
59
+ json,
60
+ };
61
+ }
62
+
63
+ function fail(format: string, source: string, error: string): ImportResult {
64
+ return {
65
+ ok: false, command: 'import', exitCode: 1,
66
+ format, source, newFiles: [], merged: [], conflicts: [], error,
67
+ };
68
+ }
69
+
70
+ function slugify(input: string): string {
71
+ return input
72
+ .trim()
73
+ .toLowerCase()
74
+ .replace(/["“”]/g, '')
75
+ .replace(/[^a-z0-9]+/g, '-')
76
+ .replace(/^-+|-+$/g, '');
77
+ }
78
+
79
+ /** §4 canonical ref form: spec-slug ref targets carry the `.md` extension when
80
+ * written into the workspace (`see: 02-authentication#Sessions` →
81
+ * `see: 02-authentication.md#Sessions`). Converter output stays mechanical;
82
+ * the importer emits workspace-conformant refs (QA-05 F2/F3). Idempotent. */
83
+ function canonicalizeRefTargets(text: string): string {
84
+ return text.replace(
85
+ /\bsee:?\s+([^\s#]+)(#[^\s]+)?/g,
86
+ (m, target: string, anchor: string | undefined) => {
87
+ if (/^\d{2}-/.test(target) && !target.endsWith('.md')) {
88
+ return `see: ${target}.md${anchor ?? ''}`;
89
+ }
90
+ return m;
91
+ },
92
+ );
93
+ }
94
+
95
+ /** Normalized key for fuzzy text matching: lowercase, strip punctuation, collapse whitespace. */
96
+ function normKey(text: string): string {
97
+ return text.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
98
+ }
99
+
100
+ /**
101
+ * Near-match: same words (len > 1) with ≥ 0.75 overlap.
102
+ * Threshold note: the pasted patch said 0.8, but the QA-05 F8 flagship conflict
103
+ * pair — "Expire after 24 hours" vs "Expire after 48 hours" — shares 3 of 4
104
+ * words (0.75) and MUST be flagged, so the threshold is tuned to 0.75.
105
+ */
106
+ function isNearMatch(a: string, b: string): boolean {
107
+ const wa = new Set(normKey(a).split(' ').filter(w => w.length > 1));
108
+ const wb = new Set(normKey(b).split(' ').filter(w => w.length > 1));
109
+ if (wa.size === 0 || wb.size === 0) return false;
110
+ let shared = 0;
111
+ for (const w of wa) if (wb.has(w)) shared++;
112
+ return shared / Math.max(wa.size, wb.size) >= 0.75;
113
+ }
114
+
115
+ /** Source files to import: a single file, or every supported file inside a directory. */
116
+ function sourceFiles(path: string, format: string): string[] | null {
117
+ if (isFile(path)) return [path];
118
+ if (dirExists(path)) {
119
+ const ext = format === 'opml' || format === 'dynalist' ? '.opml' : '.md';
120
+ let names: string[] = [];
121
+ try {
122
+ names = readdirSync(path).filter(n => n.endsWith(ext));
123
+ } catch {
124
+ return null;
125
+ }
126
+ return names.sort().map(n => join(path, n));
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** Map every node's text (depth-first) through `f`, preserving structure. */
132
+ function mapText(nodes: ExternalNode[], f: (t: string) => string): ExternalNode[] {
133
+ return nodes.map((n) => ({ ...n, text: f(n.text), children: mapText(n.children, f) }));
134
+ }
135
+
136
+ function parseSource(text: string, format: string): ExternalNode[] {
137
+ if (format === 'opml' || format === 'dynalist') {
138
+ let nodes = parseOpml(text);
139
+ // §28 table inverse: the exported `→ X.md#Y` marker restores as `see: X.md#Y`
140
+ // (QA-05 F16 / QA-09 D9 — refs must survive the OPML round-trip).
141
+ nodes = mapText(nodes, (t) => convertArrowRefs(t));
142
+ if (format === 'dynalist') {
143
+ // dynalist exports carry app metadata (^block-ids, #tags, emphasis) inside text
144
+ nodes = mapText(nodes, (t) => stripMetadata(t, 'dynalist'));
145
+ }
146
+ return nodes;
147
+ }
148
+ if (format === 'logseq') return parseLogseq(text);
149
+ if (format === 'obsidian') return parseObsidian(stripFrontmatter(text));
150
+ return [];
151
+ }
152
+
153
+ /** Max spec number in the target dir + 1 (starts at 7). */
154
+ function nextSpecNumber(targetDir: string): number {
155
+ let max = 6;
156
+ for (const rel of discoverSpecFiles(targetDir)) {
157
+ const m = basename(rel).match(/^(\d{2})-/);
158
+ if (m !== null) max = Math.max(max, Number(m[1]));
159
+ }
160
+ return max + 1;
161
+ }
162
+
163
+ /** Existing spec file with the same slug (ignoring the NN- prefix) — merge target. */
164
+ function findExistingBySlug(targetDir: string, slug: string): string | null {
165
+ for (const rel of discoverSpecFiles(targetDir)) {
166
+ const stripped = basename(rel).replace(/\.md$/, '').replace(/^\d{2}-/, '');
167
+ if (slugify(stripped) === slug) return rel;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ interface MergeOutcome {
173
+ content: string | null; // null = no write (ask)
174
+ conflicts: ImportConflict[];
175
+ }
176
+
177
+ /**
178
+ * Tree-level merge (QA-05 F8/F9). One single-pass walk of the import tree:
179
+ * for each imported node, match it against the existing tree by normalized text
180
+ * (global exact index) and, failing that, against its sibling slot by word
181
+ * overlap; brand-new nodes are inserted under the CORRECT parent so tree
182
+ * position is preserved (the old flat-append corrupts the hierarchy).
183
+ * exact normalized match → conflict only if text differs
184
+ * (cans-wins keeps the CANS text, import-wins overwrites it)
185
+ * near-match (word overlap ≥ 0.75) → conflict + strategy
186
+ * new node → inserted under the matched/near parent; `ask` reports it, no write
187
+ */
188
+ function mergeInto(
189
+ existingText: string,
190
+ imported: ExternalNode[],
191
+ strategy: MergeStrategy,
192
+ relName: string,
193
+ ): MergeOutcome {
194
+ const conflicts: ImportConflict[] = [];
195
+ const existingTree = parseFromCans(existingText);
196
+
197
+ // Real source line per existing node text (first occurrence, document order).
198
+ const lineOfKey = new Map<string, number>();
199
+ existingText.split(/\r?\n/).forEach((l, i) => {
200
+ if (!/^\s*-\s+/.test(l)) return;
201
+ const k = normKey(parseCheckbox(l).clean);
202
+ if (k !== '' && !lineOfKey.has(k)) lineOfKey.set(k, i + 1);
203
+ });
204
+
205
+ // Index existing nodes by normalized text (first occurrence wins).
206
+ const existingIndex = new Map<string, ExternalNode>();
207
+ const indexTree = (nodes: ExternalNode[]): void => {
208
+ for (const n of nodes) {
209
+ const k = normKey(n.text);
210
+ if (k !== '' && !existingIndex.has(k)) existingIndex.set(k, n);
211
+ indexTree(n.children);
212
+ }
213
+ };
214
+ indexTree(existingTree);
215
+
216
+ let newEntryLine = existingText.split(/\r?\n/).length; // approx. line for inserted content
217
+
218
+ const mergeNodes = (importNodes: ExternalNode[], targetChildren: ExternalNode[]): void => {
219
+ for (const imp of importNodes) {
220
+ const key = normKey(imp.text);
221
+ const exact = key !== '' ? existingIndex.get(key) : undefined;
222
+
223
+ if (exact !== undefined) {
224
+ // Matched by normalized text → conflict only when the wording differs.
225
+ if (exact.text !== imp.text) {
226
+ conflicts.push({
227
+ file: relName,
228
+ line: lineOfKey.get(key) ?? 0,
229
+ cansVersion: exact.text,
230
+ importVersion: imp.text,
231
+ resolution: strategy,
232
+ });
233
+ if (strategy === 'import-wins') exact.text = imp.text;
234
+ // cans-wins / ask: keep the CANS version
235
+ }
236
+ mergeNodes(imp.children, exact.children);
237
+ continue;
238
+ }
239
+
240
+ // Near-match check against the siblings at this slot (word overlap).
241
+ const near = targetChildren.find(c => isNearMatch(c.text, imp.text));
242
+ if (near !== undefined) {
243
+ conflicts.push({
244
+ file: relName,
245
+ line: lineOfKey.get(normKey(near.text)) ?? 0,
246
+ cansVersion: near.text,
247
+ importVersion: imp.text,
248
+ resolution: strategy,
249
+ });
250
+ if (strategy === 'import-wins') near.text = imp.text;
251
+ mergeNodes(imp.children, near.children);
252
+ continue;
253
+ }
254
+
255
+ if (strategy === 'ask') {
256
+ // ask = report, don't merge: the would-be addition is surfaced too,
257
+ // otherwise ask would silently drop new content with no trace.
258
+ conflicts.push({
259
+ file: relName,
260
+ line: ++newEntryLine,
261
+ cansVersion: '',
262
+ importVersion: imp.text,
263
+ resolution: 'ask',
264
+ });
265
+ continue;
266
+ }
267
+
268
+ // New node → insert under the correct parent, re-index so deeper
269
+ // children can find it, then merge its subtree.
270
+ const inserted: ExternalNode = { ...imp, children: [] };
271
+ targetChildren.push(inserted);
272
+ if (key !== '') existingIndex.set(key, inserted);
273
+ mergeNodes(imp.children, inserted.children);
274
+ }
275
+ };
276
+
277
+ mergeNodes(imported, existingTree);
278
+
279
+ if (strategy === 'ask') return { content: null, conflicts };
280
+ return { content: serializeToCans(existingTree), conflicts };
281
+ }
282
+
283
+ /** §27/§28 (QA-09 D12): OPML/Dynalist exports carry the SOURCE SPEC FILENAME in
284
+ * `<head><title>` (e.g. `02-authentication.md`). When the title names a spec
285
+ * file it — not the first node's text — drives merge-target matching and
286
+ * new-file naming, so re-importing an edited export lands on the original
287
+ * file instead of silently forking. Other titles (e.g. "Project Backlog")
288
+ * fall back to first-node naming. */
289
+ const SPEC_TITLE_RE = /^\d{2}-[a-z0-9-]+(?:\.md)?$/i;
290
+
291
+ export async function run(args: string[]): Promise<ImportResult> {
292
+ const opts = parseImportArgs(args);
293
+ const fmt = opts.format.toLowerCase(); // §27 formats are lowercase; accept OPML/Obsidian casing
294
+
295
+ // §37/§27: invalid enum values are rejected, never silently defaulted (QA-05 F10).
296
+ if (opts.mergeStrategyRaw !== null && !(STRATEGIES as readonly string[]).includes(opts.mergeStrategyRaw)) {
297
+ return fail(fmt, opts.path,
298
+ `unknown merge strategy "${opts.mergeStrategyRaw}" — valid: cans-wins, import-wins, ask`);
299
+ }
300
+
301
+ if (opts.path === '') {
302
+ return fail(fmt, opts.path,
303
+ 'usage: cans import <format> <path>\n Formats: opml, dynalist, logseq, obsidian');
304
+ }
305
+ if (!FORMATS.includes(fmt)) {
306
+ return fail(fmt, opts.path,
307
+ `unknown format "${opts.format}" — valid formats: opml, dynalist, logseq, obsidian`);
308
+ }
309
+
310
+ const files = sourceFiles(opts.path, fmt);
311
+ if (files === null || files.length === 0) {
312
+ return fail(fmt, opts.path,
313
+ `source not found: ${opts.path}\n Check the path and try again.`);
314
+ }
315
+
316
+ // §20/§36: --out overrides workspace discovery; otherwise a workspace is required.
317
+ let workspace: string;
318
+ if (opts.out !== null) {
319
+ workspace = opts.out;
320
+ if (!opts.dryRun) mkdirp(workspace);
321
+ } else {
322
+ const ws = resolveWorkspaceRoot();
323
+ if (ws === null) {
324
+ return fail(fmt, opts.path,
325
+ 'no cans workspace found — run `cans init` first, or pass --out <dir>');
326
+ }
327
+ workspace = ws;
328
+ }
329
+
330
+ const newFiles: string[] = [];
331
+ const merged: string[] = [];
332
+ const conflicts: ImportConflict[] = [];
333
+
334
+ let nextNum = nextSpecNumber(workspace);
335
+
336
+ for (const src of files) {
337
+ let text = '';
338
+ try {
339
+ text = await Bun.file(src).text();
340
+ } catch {
341
+ continue;
342
+ }
343
+
344
+ // §27/§28 (QA-09 D12): use the export's source-filename title as the file
345
+ // identity when it names a spec file; otherwise first-node naming.
346
+ let titleBase: string | null = null;
347
+ if (fmt === 'opml' || fmt === 'dynalist') {
348
+ const title = parseOpmlTitle(text);
349
+ if (title !== null && SPEC_TITLE_RE.test(title)) {
350
+ titleBase = title.replace(/\.md$/i, '');
351
+ }
352
+ }
353
+
354
+ // §27: fenced code blocks under bullets are extracted to overflow files
355
+ // before parsing, so their content survives as files + see: refs (QA-05 F5).
356
+ let overflow: OverflowExtraction[] = [];
357
+ let cleanText = text;
358
+ if (fmt === 'obsidian' || fmt === 'logseq') {
359
+ const baseSlug = slugify(basename(src).replace(/\.[^.]+$/, '')) || 'import';
360
+ const extracted = extractOverflowContent(text, baseSlug);
361
+ cleanText = extracted.cleanedSource;
362
+ overflow = extracted.extractions;
363
+ }
364
+
365
+ let imported: ExternalNode[];
366
+ try {
367
+ imported = parseSource(cleanText, fmt);
368
+ } catch (e) {
369
+ // e.g. non-XML garbage passed as .opml (QA-05 F12) — fail loudly, write nothing.
370
+ return fail(fmt, opts.path,
371
+ `invalid OPML in ${basename(src)} — ${(e as Error).message}`);
372
+ }
373
+ if (imported.length === 0) continue;
374
+
375
+ // Merge target: the export's source-file identity when available, else the
376
+ // first node's text (QA-09 D12 — `02-authentication.opml` must re-match
377
+ // 02-authentication.md, not slug the first node "sessions" into a fork).
378
+ const slug = titleBase !== null
379
+ ? slugify(titleBase.replace(/^\d{2}-/, ''))
380
+ : slugify(imported[0].text);
381
+ if (slug === '') continue;
382
+
383
+ // Same-slug spec already present → merge; otherwise a new NN-slug.md file.
384
+ const existingRel = findExistingBySlug(workspace, slug);
385
+ if (existingRel !== null) {
386
+ const absTarget = join(workspace, existingRel);
387
+ const outcome = mergeInto(
388
+ await Bun.file(absTarget).text(),
389
+ imported,
390
+ opts.mergeStrategy,
391
+ existingRel,
392
+ );
393
+ conflicts.push(...outcome.conflicts);
394
+ if (outcome.content !== null) {
395
+ if (!opts.dryRun) {
396
+ await Bun.write(absTarget, canonicalizeRefTargets(outcome.content));
397
+ for (const ovf of overflow) {
398
+ const ovfAbs = join(workspace, ovf.overflowFile);
399
+ mkdirp(dirname(ovfAbs));
400
+ await Bun.write(ovfAbs, `${ovf.content}\n`);
401
+ }
402
+ }
403
+ merged.push(existingRel);
404
+ for (const ovf of overflow) newFiles.push(ovf.overflowFile);
405
+ }
406
+ continue;
407
+ }
408
+
409
+ // New file: preserve the source spec's NN-name identity when the export
410
+ // carries it (QA-09 D12/D4), else the next free NN-first-node-slug.md.
411
+ const relName = titleBase !== null
412
+ ? `${titleBase}.md`
413
+ : `${String(nextNum).padStart(2, '0')}-${slug}.md`;
414
+ const absTarget = join(workspace, relName);
415
+
416
+ const cansText = canonicalizeRefTargets(serializeToCans(imported));
417
+ if (!opts.dryRun) {
418
+ mkdirp(dirname(absTarget));
419
+ await Bun.write(absTarget, cansText);
420
+ for (const ovf of overflow) {
421
+ const ovfAbs = join(workspace, ovf.overflowFile);
422
+ mkdirp(dirname(ovfAbs));
423
+ await Bun.write(ovfAbs, `${ovf.content}\n`);
424
+ }
425
+ }
426
+ newFiles.push(relName);
427
+ for (const ovf of overflow) newFiles.push(ovf.overflowFile);
428
+ if (titleBase === null) nextNum++;
429
+ }
430
+
431
+ return {
432
+ ok: true, command: 'import', exitCode: 0,
433
+ format: fmt, source: opts.path, newFiles, merged, conflicts,
434
+ dryRun: opts.dryRun || undefined,
435
+ };
436
+ }