@intentius/chant 0.53.0 → 0.54.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.
Files changed (75) hide show
  1. package/dist/audit/catalog.d.ts +4 -0
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/nginx.d.ts +53 -0
  4. package/dist/audit/nginx.d.ts.map +1 -0
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-status.d.ts +56 -0
  9. package/dist/cli/commands/carve-status.d.ts.map +1 -0
  10. package/dist/cli/handlers/carve-status.d.ts +9 -0
  11. package/dist/cli/handlers/carve-status.d.ts.map +1 -0
  12. package/dist/cli/handlers/components.d.ts.map +1 -1
  13. package/dist/cli/handlers/op-progress.d.ts +37 -4
  14. package/dist/cli/handlers/op-progress.d.ts.map +1 -1
  15. package/dist/cli/handlers/operator.d.ts +61 -1
  16. package/dist/cli/handlers/operator.d.ts.map +1 -1
  17. package/dist/cli/handlers/run.d.ts.map +1 -1
  18. package/dist/cli/main.d.ts.map +1 -1
  19. package/dist/cli/registry.d.ts +9 -1
  20. package/dist/cli/registry.d.ts.map +1 -1
  21. package/dist/components/auto-release.d.ts +3 -1
  22. package/dist/components/auto-release.d.ts.map +1 -1
  23. package/dist/components/cli-support.d.ts +2 -0
  24. package/dist/components/cli-support.d.ts.map +1 -1
  25. package/dist/graph-ir.d.ts +7 -0
  26. package/dist/graph-ir.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +8 -0
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/lifecycle/converge-ledger.d.ts +81 -1
  30. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  31. package/dist/lifecycle/gate-ledger.d.ts +43 -1
  32. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  33. package/dist/lifecycle/release-ledger.d.ts +55 -1
  34. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  35. package/dist/op/activity-contract.d.ts +15 -1
  36. package/dist/op/activity-contract.d.ts.map +1 -1
  37. package/dist/terraform/manifest.d.ts +31 -6
  38. package/dist/terraform/manifest.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/audit/catalog.ts +35 -0
  41. package/src/audit/edge-init-safety.test.ts +36 -3
  42. package/src/audit/nginx.test.ts +180 -0
  43. package/src/audit/nginx.ts +379 -0
  44. package/src/audit/rules-doc.ts +1 -0
  45. package/src/cli/commands/audit.ts +8 -3
  46. package/src/cli/commands/carve-apply.test.ts +4 -1
  47. package/src/cli/commands/carve-apply.ts +4 -2
  48. package/src/cli/commands/carve-bridge.test.ts +2 -1
  49. package/src/cli/commands/carve-emit-state.test.ts +3 -1
  50. package/src/cli/commands/carve-status.test.ts +116 -0
  51. package/src/cli/commands/carve-status.ts +163 -0
  52. package/src/cli/handlers/carve-status.ts +30 -0
  53. package/src/cli/handlers/components.ts +5 -1
  54. package/src/cli/handlers/graph.ts +3 -0
  55. package/src/cli/handlers/op-progress.test.ts +67 -1
  56. package/src/cli/handlers/op-progress.ts +70 -14
  57. package/src/cli/handlers/operator.test.ts +302 -1
  58. package/src/cli/handlers/operator.ts +193 -5
  59. package/src/cli/handlers/run.ts +15 -14
  60. package/src/cli/main.test.ts +19 -0
  61. package/src/cli/main.ts +34 -5
  62. package/src/cli/registry.ts +9 -1
  63. package/src/components/auto-release.ts +4 -1
  64. package/src/components/cli-support.ts +4 -2
  65. package/src/graph-ir.ts +7 -0
  66. package/src/lexicon.ts +8 -0
  67. package/src/lifecycle/converge-ledger.test.ts +110 -0
  68. package/src/lifecycle/converge-ledger.ts +110 -2
  69. package/src/lifecycle/gate-ledger.test.ts +81 -1
  70. package/src/lifecycle/gate-ledger.ts +64 -1
  71. package/src/lifecycle/release-ledger.test.ts +64 -0
  72. package/src/lifecycle/release-ledger.ts +79 -1
  73. package/src/op/activity-contract.ts +14 -0
  74. package/src/terraform/manifest.test.ts +52 -0
  75. package/src/terraform/manifest.ts +72 -10
@@ -11,6 +11,7 @@ import {
11
11
  InvalidReleaseRecordError,
12
12
  latestPerComponent,
13
13
  recordsForDigest,
14
+ resolveRunId,
14
15
  type ReleaseRecord,
15
16
  type ReleaseRecordInput,
16
17
  } from "./release-ledger";
@@ -234,4 +235,67 @@ describe("release-ledger", () => {
234
235
  expect(found.every((r) => r.digest === "sha256:shared")).toBe(true);
235
236
  });
236
237
  });
238
+
239
+ describe("resolveRunId (#2045)", () => {
240
+ test("GitHub Actions env resolves id + origin together, with repo and run url", () => {
241
+ const env = {
242
+ GITHUB_RUN_ID: "18234771902",
243
+ GITHUB_REPOSITORY: "INTENTIUS/chant",
244
+ GITHUB_SERVER_URL: "https://github.com",
245
+ };
246
+ expect(resolveRunId(undefined, env)).toEqual({
247
+ runId: "18234771902",
248
+ runOrigin: {
249
+ forge: "github",
250
+ repo: "INTENTIUS/chant",
251
+ url: "https://github.com/INTENTIUS/chant/actions/runs/18234771902",
252
+ },
253
+ });
254
+ });
255
+
256
+ test("a Forgejo instance resolves through the same env contract to its own host", () => {
257
+ const env = {
258
+ GITHUB_RUN_ID: "42",
259
+ GITHUB_REPOSITORY: "intentius/loomster",
260
+ GITHUB_SERVER_URL: "https://forge.example.dev",
261
+ };
262
+ expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/intentius/loomster/actions/runs/42");
263
+ });
264
+
265
+ test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
266
+ const env = {
267
+ CI_PIPELINE_ID: "991",
268
+ CI_PROJECT_PATH: "group/app",
269
+ CI_PIPELINE_URL: "https://gitlab.com/group/app/-/pipelines/991",
270
+ };
271
+ expect(resolveRunId(undefined, env)).toEqual({
272
+ runId: "991",
273
+ runOrigin: { forge: "gitlab", repo: "group/app", url: "https://gitlab.com/group/app/-/pipelines/991" },
274
+ });
275
+ });
276
+
277
+ test("outside CI the mint says local in a field, not only in the id's spelling", () => {
278
+ const { runId, runOrigin } = resolveRunId(undefined, {});
279
+ expect(runId).toMatch(/^local-\d+$/);
280
+ expect(runOrigin).toEqual({ forge: "local" });
281
+ });
282
+
283
+ test("an explicit id gets an origin only when it matches what the environment advertises", () => {
284
+ const env = { GITHUB_RUN_ID: "42", GITHUB_REPOSITORY: "o/r", GITHUB_SERVER_URL: "https://github.com" };
285
+ expect(resolveRunId("42", env).runOrigin?.forge).toBe("github");
286
+ expect(resolveRunId("something-else", env).runOrigin).toBeUndefined();
287
+ });
288
+
289
+ test("a record carrying runOrigin round-trips through the ledger", async () => {
290
+ await withTestDir(async (dir) => {
291
+ await initRepo(dir);
292
+ const input = makeInput({
293
+ runOrigin: { forge: "github", repo: "o/r", url: "https://github.com/o/r/actions/runs/1" },
294
+ });
295
+ await appendReleaseRecord(input, { cwd: dir });
296
+ const { records } = await readReleaseLedger("prod", { cwd: dir });
297
+ expect(records[0].runOrigin).toEqual(input.runOrigin);
298
+ });
299
+ });
300
+ });
237
301
  });
@@ -31,6 +31,25 @@
31
31
  import { sortedJsonReplacer } from "../utils";
32
32
  import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments } from "./git";
33
33
 
34
+ /**
35
+ * The id space a `ReleaseRecord.runId` lives in, and how to reach the run
36
+ * (#2045).
37
+ */
38
+ export interface RunOrigin {
39
+ /**
40
+ * Which id space minted `runId`: `github` (a GitHub Actions run id — also
41
+ * the spelling Forgejo Actions uses, since it implements the same env
42
+ * contract; `url` disambiguates the actual host), `gitlab` (a CI pipeline
43
+ * id), `op` (an orchestrator/Op run id), or `local` (minted on the machine
44
+ * that ran the deploy, resolvable nowhere).
45
+ */
46
+ forge: "github" | "gitlab" | "op" | "local";
47
+ /** Repo/project slug on the forge (`GITHUB_REPOSITORY` / `CI_PROJECT_PATH`), when the environment named one. */
48
+ repo?: string;
49
+ /** Resolved link to the run, when the environment provided enough to build one. */
50
+ url?: string;
51
+ }
52
+
34
53
  /**
35
54
  * One immutable deploy record: `(component, env, artifact digest, git sha,
36
55
  * run id, timestamp, actor)`, referencing the build archive by digest — the
@@ -54,8 +73,19 @@ export interface ReleaseRecord {
54
73
  digest: string;
55
74
  /** Git commit SHA the deploy was built from. */
56
75
  gitSha: string;
57
- /** Orchestrator run identifier (a `DriverRunResult`/Op run id, or a CI run id in generate mode) — the pointer back into the deploy event log. */
76
+ /** Orchestrator run identifier (a `DriverRunResult`/Op run id, or a CI run id in generate mode) — the pointer back into the deploy event log. Which id space it lives in, and how to follow it, is `runOrigin`'s job (#2045). */
58
77
  runId: string;
78
+ /**
79
+ * Where `runId` can be resolved (#2045). A bare `runId` names a run in one
80
+ * of several mutually incompatible id spaces — a GitHub Actions run id, a
81
+ * GitLab pipeline id, an Op run id, a locally minted id — and without this
82
+ * a reader can tell them apart only by inference from whoever wrote the
83
+ * record. Written at record time from the same environment the id came
84
+ * from, so the two cannot skew. Optional: records written before this
85
+ * field existed, and callers passing an id whose origin they did not
86
+ * declare, simply have none.
87
+ */
88
+ runOrigin?: RunOrigin;
59
89
  /**
60
90
  * When this record was written. The caller supplies this — the session
61
91
  * cannot call `Date.now()` reliably in every context, so the CLI resolves
@@ -132,6 +162,54 @@ export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[]
132
162
  /** Input a caller supplies to record one deploy — `version` is filled in, everything else is required (see `ReleaseRecord`). */
133
163
  export type ReleaseRecordInput = Omit<ReleaseRecord, "version">;
134
164
 
165
+ /**
166
+ * Resolve a run id and its origin from the same environment, in one step, so
167
+ * the two cannot skew (#2045). Resolution order mirrors what
168
+ * `chant components release` always did — explicit id, then
169
+ * `GITHUB_RUN_ID`, then `CI_PIPELINE_ID`, then a locally minted
170
+ * `local-<ms>` — but now each source also names its id space:
171
+ *
172
+ * - the GitHub path records `repo` (`GITHUB_REPOSITORY`) and builds the run
173
+ * `url` from `GITHUB_SERVER_URL` — which also makes a Forgejo instance's
174
+ * runs resolvable, since Forgejo Actions implements the same env contract;
175
+ * - the GitLab path records `repo` (`CI_PROJECT_PATH`) and takes
176
+ * `CI_PIPELINE_URL` verbatim;
177
+ * - the local mint says `local` in a field instead of only in the id's
178
+ * spelling.
179
+ *
180
+ * An explicit id still gets an origin when it matches the id the environment
181
+ * itself is advertising (a CI job passing `--run-id $GITHUB_RUN_ID`);
182
+ * otherwise the caller's id is recorded without one — guessing an origin the
183
+ * caller did not state would be exactly the inference this field exists to
184
+ * remove.
185
+ */
186
+ export function resolveRunId(
187
+ explicit?: string,
188
+ env: NodeJS.ProcessEnv = process.env,
189
+ ): { runId: string; runOrigin?: RunOrigin } {
190
+ const githubOrigin = (id: string): RunOrigin => ({
191
+ forge: "github",
192
+ ...(env.GITHUB_REPOSITORY ? { repo: env.GITHUB_REPOSITORY } : {}),
193
+ ...(env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY
194
+ ? { url: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${id}` }
195
+ : {}),
196
+ });
197
+ const gitlabOrigin = (): RunOrigin => ({
198
+ forge: "gitlab",
199
+ ...(env.CI_PROJECT_PATH ? { repo: env.CI_PROJECT_PATH } : {}),
200
+ ...(env.CI_PIPELINE_URL ? { url: env.CI_PIPELINE_URL } : {}),
201
+ });
202
+
203
+ if (explicit) {
204
+ if (env.GITHUB_RUN_ID && explicit === env.GITHUB_RUN_ID) return { runId: explicit, runOrigin: githubOrigin(explicit) };
205
+ if (env.CI_PIPELINE_ID && explicit === env.CI_PIPELINE_ID) return { runId: explicit, runOrigin: gitlabOrigin() };
206
+ return { runId: explicit };
207
+ }
208
+ if (env.GITHUB_RUN_ID) return { runId: env.GITHUB_RUN_ID, runOrigin: githubOrigin(env.GITHUB_RUN_ID) };
209
+ if (env.CI_PIPELINE_ID) return { runId: env.CI_PIPELINE_ID, runOrigin: gitlabOrigin() };
210
+ return { runId: `local-${Date.now()}`, runOrigin: { forge: "local" } };
211
+ }
212
+
135
213
  /**
136
214
  * Append one immutable release record to the `<env>` ledger on the
137
215
  * `chant/lifecycle` orphan branch. Throws `InvalidReleaseRecordError` before
@@ -75,6 +75,18 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
75
75
  * per #1290 — a later step's reference into this one's output.
76
76
  */
77
77
  returns?: z.ZodType<Return>;
78
+ /**
79
+ * Names of top-level args whose values identify what the activity's effect
80
+ * touches in the estate (#2022) — an entity id, a resource address, a
81
+ * target endpoint — as opposed to scope (`env`, a stack name) or mechanics
82
+ * (a timeout, a mode). op.json's IR resolves these per step into
83
+ * `OpIRActivityStep.entities`, so a renderer can join a step to the estate
84
+ * nodes it touches instead of inventing joins off scope fields. Optional
85
+ * and partial by design, like the contract itself: an activity whose
86
+ * touched set is not spelled in its args (e.g. an applier that derives
87
+ * stack members internally) simply declares none.
88
+ */
89
+ entities?: string[];
78
90
  }
79
91
 
80
92
  /** Declare an activity contract. */
@@ -82,12 +94,14 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
82
94
  name: string,
83
95
  args: ArgsSchema,
84
96
  returns?: ReturnSchema,
97
+ opts?: { entities?: string[] },
85
98
  ): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
86
99
  return {
87
100
  [CONTRACT_BRAND]: true,
88
101
  name,
89
102
  args,
90
103
  ...(returns ? { returns } : {}),
104
+ ...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
91
105
  } as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
92
106
  }
93
107
 
@@ -3,10 +3,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import {
6
+ CARVE_MANIFEST_VERSION,
6
7
  carveManifestPath,
7
8
  listCarveManifests,
8
9
  readCarveManifest,
9
10
  resolveCarveManifest,
11
+ resolveManifestFilePath,
10
12
  updateCarveManifest,
11
13
  writeCarveManifest,
12
14
  type CarveManifest,
@@ -89,6 +91,56 @@ describe("carve manifest", () => {
89
91
  });
90
92
  });
91
93
 
94
+ test("records file paths relative to its own directory, so the tree can move (#2039)", () => {
95
+ withDir((dir) => {
96
+ const m = manifest("aws_s3_bucket.assets");
97
+ m.emit = { source: "tfstate", files: [join(dir, "src", "assets.ts")], at: "t" };
98
+ m.bridge = {
99
+ written: [join(dir, "aws_s3_bucket-assets-runbook.md"), join(dir, "..", "api.tf")],
100
+ appliedInPlace: false,
101
+ patch: join(dir, "aws_s3_bucket-assets-bridge.patch"),
102
+ at: "t",
103
+ };
104
+ const path = writeCarveManifest(dir, m);
105
+ const read = readCarveManifest(path)!;
106
+ expect(read.version).toBe(CARVE_MANIFEST_VERSION);
107
+ expect(read.emit!.files).toEqual([join("src", "assets.ts")]);
108
+ expect(read.bridge!.written).toEqual(["aws_s3_bucket-assets-runbook.md", join("..", "api.tf")]);
109
+ expect(read.bridge!.patch).toBe("aws_s3_bucket-assets-bridge.patch");
110
+ // `from`/`statePath` locate the estate, a tree the manifest does not
111
+ // travel with — they stay absolute.
112
+ expect(read.from).toBe("/estate");
113
+ });
114
+ });
115
+
116
+ test("resolveManifestFilePath joins relative entries and passes absolute (v1) entries through", () => {
117
+ expect(resolveManifestFilePath("/moved/carveout", join("src", "assets.ts"))).toBe(join("/moved/carveout", "src", "assets.ts"));
118
+ expect(resolveManifestFilePath("/moved/carveout", "/original/carveout/src/assets.ts")).toBe("/original/carveout/src/assets.ts");
119
+ });
120
+
121
+ test("reads a version-1 manifest (absolute paths) and migrates it on update", () => {
122
+ withDir((dir) => {
123
+ const path = carveManifestPath(dir, "aws_s3_bucket.assets");
124
+ const v1: CarveManifest = {
125
+ ...manifest("aws_s3_bucket.assets"),
126
+ version: 1,
127
+ emit: { source: "tfstate", files: [join(dir, "src", "assets.ts")], at: "t" },
128
+ };
129
+ writeFileSync(path, JSON.stringify(v1, null, 2) + "\n");
130
+ // Readable as-is, absolute paths intact.
131
+ expect(readCarveManifest(path)!.emit!.files).toEqual([join(dir, "src", "assets.ts")]);
132
+
133
+ // Any update rewrites the whole manifest to the version-2 contract.
134
+ updateCarveManifest(dir, "aws_s3_bucket.assets", {
135
+ bridge: { written: [join(dir, "runbook.md")], appliedInPlace: false, at: "t" },
136
+ });
137
+ const migrated = readCarveManifest(path)!;
138
+ expect(migrated.version).toBe(CARVE_MANIFEST_VERSION);
139
+ expect(migrated.emit!.files).toEqual([join("src", "assets.ts")]);
140
+ expect(migrated.bridge!.written).toEqual(["runbook.md"]);
141
+ });
142
+ });
143
+
92
144
  test("update patches an existing manifest and no-ops on a missing one", () => {
93
145
  withDir((dir) => {
94
146
  expect(updateCarveManifest(dir, "aws_s3_bucket.assets", { bridge: { written: [], appliedInPlace: false, at: "t" } })).toBeUndefined();
@@ -9,14 +9,23 @@
9
9
  */
10
10
 
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
12
- import { join } from "path";
12
+ import { isAbsolute, join, relative } from "path";
13
13
  import type { CarveReport } from "./carve";
14
14
  import type { OwnershipMarker } from "../ownership";
15
15
 
16
16
  export const CARVE_MANIFEST_SUFFIX = ".carve.json";
17
17
 
18
+ /**
19
+ * Version 2 (#2039): `emit.files`, `bridge.written`, `bridge.patch` and
20
+ * `apply.stampedFiles` are recorded relative to the manifest's own directory,
21
+ * so the manifest stays true when the tree is copied, moved, or checked out
22
+ * elsewhere. Version-1 manifests recorded them absolute; readers resolve
23
+ * either through `resolveManifestFilePath`.
24
+ */
25
+ export const CARVE_MANIFEST_VERSION = 2;
26
+
18
27
  export interface CarveManifest {
19
- version: 1;
28
+ version: 1 | 2;
20
29
  /** Terraform address of the carved resource, e.g. `aws_s3_bucket.assets`. */
21
30
  target: string;
22
31
  /** Terraform resource type, when known. */
@@ -30,7 +39,13 @@ export interface CarveManifest {
30
39
  /** Recorded by `carve emit`. */
31
40
  emit?: {
32
41
  source: "tfstate" | "live";
33
- /** Emitted chant source file path(s). */
42
+ /**
43
+ * Emitted chant source file path(s), relative to the manifest's directory
44
+ * (absolute in version-1 manifests). The manifest sits at the scaffolded
45
+ * project's root, so these are also project-root-relative — the same base
46
+ * `chant graph --format ir` uses for `sourceLoc.file`, which is the join
47
+ * from a carved Terraform address to the chant entity it became (#2040).
48
+ */
34
49
  files: string[];
35
50
  /**
36
51
  * Deferred outbound inputs declared as build parameters in the emitted
@@ -53,9 +68,10 @@ export interface CarveManifest {
53
68
  };
54
69
  /** Recorded by `carve bridge`. */
55
70
  bridge?: {
71
+ /** Files written, relative to the manifest's directory (absolute in version-1 manifests). */
56
72
  written: string[];
57
73
  appliedInPlace: boolean;
58
- /** The git-applyable `.patch` carrying the whole survivor edit. */
74
+ /** The git-applyable `.patch` carrying the whole survivor edit, relative to the manifest's directory (absolute in version-1 manifests). */
59
75
  patch?: string;
60
76
  /** Carved addresses whose own `.tf` block the rewrites remove (#998). */
61
77
  excised?: string[];
@@ -65,7 +81,7 @@ export interface CarveManifest {
65
81
  apply?: {
66
82
  marker: OwnershipMarker;
67
83
  ownershipTags: Record<string, string>;
68
- /** Emitted files the marker was stamped into (`--write-source`). */
84
+ /** Emitted files the marker was stamped into (`--write-source`), relative to the manifest's directory (absolute in version-1 manifests). */
69
85
  stampedFiles?: string[];
70
86
  at: string;
71
87
  };
@@ -81,20 +97,62 @@ export function carveManifestPath(outDir: string, target: string): string {
81
97
  return join(outDir, `${carveSlug(target)}${CARVE_MANIFEST_SUFFIX}`);
82
98
  }
83
99
 
100
+ /**
101
+ * Relative-to-`outDir` spelling of a recorded file path. Absolute entries
102
+ * (the run-time spelling every step naturally produces, and every version-1
103
+ * manifest on disk) are rewritten; already-relative entries pass through.
104
+ */
105
+ function relativizePath(outDir: string, p: string): string {
106
+ return isAbsolute(p) ? relative(outDir, p) : p;
107
+ }
108
+
109
+ /**
110
+ * Normalize a manifest to the version-2 on-disk contract: recorded file
111
+ * paths relative to the manifest's own directory (#2039). `from` and
112
+ * `statePath` stay absolute — they locate the Terraform estate, a different
113
+ * tree the manifest does not travel with.
114
+ */
115
+ function normalizeManifest(outDir: string, manifest: CarveManifest): CarveManifest {
116
+ const out: CarveManifest = { ...manifest, version: CARVE_MANIFEST_VERSION };
117
+ if (manifest.emit) {
118
+ out.emit = { ...manifest.emit, files: manifest.emit.files.map((f) => relativizePath(outDir, f)) };
119
+ }
120
+ if (manifest.bridge) {
121
+ out.bridge = {
122
+ ...manifest.bridge,
123
+ written: manifest.bridge.written.map((f) => relativizePath(outDir, f)),
124
+ ...(manifest.bridge.patch !== undefined ? { patch: relativizePath(outDir, manifest.bridge.patch) } : {}),
125
+ };
126
+ }
127
+ if (manifest.apply?.stampedFiles) {
128
+ out.apply = { ...manifest.apply, stampedFiles: manifest.apply.stampedFiles.map((f) => relativizePath(outDir, f)) };
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Resolve a manifest-recorded file path against the manifest's directory.
135
+ * Absolute paths (version-1 manifests) pass through untouched, so both
136
+ * on-disk spellings read the same.
137
+ */
138
+ export function resolveManifestFilePath(manifestDir: string, p: string): string {
139
+ return isAbsolute(p) ? p : join(manifestDir, p);
140
+ }
141
+
84
142
  /** Write (or overwrite) a manifest into the output dir. Returns the path. */
85
143
  export function writeCarveManifest(outDir: string, manifest: CarveManifest): string {
86
144
  mkdirSync(outDir, { recursive: true });
87
145
  const path = carveManifestPath(outDir, manifest.target);
88
- writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n");
146
+ writeFileSync(path, JSON.stringify(normalizeManifest(outDir, manifest), null, 2) + "\n");
89
147
  return path;
90
148
  }
91
149
 
92
- /** Read a manifest, or null when absent/unreadable/not a v1 manifest. */
150
+ /** Read a manifest, or null when absent/unreadable/not a known-version manifest. */
93
151
  export function readCarveManifest(path: string): CarveManifest | null {
94
152
  if (!existsSync(path)) return null;
95
153
  try {
96
154
  const parsed = JSON.parse(readFileSync(path, "utf-8")) as CarveManifest;
97
- if (parsed?.version !== 1) return null;
155
+ if (parsed?.version !== 1 && parsed?.version !== 2) return null;
98
156
  if (typeof parsed.target !== "string" || typeof parsed.boundary !== "object") return null;
99
157
  return parsed;
100
158
  } catch {
@@ -147,7 +205,11 @@ export function resolveCarveManifest(outDir: string, select?: string): ResolvedM
147
205
  return { manifest: manifests[0].manifest, path: manifests[0].path };
148
206
  }
149
207
 
150
- /** Read-modify-write a section of an existing manifest. No-op when absent. */
208
+ /**
209
+ * Read-modify-write a section of an existing manifest. No-op when absent.
210
+ * The whole manifest is re-normalized on write, so updating a version-1
211
+ * manifest also migrates its recorded paths to the relative contract.
212
+ */
151
213
  export function updateCarveManifest(
152
214
  outDir: string,
153
215
  target: string,
@@ -157,6 +219,6 @@ export function updateCarveManifest(
157
219
  const manifest = readCarveManifest(path);
158
220
  if (!manifest) return undefined;
159
221
  Object.assign(manifest, patch);
160
- writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n");
222
+ writeFileSync(path, JSON.stringify(normalizeManifest(outDir, manifest), null, 2) + "\n");
161
223
  return path;
162
224
  }