@taprootio/trellis 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/src/import.mjs ADDED
@@ -0,0 +1,583 @@
1
+ // Trellis import engine (zero-dependency).
2
+ //
3
+ // Converts an existing backlog on a foreign schema into Trellis items, driven by a
4
+ // declarative, JSON-serializable mapping. Mirrors src/init.mjs: every entry point
5
+ // takes an explicit targetRoot/sourceRoot and holds no process-wide state, so the
6
+ // `trellis import` CLI, the MCP `import` tool, and `init --import` share one
7
+ // implementation, and named source profiles (src/profiles.mjs) are just mapping
8
+ // objects of the same shape.
9
+ //
10
+ // Contracts (SPEC §4–§5, §8; TRL0021): the source tree is READ-ONLY (copy-out,
11
+ // never delete); ids are assigned fresh-sequentially from the target's nextId so an
12
+ // import is safe into a non-empty target; colliding source ids dedupe by
13
+ // construction and every depends_on is rewritten through the id map (an ambiguous
14
+ // or dangling reference is a hard error); a real run regenerates via the TRL0002
15
+ // core and ROLLS BACK on any failure, so the target is never left invalid or
16
+ // half-written.
17
+ //
18
+ // The mapping shape (documented in full in docs/import.md, with the built-in
19
+ // profiles/ as worked examples):
20
+ // {
21
+ // sources: { active|completed|removed: { dirs: [..], file: "*.md" } },
22
+ // fields: { title, id, priority, effort, milestone, summary, depends_on,
23
+ // owner, collaborators, completed_on, removed_on, removed_reason: <extractor> },
24
+ // remap: { priority: {..}, milestone: {..}, owner: {..} }, // case-insensitive keys
25
+ // summary: { strategy: "firstSentence" | "title" },
26
+ // defaults:{ milestone, priority, effort, owner, removed_reason }, // used when the source lacks a value
27
+ // }
28
+ // `remap.owner` maps a source assignee to a roster handle (SPEC §7.2) and applies to
29
+ // both `owner` and `collaborators`; `defaults.owner` fills an unresolved owner on
30
+ // active items only. An owner that resolves to no active member never invents one —
31
+ // active items drop to unassigned; closed items keep a valid historical handle (after
32
+ // remap) and drop a non-handle value that wouldn't round-trip.
33
+ // `defaults` chiefly fills the historical metadata that header-style legacy closed
34
+ // items lack but the schema still requires on completed/removed items (SPEC §5.1).
35
+ // An <extractor> is { from: "yaml", key } | { from:"inline"|"header", label }
36
+ // | { from:"h1" } | { from:"filename", pattern? } | { from:"const", value },
37
+ // each optionally carrying { fallback: <extractor> } and { list: true }.
38
+ // The `title` field additionally honours { stripIdPrefix: true }: a leading token equal
39
+ // to the item's source id (plus an optional `. : - – —` and whitespace) is dropped from
40
+ // the stored title (TRL0029); it is set on the numeric-prefix profiles, off elsewhere.
41
+
42
+ import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
43
+ import { join, relative, dirname, isAbsolute } from "node:path";
44
+ import { execFileSync } from "node:child_process";
45
+ import {
46
+ loadConfig,
47
+ readBacklog,
48
+ generateArtifacts,
49
+ resolveEffort,
50
+ findMember,
51
+ isValidHandle,
52
+ nextId,
53
+ parseFrontMatter,
54
+ paths,
55
+ composeFile,
56
+ } from "./backlog.mjs";
57
+
58
+ // Target subdir for each status; statuses are always processed in this fixed order
59
+ // (not the mapping's key order) so id assignment is deterministic.
60
+ const STATUS_DIRS = { active: "active", completed: join("completed", "tasks"), removed: "removed" };
61
+ const STATUS_ORDER = ["active", "completed", "removed"];
62
+
63
+ // ----------------------------------------------------------- small helpers
64
+ function escRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
65
+
66
+ // A trivial `*`-only glob → anchored RegExp (dependency-free; no `**`/`?`/classes).
67
+ function globToRe(glob) { return new RegExp("^" + String(glob).split("*").map(escRe).join(".*") + "$"); }
68
+
69
+ function firstMatch(text, re) { const m = text.match(re); return m ? m[1] : undefined; }
70
+
71
+ // `None` / `N/A` / `-` (and the empty string) mean "no dependencies"; otherwise
72
+ // split a `a, b; c` (or `[a, b]`) list into trimmed, non-empty tokens.
73
+ function asList(raw) {
74
+ if (raw == null) return [];
75
+ const s = String(raw).trim();
76
+ if (!s || /^(none|n\/a|-|—|\[\])$/i.test(s)) return [];
77
+ return s.replace(/^\[|\]$/g, "").split(/[,;]/).map((x) => x.trim()).filter(Boolean);
78
+ }
79
+
80
+ // Normalize a date token to ISO `YYYY-MM-DD` (padding single-digit month/day), or
81
+ // null if it isn't a real calendar date — the caller turns null into a hard error
82
+ // rather than guessing a close date (SPEC §5.1 requires a valid ISO close date).
83
+ // A UTC round-trip rejects impossible dates (month 13, day 0, Feb 31, …): an
84
+ // out-of-range component rolls Date over to a different y/m/d than we put in.
85
+ function toISO(raw) {
86
+ if (raw == null) return null;
87
+ const m = String(raw).trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
88
+ if (!m) return null;
89
+ const y = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
90
+ const dt = new Date(Date.UTC(y, month - 1, day));
91
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) return null;
92
+ return `${m[1]}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
93
+ }
94
+
95
+ // Most-recent commit author-date (YYYY-MM-DD) for a source path, read against the
96
+ // source repo — the git-commit-date fallback for a legacy close-date with no header
97
+ // (SPEC §5.1 requires an ISO date; many legacy closed items have none). Import-time
98
+ // ONLY: git lives here in the importer, never in the generator/`--check` (SPEC §8.4),
99
+ // mirroring src/history.mjs. Returns null on ANY failure (no git on PATH, not a repo,
100
+ // an unborn HEAD, a shallow clone missing the commit, or a path never committed) so
101
+ // the caller degrades to the next fallback instead of throwing. `--follow` tracks the
102
+ // date across renames (e.g. a legacy active→completed move). No shell; arg array only.
103
+ function gitCommitDate(repoRoot, relPath) {
104
+ try {
105
+ const out = execFileSync(
106
+ "git",
107
+ ["-C", repoRoot, "log", "-1", "--follow", "--format=%ad", "--date=short", "--", relPath],
108
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024 },
109
+ ).trim();
110
+ return toISO(out); // "" (path not committed) or a bad value → null
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ // ------------------------------------------------------------- extraction
117
+ // Locate one field in a source item via its declared extractor, falling back to a
118
+ // nested `fallback` extractor when the primary yields nothing. Returns a trimmed
119
+ // string (or the raw yaml value's type) or undefined. Pure over `ctx`.
120
+ function runExtractor(ex, ctx) {
121
+ if (!ex || typeof ex !== "object") return undefined;
122
+ let v;
123
+ switch (ex.from) {
124
+ case "const": v = ex.value; break;
125
+ case "yaml": v = ctx.yaml[ex.key]; break;
126
+ case "filename": v = ex.pattern ? matchGroup(ctx.basename, ex.pattern) : ctx.basename; break;
127
+ case "h1": v = firstMatch(ctx.raw, /^[ \t]*#[ \t]+(.+)$/m); break;
128
+ // `**Label:**` / `**Label**:` (colon optional, either side), value = rest of line.
129
+ case "inline": v = firstMatch(ctx.raw, new RegExp(`^[ \\t]*\\*\\*[ \\t]*${escRe(ex.label)}[ \\t]*:?[ \\t]*\\*\\*[ \\t]*:?[ \\t]*(.+)$`, "m")); break;
130
+ // A header line `Label: value` (won't match a `**Label:**` line — that starts with `*`).
131
+ case "header": v = firstMatch(ctx.raw, new RegExp(`^[ \\t]*${escRe(ex.label)}[ \\t]*:[ \\t]*(.+)$`, "m")); break;
132
+ default: return undefined; // unknown kind — surfaced by validateMapping
133
+ }
134
+ if (v == null || String(v).trim() === "") return ex.fallback ? runExtractor(ex.fallback, ctx) : undefined;
135
+ return typeof v === "string" ? v.trim() : v;
136
+ }
137
+
138
+ function matchGroup(s, pattern) {
139
+ let re;
140
+ try { re = new RegExp(pattern); } catch { return undefined; }
141
+ const m = String(s).match(re);
142
+ return m ? (m[1] ?? m[0]) : undefined;
143
+ }
144
+
145
+ // Drop a leading source-id token from an imported title (TRL0029). When `enabled` and
146
+ // the title begins with the item's OWN source id followed by a separator — whitespace,
147
+ // optionally around a single `. : - – —` — strip that token, so a foreign
148
+ // "001 README Truth Pass" reads as "README Truth Pass". Conservative by construction:
149
+ // the separator's trailing whitespace is REQUIRED, so the matched run is the whole id
150
+ // plus a real break — id "04" never bites into "047 Foo", "001README" (no break) is
151
+ // left intact, and "001 .NET" keeps its dot. Only the item's resolved source id is
152
+ // matched, so a genuinely number-leading title ("2024 Roadmap" under a different id) is
153
+ // never touched; a title that is nothing but the id is returned unchanged, never blanked.
154
+ function stripLeadingId(title, sourceId, enabled) {
155
+ if (!enabled || !title) return title;
156
+ const id = String(sourceId == null ? "" : sourceId).trim();
157
+ if (!id) return title;
158
+ const stripped = title.replace(new RegExp(`^${escRe(id)}[ \\t]*[.:–—-]?[ \\t]+`), "").trim();
159
+ return stripped || title;
160
+ }
161
+
162
+ // Case-insensitive remap lookup → the mapped value (trimmed), or the input unchanged.
163
+ // Shared by enum resolution (priority/milestone) and assignee resolution (owner/
164
+ // collaborators), so all three honour `remap.<field>` the same way.
165
+ function remapLookup(table, value) {
166
+ const s = String(value).trim();
167
+ if (table && typeof table === "object") {
168
+ const key = Object.keys(table).find((k) => k.toLowerCase() === s.toLowerCase());
169
+ if (key) return String(table[key]).trim();
170
+ }
171
+ return s;
172
+ }
173
+
174
+ // Resolve a foreign enum to a configured one: a `remap` entry (case-insensitive
175
+ // key) wins, then a direct case-insensitive match against the allowed set;
176
+ // otherwise an actionable error (the §7.1 milestone collapse must be an explicit
177
+ // mapping decision, never a silent guess).
178
+ function resolveEnum(raw, remap, allowed, label) {
179
+ if (raw == null || String(raw).trim() === "") return { error: `missing ${label}` };
180
+ const s = String(raw).trim();
181
+ const mapped = remapLookup(remap, s);
182
+ const hit = allowed.find((a) => a.toLowerCase() === String(mapped).toLowerCase());
183
+ if (hit) return { value: hit };
184
+ return { error: `${label} "${s}" is not a configured ${label} (${allowed.join(", ")}) and has no \`remap.${label}\` entry` };
185
+ }
186
+
187
+ // First-sentence (or title) synthesis for a missing summary (SPEC §5.1: summary is
188
+ // required and feeds the README). Skips the H1, blank lines, and metadata-shaped
189
+ // lines (bold `**Field:**`, list bullets, short `Key: value` headers) so it lands
190
+ // on real prose, then takes that line's first sentence, single-lined. A heuristic —
191
+ // summary is descriptive, not correctness-critical — that fails safe to the title.
192
+ function synthSummary(body, title, strategy) {
193
+ if (strategy === "title") return title;
194
+ const isMeta = (l) => l.startsWith("**") || /^[-*+]\s/.test(l) || /^[A-Za-z][\w ()/-]{0,30}:\s+\S/.test(l);
195
+ for (const line of body.split("\n")) {
196
+ const l = line.trim();
197
+ if (!l || l.startsWith("#") || isMeta(l)) continue;
198
+ const m = l.match(/^(.+?[.!?])(\s|$)/);
199
+ const s = (m ? m[1] : l).replace(/\s+/g, " ").trim();
200
+ if (s) return s;
201
+ }
202
+ return title;
203
+ }
204
+
205
+ // Rebuild a source file's prose as a Trellis body: drop a leading YAML block and a
206
+ // leading H1 (foreign or ours), then re-head with the canonical `# <id> — <title>`.
207
+ // Faithful copy-out — the original prose (including any inline metadata lines) is
208
+ // preserved verbatim under the new heading; composeFile normalizes the trailing NL.
209
+ function buildBody(raw, newId, title) {
210
+ let body = raw.replace(/\r\n/g, "\n");
211
+ body = body.replace(/^---\n[\s\S]*?\n---\n?/, ""); // strip front-matter if any
212
+ body = body.replace(/^\s*#[ \t]+[^\n]*\n?/, ""); // strip a leading H1
213
+ body = body.replace(/^\n+/, "").replace(/\s+$/, "");
214
+ return body ? `# ${newId} — ${title}\n\n${body}\n` : `# ${newId} — ${title}\n`;
215
+ }
216
+
217
+ // --------------------------------------------------------------- discovery
218
+ function listFiles(dir, re) {
219
+ if (!existsSync(dir)) return null; // null = dir absent (a warning), [] = present-but-empty
220
+ return readdirSync(dir)
221
+ .filter((f) => re.test(f) && statSync(join(dir, f)).isFile())
222
+ .sort();
223
+ }
224
+
225
+ // Gather every source item across the mapped status dirs, in a deterministic global
226
+ // order (status order, then path) so fresh-sequential ids are reproducible.
227
+ function discoverSources(sourceRoot, mapping, warnings) {
228
+ const out = [];
229
+ for (const status of STATUS_ORDER) {
230
+ const spec = mapping.sources[status];
231
+ if (!spec) continue;
232
+ const re = globToRe(spec.file || "*.md");
233
+ const hits = [];
234
+ for (const d of spec.dirs) {
235
+ const dir = join(sourceRoot, d);
236
+ const files = listFiles(dir, re);
237
+ if (files === null) { warnings.push(`source dir not found, skipped: ${d}`); continue; }
238
+ for (const f of files) hits.push({ status, dir, file: join(dir, f), basename: f.replace(/\.[^.]+$/, ""), rel: relative(sourceRoot, join(dir, f)) });
239
+ }
240
+ hits.sort((a, b) => a.rel.localeCompare(b.rel));
241
+ out.push(...hits);
242
+ }
243
+ return out;
244
+ }
245
+
246
+ // ------------------------------------------------------ mapping validation
247
+ function validateMapping(mapping) {
248
+ const errors = [];
249
+ if (!mapping || typeof mapping !== "object") return ["mapping must be an object"];
250
+ if (!mapping.sources || typeof mapping.sources !== "object") errors.push("mapping.sources must be an object");
251
+ else {
252
+ const present = STATUS_ORDER.filter((s) => mapping.sources[s]);
253
+ if (!present.length) errors.push("mapping.sources must define at least one of active/completed/removed");
254
+ for (const s of present) {
255
+ const spec = mapping.sources[s];
256
+ if (!Array.isArray(spec.dirs) || !spec.dirs.length) errors.push(`mapping.sources.${s}.dirs must be a non-empty array`);
257
+ else for (const d of spec.dirs) {
258
+ // Source dirs must stay inside sourceRoot — reject absolute paths and any
259
+ // `..` segment so a mapping can't read outside the tree it was pointed at.
260
+ if (typeof d !== "string" || !d.trim()) errors.push(`mapping.sources.${s}.dirs entries must be non-empty strings`);
261
+ else if (isAbsolute(d) || d.split(/[/\\]/).includes("..")) errors.push(`mapping.sources.${s}.dirs entry "${d}" must be a relative path within the source (no absolute path or ".." segments)`);
262
+ }
263
+ if (spec.file != null && typeof spec.file !== "string") errors.push(`mapping.sources.${s}.file must be a string`);
264
+ }
265
+ }
266
+ if (!mapping.fields || typeof mapping.fields !== "object") errors.push("mapping.fields must be an object");
267
+ if (mapping.defaults != null && (typeof mapping.defaults !== "object" || Array.isArray(mapping.defaults))) errors.push("mapping.defaults must be an object when present");
268
+ return errors;
269
+ }
270
+
271
+ // ----------------------------------------------------------------- plan
272
+ // Build the full import plan WITHOUT touching disk: resolve every field, assign
273
+ // fresh ids, rewrite dependencies, and collect per-item errors + warnings. Read
274
+ // access is limited to the source tree and the target's config/backlog.
275
+ export function planImport(targetRoot, sourceRoot, mapping, opts = {}) {
276
+ // A factory (not a shared literal) so each early return gets its own arrays/objects
277
+ // and results can never alias one another.
278
+ const empty = () => ({ cfg: null, root: null, items: [], idMap: [], counts: { active: 0, completed: 0, removed: 0, total: 0 }, provenance: { gitDated: 0, dateDefaulted: 0, effortEstimated: 0 }, warnings: [], errors: [] });
279
+
280
+ const mapErrors = validateMapping(mapping);
281
+ if (mapErrors.length) return { ...empty(), errors: mapErrors };
282
+
283
+ const { cfg, errors: cfgErrors } = loadConfig(targetRoot);
284
+ if (cfgErrors.length) return { ...empty(), errors: [`target config: ${cfgErrors.join("; ")}`] };
285
+ const root = cfg.tasksDir || "trellis";
286
+ const p = paths(targetRoot, cfg);
287
+
288
+ // The target must already be a Trellis repo — import emits items + regenerates,
289
+ // it does not scaffold (that's `trellis init`, or the `init --import` on-ramp
290
+ // that scaffolds first, then calls this).
291
+ if (!existsSync(p.readme) || !existsSync(p.completedIndex) || !existsSync(p.removedIndex)) {
292
+ return { ...empty(), cfg, root, errors: ["target is not an initialized Trellis backlog (missing generated indexes); run `npx @taprootio/trellis init` first"] };
293
+ }
294
+ const data = readBacklog(targetRoot, cfg);
295
+ if (data.errors.length) return { ...empty(), cfg, root, errors: [`target backlog has errors; fix them before importing: ${data.errors.join("; ")}`] };
296
+ const roster = data.roster; // resolve owners/collaborators against the target roster (SPEC §7.2)
297
+
298
+ const warnings = [];
299
+ const sources = discoverSources(sourceRoot, mapping, warnings);
300
+ if (!sources.length) warnings.push("no source items matched — check `sources.dirs` and the `file` pattern");
301
+
302
+ // Fresh-sequential id allocation from the target's current nextId.
303
+ const fields = mapping.fields;
304
+ const idEx = fields.id || { from: "filename" };
305
+ let n = Number(nextId(data.ids, cfg).slice(cfg.idPrefix.length));
306
+ const fmtId = (num) => cfg.idPrefix + String(num).padStart(cfg.idWidth, "0");
307
+
308
+ const errors = [];
309
+ const items = [];
310
+ // Provenance of inferred values, surfaced in the import summary (not in the items —
311
+ // volatile git data stays out of the gated files, SPEC §8.4). `gitDate` is injected
312
+ // in tests; in production it reads the source repo's git (import-time only).
313
+ const provenance = { gitDated: 0, dateDefaulted: 0, effortEstimated: 0 };
314
+ const gitDate = opts.gitDate || ((rel) => gitCommitDate(sourceRoot, rel));
315
+ const bySourceId = new Map(); // source id → [newId, …]; >1 ⇒ a collision (ambiguous for deps)
316
+
317
+ for (const src of sources) {
318
+ const raw = readFileSync(src.file, "utf8");
319
+ const ctx = { raw, basename: src.basename, yaml: parseFrontMatter(raw, src.rel, []) || {} };
320
+ const newId = fmtId(n++);
321
+ const sourceId = String(runExtractor(idEx, ctx) ?? src.basename).trim();
322
+ const at = bySourceId.get(sourceId) || []; at.push(newId); bySourceId.set(sourceId, at);
323
+
324
+ const ierr = (m) => errors.push(`${src.rel}: ${m}`);
325
+ const isActive = src.status === "active";
326
+
327
+ const titleEx = fields.title || { from: "h1" };
328
+ const title = stripLeadingId(runExtractor(titleEx, ctx), sourceId, titleEx.stripIdPrefix === true);
329
+ if (!title) ierr("could not derive a `title`");
330
+
331
+ // Enums: active items must resolve (the core validates them); on closed items
332
+ // the value is historical and not re-validated, so an unresolved one keeps the
333
+ // raw source value and only warns (SPEC §5.1, §8.3).
334
+ const resolveOrCarry = (raw0, remap, allowed, label) => {
335
+ const r = resolveEnum(raw0, remap, allowed, label);
336
+ if (!r.error) return r.value;
337
+ if (isActive) { ierr(r.error); return undefined; }
338
+ // Closed items: enums are historical and not re-validated (SPEC §5.1, §8.3).
339
+ // Absence is normal for header-style legacy items, so a missing value just
340
+ // drops to null; only a *present* value that didn't map is worth a warning.
341
+ const has = raw0 != null && String(raw0).trim() !== "";
342
+ if (has) warnings.push(`${src.rel}: ${label} "${String(raw0).trim()}" not mapped — kept as a historical value`);
343
+ return has ? String(raw0).trim() : undefined;
344
+ };
345
+ const remap = mapping.remap || {};
346
+ // `mapping.defaults` supplies a value when the source has none — chiefly for the
347
+ // historical metadata that header-style legacy closed items lack but the schema
348
+ // still requires (SPEC §5.1). A defaulted value is treated like any extracted
349
+ // one (validated for active items, carried for closed).
350
+ const defaults = mapping.defaults || {};
351
+ const withDefault = (v, field) => (v != null && String(v).trim() !== "" ? v : defaults[field]);
352
+ const priority = resolveOrCarry(withDefault(runExtractor(fields.priority, ctx), "priority"), remap.priority, cfg.priorities, "priority");
353
+ const milestone = resolveOrCarry(withDefault(runExtractor(fields.milestone, ctx), "milestone"), remap.milestone, cfg.milestones, "milestone");
354
+
355
+ // Effort: a real `Effort:`/`Size:` signal (the profile's effort extractor may chain
356
+ // `Effort` → `Size`) is remapped through `remap.effort` (a foreign size → a canonical
357
+ // value, mirroring remap.priority/milestone) then resolved. With NO signal we fall to
358
+ // `defaults.effort`; on a CLOSED item that default is frozen history, so flag it
359
+ // estimated (a per-item warning + a summary count) — nothing passes as authored. A
360
+ // present-but-unresolved signal on a closed item is kept as a historical value with a
361
+ // warning (SPEC §5.1, §8.3); active items must resolve.
362
+ const rawEffort = runExtractor(fields.effort, ctx);
363
+ const hasEffort = rawEffort != null && String(rawEffort).trim() !== "";
364
+ let effort;
365
+ if (hasEffort) {
366
+ const eff = resolveEffort(cfg, remapLookup(remap.effort, rawEffort));
367
+ if (!eff.error) effort = eff.value;
368
+ else if (isActive) ierr(`effort: ${eff.error}`);
369
+ else { warnings.push(`${src.rel}: effort "${rawEffort}" not resolved — kept as a historical value`); effort = String(rawEffort).trim(); }
370
+ } else {
371
+ const eff = resolveEffort(cfg, defaults.effort);
372
+ if (!eff.error) {
373
+ effort = eff.value;
374
+ if (!isActive) {
375
+ warnings.push(`${src.rel}: effort ${effort} estimated (no \`Effort:\`/\`Size:\` signal — used defaults.effort)`);
376
+ provenance.effortEstimated++;
377
+ }
378
+ } else if (isActive) {
379
+ ierr(`effort: ${eff.error}`);
380
+ }
381
+ // closed + no/invalid `defaults.effort` → effort stays undefined → the missing
382
+ // historical-metadata check below reports it.
383
+ }
384
+
385
+ // Closed items still require the descriptive metadata as a historical snapshot
386
+ // (SPEC §5.1) — if the source lacks it and no mapping default fills it, fail loud
387
+ // (a clear plan-time error) rather than emit an item the core would reject.
388
+ if (!isActive) {
389
+ for (const [k, v] of [["milestone", milestone], ["priority", priority], ["effort", effort]]) {
390
+ if (v === undefined) ierr(`${src.status} item is missing \`${k}\` (source has none — set \`defaults.${k}\` in the mapping)`);
391
+ }
392
+ }
393
+
394
+ const body = buildBody(raw, newId, title || newId);
395
+ let summary = runExtractor(fields.summary, ctx);
396
+ if (summary == null || String(summary).trim() === "") summary = synthSummary(body, title || newId, (mapping.summary && mapping.summary.strategy) || "firstSentence");
397
+ summary = String(summary).replace(/\s+/g, " ").trim();
398
+
399
+ const srcDeps = fields.depends_on ? asList(runExtractor({ ...fields.depends_on, list: true }, ctx)) : [];
400
+
401
+ // ----- ownership (owner + collaborators) -----------------------------------
402
+ // `remap.owner` (case-insensitive) maps a source assignee to a roster handle and
403
+ // applies to both owner and collaborators (one identity space). resolveAssignee
404
+ // returns the target handle to store, or null:
405
+ // - active item: an ACTIVE roster member's canonical handle, else null (silent —
406
+ // the caller falls back to defaults / unassigned).
407
+ // - closed item (historical, SPEC §5.1/§8.3): any roster member's canonical handle
408
+ // (no warning); else the *remapped* value if it is a valid handle (a former
409
+ // member — kept, with a warning); else null (a non-handle that would not
410
+ // round-trip — dropped, with a warning). Never invents a member.
411
+ const resolveAssignee = (raw, kind) => {
412
+ const s = raw == null ? "" : String(raw).trim();
413
+ if (!s) return null;
414
+ const remapped = remapLookup(remap.owner, s);
415
+ const m = findMember(roster, remapped);
416
+ if (isActive) return m && m.status === "active" ? m.handle : null;
417
+ if (m) return m.handle;
418
+ if (isValidHandle(remapped)) {
419
+ warnings.push(`${src.rel}: ${kind} "${s}" is not in the roster — kept as a historical value`);
420
+ return remapped;
421
+ }
422
+ warnings.push(`${src.rel}: ${kind} "${s}" is not a valid handle — dropped`);
423
+ return null;
424
+ };
425
+
426
+ // owner (optional): on active items chain resolve → `defaults.owner` → unassigned
427
+ // (warn if a source owner was present but didn't resolve); on closed items
428
+ // resolveAssignee has already carried or dropped it as a historical value.
429
+ const rawOwner = runExtractor(fields.owner, ctx);
430
+ let owner = resolveAssignee(rawOwner, "owner") ?? undefined;
431
+ if (isActive && owner === undefined) {
432
+ if (defaults.owner != null && String(defaults.owner).trim() !== "") {
433
+ owner = resolveAssignee(defaults.owner, "owner") ?? undefined;
434
+ }
435
+ if (owner === undefined && rawOwner != null && String(rawOwner).trim() !== "") {
436
+ warnings.push(`${src.rel}: owner "${String(rawOwner).trim()}" is not an active roster member — imported unassigned`);
437
+ }
438
+ }
439
+
440
+ // collaborators (optional list): per-entry resolution, deduped. Active items drop a
441
+ // non-member with a warning; closed items keep/drop via resolveAssignee (which warns),
442
+ // so there is no double-warning here.
443
+ const collaborators = [];
444
+ const rawCollabs = fields.collaborators ? asList(runExtractor({ ...fields.collaborators, list: true }, ctx)) : [];
445
+ for (const c of rawCollabs) {
446
+ const h = resolveAssignee(c, "collaborator");
447
+ if (h != null) {
448
+ if (!collaborators.includes(h)) collaborators.push(h);
449
+ } else if (isActive) {
450
+ warnings.push(`${src.rel}: collaborator "${String(c).trim()}" is not an active roster member — dropped`);
451
+ }
452
+ }
453
+
454
+ const fm = { id: newId, title: title || newId, status: src.status, milestone, priority, effort, depends_on: [], summary };
455
+ if (owner !== undefined) fm.owner = owner;
456
+ if (collaborators.length) fm.collaborators = collaborators;
457
+ // Close-date resolution (SPEC §5.1 requires an ISO date). A date field that is
458
+ // PRESENT but malformed is a hard error — never papered over by the fallback chain;
459
+ // only an ABSENT field falls through: extractor → git last-commit date → a
460
+ // `defaults.<field>` floor → unresolved. A git-derived or defaulted date is flagged
461
+ // (a per-item warning + a summary count) so it never passes as authored.
462
+ const resolveCloseDate = (extractor, field) => {
463
+ const raw = runExtractor(extractor, ctx);
464
+ if (raw != null && String(raw).trim() !== "") {
465
+ const authored = toISO(raw);
466
+ return authored
467
+ ? { date: authored }
468
+ : { error: `${field} "${String(raw).trim()}" is not an ISO date (expected YYYY-MM-DD)` };
469
+ }
470
+ // Absent: normalize each fallback through toISO so the resolver boundary is
471
+ // uniformly defensive — the production resolver returns ISO-or-null, but an
472
+ // injected one must not mint a date the downstream gate would later reject.
473
+ const fromGit = toISO(gitDate(src.rel));
474
+ if (fromGit) {
475
+ warnings.push(`${src.rel}: ${field} ${fromGit} derived from git history (source had no date header)`);
476
+ provenance.gitDated++;
477
+ return { date: fromGit };
478
+ }
479
+ const floor = toISO(defaults[field]);
480
+ if (floor) {
481
+ warnings.push(`${src.rel}: ${field} ${floor} from defaults.${field} (no date header, no git date)`);
482
+ provenance.dateDefaulted++;
483
+ return { date: floor };
484
+ }
485
+ return { date: null };
486
+ };
487
+ if (src.status === "completed") {
488
+ const r = resolveCloseDate(fields.completed_on, "completed_on");
489
+ if (r.error) ierr(r.error);
490
+ else if (!r.date) ierr("could not resolve a `completed_on` date (no date header, no git commit date, no `defaults.completed_on`)");
491
+ else fm.completed_on = r.date;
492
+ } else if (src.status === "removed") {
493
+ const r = resolveCloseDate(fields.removed_on, "removed_on");
494
+ if (r.error) ierr(r.error);
495
+ else if (!r.date) ierr("could not resolve a `removed_on` date (no date header, no git commit date, no `defaults.removed_on`)");
496
+ else fm.removed_on = r.date;
497
+ const reason = runExtractor(fields.removed_reason, ctx) || (mapping.defaults && mapping.defaults.removed_reason);
498
+ if (!reason) ierr("missing `removed_reason` (no field value and no `defaults.removed_reason`)"); else fm.removed_reason = String(reason).trim();
499
+ }
500
+
501
+ items.push({ sourceRel: src.rel, status: src.status, sourceId, newId, srcDeps, fm, body, targetRel: `${root}/${STATUS_DIRS[src.status]}/${newId}.md` });
502
+ }
503
+
504
+ // Rewrite depends_on through the id map now that every source id is known. A dep
505
+ // on a collided source id is ambiguous, and one with no match is dangling — both
506
+ // hard errors (the sharp edge from the Risk; never point at the wrong task).
507
+ for (const it of items) {
508
+ const out = [];
509
+ for (const dep of it.srcDeps) {
510
+ const hits = bySourceId.get(dep);
511
+ if (!hits) errors.push(`${it.sourceRel}: depends_on "${dep}" does not resolve to any imported item`);
512
+ else if (hits.length > 1) errors.push(`${it.sourceRel}: depends_on "${dep}" is ambiguous — source id maps to ${hits.length} items (${hits.join(", ")})`);
513
+ else out.push(hits[0]);
514
+ }
515
+ it.fm.depends_on = out;
516
+ }
517
+
518
+ const counts = { active: 0, completed: 0, removed: 0, total: items.length };
519
+ for (const it of items) counts[it.status]++;
520
+ const idMap = items.map((it) => ({ sourceFile: it.sourceRel, sourceId: it.sourceId, newId: it.newId, status: it.status }));
521
+ return { cfg, root, items, idMap, counts, provenance, warnings, errors };
522
+ }
523
+
524
+ // The four generated-artifact paths, repo-relative under the backlog root.
525
+ function generatedRels(root) {
526
+ return [`${root}/README.md`, `${root}/completed/index.md`, `${root}/removed/index.md`, `${root}/backlog.json`];
527
+ }
528
+
529
+ // ---------------------------------------------------------------- apply
530
+ // Execute the plan: write the item files, then regenerate via the TRL0002 core so
531
+ // the backlog is --check-green. On ANY failure, roll back (remove the new items,
532
+ // restore the artifacts) so a rejected import leaves the target exactly as it was.
533
+ // `dryRun` returns the plan without writing. The source tree is never written.
534
+ export function applyImport(targetRoot, sourceRoot, mapping, { dryRun = false, gitDate } = {}) {
535
+ const summary = { imported: [], created: [], generated: [], idMap: [], counts: null, provenance: null, root: null, warnings: [], errors: [] };
536
+ const plan = planImport(targetRoot, sourceRoot, mapping, { gitDate });
537
+ summary.idMap = plan.idMap;
538
+ summary.counts = plan.counts;
539
+ summary.provenance = plan.provenance;
540
+ summary.root = plan.root;
541
+ summary.warnings = plan.warnings;
542
+ if (plan.errors.length) { summary.errors.push(...plan.errors); return { summary }; }
543
+
544
+ if (dryRun) {
545
+ summary.imported = plan.items.map((i) => i.newId);
546
+ summary.created = plan.items.map((i) => i.targetRel);
547
+ summary.generated = generatedRels(plan.root);
548
+ return { summary };
549
+ }
550
+
551
+ const p = paths(targetRoot, plan.cfg);
552
+ const artifacts = [p.readme, p.completedIndex, p.removedIndex, p.backlogJson];
553
+ const priorArtifacts = artifacts.map((path) => ({ path, before: existsSync(path) ? readFileSync(path, "utf8") : null }));
554
+ const written = [];
555
+ try {
556
+ for (const it of plan.items) {
557
+ const abs = join(targetRoot, it.targetRel);
558
+ // Fresh ids never collide with existing items; a pre-existing target file
559
+ // would mean a corrupt plan, so refuse rather than clobber.
560
+ if (existsSync(abs)) throw new Error(`refusing to overwrite existing ${it.targetRel}`);
561
+ mkdirSync(dirname(abs), { recursive: true });
562
+ writeFileSync(abs, composeFile(it.fm, it.body));
563
+ written.push(abs);
564
+ }
565
+ const data = readBacklog(targetRoot, plan.cfg);
566
+ if (data.errors.length) throw new Error(`imported backlog is invalid: ${data.errors.join("; ")}`);
567
+ const { files, errors } = generateArtifacts(targetRoot, plan.cfg, data);
568
+ if (errors.length) throw new Error(`generate failed: ${errors.join("; ")}`);
569
+ for (const f of files) writeFileSync(f.path, f.content);
570
+
571
+ summary.imported = plan.items.map((i) => i.newId);
572
+ summary.created = plan.items.map((i) => i.targetRel);
573
+ summary.generated = files.map((f) => relative(targetRoot, f.path));
574
+ return { summary };
575
+ } catch (e) {
576
+ for (const abs of written) { try { rmSync(abs, { force: true }); } catch { /* best-effort */ } }
577
+ for (const a of priorArtifacts) {
578
+ try { a.before === null ? rmSync(a.path, { force: true }) : writeFileSync(a.path, a.before); } catch { /* best-effort */ }
579
+ }
580
+ summary.errors.push(e.message);
581
+ return { summary };
582
+ }
583
+ }