@metaobjectsdev/cli 1.0.1 → 1.0.3

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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/src/commands/deps.d.ts +3 -0
  3. package/dist/src/commands/deps.d.ts.map +1 -0
  4. package/dist/src/commands/deps.js +200 -0
  5. package/dist/src/commands/deps.js.map +1 -0
  6. package/dist/src/commands/docs.d.ts.map +1 -1
  7. package/dist/src/commands/docs.js +19 -3
  8. package/dist/src/commands/docs.js.map +1 -1
  9. package/dist/src/commands/gen.d.ts.map +1 -1
  10. package/dist/src/commands/gen.js +54 -1
  11. package/dist/src/commands/gen.js.map +1 -1
  12. package/dist/src/commands/init.d.ts.map +1 -1
  13. package/dist/src/commands/init.js +12 -18
  14. package/dist/src/commands/init.js.map +1 -1
  15. package/dist/src/commands/migrate.d.ts.map +1 -1
  16. package/dist/src/commands/migrate.js +34 -33
  17. package/dist/src/commands/migrate.js.map +1 -1
  18. package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
  19. package/dist/src/commands/prompt-snapshot.js +2 -1
  20. package/dist/src/commands/prompt-snapshot.js.map +1 -1
  21. package/dist/src/commands/verify.d.ts.map +1 -1
  22. package/dist/src/commands/verify.js +154 -23
  23. package/dist/src/commands/verify.js.map +1 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +62 -2
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/lib/agent-context-staleness.d.ts.map +1 -1
  28. package/dist/src/lib/agent-context-staleness.js +7 -1
  29. package/dist/src/lib/agent-context-staleness.js.map +1 -1
  30. package/dist/src/lib/allow.d.ts.map +1 -1
  31. package/dist/src/lib/allow.js +7 -1
  32. package/dist/src/lib/allow.js.map +1 -1
  33. package/dist/src/lib/args.d.ts +57 -1
  34. package/dist/src/lib/args.d.ts.map +1 -1
  35. package/dist/src/lib/args.js +39 -2
  36. package/dist/src/lib/args.js.map +1 -1
  37. package/dist/src/lib/collection-load-options.d.ts +13 -0
  38. package/dist/src/lib/collection-load-options.d.ts.map +1 -0
  39. package/dist/src/lib/collection-load-options.js +19 -0
  40. package/dist/src/lib/collection-load-options.js.map +1 -0
  41. package/dist/src/lib/dependency-sync.d.ts +170 -0
  42. package/dist/src/lib/dependency-sync.d.ts.map +1 -0
  43. package/dist/src/lib/dependency-sync.js +457 -0
  44. package/dist/src/lib/dependency-sync.js.map +1 -0
  45. package/dist/src/lib/migrate-scope.d.ts +32 -1
  46. package/dist/src/lib/migrate-scope.d.ts.map +1 -1
  47. package/dist/src/lib/migrate-scope.js +51 -3
  48. package/dist/src/lib/migrate-scope.js.map +1 -1
  49. package/dist/src/lib/overlay-lint.d.ts +37 -0
  50. package/dist/src/lib/overlay-lint.d.ts.map +1 -0
  51. package/dist/src/lib/overlay-lint.js +102 -0
  52. package/dist/src/lib/overlay-lint.js.map +1 -0
  53. package/dist/src/lib/requirement-check.d.ts +9 -1
  54. package/dist/src/lib/requirement-check.d.ts.map +1 -1
  55. package/dist/src/lib/requirement-check.js +23 -7
  56. package/dist/src/lib/requirement-check.js.map +1 -1
  57. package/package.json +10 -10
  58. package/src/commands/deps.ts +231 -0
  59. package/src/commands/docs.ts +19 -3
  60. package/src/commands/gen.ts +61 -1
  61. package/src/commands/init.ts +13 -17
  62. package/src/commands/migrate.ts +36 -29
  63. package/src/commands/prompt-snapshot.ts +2 -1
  64. package/src/commands/verify.ts +174 -23
  65. package/src/index.ts +62 -2
  66. package/src/lib/agent-context-staleness.ts +7 -1
  67. package/src/lib/allow.ts +7 -1
  68. package/src/lib/args.ts +89 -3
  69. package/src/lib/collection-load-options.ts +36 -0
  70. package/src/lib/dependency-sync.ts +656 -0
  71. package/src/lib/migrate-scope.ts +59 -4
  72. package/src/lib/overlay-lint.ts +129 -0
  73. package/src/lib/requirement-check.ts +33 -7
@@ -0,0 +1,656 @@
1
+ // server/typescript/packages/cli/src/lib/dependency-sync.ts
2
+ //
3
+ // FR-023 Phase 1a, Task 14 — the algorithm `meta deps sync` runs (DESIGN §4.1
4
+ // steps 1-5, 7-8; step 6, the usage-aware classifier, is superseded — DESIGN
5
+ // §11.3, "the committed artifact's diff in the consumer's history is the
6
+ // review"). Five functions, split so `--dry-run` is "run everything except
7
+ // the write": `readManifestDir` / `validateAgainstSpec` / `standaloneLoadCheck`
8
+ // read and validate one already-resolved dependency directory;
9
+ // `planSync` composes them over every declared dependency (transport
10
+ // resolution, per-dependency validation, the cross-dependency node-collision
11
+ // check); `applySync` is the only function that touches the filesystem.
12
+ import { existsSync, statSync } from "node:fs";
13
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
14
+ import { join, resolve } from "node:path";
15
+ import {
16
+ codeSource,
17
+ InMemoryStringSource,
18
+ MetaDataLoader,
19
+ METAMODEL_VERSION,
20
+ packageOfResolutionKey,
21
+ ParseError,
22
+ type ErrorCode,
23
+ } from "@metaobjectsdev/metadata";
24
+ import {
25
+ DEPS_DIR,
26
+ DEFAULT_METAOBJECTS_DIR,
27
+ INTEGRITY_PREFIX,
28
+ LOCK_FILE,
29
+ MANIFEST_FILE,
30
+ DependencyManifestSchema,
31
+ dependencyName,
32
+ readLock,
33
+ sha256Integrity,
34
+ writeLock,
35
+ type DependencyManifest,
36
+ type DependencySpec,
37
+ type Lock,
38
+ type LockEntry,
39
+ } from "@metaobjectsdev/sdk";
40
+
41
+ /** `dependency-sync.ts`'s own `ParseError` factory for every
42
+ * `ERR_DEPENDENCY_MANIFEST_INVALID` case (DESIGN §4.1 step 2 and step 4):
43
+ * absent/invalid manifest, name mismatch, missing artifact, hash mismatch,
44
+ * a standalone load failure, or a `nodes`/`packages` mismatch against what
45
+ * the artifact actually declares. One thrower so every case names the
46
+ * dependency and carries the same code. */
47
+ function manifestInvalid(name: string, caller: string, detail: string): never {
48
+ throw new ParseError(`dependency "${name}": ${detail}`, {
49
+ code: "ERR_DEPENDENCY_MANIFEST_INVALID",
50
+ source: codeSource(caller),
51
+ });
52
+ }
53
+
54
+ /** The MAJOR half of a `major.minor` metamodel version string — mirrors the
55
+ * private helper of the same name in `sdk/src/dependencies.ts` (not
56
+ * exported there; the contract, "compare on the major alone" per ADR-0035
57
+ * Amendment 2, is small enough to restate rather than widen that module's
58
+ * public surface for one caller). */
59
+ function metamodelMajor(version: string): string {
60
+ return version.split(".")[0] ?? version;
61
+ }
62
+
63
+ /** The MINOR half, as a number — 0 when absent (defensive; the manifest
64
+ * schema's `^\d+\.\d+$` regex already guarantees a minor is present). */
65
+ function metamodelMinor(version: string): number {
66
+ return Number(version.split(".")[1] ?? 0);
67
+ }
68
+
69
+ function sameSortedArray(a: readonly string[], b: readonly string[]): boolean {
70
+ if (a.length !== b.length) return false;
71
+ const left = [...a].sort();
72
+ const right = [...b].sort();
73
+ return left.every((v, i) => v === right[i]);
74
+ }
75
+
76
+ /**
77
+ * DESIGN §4.1 step 1 — resolve a dependency spec's transport to a directory.
78
+ * `npm` / `python` refuse by design (Phase 1a ships `path` only — DESIGN
79
+ * §11.3's transports table); a `path` that does not resolve to a directory on
80
+ * disk is the SAME code (`ERR_DEPENDENCY_UNRESOLVED`) with a different
81
+ * detail, because both are "the transport could not locate a directory
82
+ * holding metaobjects.pkg.json" — the manifest read is a separate step
83
+ * (`readManifestDir`) that only runs once a directory is in hand.
84
+ */
85
+ export function resolveDependencyDir(configDir: string, spec: DependencySpec): string {
86
+ const name = dependencyName(spec);
87
+ if ("path" in spec) {
88
+ const dir = resolve(configDir, spec.path);
89
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
90
+ throw new ParseError(
91
+ `dependency "${name}": the "path" transport resolved to ${dir}, but no directory exists there`,
92
+ { code: "ERR_DEPENDENCY_UNRESOLVED", source: codeSource("resolveDependencyDir") },
93
+ );
94
+ }
95
+ return dir;
96
+ }
97
+ const kind = "npm" in spec ? "npm" : "python";
98
+ throw new ParseError(
99
+ `dependency "${name}": transport \`${kind}\` is not supported by this toolchain yet; use \`path\``,
100
+ { code: "ERR_DEPENDENCY_UNRESOLVED", source: codeSource("resolveDependencyDir") },
101
+ );
102
+ }
103
+
104
+ export interface ManifestRead {
105
+ readonly manifest: DependencyManifest;
106
+ readonly artifactContent: string;
107
+ }
108
+
109
+ /**
110
+ * DESIGN §4.1 step 2 — read `<dir>/metaobjects.pkg.json`, strict, and verify
111
+ * it is internally consistent with the artifact sitting beside it. Every
112
+ * failure here is `ERR_DEPENDENCY_MANIFEST_INVALID`: absent, not JSON, fails
113
+ * the schema, names a missing artifact, or the artifact's bytes don't hash
114
+ * to the recorded `integrity` — a publisher who bypassed `sharedModelFile()`
115
+ * is caught here, before the (more expensive) standalone load in
116
+ * {@link standaloneLoadCheck}.
117
+ */
118
+ export async function readManifestDir(dir: string, name: string): Promise<ManifestRead> {
119
+ const manifestPath = join(dir, MANIFEST_FILE);
120
+ let raw: string;
121
+ try {
122
+ raw = await readFile(manifestPath, "utf8");
123
+ } catch {
124
+ manifestInvalid(name, "readManifestDir", `no ${MANIFEST_FILE} found at ${manifestPath}`);
125
+ }
126
+
127
+ let parsed: unknown;
128
+ try {
129
+ parsed = JSON.parse(raw);
130
+ } catch (err) {
131
+ manifestInvalid(name, "readManifestDir", `${manifestPath} is not valid JSON: ${(err as Error).message}`);
132
+ }
133
+
134
+ const result = DependencyManifestSchema.safeParse(parsed);
135
+ if (!result.success) {
136
+ const issues = result.error.issues
137
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
138
+ .join("; ");
139
+ manifestInvalid(name, "readManifestDir", `${manifestPath} failed schema validation — ${issues}`);
140
+ }
141
+ const manifest = result.data;
142
+
143
+ const artifactPath = join(dir, manifest.artifact);
144
+ let artifactContent: string;
145
+ try {
146
+ artifactContent = await readFile(artifactPath, "utf8");
147
+ } catch {
148
+ manifestInvalid(
149
+ name,
150
+ "readManifestDir",
151
+ `the manifest names artifact "${manifest.artifact}", but it is missing at ${artifactPath}`,
152
+ );
153
+ }
154
+
155
+ const actual = sha256Integrity(artifactContent);
156
+ if (actual !== manifest.integrity) {
157
+ manifestInvalid(
158
+ name,
159
+ "readManifestDir",
160
+ `${artifactPath} hashes to ${actual}, but the manifest records ${manifest.integrity}`,
161
+ );
162
+ }
163
+
164
+ return { manifest, artifactContent };
165
+ }
166
+
167
+ /**
168
+ * DESIGN §4.1 step 2 (the `name ≠ spec.name` arm) + step 3 (`metamodelVersion`
169
+ * major/minor). Returns an advisory warning string when the dependency was
170
+ * published against a newer metamodel MINOR (the metadata contract still
171
+ * loads; a MAJOR mismatch throws — ADR-0035 Amendment 2: the metadata
172
+ * contract is promised on the major alone), `undefined` otherwise.
173
+ */
174
+ export function validateAgainstSpec(manifest: DependencyManifest, spec: DependencySpec): string | undefined {
175
+ const name = dependencyName(spec);
176
+ if (manifest.name !== name) {
177
+ manifestInvalid(
178
+ name,
179
+ "validateAgainstSpec",
180
+ `the manifest declares name "${manifest.name}", but it is declared in config as "${name}"`,
181
+ );
182
+ }
183
+
184
+ const manifestMajor = metamodelMajor(manifest.metamodelVersion);
185
+ const toolchainMajor = metamodelMajor(METAMODEL_VERSION);
186
+ if (manifestMajor !== toolchainMajor) {
187
+ throw new ParseError(
188
+ `dependency "${name}" was published against metamodel ${manifest.metamodelVersion}; this ` +
189
+ `toolchain speaks ${METAMODEL_VERSION}. A different metamodel MAJOR is a different metadata ` +
190
+ `contract — upgrade the toolchain, or use a release of "${name}" built against it.`,
191
+ { code: "ERR_DEPENDENCY_METAMODEL_INCOMPATIBLE", source: codeSource("validateAgainstSpec") },
192
+ );
193
+ }
194
+
195
+ if (metamodelMinor(manifest.metamodelVersion) > metamodelMinor(METAMODEL_VERSION)) {
196
+ return (
197
+ `dependency "${name}" was published against metamodel ${manifest.metamodelVersion}, newer than ` +
198
+ `this toolchain's ${METAMODEL_VERSION} — vocabulary it uses that this toolchain does not yet ` +
199
+ `have may fail to load.`
200
+ );
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ /**
206
+ * DESIGN §4.1 step 4 — load the artifact standalone with CORE providers only
207
+ * (a consumer loads it with ITS OWN providers, never the publisher's; a
208
+ * `MetaDataLoader` built with no `registry` option composes core providers by
209
+ * default), strict, and check it declares EXACTLY the manifest's `nodes` and
210
+ * `packages` — a publisher who hand-edited the manifest (or bypassed
211
+ * `sharedModelFile()` entirely) is caught here.
212
+ */
213
+ export async function standaloneLoadCheck(manifest: DependencyManifest, artifactContent: string): Promise<void> {
214
+ const loader = new MetaDataLoader({ strict: true });
215
+ const loaded = await loader.load([
216
+ new InMemoryStringSource(artifactContent, { id: manifest.artifact }),
217
+ ]);
218
+ if (loaded.errors.length > 0) {
219
+ manifestInvalid(
220
+ manifest.name,
221
+ "standaloneLoadCheck",
222
+ `the artifact does not load standalone: ${loaded.errors[0]!.message}`,
223
+ );
224
+ }
225
+
226
+ // ADR-0039 sanctioned own-only case: `MetaRoot` never `extends`, so its own
227
+ // children ARE its effective children (the same reasoning
228
+ // `sharedModelFile()`'s step 2 documents on the publisher side).
229
+ const nodes = loaded.root.ownChildren().map((n) => n.resolutionKey());
230
+ const packages = [...new Set(nodes.map((n) => packageOfResolutionKey(n)))];
231
+
232
+ if (!sameSortedArray(nodes, manifest.nodes)) {
233
+ manifestInvalid(
234
+ manifest.name,
235
+ "standaloneLoadCheck",
236
+ `the artifact's top-level nodes (${[...nodes].sort().join(", ") || "(none)"}) do not match the ` +
237
+ `manifest's "nodes" (${[...manifest.nodes].sort().join(", ") || "(none)"})`,
238
+ );
239
+ }
240
+ if (!sameSortedArray(packages, manifest.packages)) {
241
+ manifestInvalid(
242
+ manifest.name,
243
+ "standaloneLoadCheck",
244
+ `the artifact's packages (${[...packages].sort().join(", ") || "(none)"}) do not match the ` +
245
+ `manifest's "packages" (${[...manifest.packages].sort().join(", ") || "(none)"})`,
246
+ );
247
+ }
248
+ }
249
+
250
+ /** The declared spec's transport, verbatim, minus `name` — a lock entry's
251
+ * `resolvedFrom` (DESIGN §3.3). */
252
+ function resolvedFromOf(spec: DependencySpec): LockEntry["resolvedFrom"] {
253
+ if ("path" in spec) return { path: spec.path };
254
+ if ("npm" in spec) return spec.dir === undefined ? { npm: spec.npm } : { npm: spec.npm, dir: spec.dir };
255
+ return spec.dir === undefined ? { python: spec.python } : { python: spec.python, dir: spec.dir };
256
+ }
257
+
258
+ /** One dependency this run will (re)sync — either brand new or a changed/
259
+ * unchanged re-sync of one already in the lock. */
260
+ export interface SyncedDependencyPlan {
261
+ readonly name: string;
262
+ readonly action: "sync" | "unchanged";
263
+ readonly manifest: DependencyManifest;
264
+ readonly artifactContent: string;
265
+ readonly resolvedFrom: LockEntry["resolvedFrom"];
266
+ readonly oldEntry: LockEntry | undefined;
267
+ }
268
+
269
+ /** A lock entry whose dependency is no longer declared in config. */
270
+ export interface PrunedDependencyPlan {
271
+ readonly name: string;
272
+ readonly entry: LockEntry;
273
+ }
274
+
275
+ export interface SyncPlan {
276
+ /** Target dependencies (all declared, or the requested subset), in name order. */
277
+ readonly plans: readonly SyncedDependencyPlan[];
278
+ /** Declared-but-not-targeted lock entries (only when a name filter was
279
+ * given) — carried into the new lock untouched. */
280
+ readonly carryForward: ReadonlyMap<string, LockEntry>;
281
+ /** Lock entries whose dependency config no longer declares, in name order. */
282
+ readonly prunes: readonly PrunedDependencyPlan[];
283
+ /** Advisory metamodel-minor-ahead warnings, one per affected dependency. */
284
+ readonly warnings: readonly string[];
285
+ }
286
+
287
+ /**
288
+ * DESIGN §4.1 steps 1-5, for every declared dependency (or the requested
289
+ * `filterNames` subset), in name order. Read-only: resolves each transport,
290
+ * validates its manifest and artifact, and checks the WHOLE resulting node
291
+ * set (freshly-resolved targets plus whatever untouched lock entries carry
292
+ * forward) for collisions — nothing is written here, which is what makes
293
+ * `--dry-run` "run this and skip `applySync`."
294
+ */
295
+ export async function planSync(
296
+ configDir: string,
297
+ specs: readonly DependencySpec[],
298
+ lock: Lock | undefined,
299
+ filterNames: readonly string[] = [],
300
+ ): Promise<SyncPlan> {
301
+ const allNames = specs.map(dependencyName);
302
+ const declared = new Set(allNames);
303
+
304
+ let targets: DependencySpec[];
305
+ if (filterNames.length === 0) {
306
+ targets = [...specs];
307
+ } else {
308
+ const unknown = filterNames.filter((n) => !declared.has(n));
309
+ if (unknown.length > 0) {
310
+ throw new ParseError(
311
+ `meta deps sync: not declared in ${DEFAULT_METAOBJECTS_DIR}/config.json: ${unknown.join(", ")}` +
312
+ (allNames.length > 0
313
+ ? ` (declared: ${[...declared].sort().join(", ")})`
314
+ : " (no dependencies declared)"),
315
+ { code: "ERR_DEPENDENCY_UNRESOLVED", source: codeSource("planSync") },
316
+ );
317
+ }
318
+ targets = specs.filter((s) => filterNames.includes(dependencyName(s)));
319
+ }
320
+ targets.sort((a, b) => dependencyName(a).localeCompare(dependencyName(b)));
321
+
322
+ const targetNames = new Set(targets.map(dependencyName));
323
+ const oldEntries = lock?.dependencies ?? {};
324
+
325
+ // Node ownership seeds from untouched (carried-forward) lock entries, so a
326
+ // freshly-resolved target's nodes are checked against what already stands,
327
+ // not just against each other.
328
+ const nodeOwner = new Map<string, string>();
329
+ const carryForward = new Map<string, LockEntry>();
330
+ for (const [name, entry] of Object.entries(oldEntries)) {
331
+ if (declared.has(name) && !targetNames.has(name)) {
332
+ carryForward.set(name, entry);
333
+ for (const node of entry.nodes) nodeOwner.set(node, name);
334
+ }
335
+ }
336
+
337
+ const warnings: string[] = [];
338
+ const plans: SyncedDependencyPlan[] = [];
339
+ for (const spec of targets) {
340
+ const name = dependencyName(spec);
341
+ const dir = resolveDependencyDir(configDir, spec);
342
+ const { manifest, artifactContent } = await readManifestDir(dir, name);
343
+ const warning = validateAgainstSpec(manifest, spec);
344
+ if (warning !== undefined) warnings.push(warning);
345
+ await standaloneLoadCheck(manifest, artifactContent);
346
+
347
+ for (const node of manifest.nodes) {
348
+ const prior = nodeOwner.get(node);
349
+ if (prior !== undefined && prior !== name) {
350
+ throw new ParseError(
351
+ `dependencies "${prior}" and "${name}" both export "${node}" — one fully-qualified node ` +
352
+ `cannot come from two places, and whichever loaded second would silently win`,
353
+ { code: "ERR_DEPENDENCY_NODE_COLLISION", source: codeSource("planSync") },
354
+ );
355
+ }
356
+ nodeOwner.set(node, name);
357
+ }
358
+
359
+ const oldEntry = oldEntries[name];
360
+ const action: "sync" | "unchanged" =
361
+ oldEntry !== undefined && oldEntry.integrity === manifest.integrity ? "unchanged" : "sync";
362
+ plans.push({ name, action, manifest, artifactContent, resolvedFrom: resolvedFromOf(spec), oldEntry });
363
+ }
364
+
365
+ const prunes: PrunedDependencyPlan[] = Object.entries(oldEntries)
366
+ .filter(([name]) => !declared.has(name))
367
+ .map(([name, entry]) => ({ name, entry }))
368
+ .sort((a, b) => a.name.localeCompare(b.name));
369
+
370
+ return { plans, carryForward, prunes, warnings };
371
+ }
372
+
373
+ export interface DependencyReportLine {
374
+ readonly name: string;
375
+ readonly action: "sync" | "unchanged" | "prune";
376
+ readonly line: string;
377
+ }
378
+
379
+ export interface ApplySyncResult {
380
+ readonly lock: Lock;
381
+ readonly report: readonly DependencyReportLine[];
382
+ }
383
+
384
+ /** First 8 hex chars after the `sha256-` (`INTEGRITY_PREFIX`) prefix — the
385
+ * report format's `<hash8>`. Exported so every caller — `deps.ts`'s `deps
386
+ * list` rendering, tests — shares this ONE definition rather than each
387
+ * reimplementing the slice with its own inlined `"sha256-"` literal. */
388
+ export function hash8(integrity: string): string {
389
+ return integrity.slice(INTEGRITY_PREFIX.length, INTEGRITY_PREFIX.length + 8);
390
+ }
391
+
392
+ /**
393
+ * DESIGN §4.1 steps 7-8. `opts.dryRun` runs the exact same reporting pass and
394
+ * writes nothing — no directory emptied, no artifact copied, no lock touched
395
+ * — which is what makes `--dry-run` a true preview of `planSync`'s verdict
396
+ * rather than a second code path that could drift from it.
397
+ */
398
+ export async function applySync(
399
+ configDir: string,
400
+ plan: SyncPlan,
401
+ opts: { readonly dryRun: boolean },
402
+ ): Promise<ApplySyncResult> {
403
+ const dependencies: Record<string, LockEntry> = {};
404
+ for (const [name, entry] of plan.carryForward) dependencies[name] = entry;
405
+
406
+ const report: DependencyReportLine[] = [];
407
+
408
+ for (const p of plan.plans) {
409
+ const newEntry: LockEntry = {
410
+ version: p.manifest.version,
411
+ metamodelVersion: p.manifest.metamodelVersion,
412
+ artifact: p.manifest.artifact,
413
+ integrity: p.manifest.integrity,
414
+ packages: p.manifest.packages,
415
+ nodes: p.manifest.nodes,
416
+ resolvedFrom: p.resolvedFrom,
417
+ };
418
+ dependencies[p.name] = newEntry;
419
+
420
+ if (p.action === "unchanged") {
421
+ report.push({ name: p.name, action: "unchanged", line: `unchanged ${p.name} ${p.manifest.version}` });
422
+ continue;
423
+ }
424
+
425
+ const oldVersion = p.oldEntry?.version ?? "(new)";
426
+ const oldHash = p.oldEntry !== undefined ? hash8(p.oldEntry.integrity) : "(new)";
427
+ const newHash = hash8(p.manifest.integrity);
428
+ const verb = opts.dryRun ? "would sync" : "synced";
429
+ report.push({
430
+ name: p.name,
431
+ action: "sync",
432
+ line: `${verb} ${p.name} ${oldVersion}→${p.manifest.version} (${oldHash}→${newHash})`,
433
+ });
434
+
435
+ if (!opts.dryRun) {
436
+ const depDir = join(configDir, DEFAULT_METAOBJECTS_DIR, DEPS_DIR, p.name);
437
+ await rm(depDir, { recursive: true, force: true });
438
+ await mkdir(depDir, { recursive: true });
439
+ await writeFile(join(depDir, p.manifest.artifact), p.artifactContent, "utf8");
440
+ }
441
+ }
442
+
443
+ for (const pr of plan.prunes) {
444
+ report.push({
445
+ name: pr.name,
446
+ action: "prune",
447
+ line: opts.dryRun ? `would prune ${pr.name}` : `pruned ${pr.name}`,
448
+ });
449
+ if (!opts.dryRun) {
450
+ const depDir = join(configDir, DEFAULT_METAOBJECTS_DIR, DEPS_DIR, pr.name);
451
+ await rm(depDir, { recursive: true, force: true });
452
+ }
453
+ }
454
+
455
+ const lock: Lock = { schema_version: 1, dependencies };
456
+ // Nothing to reconcile (no target dependency and no prune) is a true no-op —
457
+ // a project declaring no dependencies (and never having synced any) must
458
+ // not gain a `deps.lock.json` from a bare `meta deps sync`. `plan.prunes`
459
+ // being non-empty is what still writes when EVERY dependency was just
460
+ // removed from config: that run must produce a lock with an EMPTY
461
+ // `dependencies` map, not leave the stale one standing.
462
+ if (!opts.dryRun && (plan.plans.length > 0 || plan.prunes.length > 0)) {
463
+ await writeLock(configDir, lock);
464
+ }
465
+
466
+ return { lock, report };
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Task 15 — `meta deps check` / `verify --deps`: does the INSTALLED artifact
471
+ // (re-resolved live, exactly as `sync` steps 1-2 do) still hash to what the
472
+ // LOCK pinned? (DESIGN §2.3 "the lock pins bytes", §4.1 as amended by §11 —
473
+ // no usage-aware classification, just current/drifted/unresolved.)
474
+ // ---------------------------------------------------------------------------
475
+
476
+ /**
477
+ * `readLock`, converted into a diagnostic that NAMES the file rather than
478
+ * letting a corrupted committed lock's raw `JSON.parse`/`ZodError` surface as
479
+ * an unhandled rejection. `deps.lock.json` is checked-in, hand-editable, and
480
+ * merge-conflictable — a realistic way for it to break — and `bin/meta.ts`'s
481
+ * `run(...).then((code) => process.exit(code))` has no top-level `.catch()`,
482
+ * so an uncaught throw here would crash the process with a stack trace
483
+ * instead of the clean exit code every other failure in this command gets.
484
+ *
485
+ * The ONE call site for the sdk's `readLock` in `cli/src` — `deps.ts`'s
486
+ * `sync`/`list`/`check` handlers and `verify.ts`'s `--deps` gate all call
487
+ * THIS wrapper instead, so the diagnostic can never go stale in one of them.
488
+ */
489
+ export async function readLockOrThrow(configDir: string): Promise<Lock | undefined> {
490
+ try {
491
+ return await readLock(configDir);
492
+ } catch (err) {
493
+ throw new Error(
494
+ `${DEFAULT_METAOBJECTS_DIR}/${LOCK_FILE} is corrupted and could not be read: ${(err as Error).message}. ` +
495
+ "Fix it by hand, or delete it and re-run `meta deps sync` to regenerate it.",
496
+ );
497
+ }
498
+ }
499
+
500
+ /**
501
+ * `meta deps check` / `verify --deps`'s classification for one declared
502
+ * dependency (DESIGN §4.1 as amended by §11 — no usage-aware classifier, just
503
+ * "does the installed hash match the lock"):
504
+ * - `current` — resolved fine, and its installed artifact hashes to
505
+ * exactly what the lock pinned.
506
+ * - `drifted` — resolved fine, but the hash differs.
507
+ * - `unresolved` — step 1 (`resolveDependencyDir`) or step 2
508
+ * (`readManifestDir`) failed, OR the dependency is declared
509
+ * but the lock has no entry for it at all (nothing to
510
+ * compare against — "a check that cannot check must not
511
+ * pass"). `detail` carries WHY.
512
+ *
513
+ * `lockVersion`/`lockIntegrity` are `undefined` exactly when there is no lock
514
+ * entry; `installedVersion`/`installedIntegrity` are `undefined` exactly when
515
+ * resolution itself failed.
516
+ */
517
+ export interface DependencyCheckResult {
518
+ readonly name: string;
519
+ readonly status: "current" | "drifted" | "unresolved";
520
+ readonly lockVersion: string | undefined;
521
+ readonly lockIntegrity: string | undefined;
522
+ readonly installedVersion: string | undefined;
523
+ readonly installedIntegrity: string | undefined;
524
+ /** Populated for `unresolved` only — the reason, with the redundant
525
+ * `dependency "<name>": ` prefix (`manifestInvalid`/`resolveDependencyDir`'s
526
+ * own convention) stripped, since `formatCheckLine` already names the
527
+ * dependency once in its own lead-in. */
528
+ readonly detail: string | undefined;
529
+ }
530
+
531
+ /** Strips the `dependency "<name>": ` prefix `resolveDependencyDir` and
532
+ * `readManifestDir`'s errors always carry — `formatCheckLine` names the
533
+ * dependency itself, so repeating it verbatim inside `detail` would read
534
+ * twice. */
535
+ function stripDependencyPrefix(name: string, message: string): string {
536
+ const prefix = `dependency "${name}": `;
537
+ return message.startsWith(prefix) ? message.slice(prefix.length) : message;
538
+ }
539
+
540
+ /**
541
+ * DESIGN §4.1 as amended by §11 — resolve each declared dependency EXACTLY as
542
+ * `sync` steps 1-2 do (`resolveDependencyDir` then `readManifestDir`; never
543
+ * step 3's `validateAgainstSpec` — `check` compares hashes, it does not
544
+ * re-validate the spec), then compare the freshly-read artifact's TRUE hash
545
+ * (re-hashed here from the bytes `readManifestDir` returned, never the
546
+ * installed manifest's `integrity` field taken on faith) against the lock's.
547
+ *
548
+ * Read-only: nothing here writes the lock, the snapshot, or touches
549
+ * `.metaobjects/deps/` — that is `sync`'s job. Every declared dependency gets
550
+ * exactly one result, in name order.
551
+ */
552
+ export async function checkDependencies(
553
+ configDir: string,
554
+ specs: readonly DependencySpec[],
555
+ lock: Lock | undefined,
556
+ ): Promise<DependencyCheckResult[]> {
557
+ const entries = lock?.dependencies ?? {};
558
+ const sorted = [...specs].sort((a, b) => dependencyName(a).localeCompare(dependencyName(b)));
559
+ const results: DependencyCheckResult[] = [];
560
+
561
+ for (const spec of sorted) {
562
+ const name = dependencyName(spec);
563
+ const lockEntry = entries[name];
564
+
565
+ try {
566
+ const dir = resolveDependencyDir(configDir, spec);
567
+ const { manifest, artifactContent } = await readManifestDir(dir, name);
568
+ // Never trust the manifest's own `integrity` field for the comparison —
569
+ // re-hash the bytes actually sitting on disk right now.
570
+ //
571
+ // Right now this cannot observe a divergence from `manifest.integrity`:
572
+ // `readManifestDir` above (§4.1 step 2, :152-159) already throws
573
+ // `ERR_DEPENDENCY_MANIFEST_INVALID` unless `sha256Integrity(artifactContent)
574
+ // === manifest.integrity`, so by the time this line runs the two values are
575
+ // provably equal — this call is redundant WORK, not redundant SAFETY. The
576
+ // safety it buys is against `readManifestDir` changing out from under this
577
+ // function: if that check is ever relaxed, moved, or made conditional, this
578
+ // line becomes the ONLY thing standing between `check` and trusting a
579
+ // self-declared field, with no test that would fail to say so (the
580
+ // divergent state is unreachable through `checkDependencies`'s public
581
+ // signature today, precisely because `readManifestDir` forecloses it
582
+ // first). Read `manifest.integrity` here only if you have also confirmed
583
+ // `readManifestDir` still enforces the hash match unconditionally.
584
+ const installedIntegrity = sha256Integrity(artifactContent);
585
+
586
+ if (lockEntry === undefined) {
587
+ results.push({
588
+ name,
589
+ status: "unresolved",
590
+ lockVersion: undefined,
591
+ lockIntegrity: undefined,
592
+ installedVersion: manifest.version,
593
+ installedIntegrity,
594
+ detail: "declared, and resolves, but is not in the lock yet",
595
+ });
596
+ continue;
597
+ }
598
+
599
+ results.push({
600
+ name,
601
+ status: installedIntegrity === lockEntry.integrity ? "current" : "drifted",
602
+ lockVersion: lockEntry.version,
603
+ lockIntegrity: lockEntry.integrity,
604
+ installedVersion: manifest.version,
605
+ installedIntegrity,
606
+ detail: undefined,
607
+ });
608
+ } catch (err) {
609
+ results.push({
610
+ name,
611
+ status: "unresolved",
612
+ lockVersion: lockEntry?.version,
613
+ lockIntegrity: lockEntry?.integrity,
614
+ installedVersion: undefined,
615
+ installedIntegrity: undefined,
616
+ detail: stripDependencyPrefix(name, (err as Error).message),
617
+ });
618
+ }
619
+ }
620
+
621
+ return results;
622
+ }
623
+
624
+ /** The one code every `checkDependencies` failure (`drifted` or `unresolved`)
625
+ * is reported under — `deps.ts`'s `check` handler and `verify.ts`'s `--deps`
626
+ * gate both surface it, so it is named once here rather than inlined twice. */
627
+ export const ERR_DEPENDENCY_UPSTREAM_DRIFT: ErrorCode = "ERR_DEPENDENCY_UPSTREAM_DRIFT";
628
+
629
+ /**
630
+ * `meta deps check` / `verify --deps`'s one report line per dependency. The
631
+ * `drifted` shape is pinned exactly by the task brief:
632
+ * `<name>: drifted — lock <v> (<hash8>), installed <v> (<hash8>); run meta
633
+ * deps sync and review the artifact diff`.
634
+ */
635
+ export function formatCheckLine(result: DependencyCheckResult): string {
636
+ const lockDesc = `${result.lockVersion ?? "(none)"} (${
637
+ result.lockIntegrity !== undefined ? hash8(result.lockIntegrity) : "n/a"
638
+ })`;
639
+ const installedDesc = `${result.installedVersion ?? "(none)"} (${
640
+ result.installedIntegrity !== undefined ? hash8(result.installedIntegrity) : "n/a"
641
+ })`;
642
+ switch (result.status) {
643
+ case "current":
644
+ return `${result.name}: current — ${lockDesc}`;
645
+ case "drifted":
646
+ return (
647
+ `${result.name}: drifted — lock ${lockDesc}, installed ${installedDesc}; ` +
648
+ "run meta deps sync and review the artifact diff"
649
+ );
650
+ case "unresolved":
651
+ return (
652
+ `${result.name}: unresolved — ${result.detail ?? "could not resolve the dependency"}; ` +
653
+ "run meta deps sync once the dependency is reachable"
654
+ );
655
+ }
656
+ }