@metaobjectsdev/cli 1.0.3 → 1.0.4-rc.1
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 +13 -5
- package/dist/src/commands/eject.d.ts +2 -1
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +235 -75
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +119 -29
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/init.d.ts +5 -9
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +80 -384
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +40 -14
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +36 -14
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +6 -0
- package/dist/src/lib/advisory.d.ts.map +1 -1
- package/dist/src/lib/advisory.js +10 -0
- package/dist/src/lib/advisory.js.map +1 -1
- package/dist/src/lib/args.d.ts +18 -2
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +15 -3
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +92 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -0
- package/dist/src/lib/catalog-listing.js +268 -0
- package/dist/src/lib/catalog-listing.js.map +1 -0
- package/dist/src/lib/catalog.d.ts +41 -0
- package/dist/src/lib/catalog.d.ts.map +1 -0
- package/dist/src/lib/catalog.js +89 -0
- package/dist/src/lib/catalog.js.map +1 -0
- package/dist/src/lib/collection-load-options.d.ts +1 -1
- package/dist/src/lib/collection-load-options.d.ts.map +1 -1
- package/dist/src/lib/collection-load-options.js +6 -0
- package/dist/src/lib/collection-load-options.js.map +1 -1
- package/dist/src/lib/install-set.d.ts +31 -0
- package/dist/src/lib/install-set.d.ts.map +1 -0
- package/dist/src/lib/install-set.js +103 -0
- package/dist/src/lib/install-set.js.map +1 -0
- package/dist/src/lib/library-eject.d.ts +79 -0
- package/dist/src/lib/library-eject.d.ts.map +1 -0
- package/dist/src/lib/library-eject.js +241 -0
- package/dist/src/lib/library-eject.js.map +1 -0
- package/dist/src/lib/library-listing.d.ts +63 -0
- package/dist/src/lib/library-listing.d.ts.map +1 -0
- package/dist/src/lib/library-listing.js +178 -0
- package/dist/src/lib/library-listing.js.map +1 -0
- package/dist/src/lib/library-prefix-advisory.d.ts +17 -0
- package/dist/src/lib/library-prefix-advisory.d.ts.map +1 -0
- package/dist/src/lib/library-prefix-advisory.js +67 -0
- package/dist/src/lib/library-prefix-advisory.js.map +1 -0
- package/dist/src/lib/load-metaobjects-config.d.ts +1 -2
- package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
- package/dist/src/lib/load-metaobjects-config.js +11 -17
- package/dist/src/lib/load-metaobjects-config.js.map +1 -1
- package/dist/src/lib/requirement-check.d.ts +17 -2
- package/dist/src/lib/requirement-check.d.ts.map +1 -1
- package/dist/src/lib/requirement-check.js +51 -9
- package/dist/src/lib/requirement-check.js.map +1 -1
- package/package.json +10 -10
- package/src/commands/eject.ts +304 -90
- package/src/commands/gen.ts +128 -28
- package/src/commands/init.ts +82 -390
- package/src/commands/verify.ts +47 -15
- package/src/index.ts +36 -14
- package/src/lib/advisory.ts +13 -0
- package/src/lib/args.ts +29 -5
- package/src/lib/catalog-listing.ts +369 -0
- package/src/lib/catalog.ts +107 -0
- package/src/lib/collection-load-options.ts +7 -1
- package/src/lib/install-set.ts +116 -0
- package/src/lib/library-eject.ts +298 -0
- package/src/lib/library-listing.ts +258 -0
- package/src/lib/library-prefix-advisory.ts +78 -0
- package/src/lib/load-metaobjects-config.ts +15 -21
- package/src/lib/requirement-check.ts +71 -11
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// `meta eject <library>` — FR-043 §3.4, the copy door for a shipped library's METADATA.
|
|
2
|
+
//
|
|
3
|
+
// ADR-0034 ruled that a reference GENERATOR is copied into the adopter's repo because
|
|
4
|
+
// the adopter owns their code. §3.4 extends the same ruling to declared design, and
|
|
5
|
+
// makes it the EXPECTED mode rather than the fallback: a library is first a reference —
|
|
6
|
+
// something to copy, rename, and cut down. Using one in place, tracking upstream, is the
|
|
7
|
+
// deliberate minority choice.
|
|
8
|
+
//
|
|
9
|
+
// Two things follow, and both are here rather than in the generator path:
|
|
10
|
+
//
|
|
11
|
+
// The destination is `Collection.sourceRoots[0]` — the project's first DECLARED
|
|
12
|
+
// source root, resolved through `resolveCollection()`. Never `metaobjects/`: that
|
|
13
|
+
// string is the default value of `sources` and nothing else (CLAUDE.md names the five
|
|
14
|
+
// sites allowed to spell it, and this is not one of them).
|
|
15
|
+
//
|
|
16
|
+
// Every file carries a provenance header. It is what makes the copy's origin legible
|
|
17
|
+
// a year later, what `--list` reads to report staleness against the shipped tree, and
|
|
18
|
+
// what carries the ONE instruction that makes the eject complete: remove the library
|
|
19
|
+
// from `libraries`, or both trees load and merge (ERR_LIBRARY_PACKAGE_COLLISION).
|
|
20
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
21
|
+
import { basename, join } from "node:path";
|
|
22
|
+
import { MetaDataLoader, PACKAGE_SEPARATOR, canonicalSerialize, type MetaData } from "@metaobjectsdev/metadata";
|
|
23
|
+
import {
|
|
24
|
+
libraryManifests, libraryRefSource, libraryRefs, librarySources,
|
|
25
|
+
knownLibraryTokens, splitLayerToken,
|
|
26
|
+
} from "@metaobjectsdev/metadata/library";
|
|
27
|
+
import { resolveCollection } from "@metaobjectsdev/sdk";
|
|
28
|
+
import { cliVersion } from "./version.js";
|
|
29
|
+
|
|
30
|
+
/** The first line of every ejected file — the marker `--list` scans for. */
|
|
31
|
+
export const EJECT_MARKER = "# meta-eject:";
|
|
32
|
+
|
|
33
|
+
/** True when `name` is a shipped library rather than a generator. */
|
|
34
|
+
export function isLibraryName(name: string): boolean {
|
|
35
|
+
return name in libraryManifests();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every shipped library name, sorted. */
|
|
39
|
+
export function ejectableLibraryNames(): string[] {
|
|
40
|
+
return Object.keys(libraryManifests()).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where an ejected ref lands: `meta.<library>.<file>.yaml`.
|
|
45
|
+
*
|
|
46
|
+
* Not the library's own basename. `model.yaml` / `db.yaml` / `requirements.yaml` are
|
|
47
|
+
* meaningful inside `library/iam/` and meaningless in a directory holding an
|
|
48
|
+
* application's whole model — and three libraries ejected into one root would collide
|
|
49
|
+
* outright. The `meta.<concept>.<...>` shape is the file-naming convention this project
|
|
50
|
+
* documents.
|
|
51
|
+
*/
|
|
52
|
+
export function ejectedFileName(library: string, ref: string): string {
|
|
53
|
+
return `meta.${library}.${basename(ref)}.yaml`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function header(library: string, ref: string): string {
|
|
57
|
+
return [
|
|
58
|
+
`${EJECT_MARKER} library=${library} ref=${ref} cli=${cliVersion()}`,
|
|
59
|
+
`# Copied from the MetaObjects shipped library "${library}". YOU OWN THIS FILE: rename`,
|
|
60
|
+
`# the package, delete what you do not need, change anything. Nothing regenerates it,`,
|
|
61
|
+
`# and no gate compares it to the shipped tree except \`meta eject --list\`.`,
|
|
62
|
+
`#`,
|
|
63
|
+
`# ONE STEP REMAINS: remove "${library}" (and any "${library}/<layer>") from`,
|
|
64
|
+
`# \`libraries\` in .metaobjects/config.json. Left there, the shipped tree and this`,
|
|
65
|
+
`# copy BOTH load and merge — additions take effect and deletions do not, because the`,
|
|
66
|
+
`# library still declares what you removed. The loader refuses that outright`,
|
|
67
|
+
`# (ERR_LIBRARY_PACKAGE_COLLISION) rather than letting it run.`,
|
|
68
|
+
"",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface EjectedLibraryFile {
|
|
73
|
+
ref: string;
|
|
74
|
+
/** Path relative to the project root. */
|
|
75
|
+
path: string;
|
|
76
|
+
status: "created" | "preserved" | "replaced";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LibraryEjectResult {
|
|
80
|
+
library: string;
|
|
81
|
+
/** Absolute destination directory. */
|
|
82
|
+
root: string;
|
|
83
|
+
files: EjectedLibraryFile[];
|
|
84
|
+
/** The tokens for this library the project currently declares — what to remove. */
|
|
85
|
+
stillOptedIn: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
return (await stat(p)).isFile();
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function ejectLibrary(opts: {
|
|
97
|
+
cwd: string;
|
|
98
|
+
name: string;
|
|
99
|
+
force?: boolean;
|
|
100
|
+
}): Promise<LibraryEjectResult> {
|
|
101
|
+
const manifest = libraryManifests()[opts.name];
|
|
102
|
+
if (manifest === undefined) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`unknown library "${opts.name}". Shipped libraries: ${ejectableLibraryNames().join(", ")}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const collection = await resolveCollection(opts.cwd);
|
|
108
|
+
const root = collection.sourceRoots[0];
|
|
109
|
+
if (root === undefined) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`this project declares no metadata source root, so there is nowhere to put ` +
|
|
112
|
+
`"${opts.name}". Declare "sources" in .metaobjects/config.json, or run 'meta init'.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await mkdir(root, { recursive: true });
|
|
117
|
+
const files: EjectedLibraryFile[] = [];
|
|
118
|
+
for (const ref of libraryRefs(opts.name)) {
|
|
119
|
+
const text = header(opts.name, ref) + (await libraryRefSource(ref).read());
|
|
120
|
+
const name = ejectedFileName(opts.name, ref);
|
|
121
|
+
const abs = join(root, name);
|
|
122
|
+
const existed = await fileExists(abs);
|
|
123
|
+
if (existed && opts.force !== true) {
|
|
124
|
+
files.push({ ref, path: name, status: "preserved" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
await writeFile(abs, text, "utf8");
|
|
128
|
+
files.push({ ref, path: name, status: existed ? "replaced" : "created" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
library: opts.name,
|
|
133
|
+
root,
|
|
134
|
+
files,
|
|
135
|
+
stillOptedIn: collection.libraries.filter((t) => splitLayerToken(t)[0] === opts.name),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// staleness — `meta eject --list`
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export interface LibraryStaleness {
|
|
144
|
+
library: string;
|
|
145
|
+
/** Files in this project carrying the eject marker for this library. */
|
|
146
|
+
files: string[];
|
|
147
|
+
verdict: "identical" | "differs" | "unreadable";
|
|
148
|
+
/** Nodes present in both whose own-mode serialization differs. */
|
|
149
|
+
changed: number;
|
|
150
|
+
/** Nodes the SHIPPED tree has that the ejected copy does not — upstream moved. */
|
|
151
|
+
upstreamOnly: number;
|
|
152
|
+
/** Nodes the ejected copy has that the shipped tree does not — your additions. */
|
|
153
|
+
localOnly: number;
|
|
154
|
+
/** Why the comparison could not be made, when `verdict` is "unreadable". */
|
|
155
|
+
reason?: string;
|
|
156
|
+
/** Still named in `libraries` — the ejection is not finished. */
|
|
157
|
+
stillOptedIn: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The longest package prefix every node shares — the library's own root package.
|
|
161
|
+
*
|
|
162
|
+
* Segment-wise, never by string prefix: `acme::identity` and `acme::identityhub` share
|
|
163
|
+
* six characters and no package at all. */
|
|
164
|
+
function commonPackagePrefix(packages: readonly string[]): string {
|
|
165
|
+
if (packages.length === 0) return "";
|
|
166
|
+
let common = packages[0]!.split(PACKAGE_SEPARATOR);
|
|
167
|
+
for (const pkg of packages.slice(1)) {
|
|
168
|
+
const segments = pkg.split(PACKAGE_SEPARATOR);
|
|
169
|
+
let i = 0;
|
|
170
|
+
while (i < common.length && i < segments.length && common[i] === segments[i]) i++;
|
|
171
|
+
common = common.slice(0, i);
|
|
172
|
+
}
|
|
173
|
+
return common.join(PACKAGE_SEPARATOR);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Root-level nodes keyed by their package path RELATIVE to the library's own root
|
|
177
|
+
* package, canonically serialized in OWN mode.
|
|
178
|
+
*
|
|
179
|
+
* Relative rather than by resolution key, because §3.4 says an adopter who ejects "may
|
|
180
|
+
* rename the package freely" — keying on the FQN would report every node of a renamed
|
|
181
|
+
* copy as both upstream-only and local-only, which is the least useful answer
|
|
182
|
+
* available. Relative rather than by BARE NAME, because a library free to use two
|
|
183
|
+
* packages is free to declare `User` in both, and one key for two declarations means
|
|
184
|
+
* the one serialized last silently wins: a real divergence in the other is reported as
|
|
185
|
+
* `identical`. The relative path is what survives the rename — moving the root leaves
|
|
186
|
+
* every suffix below it exactly where it was — so it distinguishes without giving that
|
|
187
|
+
* edit anything to catch on. A single-package library yields the bare names it always
|
|
188
|
+
* did. Own mode, because the question is what each file DECLARES; the effective tree
|
|
189
|
+
* would fold a shipped base's fields into a subtype and report a difference that is not
|
|
190
|
+
* in either file. */
|
|
191
|
+
export function ownNodesByName(root: MetaData): Map<string, string> {
|
|
192
|
+
const nodes = [...root.children()];
|
|
193
|
+
const packageOf = (node: MetaData) => node.package ?? node.fileDefaultPackage ?? "";
|
|
194
|
+
// The ROOT package, neutralized in every node's serialization — for the same reason
|
|
195
|
+
// the keying is relative to it. A rename reaches the `package` key AND every `extends`
|
|
196
|
+
// inside the node, so comparing the raw text would report a renamed copy as wholly
|
|
197
|
+
// changed: the loudest possible answer to the one edit §3.4 explicitly invites. The
|
|
198
|
+
// root rather than each node's own package, so that a deeper node's reference back up
|
|
199
|
+
// to the root package is neutralized in it too.
|
|
200
|
+
const rootPackage = commonPackagePrefix(nodes.map(packageOf));
|
|
201
|
+
|
|
202
|
+
const out = new Map<string, string>();
|
|
203
|
+
for (const node of nodes) {
|
|
204
|
+
const pkg = packageOf(node);
|
|
205
|
+
const suffix =
|
|
206
|
+
rootPackage === "" ? pkg
|
|
207
|
+
: pkg === rootPackage ? ""
|
|
208
|
+
: pkg.slice(rootPackage.length + PACKAGE_SEPARATOR.length);
|
|
209
|
+
const key = suffix === "" ? node.name : `${suffix}${PACKAGE_SEPARATOR}${node.name}`;
|
|
210
|
+
const text = canonicalSerialize(node);
|
|
211
|
+
out.set(key, rootPackage === "" ? text : text.replaceAll(rootPackage, "<package>"));
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Every project file carrying an eject marker, grouped by library. */
|
|
217
|
+
async function ejectedFilesByLibrary(files: readonly string[]): Promise<Map<string, string[]>> {
|
|
218
|
+
const out = new Map<string, string[]>();
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
let head: string;
|
|
221
|
+
try {
|
|
222
|
+
head = (await readFile(file, "utf8")).slice(0, 400);
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const match = new RegExp(`^${EJECT_MARKER} library=(\\S+)`, "m").exec(head);
|
|
227
|
+
const library = match?.[1];
|
|
228
|
+
if (library === undefined) continue;
|
|
229
|
+
out.set(library, [...(out.get(library) ?? []), file]);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Per-library staleness for the copies THIS project owns.
|
|
236
|
+
*
|
|
237
|
+
* The same gap `--list` closes for generators, one level up: an ejected copy can sit any
|
|
238
|
+
* number of releases behind the tree it came from and nothing compares the two. The
|
|
239
|
+
* comparison is structural, through the canonical serializer, so re-indentation and key
|
|
240
|
+
* order never show up — the only thing that can be reported is a declaration that
|
|
241
|
+
* actually changed.
|
|
242
|
+
*/
|
|
243
|
+
export async function libraryStaleness(cwd: string): Promise<LibraryStaleness[]> {
|
|
244
|
+
let collection;
|
|
245
|
+
try {
|
|
246
|
+
collection = await resolveCollection(cwd);
|
|
247
|
+
} catch {
|
|
248
|
+
return []; // no project — nothing is ejected here
|
|
249
|
+
}
|
|
250
|
+
const byLibrary = await ejectedFilesByLibrary(collection.ownFiles);
|
|
251
|
+
const selected = new Set(collection.libraries.map((t) => splitLayerToken(t)[0]));
|
|
252
|
+
|
|
253
|
+
const rows: LibraryStaleness[] = [];
|
|
254
|
+
for (const [library, files] of [...byLibrary].sort(([a], [b]) => a.localeCompare(b))) {
|
|
255
|
+
const row: LibraryStaleness = {
|
|
256
|
+
library,
|
|
257
|
+
files: files.map((f) => f.slice(cwd.length + 1)),
|
|
258
|
+
verdict: "identical",
|
|
259
|
+
changed: 0,
|
|
260
|
+
upstreamOnly: 0,
|
|
261
|
+
localOnly: 0,
|
|
262
|
+
stillOptedIn: selected.has(library),
|
|
263
|
+
};
|
|
264
|
+
if (!(library in libraryManifests())) {
|
|
265
|
+
rows.push({ ...row, verdict: "unreadable", reason: `this build ships no library "${library}"` });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Both trees WHOLE, never file by file: a db layer is nothing but `overlay: true`
|
|
270
|
+
// redeclarations, so loading one alone is ERR_OVERLAY_NO_TARGET by construction.
|
|
271
|
+
const tokens = knownLibraryTokens().filter((t) => splitLayerToken(t)[0] === library);
|
|
272
|
+
const shipped = await new MetaDataLoader({ strict: true }).load(librarySources(tokens));
|
|
273
|
+
const { FileSource } = await import("@metaobjectsdev/metadata/core");
|
|
274
|
+
const local = await new MetaDataLoader({ strict: true }).load(
|
|
275
|
+
files.map((f) => new FileSource(f)),
|
|
276
|
+
);
|
|
277
|
+
if (local.errors.length > 0) {
|
|
278
|
+
rows.push({
|
|
279
|
+
...row,
|
|
280
|
+
verdict: "unreadable",
|
|
281
|
+
reason: `the ejected copy does not load on its own: ${local.errors[0]!.message}`,
|
|
282
|
+
});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const up = ownNodesByName(shipped.root);
|
|
287
|
+
const mine = ownNodesByName(local.root);
|
|
288
|
+
for (const [name, text] of up) {
|
|
289
|
+
if (!mine.has(name)) row.upstreamOnly++;
|
|
290
|
+
else if (mine.get(name) !== text) row.changed++;
|
|
291
|
+
}
|
|
292
|
+
for (const name of mine.keys()) if (!up.has(name)) row.localOnly++;
|
|
293
|
+
row.verdict =
|
|
294
|
+
row.changed + row.upstreamOnly + row.localOnly === 0 ? "identical" : "differs";
|
|
295
|
+
rows.push(row);
|
|
296
|
+
}
|
|
297
|
+
return rows;
|
|
298
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// `meta gen --list` — the LIBRARY rows (FR-043 §4).
|
|
2
|
+
//
|
|
3
|
+
// It is the codegen catalog, with `kind: "library"` rows in the same table rather than
|
|
4
|
+
// a parallel one: one door, one namespace, one `--probe`, one skill procedure. An agent
|
|
5
|
+
// about to model "users and permissions" should meet `iam` in the list it was already
|
|
6
|
+
// reading, not in a second command it has to know exists.
|
|
7
|
+
//
|
|
8
|
+
// THE ROW DESCRIBES THE BOX; THE PROJECT BLOCK DESCRIBES YOUR SHELF. `provides` counts
|
|
9
|
+
// what the library ships across ALL its layers — what you would get if you took every
|
|
10
|
+
// layer — while `project.tablesAdded` counts what YOUR selection actually put into the
|
|
11
|
+
// model. Those are different numbers whenever an adopter takes the core layer alone,
|
|
12
|
+
// which Amendment 1 makes the common case: `libraries: ["iam"]` provides nine entities
|
|
13
|
+
// and adds zero tables.
|
|
14
|
+
//
|
|
15
|
+
// Every fact here is COMPUTED — from the manifest, or by loading the library and
|
|
16
|
+
// counting. Nothing is a second copy of a sentence written somewhere else, which is the
|
|
17
|
+
// rule §4 states as "every manifest fact is resolved, not trusted".
|
|
18
|
+
import {
|
|
19
|
+
MetaDataLoader,
|
|
20
|
+
TYPE_OBJECT,
|
|
21
|
+
TYPE_REQUIREMENT,
|
|
22
|
+
OBJECT_SUBTYPE_ENTITY,
|
|
23
|
+
isWritableSource,
|
|
24
|
+
type MetaData,
|
|
25
|
+
} from "@metaobjectsdev/metadata";
|
|
26
|
+
import {
|
|
27
|
+
libraryManifests,
|
|
28
|
+
librarySources,
|
|
29
|
+
knownLibraryTokens,
|
|
30
|
+
splitLayerToken,
|
|
31
|
+
type LibraryManifest,
|
|
32
|
+
} from "@metaobjectsdev/metadata/library";
|
|
33
|
+
import { SERVER_LANGS } from "@metaobjectsdev/sdk";
|
|
34
|
+
|
|
35
|
+
/** One `--list` row for a shipped library. */
|
|
36
|
+
export interface LibraryCatalogRow {
|
|
37
|
+
name: string;
|
|
38
|
+
kind: "library";
|
|
39
|
+
/** The manifest's own `kind` — what SORT of library this is (`feature`, `nfr`). A
|
|
40
|
+
* second axis from the row's `kind`, which is the catalog discriminator. */
|
|
41
|
+
libraryKind: string;
|
|
42
|
+
stability: string;
|
|
43
|
+
/** The language ports this library is reachable from. Every port embeds the whole
|
|
44
|
+
* `library/` tree from one generator script, so this is all of them — and a test
|
|
45
|
+
* resolves that against each port's embedded module rather than trusting it. */
|
|
46
|
+
ports: readonly string[];
|
|
47
|
+
description: string;
|
|
48
|
+
useWhen?: string;
|
|
49
|
+
packages: readonly string[];
|
|
50
|
+
/** Selection tokens, core first — `["iam", "iam/db"]`. The bare name IS the core
|
|
51
|
+
* layer, and a layer token implies it. This is the field an adopter acts on: under
|
|
52
|
+
* Amendment 1 you do not opt into a library, you opt into its layers. */
|
|
53
|
+
layers: ReadonlyArray<{ token: string; description?: string }>;
|
|
54
|
+
/** What is in the box, across every layer. */
|
|
55
|
+
provides: {
|
|
56
|
+
entities: number;
|
|
57
|
+
abstracts: number;
|
|
58
|
+
requirements: number;
|
|
59
|
+
generators: readonly string[];
|
|
60
|
+
};
|
|
61
|
+
project?: LibraryProjectFacts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LibraryProjectFacts {
|
|
65
|
+
/** Does this project's `libraries` name this library at all? */
|
|
66
|
+
optedIn: boolean;
|
|
67
|
+
/** The tokens this project selected, in config order. Empty when not opted in. */
|
|
68
|
+
selectedLayers: readonly string[];
|
|
69
|
+
/** Project entities whose `extends` chain reaches a node this library owns — the
|
|
70
|
+
* adoption that opting out would break. `null` unless the model was loaded. */
|
|
71
|
+
extendedBy: readonly string[] | null;
|
|
72
|
+
/** Tables this library's SELECTED layers contribute to the loaded model. Zero for a
|
|
73
|
+
* core-only selection, which is the inertness promise expressed as a number. */
|
|
74
|
+
tablesAdded: number | null;
|
|
75
|
+
/** Requirement entries the library put into this project's ledger. */
|
|
76
|
+
requirementsAdded: number | null;
|
|
77
|
+
/** Generators the manifest says this library implies, which this project has not
|
|
78
|
+
* wired. Not an error: the library is metadata, and wiring is the adopter's call. */
|
|
79
|
+
impliedGeneratorsNotWired: readonly string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What a `--list` caller knows about the project, for the library rows. */
|
|
83
|
+
export interface LibraryProjectContext {
|
|
84
|
+
/** The project's `libraries` selection, verbatim. */
|
|
85
|
+
libraries: readonly string[];
|
|
86
|
+
/** Stable names wired in `generators: [...]`. */
|
|
87
|
+
wiredNames: ReadonlySet<string>;
|
|
88
|
+
/** The loaded model, when one was loaded (`--probe`). */
|
|
89
|
+
metadata?: MetaData;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every selection token belonging to one library, core first. */
|
|
93
|
+
function tokensOf(name: string): string[] {
|
|
94
|
+
return knownLibraryTokens().filter((t) => splitLayerToken(t)[0] === name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Walk every node, since a requirement nests to any depth and an object does not. */
|
|
98
|
+
function walk(node: MetaData, visit: (n: MetaData) => void): void {
|
|
99
|
+
for (const child of node.children()) {
|
|
100
|
+
visit(child);
|
|
101
|
+
walk(child, visit);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function effectivePackage(node: MetaData): string {
|
|
106
|
+
return node.package ?? node.fileDefaultPackage ?? "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load one library with ALL of its layers and count what it ships.
|
|
111
|
+
*
|
|
112
|
+
* The FULL selection, not the core: `provides` answers "what is in this library",
|
|
113
|
+
* and an adopter reading it is deciding whether to opt in at all. What their current
|
|
114
|
+
* selection actually added is the project block's job.
|
|
115
|
+
*/
|
|
116
|
+
async function providesOf(name: string, manifest: LibraryManifest): Promise<LibraryCatalogRow["provides"]> {
|
|
117
|
+
const result = await new MetaDataLoader({ strict: true }).load(librarySources(tokensOf(name)));
|
|
118
|
+
let entities = 0;
|
|
119
|
+
let abstracts = 0;
|
|
120
|
+
let requirements = 0;
|
|
121
|
+
walk(result.root, (n) => {
|
|
122
|
+
if (n.type === TYPE_REQUIREMENT) requirements++;
|
|
123
|
+
else if (n.type === TYPE_OBJECT && n.subType === OBJECT_SUBTYPE_ENTITY) {
|
|
124
|
+
if (n.isAbstract) abstracts++;
|
|
125
|
+
else entities++;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
entities,
|
|
130
|
+
abstracts,
|
|
131
|
+
requirements,
|
|
132
|
+
generators: (manifest.generators ?? []).map((g) => g.name),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The project facts for one library.
|
|
138
|
+
*
|
|
139
|
+
* `extendedBy` and the two counts read the LOADED model rather than the library,
|
|
140
|
+
* because that is the question being answered: not "what does iam contain" but "what
|
|
141
|
+
* is iam doing in this repo". Without `--probe` there is no model and they are null —
|
|
142
|
+
* never zero, which would read as "nothing", the opposite of "not measured".
|
|
143
|
+
*/
|
|
144
|
+
function projectFactsFor(
|
|
145
|
+
manifest: LibraryManifest,
|
|
146
|
+
ctx: LibraryProjectContext,
|
|
147
|
+
): LibraryProjectFacts {
|
|
148
|
+
const name = manifest.name;
|
|
149
|
+
const selectedLayers = ctx.libraries.filter((t) => splitLayerToken(t)[0] === name);
|
|
150
|
+
const impliedGeneratorsNotWired = (manifest.generators ?? [])
|
|
151
|
+
.map((g) => g.name)
|
|
152
|
+
.filter((g) => !ctx.wiredNames.has(g));
|
|
153
|
+
|
|
154
|
+
const facts: LibraryProjectFacts = {
|
|
155
|
+
optedIn: selectedLayers.length > 0,
|
|
156
|
+
selectedLayers,
|
|
157
|
+
extendedBy: null,
|
|
158
|
+
tablesAdded: null,
|
|
159
|
+
requirementsAdded: null,
|
|
160
|
+
impliedGeneratorsNotWired,
|
|
161
|
+
};
|
|
162
|
+
if (ctx.metadata === undefined) return facts;
|
|
163
|
+
|
|
164
|
+
const owned = new Set(manifest.packages ?? []);
|
|
165
|
+
const extendedBy: string[] = [];
|
|
166
|
+
let tablesAdded = 0;
|
|
167
|
+
let requirementsAdded = 0;
|
|
168
|
+
walk(ctx.metadata, (n) => {
|
|
169
|
+
const mine = owned.has(effectivePackage(n));
|
|
170
|
+
if (n.type === TYPE_REQUIREMENT && mine) requirementsAdded++;
|
|
171
|
+
if (n.type !== TYPE_OBJECT) return;
|
|
172
|
+
if (mine) {
|
|
173
|
+
// A writable source is what migrate keys a CREATE TABLE off (#248 —
|
|
174
|
+
// persistability derives from source presence, never from the subtype), so
|
|
175
|
+
// counting them here and counting tables there cannot drift apart.
|
|
176
|
+
if (n.children().some(isWritableSource)) tablesAdded++;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// A project entity reaching this library through `extends`, at any depth: the
|
|
180
|
+
// adoption an adopter would break by removing the library from `libraries`.
|
|
181
|
+
for (let sup = n.superData; sup !== undefined; sup = sup.superData) {
|
|
182
|
+
if (owned.has(effectivePackage(sup))) {
|
|
183
|
+
extendedBy.push(`${effectivePackage(n)}::${n.name}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
facts.extendedBy = extendedBy.sort();
|
|
190
|
+
facts.tablesAdded = tablesAdded;
|
|
191
|
+
facts.requirementsAdded = requirementsAdded;
|
|
192
|
+
return facts;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every shipped library, as `--list` rows, in name order. */
|
|
196
|
+
export async function buildLibraryRows(
|
|
197
|
+
ctx?: LibraryProjectContext,
|
|
198
|
+
): Promise<LibraryCatalogRow[]> {
|
|
199
|
+
const rows: LibraryCatalogRow[] = [];
|
|
200
|
+
for (const [name, manifest] of Object.entries(libraryManifests()).sort(([a], [b]) =>
|
|
201
|
+
a.localeCompare(b),
|
|
202
|
+
)) {
|
|
203
|
+
const layers = tokensOf(name).map((token) => {
|
|
204
|
+
const layer = (manifest.layers ?? {})[splitLayerToken(token)[1]];
|
|
205
|
+
return layer?.description === undefined
|
|
206
|
+
? { token }
|
|
207
|
+
: { token, description: layer.description };
|
|
208
|
+
});
|
|
209
|
+
rows.push({
|
|
210
|
+
name,
|
|
211
|
+
kind: "library",
|
|
212
|
+
libraryKind: manifest.kind ?? "feature",
|
|
213
|
+
stability: manifest.stability ?? "preview",
|
|
214
|
+
ports: SERVER_LANGS,
|
|
215
|
+
description: manifest.description ?? "",
|
|
216
|
+
...(manifest.useWhen !== undefined ? { useWhen: manifest.useWhen } : {}),
|
|
217
|
+
packages: manifest.packages ?? [],
|
|
218
|
+
layers,
|
|
219
|
+
provides: await providesOf(name, manifest),
|
|
220
|
+
...(ctx === undefined ? {} : { project: projectFactsFor(manifest, ctx) }),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return rows;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function plural(n: number, one: string, many: string): string {
|
|
227
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The human rendering — one section, after the generator layers. */
|
|
231
|
+
export function renderLibraryText(rows: readonly LibraryCatalogRow[]): string[] {
|
|
232
|
+
if (rows.length === 0) return [];
|
|
233
|
+
const lines: string[] = [];
|
|
234
|
+
lines.push("");
|
|
235
|
+
lines.push("libraries — declared design you opt into, a LAYER at a time; the core layer adds no tables");
|
|
236
|
+
for (const r of rows) {
|
|
237
|
+
const marks: string[] = [r.stability];
|
|
238
|
+
if (r.project?.optedIn) marks.push(`opted in: ${r.project.selectedLayers.join(", ")}`);
|
|
239
|
+
if (r.project?.tablesAdded) marks.push(`${plural(r.project.tablesAdded, "table", "tables")} added`);
|
|
240
|
+
lines.push(` ${r.name} — ${r.description} [${marks.join(", ")}]`);
|
|
241
|
+
if (r.useWhen !== undefined) lines.push(` use when: ${r.useWhen}`);
|
|
242
|
+
lines.push(
|
|
243
|
+
` provides: ${plural(r.provides.entities, "entity", "entities")}, ` +
|
|
244
|
+
`${r.provides.abstracts} abstract, ` +
|
|
245
|
+
`${plural(r.provides.requirements, "requirement", "requirements")}`,
|
|
246
|
+
);
|
|
247
|
+
lines.push(` layers: ${r.layers.map((l) => l.token).join(", ")}`);
|
|
248
|
+
if (r.project !== null && r.project?.impliedGeneratorsNotWired.length) {
|
|
249
|
+
lines.push(
|
|
250
|
+
` implies: ${r.project.impliedGeneratorsNotWired.join(", ")} (not wired)`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
lines.push("");
|
|
255
|
+
lines.push('Opt in with `"libraries": ["iam", "iam/db"]` in .metaobjects/config.json —');
|
|
256
|
+
lines.push("the bare name is the core layer, and a layer token implies it.");
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// FR-043 §3.5 — `metaobjects::` in an adopter's own metadata, with no ejection provenance.
|
|
2
|
+
//
|
|
3
|
+
// The loader already REFUSES a node in a library's package while that library is opted
|
|
4
|
+
// in (ERR_LIBRARY_PACKAGE_COLLISION / ERR_LIBRARY_PACKAGE_NOT_OWNED). This is the other
|
|
5
|
+
// half, and it is advisory on purpose: with the library NOT opted in there is nothing
|
|
6
|
+
// broken to refuse — the nodes are simply the adopter's, sitting under a prefix this
|
|
7
|
+
// project ships libraries into.
|
|
8
|
+
//
|
|
9
|
+
// Two ways to arrive there, and the advice is the same for both:
|
|
10
|
+
//
|
|
11
|
+
// a hand-copy, rather than `meta eject <library>` — no provenance header, so nothing
|
|
12
|
+
// can tell later where it came from or how far it has drifted;
|
|
13
|
+
// a package名 chosen freely that happens to start `metaobjects::` — which will
|
|
14
|
+
// collide the day a library ships into it.
|
|
15
|
+
//
|
|
16
|
+
// A file carrying the eject marker is exempt: that IS the provenance, and `meta eject
|
|
17
|
+
// --list` reports its staleness.
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { EJECT_MARKER } from "./library-eject.js";
|
|
20
|
+
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
21
|
+
|
|
22
|
+
/** The prefix every shipped library declares into. */
|
|
23
|
+
export const LIBRARY_PACKAGE_PREFIX = "metaobjects::";
|
|
24
|
+
|
|
25
|
+
export interface LibraryPrefixFinding {
|
|
26
|
+
file: string;
|
|
27
|
+
fqn: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Root nodes under `metaobjects::` whose file carries no ejection provenance.
|
|
33
|
+
*
|
|
34
|
+
* Reads the loaded tree for the nodes and the FILES only for the marker — the marker is
|
|
35
|
+
* `meta eject`'s own bookkeeping, not metadata, so there is nothing in the model to read
|
|
36
|
+
* it from.
|
|
37
|
+
*/
|
|
38
|
+
export async function scanForUnprovenancedLibraryPrefix(
|
|
39
|
+
root: MetaData,
|
|
40
|
+
ownFiles: readonly string[],
|
|
41
|
+
projectRoot: string,
|
|
42
|
+
): Promise<LibraryPrefixFinding[]> {
|
|
43
|
+
const suspect = root
|
|
44
|
+
.children()
|
|
45
|
+
.filter((n) => n.resolutionKey().startsWith(LIBRARY_PACKAGE_PREFIX));
|
|
46
|
+
if (suspect.length === 0) return [];
|
|
47
|
+
|
|
48
|
+
// Which of this project's own files carry the marker — read once, not per node.
|
|
49
|
+
const provenanced = new Set<string>();
|
|
50
|
+
for (const file of ownFiles) {
|
|
51
|
+
try {
|
|
52
|
+
if ((await readFile(file, "utf8")).slice(0, 400).includes(EJECT_MARKER)) {
|
|
53
|
+
provenanced.add(file.slice(projectRoot.length + 1));
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// unreadable — it contributes no exemption, which is the safe direction
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const out: LibraryPrefixFinding[] = [];
|
|
61
|
+
for (const node of suspect) {
|
|
62
|
+
const files = "files" in node.source ? node.source.files : [];
|
|
63
|
+
// A node the SHIPPED library contributed is not the adopter's — `library:` ids say so.
|
|
64
|
+
if (files.some((f) => f.startsWith("library:"))) continue;
|
|
65
|
+
if (files.some((f) => provenanced.has(f) || [...provenanced].some((p) => p.endsWith(f)))) continue;
|
|
66
|
+
out.push({
|
|
67
|
+
file: files[0] ?? "(unknown)",
|
|
68
|
+
fqn: node.resolutionKey(),
|
|
69
|
+
message:
|
|
70
|
+
`${node.resolutionKey()} is declared under "${LIBRARY_PACKAGE_PREFIX}", which is where ` +
|
|
71
|
+
`MetaObjects ships its libraries, and no file declaring it carries an ejection header. ` +
|
|
72
|
+
`If it was copied from a library, re-copy it with 'meta eject <library>' so its origin ` +
|
|
73
|
+
`and its drift stay legible; if it is your own, move it to a package you own — a later ` +
|
|
74
|
+
`release shipping a node of that name would merge into yours.`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|