@ybotok/loam 0.0.0 → 0.1.0-beta.2

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +246 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +142 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +240 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1313 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +388 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +83 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/docs-repo-gate.js +104 -0
  19. package/dist/commands/docs-repo-gate.js.map +1 -0
  20. package/dist/commands/doctor.js +85 -0
  21. package/dist/commands/doctor.js.map +1 -0
  22. package/dist/commands/explore.js +195 -0
  23. package/dist/commands/explore.js.map +1 -0
  24. package/dist/commands/format.js +17 -0
  25. package/dist/commands/format.js.map +1 -0
  26. package/dist/commands/gherkin.js +309 -0
  27. package/dist/commands/gherkin.js.map +1 -0
  28. package/dist/commands/init.js +250 -0
  29. package/dist/commands/init.js.map +1 -0
  30. package/dist/commands/instructions.js +70 -0
  31. package/dist/commands/instructions.js.map +1 -0
  32. package/dist/commands/list.js +291 -0
  33. package/dist/commands/list.js.map +1 -0
  34. package/dist/commands/migrate-openspec.js +950 -0
  35. package/dist/commands/migrate-openspec.js.map +1 -0
  36. package/dist/commands/new.js +413 -0
  37. package/dist/commands/new.js.map +1 -0
  38. package/dist/commands/rebase.js +356 -0
  39. package/dist/commands/rebase.js.map +1 -0
  40. package/dist/commands/show.js +320 -0
  41. package/dist/commands/show.js.map +1 -0
  42. package/dist/commands/status.js +254 -0
  43. package/dist/commands/status.js.map +1 -0
  44. package/dist/commands/unarchive.js +317 -0
  45. package/dist/commands/unarchive.js.map +1 -0
  46. package/dist/commands/validate.js +1591 -0
  47. package/dist/commands/validate.js.map +1 -0
  48. package/dist/commands/verify.js +775 -0
  49. package/dist/commands/verify.js.map +1 -0
  50. package/dist/commands/vouch.js +343 -0
  51. package/dist/commands/vouch.js.map +1 -0
  52. package/dist/core/agent.js +2065 -0
  53. package/dist/core/agent.js.map +1 -0
  54. package/dist/core/agents-stamp.js +68 -0
  55. package/dist/core/agents-stamp.js.map +1 -0
  56. package/dist/core/arch.js +130 -0
  57. package/dist/core/arch.js.map +1 -0
  58. package/dist/core/brief.js +733 -0
  59. package/dist/core/brief.js.map +1 -0
  60. package/dist/core/coherence.js +577 -0
  61. package/dist/core/coherence.js.map +1 -0
  62. package/dist/core/concurrency.js +51 -0
  63. package/dist/core/concurrency.js.map +1 -0
  64. package/dist/core/config.js +180 -0
  65. package/dist/core/config.js.map +1 -0
  66. package/dist/core/delta.js +476 -0
  67. package/dist/core/delta.js.map +1 -0
  68. package/dist/core/dependencies.js +356 -0
  69. package/dist/core/dependencies.js.map +1 -0
  70. package/dist/core/docs.js +144 -0
  71. package/dist/core/docs.js.map +1 -0
  72. package/dist/core/doctor.js +559 -0
  73. package/dist/core/doctor.js.map +1 -0
  74. package/dist/core/document-bytes.js +59 -0
  75. package/dist/core/document-bytes.js.map +1 -0
  76. package/dist/core/explore.js +225 -0
  77. package/dist/core/explore.js.map +1 -0
  78. package/dist/core/fleet-context.js +225 -0
  79. package/dist/core/fleet-context.js.map +1 -0
  80. package/dist/core/frontmatter.js +199 -0
  81. package/dist/core/frontmatter.js.map +1 -0
  82. package/dist/core/gherkin.js +463 -0
  83. package/dist/core/gherkin.js.map +1 -0
  84. package/dist/core/health.js +67 -0
  85. package/dist/core/health.js.map +1 -0
  86. package/dist/core/ids.js +122 -0
  87. package/dist/core/ids.js.map +1 -0
  88. package/dist/core/issue.js +9 -0
  89. package/dist/core/issue.js.map +1 -0
  90. package/dist/core/json.js +74 -0
  91. package/dist/core/json.js.map +1 -0
  92. package/dist/core/likec4.js +479 -0
  93. package/dist/core/likec4.js.map +1 -0
  94. package/dist/core/maturity.js +66 -0
  95. package/dist/core/maturity.js.map +1 -0
  96. package/dist/core/openapi-merge.js +552 -0
  97. package/dist/core/openapi-merge.js.map +1 -0
  98. package/dist/core/openapi.js +255 -0
  99. package/dist/core/openapi.js.map +1 -0
  100. package/dist/core/openspec-inventory.js +1440 -0
  101. package/dist/core/openspec-inventory.js.map +1 -0
  102. package/dist/core/path-safety.js +168 -0
  103. package/dist/core/path-safety.js.map +1 -0
  104. package/dist/core/provenance.js +720 -0
  105. package/dist/core/provenance.js.map +1 -0
  106. package/dist/core/records.js +33 -0
  107. package/dist/core/records.js.map +1 -0
  108. package/dist/core/repo.js +436 -0
  109. package/dist/core/repo.js.map +1 -0
  110. package/dist/core/report.js +47 -0
  111. package/dist/core/report.js.map +1 -0
  112. package/dist/core/results.js +199 -0
  113. package/dist/core/results.js.map +1 -0
  114. package/dist/core/spec.js +481 -0
  115. package/dist/core/spec.js.map +1 -0
  116. package/dist/core/staging.js +890 -0
  117. package/dist/core/staging.js.map +1 -0
  118. package/dist/core/status.js +1104 -0
  119. package/dist/core/status.js.map +1 -0
  120. package/dist/core/steps.js +72 -0
  121. package/dist/core/steps.js.map +1 -0
  122. package/dist/core/verify.js +807 -0
  123. package/dist/core/verify.js.map +1 -0
  124. package/dist/core/version.js +24 -0
  125. package/dist/core/version.js.map +1 -0
  126. package/package.json +63 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam status` — the read-only projection. Nothing here computes anything: the\n * derivation lives in core/status.ts (and, underneath it, in the same coherence\n * and verification calls `validate`, `verify` and `archive` make). This file\n * resolves the target, refuses the arguments it cannot honour, and renders.\n *\n * It reports and does not gate, which is why it never sets a non-zero exit code\n * on a successful run — see `registerStatus`.\n */\nimport type { Command } from \"commander\";\nimport { loadConfig } from \"../core/config.js\";\nimport { FleetContext } from \"../core/fleet-context.js\";\nimport { emitJson, fail, reportNoConfig } from \"../core/json.js\";\nimport { findingJson, SEVERITY_MARK } from \"../core/report.js\";\nimport {\n DocsRepoUnavailableError,\n featureCandidates,\n missingFeatureMessage,\n resolveFeature,\n} from \"../core/repo.js\";\nimport {\n featureStatus,\n fleetStatus,\n type ArtifactState,\n type FeatureStatusReport,\n type FleetStatusReport,\n type InterruptedCommit,\n type NextStep,\n type VerificationState,\n} from \"../core/status.js\";\nimport { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable } from \"./docs-repo-gate.js\";\n\ninterface StatusOptions {\n json?: boolean;\n service?: string;\n}\n\nexport function registerStatus(program: Command): void {\n program\n .command(\"status\")\n .argument(\"[feature]\", \"feature id or directory name; omit for the whole repository\")\n .description(\"Report where the work stands and what to do next, derived from the files\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .option(\"--service <id>\", \"narrow the per-service view to one service\")\n .action(async (featureArg: string | undefined, opts: StatusOptions) => {\n const json = opts.json === true;\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n const { docsDir } = config;\n // A docsDir that is not a docs repo is refused, never rendered as a repo\n // with nothing in it — the doctrine docs-repo-gate.ts's docsRepoReady exists\n // for. `status` counts services, so it needs services/ in both modes.\n if (!docsRepoReady(json, docsDir, \"services\")) return;\n const context = new FleetContext();\n // What the refusal below says it was answering when it gave up.\n let scope = \"this repository\";\n\n try {\n if (featureArg === undefined) {\n const report = await fleetStatus(docsDir, { service: opts.service, context });\n // A `--service` that names nothing is refused rather than answered\n // with an empty fleet: \"no services and no features\" and \"you\n // misspelled it\" are opposite facts, and only one of them is worth\n // acting on.\n if (opts.service !== undefined && report.services.total === 0) {\n fail(json, \"unknown-service\", `No service '${opts.service}' under ${docsDir}/services/.`);\n return;\n }\n if (json) emitJson({ command: \"status\", docsDir, scope: \"fleet\", ...fleetJson(report) });\n else printFleet(report);\n return;\n }\n\n // Archived features are IN scope: an agent asking where FEAT-101 stands\n // after it shipped deserves \"it shipped\", not \"no such feature\" — the\n // same reading `show` takes. An argument naming two directories is\n // still answered (status writes nothing), with the tie broken the way\n // resolveFeature always breaks it.\n const feature = await resolveFeature(docsDir, featureArg, \"include\", context);\n if (!feature) {\n fail(json, \"unknown-target\", await missingFeatureMessage(docsDir, featureArg, context));\n return;\n }\n scope = feature.id;\n // `config.service` is not a lens like `--service`: it says which\n // repository this IS, and three of the steps (`loam gherkin`, `verify\n // --record --service`) refuse anywhere else. Without it status handed a\n // docs-repo reader commands only a service repo can run, and never told\n // a service repo that the work in front of it was its own.\n const report = await featureStatus(docsDir, feature, {\n service: opts.service,\n boundService: config.service,\n context,\n });\n if (opts.service !== undefined && !report.feature.services.includes(opts.service)) {\n fail(\n json,\n \"unknown-service\",\n `${feature.id} carries no delta for '${opts.service}'. It touches: ${\n report.feature.services.length > 0 ? report.feature.services.join(\", \") : \"(no services)\"\n }.`,\n );\n return;\n }\n const ambiguous = await featureCandidates(docsDir, featureArg, \"include\", context);\n if (json) {\n emitJson({ command: \"status\", docsDir, scope: \"feature\", ...featureJson(report) });\n } else {\n printFeature(report, ambiguous.length > 1 ? ambiguous.map((c) => c.dirName) : []);\n }\n } catch (err) {\n if (err instanceof DocsRepoUnavailableError) {\n reportDocsRepoError(json, err);\n return;\n }\n // One unreadable artifact must not escape as a stack trace and an\n // `internal` envelope that names nothing: the projection is\n // all-or-nothing (a status missing one artifact reads as a feature that\n // does not owe it), so the honest answer is a refusal naming the path.\n // The errno-vs-path reading this branch depends on — the reason one\n // directory where a file belongs used to kill BOTH forms of status —\n // now lives once, with its reasoning, on the shared helper.\n reportRepositoryUnavailable(\n json,\n err,\n `the status of ${scope} would be missing an artifact`,\n docsDir,\n );\n }\n // No exit code is set on any path above that succeeded, deliberately.\n // `doctor` exits 1 because a broken installation makes every later\n // command lie, and `validate` exits 1 because it IS the fleet gate. This\n // command is a question, and its answer is equally true whether the\n // feature is finished or not — an agent runs it precisely BECAUSE work is\n // outstanding, and exiting 1 there would make every `set -e` script and\n // every CI step read \"there is work to do\" as a failure.\n });\n}\n\n/* ------------------------------------------------------------------ */\n/* JSON */\n/* ------------------------------------------------------------------ */\n\n/**\n * `issues` go out through `findingJson`, the same serializer `validate` uses:\n * an `Issue` is a `Finding` without details, and one breach reported by two\n * commands must arrive in one shape or a consumer needs two parsers.\n */\nfunction featureJson(r: FeatureStatusReport): Record<string, unknown> {\n return {\n interrupted: r.interrupted,\n feature: r.feature,\n service: r.service,\n artifacts: r.artifacts,\n checks: { ...r.checks, issues: r.checks.issues.map(findingJson) },\n verification: r.verification,\n next: r.next,\n };\n}\n\nfunction fleetJson(r: FleetStatusReport): Record<string, unknown> {\n return {\n interrupted: r.interrupted,\n service: r.service,\n services: r.services,\n features: r.features,\n order: r.order,\n next: r.next,\n };\n}\n\n/* ------------------------------------------------------------------ */\n/* Text */\n/* ------------------------------------------------------------------ */\n\nfunction pad(values: string[]): number {\n return Math.max(0, ...values.map((v) => v.length));\n}\n\n/**\n * The half-merged-repo banner, printed FIRST and on stderr.\n *\n * stderr, alone among this command's output, because it is not part of the\n * answer: it is the reason the answer may be wrong. A reader piping `status`\n * into a pager still sees it, and the human view stays the shape every other\n * run has. `--json` carries the same fact in `interrupted`.\n */\nfunction printInterrupted(i: InterruptedCommit): void {\n console.error(\n i.unreadable\n ? \"⚠ this docs repo holds a .loam-commit that cannot be read — a commit was interrupted and nothing here can be trusted until a human compares the living docs against version control.\"\n : `⚠ a \\`${i.recover}\\` was killed mid-commit (${i.host}, pid ${i.pid}, ${i.at}) — ${i.files.length} file(s) may be half-written: ${i.files.join(\", \")}. Everything below is derived from them.`,\n );\n console.error(\"\");\n}\n\n/** `spec (payment-service)` — the artifact id plus the service, when it has one. */\nfunction artifactLabel(a: ArtifactState): string {\n return a.service === null ? a.id : `${a.id} (${a.service})`;\n}\n\n/**\n * The counts come from the record's `claims:` array (verify's `tallyRecord`),\n * never from its `summary:` block. The verdict is printed beside the state\n * because they answer different questions and only together are they honest:\n * `recorded 4/4 confirmed` used to be the whole line for a feature `verify`\n * calls `attested` — complete, and resting on somebody's word about a test.\n */\nfunction verificationLine(v: VerificationState): string {\n if (v.state === \"absent\") return \"none\";\n if (v.state === \"unreadable\") return \"unreadable\";\n return (\n `${v.state} · ${v.verdict} ${v.confirmed}/${v.claims} confirmed` +\n (v.unanswered > 0 ? ` · ${v.unanswered} unanswered` : \"\") +\n (v.attested > 0 ? ` · ${v.attested} scenario(s) on an agent's word, not a test run` : \"\") +\n ` (recorded ${v.recorded ?? \"?\"})`\n );\n}\n\n/**\n * The steps, numbered, with the command on its own indented line — doctor's\n * layout, for doctor's reason: what to do and what to type are two different\n * sentences, and someone scanning a half-built feature reads down the `run:`\n * column.\n */\nfunction printNext(steps: NextStep[]): void {\n console.log(\"\\n next\");\n for (const [i, step] of steps.entries()) {\n console.log(` ${i + 1}. ${step.code}: ${step.statement}`);\n console.log(` run: ${step.command}`);\n }\n}\n\nfunction printFeature(r: FeatureStatusReport, ambiguous: string[]): void {\n if (r.interrupted !== null) printInterrupted(r.interrupted);\n const f = r.feature;\n console.log(`loam status ${f.id} — ${f.stage}${f.archived ? \" (archived)\" : \"\"}`);\n console.log(` feature ${f.dirName} ${f.path}`);\n console.log(` services ${f.services.length > 0 ? f.services.join(\", \") : \"(none)\"}`);\n if (r.service !== null) console.log(` narrowed to ${r.service}`);\n if (f.blockedBy.length > 0) console.log(` waiting on ${f.blockedBy.join(\", \")}`);\n console.log(\n ` coherence ${\n r.checks.ran\n ? `${r.checks.errors} error(s) · ${r.checks.warnings} warning(s) · ${r.checks.gating} gate archive`\n : \"not run (archived)\"\n }`,\n );\n console.log(` verification ${verificationLine(r.verification)}`);\n if (ambiguous.length > 0) {\n console.log(` note '${f.id}' also names ${ambiguous.filter((d) => d !== f.dirName).join(\", \")}`);\n }\n\n console.log(\"\\n artifacts\");\n const labels = r.artifacts.map(artifactLabel);\n const labelWidth = pad(labels);\n const statusWidth = pad(r.artifacts.map((a) => a.status));\n for (const [i, a] of r.artifacts.entries()) {\n // `done` on a file that is not there is the one row a reader misreads: it\n // means the feature owes no such artifact, not that one was written. The\n // JSON contract says it with `exists`; the human view has to say it in\n // words, or the table quietly claims files that do not exist.\n const note =\n a.blockedBy.length > 0\n ? ` (needs ${a.blockedBy.join(\", \")})`\n : !a.exists && a.status === \"done\"\n ? \" (not written — none owed)\"\n : \"\";\n console.log(\n ` ${a.status.padEnd(statusWidth)} ${labels[i]!.padEnd(labelWidth)} ${a.path}${note}`.trimEnd(),\n );\n }\n\n if (r.checks.issues.length > 0) {\n console.log(\"\\n findings\");\n for (const issue of r.checks.issues) {\n console.log(` ${SEVERITY_MARK[issue.severity]} ${issue.code}: ${issue.message}`);\n }\n }\n\n printNext(r.next);\n}\n\nfunction printFleet(r: FleetStatusReport): void {\n if (r.interrupted !== null) printInterrupted(r.interrupted);\n const s = r.services;\n console.log(`loam status — ${s.total} service(s) · ${r.features.length} feature(s) in flight`);\n console.log(\n ` services ${s.undocumented} undocumented · ${s.draft} draft · ${s.vouched} vouched`,\n );\n if (r.service !== null) console.log(` narrowed to ${r.service}`);\n if (r.order.length > 1) console.log(` order ${r.order.join(\" → \")}`);\n\n if (r.features.length > 0) {\n console.log(\"\\n features\");\n const ids = pad(r.features.map((f) => f.id));\n const stages = pad(r.features.map((f) => f.stage));\n for (const f of r.features) {\n const detail =\n f.blockedBy.length > 0\n ? `waiting on ${f.blockedBy.join(\", \")}`\n : f.missing.length > 0\n ? `missing ${f.missing.join(\", \")}`\n : f.services.join(\", \");\n console.log(` ${f.stage.padEnd(stages)} ${f.id.padEnd(ids)} ${detail}`.trimEnd());\n }\n }\n\n printNext(r.next);\n}\n"]}
@@ -0,0 +1,317 @@
1
+ import { existsSync, lstatSync } from "node:fs";
2
+ import { readdir, readFile, rename, rmdir } from "node:fs/promises";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { emitJson, fail, repoPath, reportNoConfig } from "../core/json.js";
6
+ import { resolveInside, resolvePortableFileInside } from "../core/path-safety.js";
7
+ import { isRecord } from "../core/records.js";
8
+ import { archiveDir as archiveRoot, featuresDir as featuresRoot, resolveFeature } from "../core/repo.js";
9
+ import { sayRecovery } from "./archive.js";
10
+ import { acquireDocsLock, clearCommitIntent, DocsBusyError, InterruptedCommitError, message, planWrite, quietRm, recoverInterruptedCommit, rollbackError, rollbackStaged, sha256, stageWrites, swapStaged, writeCommitIntent, SNAPSHOT_DIR, SNAPSHOT_MANIFEST, SNAPSHOT_VERSION, } from "../core/staging.js";
11
+ /**
12
+ * A commit-phase failure with its stable `--json` code attached — the mirror of
13
+ * archive's discipline. `restore-failed` answers "can I trust the repo?" with
14
+ * yes: nothing was restored, or everything was rolled back, and re-running can
15
+ * work. `rollback-incomplete` is the code that demands a human: the restore
16
+ * failed AND some files could not be put back — the message lists them.
17
+ * Anything ELSE that escapes runUnarchive never touched the commit phase, so
18
+ * the action handler reports it as `restore-failed`.
19
+ */
20
+ class RestoreFailure extends Error {
21
+ code;
22
+ constructor(code, msg) {
23
+ super(msg);
24
+ this.code = code;
25
+ }
26
+ }
27
+ export function registerUnarchive(program) {
28
+ program
29
+ .command("unarchive")
30
+ .argument("<featureId>", "feature id, e.g. FEAT-101")
31
+ .description("Take back a `loam archive`: restore the living docs and re-open the feature")
32
+ .option("--json", "emit the machine contract instead of the human view")
33
+ .option("--force", "restore even if the living docs changed after the archive (discards those changes)")
34
+ .action(async (featureId, opts) => {
35
+ const json = opts.json === true;
36
+ try {
37
+ await runUnarchive(featureId, json, opts.force === true);
38
+ }
39
+ catch (err) {
40
+ const code = err instanceof RestoreFailure
41
+ ? err.code
42
+ : err instanceof InterruptedCommitError
43
+ ? "commit-interrupted"
44
+ : "restore-failed";
45
+ fail(json, code, `unarchive ${featureId} failed: ${message(err)}`);
46
+ }
47
+ });
48
+ }
49
+ async function runUnarchive(featureId, json, force) {
50
+ const config = await loadConfig();
51
+ if (!config) {
52
+ reportNoConfig(json);
53
+ return;
54
+ }
55
+ // The same lock archive takes, for the same reason: unarchive rewrites the
56
+ // living spec, contract and landscape from a snapshot, and an archive landing
57
+ // inside that window would have its merge silently reverted by our restore —
58
+ // or ours by its merge. One writer per docs repo, whichever direction it
59
+ // writes in.
60
+ let release;
61
+ try {
62
+ release = await acquireDocsLock(config.docsDir);
63
+ }
64
+ catch (err) {
65
+ if (err instanceof DocsBusyError)
66
+ throw new RestoreFailure("docs-busy", err.message);
67
+ throw err;
68
+ }
69
+ try {
70
+ await unarchiveLocked(config.docsDir, featureId, json, force);
71
+ }
72
+ finally {
73
+ await release();
74
+ }
75
+ }
76
+ async function unarchiveLocked(docsDir, featureId, json, force) {
77
+ // Same first move as archive, under the same lock: a commit killed between two
78
+ // renames is exactly the state an undo must not be computed on top of.
79
+ const recovered = await recoverInterruptedCommit(docsDir);
80
+ if (recovered !== null && !json)
81
+ sayRecovery(recovered);
82
+ const featuresDir = featuresRoot(docsDir);
83
+ const archiveDir = archiveRoot(docsDir);
84
+ const feature = await resolveFeature(docsDir, featureId, "only");
85
+ if (!feature) {
86
+ fail(json, "unknown-target", `No archived feature '${featureId}' under ${archiveDir}.`);
87
+ return;
88
+ }
89
+ const dest = join(featuresDir, feature.dirName);
90
+ // Refuse to clobber. A feature that is active again carries its own delta, and
91
+ // dropping the archived copy on top of it would bury work in flight. Resolved
92
+ // by the feature's OWN id, not the raw argument — the collision is with
93
+ // whatever answers to this feature's name, however the caller spelled it.
94
+ const active = await resolveFeature(docsDir, feature.id, "exclude");
95
+ if (active !== null || existsSync(dest)) {
96
+ const name = active?.dirName ?? feature.dirName;
97
+ fail(json, "feature-active", `unarchive ${feature.id} — BLOCKED: features/${name} already exists. Remove or rename it, then re-run.`);
98
+ return;
99
+ }
100
+ const manifest = await readManifest(feature.dir, docsDir, feature.id, feature.dirName);
101
+ if (manifest === null) {
102
+ fail(json, "snapshot-missing", `unarchive ${feature.id} — BLOCKED: features/archive/${feature.dirName}/${SNAPSHOT_DIR}/ is missing or was written by a different loam. ` +
103
+ `It was archived before loam recorded what it overwrote, and the previous text of a MODIFIED requirement exists nowhere else — ` +
104
+ `restore the living docs from version control instead.`);
105
+ return;
106
+ }
107
+ // What archive wrote is recorded, so a file edited since can be told from one
108
+ // this archive is still the top of. Restoring over a later change is silent data
109
+ // loss, so it takes --force to say it was meant.
110
+ const writes = [];
111
+ const drifted = [];
112
+ // Pre-images whose bytes are not the bytes archive wrote down. Collected
113
+ // whole, then refused together: "which files" is what makes the message
114
+ // actionable, and no destination is staged until every one is accounted for.
115
+ const corrupt = [];
116
+ for (const entry of manifest.files) {
117
+ const current = existsSync(entry.target) ? await readFile(entry.target) : null;
118
+ const now = current === null ? null : sha256(current);
119
+ // Drift is "restoring this would destroy something", so a file already
120
+ // holding its OWN pre-image is not drifted — putting it back writes the
121
+ // same bytes. It is also the state an interrupted restore leaves behind
122
+ // (the repair finishes the swaps it can, then this command re-runs): the
123
+ // narrower test used to call every repaired file a later change and demand
124
+ // `--force` to discard this command's own half-finished undo.
125
+ const restored = entry.existed ? now === entry.before : now === null;
126
+ if (now !== entry.after && !restored)
127
+ drifted.push(entry.path);
128
+ let before = null;
129
+ if (entry.snapshot !== null) {
130
+ before = await readFile(entry.snapshot);
131
+ // The one check that was missing entirely. `after` describes what archive
132
+ // WROTE and is checked above; nothing described the bytes it would
133
+ // RESTORE, so a pre-image edited inside the archived feature — a bad
134
+ // merge, a rebase, an editor — was written back verbatim and this command
135
+ // said "the living docs are back to what they said before the archive"
136
+ // over text nobody ever wrote.
137
+ if (sha256(before) !== entry.before)
138
+ corrupt.push(entry.path);
139
+ }
140
+ // A restore whose destination is GONE is a create, and takes the same
141
+ // no-clobber swap an archive's creates take: between the manifest read and
142
+ // the swap, another writer may have put that path back, and a rename would
143
+ // bury it. The same helper both commands use, so neither can decide
144
+ // differently about the same file.
145
+ writes.push(planWrite(entry.target, before));
146
+ }
147
+ if (corrupt.length > 0) {
148
+ fail(json, "snapshot-corrupt", `unarchive ${feature.id} — BLOCKED: ${corrupt.length} snapshot pre-image(s) no longer match the digest archive ` +
149
+ `recorded for them, so restoring them would write text nobody authored: ${corrupt.join(", ")}. ` +
150
+ `--force does not override this — it discards LATER changes to the living docs, and the damage here is to the ` +
151
+ `undo itself. Restore the living docs from version control instead.`);
152
+ return;
153
+ }
154
+ if (drifted.length > 0 && !force) {
155
+ fail(json, "snapshot-stale", `unarchive ${feature.id} — BLOCKED: ${drifted.length} file(s) changed since the archive, so this is no longer an undo: ` +
156
+ `${drifted.join(", ")}. Re-run with --force to restore anyway, discarding those changes.`);
157
+ return;
158
+ }
159
+ if (!json) {
160
+ console.log(`unarchive ${feature.id}\n`);
161
+ if (drifted.length > 0) {
162
+ console.log(`⚠ discarding later changes to ${drifted.length} file(s) (--force):`);
163
+ for (const p of drifted)
164
+ console.log(` ⚠ ${p}`);
165
+ console.log("");
166
+ }
167
+ }
168
+ const staged = await stageWrites(writes);
169
+ try {
170
+ // The same journal archive writes, in the other direction: a restore killed
171
+ // between two renames leaves the docs half-back, and the merged text it was
172
+ // replacing is recorded nowhere — so a repair FINISHES this restore rather
173
+ // than undoing it, from the same digest-checked pre-images.
174
+ await writeCommitIntent(docsDir, { command: "unarchive", restore: "after", feature: feature.id, moveFrom: feature.dir, moveTo: dest }, staged);
175
+ await swapStaged(staged);
176
+ await rename(feature.dir, dest);
177
+ await clearCommitIntent(docsDir);
178
+ }
179
+ catch (err) {
180
+ // The code is a caller's answer to "can I trust the repo?": restore-failed
181
+ // means yes (rolled back), rollback-incomplete means look at it by hand —
182
+ // rollbackError's message lists the files that need one.
183
+ const failures = await rollbackStaged(staged);
184
+ await clearCommitIntent(docsDir);
185
+ const wrapped = rollbackError(err, failures);
186
+ throw new RestoreFailure(failures.length > 0 ? "rollback-incomplete" : "restore-failed", wrapped.message);
187
+ }
188
+ // The snapshot describes an archive that no longer exists; the empty directories
189
+ // are what the merge left behind. Both are tidying — neither can fail the undo.
190
+ await quietRm(join(dest, SNAPSHOT_DIR));
191
+ for (const w of writes)
192
+ if (w.content === null)
193
+ await pruneEmptyDirs(dirname(w.path), docsDir);
194
+ await pruneEmptyDirs(archiveDir, docsDir);
195
+ const restored = writes.filter((w) => w.content !== null).map((w) => repoPath(docsDir, w.path));
196
+ const removed = writes.filter((w) => w.content === null).map((w) => repoPath(docsDir, w.path));
197
+ if (json) {
198
+ emitJson({
199
+ feature: feature.id,
200
+ dirName: feature.dirName,
201
+ path: repoPath(docsDir, dest),
202
+ restored,
203
+ removed,
204
+ discarded: drifted,
205
+ ...(recovered === null ? {} : { recovered }),
206
+ });
207
+ return;
208
+ }
209
+ for (const p of restored)
210
+ console.log(` restored: ${p}`);
211
+ for (const p of removed)
212
+ console.log(` removed: ${p}`);
213
+ console.log(`\n reopened: features/archive/${feature.dirName} → features/${feature.dirName}`);
214
+ console.log(" the living docs are back to what they said before the archive.");
215
+ }
216
+ async function readManifest(featureDir, docsDir, featureId, dirName) {
217
+ try {
218
+ const path = resolveInside(featureDir, join(SNAPSHOT_DIR, SNAPSHOT_MANIFEST), "snapshot manifest path");
219
+ if (!existsSync(path))
220
+ return null;
221
+ const parsed = JSON.parse(await readFile(path, "utf8"));
222
+ if (!isRecord(parsed))
223
+ return null;
224
+ if (parsed.version !== SNAPSHOT_VERSION)
225
+ return null;
226
+ if (parsed.feature !== featureId || parsed.dirName !== dirName)
227
+ return null;
228
+ if (typeof parsed.archivedAt !== "string" || !isCanonicalIsoDate(parsed.archivedAt))
229
+ return null;
230
+ if (!Array.isArray(parsed.files))
231
+ return null;
232
+ const seen = new Set();
233
+ const files = [];
234
+ for (const raw of parsed.files) {
235
+ if (!isRecord(raw))
236
+ return null;
237
+ if (typeof raw.path !== "string" || typeof raw.existed !== "boolean")
238
+ return null;
239
+ if (typeof raw.after !== "string" || !/^[0-9a-f]{64}$/.test(raw.after))
240
+ return null;
241
+ // A manifest that claims a pre-image must say what it should hash to, and
242
+ // one that claims none must not. Either way it is a shape question, so it
243
+ // is answered here with the rest of them; whether the bytes MATCH is a
244
+ // different answer with its own code (`snapshot-corrupt`).
245
+ if (raw.existed ? typeof raw.before !== "string" || !/^[0-9a-f]{64}$/.test(raw.before) : raw.before !== null) {
246
+ return null;
247
+ }
248
+ const before = raw.existed ? raw.before : null;
249
+ if (seen.has(raw.path))
250
+ return null;
251
+ seen.add(raw.path);
252
+ // The realpath test stays on this side, and it is not negotiable: this
253
+ // path is WRITTEN through. A symlink that leaves the repo is
254
+ // indistinguishable, lexically, from a service directory the operator
255
+ // mounted on purpose — `escape/owned.txt` and `services/<svc>/spec.md`
256
+ // are both "inside docsDir" until something resolves them — so the only
257
+ // check that can refuse the first is the one that also refuses the
258
+ // second. Restoring is the write; `staging.ts` only reads and compares a
259
+ // digest, which is why its resolution of this same field can be lexical
260
+ // and this one cannot.
261
+ const target = resolvePortableFileInside(docsDir, raw.path, `snapshot path '${raw.path}'`);
262
+ let snapshot = null;
263
+ const snapshotRel = `${SNAPSHOT_DIR}/files/${raw.path}`;
264
+ if (raw.existed) {
265
+ // Resolve from the feature directory, which always exists. The `files/`
266
+ // directory legitimately does not exist when every archive write was a
267
+ // creation; anchoring here permits that case while still inspecting an
268
+ // existing `files` component for symlink escape.
269
+ snapshot = resolvePortableFileInside(featureDir, snapshotRel, `snapshot pre-image '${raw.path}'`);
270
+ // Archive writes plain files. A missing pre-image or a symlink (even an
271
+ // internally-contained one) is not the byte snapshot this manifest
272
+ // claims, so refuse before any destination is staged.
273
+ if (!existsSync(snapshot) || !lstatSync(snapshot).isFile())
274
+ return null;
275
+ }
276
+ else {
277
+ const unexpected = resolvePortableFileInside(featureDir, snapshotRel, `snapshot pre-image '${raw.path}'`);
278
+ if (existsSync(unexpected))
279
+ return null;
280
+ }
281
+ files.push({ path: raw.path, existed: raw.existed, after: raw.after, before, target, snapshot });
282
+ }
283
+ return {
284
+ version: SNAPSHOT_VERSION,
285
+ feature: featureId,
286
+ dirName,
287
+ archivedAt: parsed.archivedAt,
288
+ files,
289
+ };
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ function isCanonicalIsoDate(value) {
296
+ const millis = Date.parse(value);
297
+ return Number.isFinite(millis) && new Date(millis).toISOString() === value;
298
+ }
299
+ /** Remove `dir` and each empty ancestor, stopping short of `stopAt`. Best effort. */
300
+ async function pruneEmptyDirs(dir, stopAt) {
301
+ let cur = dir;
302
+ for (;;) {
303
+ const rel = relative(stopAt, cur);
304
+ if (rel === "" || rel.startsWith(".."))
305
+ return;
306
+ try {
307
+ if ((await readdir(cur)).length > 0)
308
+ return;
309
+ await rmdir(cur);
310
+ }
311
+ catch {
312
+ return;
313
+ }
314
+ cur = dirname(cur);
315
+ }
316
+ }
317
+ //# sourceMappingURL=unarchive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unarchive.js","sourceRoot":"","sources":["../../src/commands/unarchive.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam unarchive <FEAT>` — take back a `loam archive`.\n *\n * Not an inverse merge, and it cannot be one. Undoing the requirements axis would\n * mean restoring a MODIFIED requirement's PREVIOUS text, which appears nowhere:\n * the delta records what the requirement became, never what it was. The landscape\n * axis is no better — the merge drops the feature tags, so the added lines are\n * indistinguishable from lines that were always there. Anything reconstructed\n * from the archived feature would be a plausible guess at the old docs, which is\n * exactly the kind of quiet fiction the rest of loam exists to prevent.\n *\n * So archive writes the bytes down instead (`<feature>/.loam-before/`), and this\n * command puts them back. The snapshot is optional on restore: a feature archived\n * before it existed fails with a clear message rather than a crash.\n */\nimport type { Command } from \"commander\";\nimport { existsSync, lstatSync } from \"node:fs\";\nimport { readdir, readFile, rename, rmdir } from \"node:fs/promises\";\nimport { dirname, join, relative } from \"node:path\";\nimport { loadConfig } from \"../core/config.js\";\nimport { emitJson, fail, repoPath, reportNoConfig, type ErrorCode } from \"../core/json.js\";\nimport { resolveInside, resolvePortableFileInside } from \"../core/path-safety.js\";\nimport { isRecord } from \"../core/records.js\";\nimport { archiveDir as archiveRoot, featuresDir as featuresRoot, resolveFeature } from \"../core/repo.js\";\nimport { sayRecovery } from \"./archive.js\";\nimport {\n acquireDocsLock,\n clearCommitIntent,\n DocsBusyError,\n InterruptedCommitError,\n message,\n planWrite,\n quietRm,\n recoverInterruptedCommit,\n rollbackError,\n rollbackStaged,\n sha256,\n stageWrites,\n swapStaged,\n writeCommitIntent,\n SNAPSHOT_DIR,\n SNAPSHOT_MANIFEST,\n SNAPSHOT_VERSION,\n type PlannedWrite,\n type SnapshotManifest,\n} from \"../core/staging.js\";\n\ninterface UnarchiveOptions {\n json?: boolean;\n force?: boolean;\n}\n\n/**\n * A commit-phase failure with its stable `--json` code attached — the mirror of\n * archive's discipline. `restore-failed` answers \"can I trust the repo?\" with\n * yes: nothing was restored, or everything was rolled back, and re-running can\n * work. `rollback-incomplete` is the code that demands a human: the restore\n * failed AND some files could not be put back — the message lists them.\n * Anything ELSE that escapes runUnarchive never touched the commit phase, so\n * the action handler reports it as `restore-failed`.\n */\nclass RestoreFailure extends Error {\n constructor(\n readonly code: ErrorCode,\n msg: string,\n ) {\n super(msg);\n }\n}\n\nexport function registerUnarchive(program: Command): void {\n program\n .command(\"unarchive\")\n .argument(\"<featureId>\", \"feature id, e.g. FEAT-101\")\n .description(\"Take back a `loam archive`: restore the living docs and re-open the feature\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .option(\"--force\", \"restore even if the living docs changed after the archive (discards those changes)\")\n .action(async (featureId: string, opts: UnarchiveOptions) => {\n const json = opts.json === true;\n try {\n await runUnarchive(featureId, json, opts.force === true);\n } catch (err) {\n const code = err instanceof RestoreFailure\n ? err.code\n : err instanceof InterruptedCommitError\n ? \"commit-interrupted\"\n : \"restore-failed\";\n fail(json, code, `unarchive ${featureId} failed: ${message(err)}`);\n }\n });\n}\n\nasync function runUnarchive(featureId: string, json: boolean, force: boolean): Promise<void> {\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n // The same lock archive takes, for the same reason: unarchive rewrites the\n // living spec, contract and landscape from a snapshot, and an archive landing\n // inside that window would have its merge silently reverted by our restore —\n // or ours by its merge. One writer per docs repo, whichever direction it\n // writes in.\n let release: () => Promise<void>;\n try {\n release = await acquireDocsLock(config.docsDir);\n } catch (err) {\n if (err instanceof DocsBusyError) throw new RestoreFailure(\"docs-busy\", err.message);\n throw err;\n }\n try {\n await unarchiveLocked(config.docsDir, featureId, json, force);\n } finally {\n await release();\n }\n}\n\nasync function unarchiveLocked(\n docsDir: string,\n featureId: string,\n json: boolean,\n force: boolean,\n): Promise<void> {\n // Same first move as archive, under the same lock: a commit killed between two\n // renames is exactly the state an undo must not be computed on top of.\n const recovered = await recoverInterruptedCommit(docsDir);\n if (recovered !== null && !json) sayRecovery(recovered);\n\n const featuresDir = featuresRoot(docsDir);\n const archiveDir = archiveRoot(docsDir);\n\n const feature = await resolveFeature(docsDir, featureId, \"only\");\n if (!feature) {\n fail(json, \"unknown-target\", `No archived feature '${featureId}' under ${archiveDir}.`);\n return;\n }\n const dest = join(featuresDir, feature.dirName);\n\n // Refuse to clobber. A feature that is active again carries its own delta, and\n // dropping the archived copy on top of it would bury work in flight. Resolved\n // by the feature's OWN id, not the raw argument — the collision is with\n // whatever answers to this feature's name, however the caller spelled it.\n const active = await resolveFeature(docsDir, feature.id, \"exclude\");\n if (active !== null || existsSync(dest)) {\n const name = active?.dirName ?? feature.dirName;\n fail(\n json,\n \"feature-active\",\n `unarchive ${feature.id} — BLOCKED: features/${name} already exists. Remove or rename it, then re-run.`,\n );\n return;\n }\n\n const manifest = await readManifest(feature.dir, docsDir, feature.id, feature.dirName);\n if (manifest === null) {\n fail(\n json,\n \"snapshot-missing\",\n `unarchive ${feature.id} — BLOCKED: features/archive/${feature.dirName}/${SNAPSHOT_DIR}/ is missing or was written by a different loam. ` +\n `It was archived before loam recorded what it overwrote, and the previous text of a MODIFIED requirement exists nowhere else — ` +\n `restore the living docs from version control instead.`,\n );\n return;\n }\n\n // What archive wrote is recorded, so a file edited since can be told from one\n // this archive is still the top of. Restoring over a later change is silent data\n // loss, so it takes --force to say it was meant.\n const writes: PlannedWrite[] = [];\n const drifted: string[] = [];\n // Pre-images whose bytes are not the bytes archive wrote down. Collected\n // whole, then refused together: \"which files\" is what makes the message\n // actionable, and no destination is staged until every one is accounted for.\n const corrupt: string[] = [];\n for (const entry of manifest.files) {\n const current = existsSync(entry.target) ? await readFile(entry.target) : null;\n const now = current === null ? null : sha256(current);\n // Drift is \"restoring this would destroy something\", so a file already\n // holding its OWN pre-image is not drifted — putting it back writes the\n // same bytes. It is also the state an interrupted restore leaves behind\n // (the repair finishes the swaps it can, then this command re-runs): the\n // narrower test used to call every repaired file a later change and demand\n // `--force` to discard this command's own half-finished undo.\n const restored = entry.existed ? now === entry.before : now === null;\n if (now !== entry.after && !restored) drifted.push(entry.path);\n let before: Buffer | null = null;\n if (entry.snapshot !== null) {\n before = await readFile(entry.snapshot);\n // The one check that was missing entirely. `after` describes what archive\n // WROTE and is checked above; nothing described the bytes it would\n // RESTORE, so a pre-image edited inside the archived feature — a bad\n // merge, a rebase, an editor — was written back verbatim and this command\n // said \"the living docs are back to what they said before the archive\"\n // over text nobody ever wrote.\n if (sha256(before) !== entry.before) corrupt.push(entry.path);\n }\n // A restore whose destination is GONE is a create, and takes the same\n // no-clobber swap an archive's creates take: between the manifest read and\n // the swap, another writer may have put that path back, and a rename would\n // bury it. The same helper both commands use, so neither can decide\n // differently about the same file.\n writes.push(planWrite(entry.target, before));\n }\n if (corrupt.length > 0) {\n fail(\n json,\n \"snapshot-corrupt\",\n `unarchive ${feature.id} — BLOCKED: ${corrupt.length} snapshot pre-image(s) no longer match the digest archive ` +\n `recorded for them, so restoring them would write text nobody authored: ${corrupt.join(\", \")}. ` +\n `--force does not override this — it discards LATER changes to the living docs, and the damage here is to the ` +\n `undo itself. Restore the living docs from version control instead.`,\n );\n return;\n }\n if (drifted.length > 0 && !force) {\n fail(\n json,\n \"snapshot-stale\",\n `unarchive ${feature.id} — BLOCKED: ${drifted.length} file(s) changed since the archive, so this is no longer an undo: ` +\n `${drifted.join(\", \")}. Re-run with --force to restore anyway, discarding those changes.`,\n );\n return;\n }\n\n if (!json) {\n console.log(`unarchive ${feature.id}\\n`);\n if (drifted.length > 0) {\n console.log(`⚠ discarding later changes to ${drifted.length} file(s) (--force):`);\n for (const p of drifted) console.log(` ⚠ ${p}`);\n console.log(\"\");\n }\n }\n\n const staged = await stageWrites(writes);\n try {\n // The same journal archive writes, in the other direction: a restore killed\n // between two renames leaves the docs half-back, and the merged text it was\n // replacing is recorded nowhere — so a repair FINISHES this restore rather\n // than undoing it, from the same digest-checked pre-images.\n await writeCommitIntent(\n docsDir,\n { command: \"unarchive\", restore: \"after\", feature: feature.id, moveFrom: feature.dir, moveTo: dest },\n staged,\n );\n await swapStaged(staged);\n await rename(feature.dir, dest);\n await clearCommitIntent(docsDir);\n } catch (err) {\n // The code is a caller's answer to \"can I trust the repo?\": restore-failed\n // means yes (rolled back), rollback-incomplete means look at it by hand —\n // rollbackError's message lists the files that need one.\n const failures = await rollbackStaged(staged);\n await clearCommitIntent(docsDir);\n const wrapped = rollbackError(err, failures);\n throw new RestoreFailure(failures.length > 0 ? \"rollback-incomplete\" : \"restore-failed\", wrapped.message);\n }\n\n // The snapshot describes an archive that no longer exists; the empty directories\n // are what the merge left behind. Both are tidying — neither can fail the undo.\n await quietRm(join(dest, SNAPSHOT_DIR));\n for (const w of writes) if (w.content === null) await pruneEmptyDirs(dirname(w.path), docsDir);\n await pruneEmptyDirs(archiveDir, docsDir);\n\n const restored = writes.filter((w) => w.content !== null).map((w) => repoPath(docsDir, w.path));\n const removed = writes.filter((w) => w.content === null).map((w) => repoPath(docsDir, w.path));\n\n if (json) {\n emitJson({\n feature: feature.id,\n dirName: feature.dirName,\n path: repoPath(docsDir, dest),\n restored,\n removed,\n discarded: drifted,\n ...(recovered === null ? {} : { recovered }),\n });\n return;\n }\n for (const p of restored) console.log(` restored: ${p}`);\n for (const p of removed) console.log(` removed: ${p}`);\n console.log(`\\n reopened: features/archive/${feature.dirName} → features/${feature.dirName}`);\n console.log(\" the living docs are back to what they said before the archive.\");\n}\n\n/**\n * The snapshot manifest, or null when there is nothing this command can honour —\n * absent, unreadable, or a layout version it would have to guess at. All three are\n * the same answer to the caller: this feature cannot be unarchived automatically.\n */\ninterface ValidatedSnapshotEntry {\n path: string;\n existed: boolean;\n after: string;\n /** sha256 the pre-image beside the manifest must still have; null when archive created the destination. */\n before: string | null;\n /** Contained destination under the docs repo. */\n target: string;\n /** Contained pre-image, or null when archive created the destination. */\n snapshot: string | null;\n}\n\ninterface ValidatedSnapshotManifest extends Omit<SnapshotManifest, \"files\"> {\n files: ValidatedSnapshotEntry[];\n}\n\nasync function readManifest(\n featureDir: string,\n docsDir: string,\n featureId: string,\n dirName: string,\n): Promise<ValidatedSnapshotManifest | null> {\n try {\n const path = resolveInside(\n featureDir,\n join(SNAPSHOT_DIR, SNAPSHOT_MANIFEST),\n \"snapshot manifest path\",\n );\n if (!existsSync(path)) return null;\n const parsed = JSON.parse(await readFile(path, \"utf8\")) as unknown;\n if (!isRecord(parsed)) return null;\n if (parsed.version !== SNAPSHOT_VERSION) return null;\n if (parsed.feature !== featureId || parsed.dirName !== dirName) return null;\n if (typeof parsed.archivedAt !== \"string\" || !isCanonicalIsoDate(parsed.archivedAt)) return null;\n if (!Array.isArray(parsed.files)) return null;\n\n const seen = new Set<string>();\n const files: ValidatedSnapshotEntry[] = [];\n for (const raw of parsed.files) {\n if (!isRecord(raw)) return null;\n if (typeof raw.path !== \"string\" || typeof raw.existed !== \"boolean\") return null;\n if (typeof raw.after !== \"string\" || !/^[0-9a-f]{64}$/.test(raw.after)) return null;\n // A manifest that claims a pre-image must say what it should hash to, and\n // one that claims none must not. Either way it is a shape question, so it\n // is answered here with the rest of them; whether the bytes MATCH is a\n // different answer with its own code (`snapshot-corrupt`).\n if (raw.existed ? typeof raw.before !== \"string\" || !/^[0-9a-f]{64}$/.test(raw.before) : raw.before !== null) {\n return null;\n }\n const before = raw.existed ? (raw.before as string) : null;\n if (seen.has(raw.path)) return null;\n seen.add(raw.path);\n\n // The realpath test stays on this side, and it is not negotiable: this\n // path is WRITTEN through. A symlink that leaves the repo is\n // indistinguishable, lexically, from a service directory the operator\n // mounted on purpose — `escape/owned.txt` and `services/<svc>/spec.md`\n // are both \"inside docsDir\" until something resolves them — so the only\n // check that can refuse the first is the one that also refuses the\n // second. Restoring is the write; `staging.ts` only reads and compares a\n // digest, which is why its resolution of this same field can be lexical\n // and this one cannot.\n const target = resolvePortableFileInside(docsDir, raw.path, `snapshot path '${raw.path}'`);\n let snapshot: string | null = null;\n const snapshotRel = `${SNAPSHOT_DIR}/files/${raw.path}`;\n if (raw.existed) {\n // Resolve from the feature directory, which always exists. The `files/`\n // directory legitimately does not exist when every archive write was a\n // creation; anchoring here permits that case while still inspecting an\n // existing `files` component for symlink escape.\n snapshot = resolvePortableFileInside(featureDir, snapshotRel, `snapshot pre-image '${raw.path}'`);\n // Archive writes plain files. A missing pre-image or a symlink (even an\n // internally-contained one) is not the byte snapshot this manifest\n // claims, so refuse before any destination is staged.\n if (!existsSync(snapshot) || !lstatSync(snapshot).isFile()) return null;\n } else {\n const unexpected = resolvePortableFileInside(featureDir, snapshotRel, `snapshot pre-image '${raw.path}'`);\n if (existsSync(unexpected)) return null;\n }\n files.push({ path: raw.path, existed: raw.existed, after: raw.after, before, target, snapshot });\n }\n\n return {\n version: SNAPSHOT_VERSION,\n feature: featureId,\n dirName,\n archivedAt: parsed.archivedAt,\n files,\n };\n } catch {\n return null;\n }\n}\n\nfunction isCanonicalIsoDate(value: string): boolean {\n const millis = Date.parse(value);\n return Number.isFinite(millis) && new Date(millis).toISOString() === value;\n}\n\n/** Remove `dir` and each empty ancestor, stopping short of `stopAt`. Best effort. */\nasync function pruneEmptyDirs(dir: string, stopAt: string): Promise<void> {\n let cur = dir;\n for (;;) {\n const rel = relative(stopAt, cur);\n if (rel === \"\" || rel.startsWith(\"..\")) return;\n try {\n if ((await readdir(cur)).length > 0) return;\n await rmdir(cur);\n } catch {\n return;\n }\n cur = dirname(cur);\n }\n}\n"]}