@metaobjectsdev/cli 1.0.2 → 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.
- package/README.md +1 -1
- package/dist/src/commands/deps.d.ts +3 -0
- package/dist/src/commands/deps.d.ts.map +1 -0
- package/dist/src/commands/deps.js +200 -0
- package/dist/src/commands/deps.js.map +1 -0
- package/dist/src/commands/docs.d.ts.map +1 -1
- package/dist/src/commands/docs.js +19 -3
- package/dist/src/commands/docs.js.map +1 -1
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +54 -1
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +12 -18
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +24 -19
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
- package/dist/src/commands/prompt-snapshot.js +2 -1
- package/dist/src/commands/prompt-snapshot.js.map +1 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +154 -23
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +62 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/agent-context-staleness.d.ts.map +1 -1
- package/dist/src/lib/agent-context-staleness.js +7 -1
- package/dist/src/lib/agent-context-staleness.js.map +1 -1
- package/dist/src/lib/args.d.ts +57 -1
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +39 -2
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/collection-load-options.d.ts +13 -0
- package/dist/src/lib/collection-load-options.d.ts.map +1 -0
- package/dist/src/lib/collection-load-options.js +19 -0
- package/dist/src/lib/collection-load-options.js.map +1 -0
- package/dist/src/lib/dependency-sync.d.ts +170 -0
- package/dist/src/lib/dependency-sync.d.ts.map +1 -0
- package/dist/src/lib/dependency-sync.js +457 -0
- package/dist/src/lib/dependency-sync.js.map +1 -0
- package/dist/src/lib/migrate-scope.d.ts +32 -1
- package/dist/src/lib/migrate-scope.d.ts.map +1 -1
- package/dist/src/lib/migrate-scope.js +51 -3
- package/dist/src/lib/migrate-scope.js.map +1 -1
- package/dist/src/lib/overlay-lint.d.ts +37 -0
- package/dist/src/lib/overlay-lint.d.ts.map +1 -0
- package/dist/src/lib/overlay-lint.js +102 -0
- package/dist/src/lib/overlay-lint.js.map +1 -0
- package/dist/src/lib/requirement-check.d.ts +9 -1
- package/dist/src/lib/requirement-check.d.ts.map +1 -1
- package/dist/src/lib/requirement-check.js +23 -7
- package/dist/src/lib/requirement-check.js.map +1 -1
- package/package.json +10 -10
- package/src/commands/deps.ts +231 -0
- package/src/commands/docs.ts +19 -3
- package/src/commands/gen.ts +61 -1
- package/src/commands/init.ts +13 -17
- package/src/commands/migrate.ts +26 -16
- package/src/commands/prompt-snapshot.ts +2 -1
- package/src/commands/verify.ts +174 -23
- package/src/index.ts +62 -2
- package/src/lib/agent-context-staleness.ts +7 -1
- package/src/lib/args.ts +89 -3
- package/src/lib/collection-load-options.ts +36 -0
- package/src/lib/dependency-sync.ts +656 -0
- package/src/lib/migrate-scope.ts +59 -4
- package/src/lib/overlay-lint.ts +129 -0
- 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
|
+
}
|