@metaobjectsdev/cli 1.0.11 → 1.0.12
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/dist/src/commands/fmt.d.ts +2 -0
- package/dist/src/commands/fmt.d.ts.map +1 -0
- package/dist/src/commands/fmt.js +80 -0
- package/dist/src/commands/fmt.js.map +1 -0
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +37 -1
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +65 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/args.d.ts +25 -0
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +18 -0
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/deprecation-lint.d.ts +11 -0
- package/dist/src/lib/deprecation-lint.d.ts.map +1 -0
- package/dist/src/lib/deprecation-lint.js +273 -0
- package/dist/src/lib/deprecation-lint.js.map +1 -0
- package/dist/src/lib/fmt-engine.d.ts +22 -0
- package/dist/src/lib/fmt-engine.d.ts.map +1 -0
- package/dist/src/lib/fmt-engine.js +128 -0
- package/dist/src/lib/fmt-engine.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/fmt.ts +90 -0
- package/src/commands/verify.ts +45 -1
- package/src/index.ts +65 -0
- package/src/lib/args.ts +38 -0
- package/src/lib/deprecation-lint.ts +327 -0
- package/src/lib/fmt-engine.ts +179 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// `meta verify` — the deprecated-REFERENCE authoring lint (#305).
|
|
2
|
+
//
|
|
3
|
+
// `deprecated` / `replacedBy` are registered documentation common-attrs in every
|
|
4
|
+
// port (doc-provider.ts) and nothing reads them: no validation pass, no `verify`
|
|
5
|
+
// check, no codegen behavior. A deprecation you cannot enforce is a comment.
|
|
6
|
+
//
|
|
7
|
+
// Smallest useful version (approved scope): warn when a node DEPENDS on a node
|
|
8
|
+
// carrying `deprecated`, through the reference kinds the metamodel already has —
|
|
9
|
+
// `extends`, `@objectRef`, `@references`, and an origin's `@from` / `@of` / `@via`
|
|
10
|
+
// path. Where the deprecated node also carries `replacedBy`, the diagnostic names
|
|
11
|
+
// the replacement. Never fails the build — ADVISORY ONLY, the same discipline as
|
|
12
|
+
// every sibling authoring lint in this directory (name-lint.ts, overlay-lint.ts,
|
|
13
|
+
// requirement-lint.ts).
|
|
14
|
+
//
|
|
15
|
+
// Explicitly NOT in scope here (tracked separately): a deprecation DATE that
|
|
16
|
+
// escalates warn -> error once passed, and per-finding suppressions with a
|
|
17
|
+
// recorded reason (#302) — both need their own design.
|
|
18
|
+
//
|
|
19
|
+
// SELF-REFERENCE IS NOT A FINDING, but ONLY the literal case: the same node, or
|
|
20
|
+
// one of the node's own ANCESTORS (the recursive-FK shape: `Category.parentId`
|
|
21
|
+
// is a child of `Category` and points back at `Category` itself — not a
|
|
22
|
+
// dependency on a different thing, it IS the deprecated thing). A DIFFERENT
|
|
23
|
+
// node that merely happens to live under the same entity — e.g. a passthrough
|
|
24
|
+
// field reading a DEPRECATED SIBLING field on its own entity — is a real
|
|
25
|
+
// cross-node dependency and DOES warn; sharing an ancestor is not sharing an
|
|
26
|
+
// identity. Guarded by `isSelfReference` below.
|
|
27
|
+
//
|
|
28
|
+
// Resolution reads the LOADED, FROZEN model only, via the RESOLVING accessors
|
|
29
|
+
// (ADR-0039: `attr()`, `children()`, `superData`) — so a target that inherits
|
|
30
|
+
// `deprecated` from its own abstract base still counts, and a reference
|
|
31
|
+
// inherited through `extends` is reported once, at its declaration
|
|
32
|
+
// (`ownChildren()`, the same sanctioned case name-lint.ts uses).
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
DOC_ATTR_DEPRECATED,
|
|
36
|
+
DOC_ATTR_REPLACED_BY,
|
|
37
|
+
TYPE_FIELD,
|
|
38
|
+
TYPE_IDENTITY,
|
|
39
|
+
TYPE_RELATIONSHIP,
|
|
40
|
+
TYPE_ORIGIN,
|
|
41
|
+
IDENTITY_SUBTYPE_REFERENCE,
|
|
42
|
+
IDENTITY_REFERENCE_ATTR_REFERENCES,
|
|
43
|
+
RELATIONSHIP_ATTR_OBJECT_REF,
|
|
44
|
+
FIELD_ATTR_OBJECT_REF,
|
|
45
|
+
// @via is spelled identically on every origin subtype (passthrough / aggregate /
|
|
46
|
+
// first); @of is identical on aggregate and first. One constant each, reused —
|
|
47
|
+
// see origin-constants.ts.
|
|
48
|
+
ORIGIN_PASSTHROUGH_ATTR_FROM,
|
|
49
|
+
ORIGIN_PASSTHROUGH_ATTR_VIA,
|
|
50
|
+
ORIGIN_AGGREGATE_ATTR_OF,
|
|
51
|
+
isMetaObject,
|
|
52
|
+
resolveObjectRef,
|
|
53
|
+
type MetaData,
|
|
54
|
+
type MetaObject,
|
|
55
|
+
} from "@metaobjectsdev/metadata";
|
|
56
|
+
import type { Diagnostic } from "./requirement-check.js";
|
|
57
|
+
|
|
58
|
+
export const WARN_DEPRECATED_REFERENCE = "WARN_DEPRECATED_REFERENCE";
|
|
59
|
+
|
|
60
|
+
function warn(path: string, message: string): Diagnostic {
|
|
61
|
+
return { severity: "warn", code: WARN_DEPRECATED_REFERENCE, path, message };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// addressing — mirrors name-lint.ts's topLevelAddress/segment, applied to every
|
|
66
|
+
// node (not only ones whose own name is the finding).
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function packageOf(node: MetaData): string {
|
|
70
|
+
return node.package ?? node.fileDefaultPackage ?? "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Walk up to the nearest enclosing `object.*` — every field/identity/relationship/
|
|
74
|
+
* origin is nested under exactly one (objects are never nested in this metamodel). */
|
|
75
|
+
function owningObject(node: MetaData): MetaObject | undefined {
|
|
76
|
+
let cur: MetaData | undefined = node.parent;
|
|
77
|
+
while (cur !== undefined && !isMetaObject(cur)) cur = cur.parent;
|
|
78
|
+
return cur;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A human-readable address for a node: the object's FQN, or `<object FQN>.<name>` for
|
|
82
|
+
* something nested inside one. Falls back to the bare name (an abstract field
|
|
83
|
+
* declared at root, outside any object) when there is no enclosing object. */
|
|
84
|
+
function addressOf(node: MetaData): string {
|
|
85
|
+
if (isMetaObject(node)) return node.resolutionKey();
|
|
86
|
+
const owner = owningObject(node);
|
|
87
|
+
return owner !== undefined ? `${owner.resolutionKey()}.${node.name}` : node.name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// self-reference guard
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function isAncestorOf(maybeAncestor: MetaData, node: MetaData): boolean {
|
|
95
|
+
let cur: MetaData | undefined = node.parent;
|
|
96
|
+
while (cur !== undefined) {
|
|
97
|
+
if (cur === maybeAncestor) return true;
|
|
98
|
+
cur = cur.parent;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True when `target` is not really an OTHER node from `referrer`'s point of
|
|
105
|
+
* view: the same node, or an ANCESTOR of it (the referrer's own enclosing
|
|
106
|
+
* object/field — the recursive-FK shape). Deliberately NOT symmetric: a
|
|
107
|
+
* DESCENDANT of `referrer` is a real, different thing to depend on (and no
|
|
108
|
+
* call site here ever resolves a target nested inside the specific referrer
|
|
109
|
+
* node anyway — a sibling field, the common case, is nested in the shared
|
|
110
|
+
* parent ENTITY, not in the referrer).
|
|
111
|
+
*/
|
|
112
|
+
function isSelfReference(referrer: MetaData, target: MetaData): boolean {
|
|
113
|
+
return referrer === target || isAncestorOf(target, referrer);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// the check: does `target` carry `deprecated`, resolved?
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Returns true when a finding was pushed — callers checking a MORE SPECIFIC
|
|
121
|
+
* target first (e.g. a field) use this to decide whether to also check the
|
|
122
|
+
* less-specific one (its entity): only when the specific one was silent. */
|
|
123
|
+
function considerTarget(referrer: MetaData, referrerPath: string, verb: string, target: MetaData, out: Diagnostic[]): boolean {
|
|
124
|
+
if (isSelfReference(referrer, target)) return false;
|
|
125
|
+
// Registry contract (documentation-definition.embedded.ts): "Presence ⇒
|
|
126
|
+
// deprecated" — an empty-string reason still means deprecated (codegen-ts's
|
|
127
|
+
// jsdoc.ts reads it the same way, via `!== undefined`, not a truthiness
|
|
128
|
+
// check). Only an ABSENT attr (undefined) means "not deprecated".
|
|
129
|
+
const reason = target.attr(DOC_ATTR_DEPRECATED);
|
|
130
|
+
if (typeof reason !== "string") return false;
|
|
131
|
+
const replacedByRaw = target.attr(DOC_ATTR_REPLACED_BY);
|
|
132
|
+
const replacedBy = typeof replacedByRaw === "string" && replacedByRaw !== "" ? replacedByRaw : undefined;
|
|
133
|
+
const targetKind = `${target.type}.${target.subType}`;
|
|
134
|
+
const reasonClause = reason !== "" ? `: ${reason}` : "";
|
|
135
|
+
out.push(
|
|
136
|
+
warn(
|
|
137
|
+
referrerPath,
|
|
138
|
+
`${verb} deprecated ${targetKind} ${addressOf(target)}${reasonClause}` +
|
|
139
|
+
(replacedBy !== undefined ? ` Replaced by ${replacedBy}.` : ""),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Split a dotted reference into its entity HEAD and member tail ("Entity.field" ->
|
|
146
|
+
* "Entity" / "field"). `@objectRef` carries no tail (a pure object ref) and is
|
|
147
|
+
* resolved whole by the caller; `@references`/`@from`/`@of`/`@via` do. */
|
|
148
|
+
function headOf(raw: string): string {
|
|
149
|
+
const dot = raw.indexOf(".");
|
|
150
|
+
return dot === -1 ? raw : raw.slice(0, dot);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveEntityRef(root: MetaData, ref: string, referrerPkg: string): MetaObject | undefined {
|
|
154
|
+
const node = resolveObjectRef(root, ref, referrerPkg).node;
|
|
155
|
+
return node !== undefined && isMetaObject(node) ? node : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The relationship / identity.reference CHILD on `entity` named `hopName` — a
|
|
159
|
+
* `@via` path segment. Resolving `children()` (ADR-0039): an inherited hop still
|
|
160
|
+
* counts. */
|
|
161
|
+
function findHop(entity: MetaObject, hopName: string): MetaData | undefined {
|
|
162
|
+
return entity.children().find(
|
|
163
|
+
(c) =>
|
|
164
|
+
(c.type === TYPE_RELATIONSHIP || (c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_REFERENCE)) &&
|
|
165
|
+
c.name === hopName,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The entity a resolved hop (relationship or identity.reference) points at, raw. */
|
|
170
|
+
function hopTargetRef(hop: MetaData): string | undefined {
|
|
171
|
+
const v = hop.type === TYPE_RELATIONSHIP
|
|
172
|
+
? hop.attr(RELATIONSHIP_ATTR_OBJECT_REF)
|
|
173
|
+
: hop.attr(IDENTITY_REFERENCE_ATTR_REFERENCES);
|
|
174
|
+
return typeof v === "string" && v !== "" ? headOf(v) : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check a dotted "Entity" or "Entity.field" reference — shared by origin
|
|
179
|
+
* `@from`/`@of` AND identity.reference's `@references` (ADR-0042 §5: the
|
|
180
|
+
* dotted form is not origin-specific; `Team.code` names an explicit FK target
|
|
181
|
+
* field exactly the way `Country.name` names a passthrough source field — see
|
|
182
|
+
* `fixtures/conformance/relationship-one-two-refs-dotted-references/`). The
|
|
183
|
+
* field is the more specific target (checked first); the entity is checked
|
|
184
|
+
* independently so a field on an otherwise-fine entity, OR a whole deprecated
|
|
185
|
+
* entity, each warn. Resolution failure (either half) is silent — bias to
|
|
186
|
+
* under-flagging, the same stance every sibling advisory pass in this
|
|
187
|
+
* directory takes.
|
|
188
|
+
*/
|
|
189
|
+
function checkEntityFieldPath(
|
|
190
|
+
root: MetaData,
|
|
191
|
+
referrer: MetaData,
|
|
192
|
+
referrerPath: string,
|
|
193
|
+
verb: string,
|
|
194
|
+
raw: string,
|
|
195
|
+
referrerPkg: string,
|
|
196
|
+
out: Diagnostic[],
|
|
197
|
+
): void {
|
|
198
|
+
const head = headOf(raw);
|
|
199
|
+
const entity = resolveEntityRef(root, head, referrerPkg);
|
|
200
|
+
if (entity === undefined) return;
|
|
201
|
+
const fieldName = raw.slice(head.length + 1);
|
|
202
|
+
const field = fieldName !== "" ? entity.fields().find((f) => f.name === fieldName) : undefined;
|
|
203
|
+
if (field !== undefined && considerTarget(referrer, referrerPath, verb, field, out)) {
|
|
204
|
+
return; // the field itself is the deprecated thing — don't ALSO warn about its (fine) entity.
|
|
205
|
+
}
|
|
206
|
+
considerTarget(referrer, referrerPath, verb, entity, out);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Walk an origin `@via` relationship path ("Entity.relOrRef[.relOrRef...]"),
|
|
211
|
+
* checking the head entity and every hop it names. Stops silently the moment a
|
|
212
|
+
* segment fails to resolve — a half-walked path still warned on everything it
|
|
213
|
+
* DID resolve, which is the right bias for an advisory that must never crash
|
|
214
|
+
* `meta verify` on a path shape it does not fully understand.
|
|
215
|
+
*/
|
|
216
|
+
function checkViaPath(
|
|
217
|
+
root: MetaData,
|
|
218
|
+
origin: MetaData,
|
|
219
|
+
referrerPath: string,
|
|
220
|
+
verb: string,
|
|
221
|
+
via: string,
|
|
222
|
+
referrerPkg: string,
|
|
223
|
+
out: Diagnostic[],
|
|
224
|
+
): void {
|
|
225
|
+
const segments = via.split(".");
|
|
226
|
+
const headSegment = segments[0];
|
|
227
|
+
if (headSegment === undefined || headSegment === "") return;
|
|
228
|
+
let current = resolveEntityRef(root, headSegment, referrerPkg);
|
|
229
|
+
if (current === undefined) return;
|
|
230
|
+
considerTarget(origin, referrerPath, verb, current, out);
|
|
231
|
+
for (const hopName of segments.slice(1)) {
|
|
232
|
+
const hop = findHop(current, hopName);
|
|
233
|
+
if (hop === undefined) return;
|
|
234
|
+
considerTarget(origin, referrerPath, verb, hop, out);
|
|
235
|
+
const targetRef = hopTargetRef(hop);
|
|
236
|
+
if (targetRef === undefined) return;
|
|
237
|
+
const next = resolveEntityRef(root, targetRef, packageOf(current));
|
|
238
|
+
if (next === undefined) return;
|
|
239
|
+
considerTarget(origin, referrerPath, verb, next, out);
|
|
240
|
+
current = next;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function checkOrigin(root: MetaData, origin: MetaData, referrerPath: string, out: Diagnostic[]): void {
|
|
245
|
+
const referrerPkg = (() => {
|
|
246
|
+
const owner = owningObject(origin);
|
|
247
|
+
return owner !== undefined ? packageOf(owner) : "";
|
|
248
|
+
})();
|
|
249
|
+
const subtypeLabel = `origin.${origin.subType}`;
|
|
250
|
+
|
|
251
|
+
// ADR-0039: own — origin.* never inherits (ADR-0029), so own is correct (the
|
|
252
|
+
// same stance every origin accessor in meta-origin.ts takes).
|
|
253
|
+
const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM);
|
|
254
|
+
if (typeof from === "string" && from !== "") {
|
|
255
|
+
checkEntityFieldPath(root, origin, referrerPath, `${subtypeLabel} @from references`, from, referrerPkg, out);
|
|
256
|
+
}
|
|
257
|
+
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
|
|
258
|
+
if (typeof of_ === "string" && of_ !== "") {
|
|
259
|
+
checkEntityFieldPath(root, origin, referrerPath, `${subtypeLabel} @of references`, of_, referrerPkg, out);
|
|
260
|
+
}
|
|
261
|
+
const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
|
|
262
|
+
if (typeof via === "string" && via !== "") {
|
|
263
|
+
checkViaPath(root, origin, referrerPath, `${subtypeLabel} @via references`, via, referrerPkg, out);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function checkNode(root: MetaData, node: MetaData, path: string, out: Diagnostic[]): void {
|
|
268
|
+
// extends — every node type may carry a resolved super (generic, type-agnostic).
|
|
269
|
+
if (node.superData !== undefined) {
|
|
270
|
+
considerTarget(node, path, "extends", node.superData, out);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// @objectRef — relationship.* (its target object) and field.object/value (a
|
|
274
|
+
// value-typed field's own objectRef). Same attr name on both (naming-refs.ts).
|
|
275
|
+
if (node.type === TYPE_RELATIONSHIP || node.type === TYPE_FIELD) {
|
|
276
|
+
const ref = node.attr(node.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : FIELD_ATTR_OBJECT_REF);
|
|
277
|
+
if (typeof ref === "string" && ref !== "") {
|
|
278
|
+
const owner = owningObject(node);
|
|
279
|
+
const target = resolveEntityRef(root, ref, owner !== undefined ? packageOf(owner) : "");
|
|
280
|
+
if (target !== undefined) considerTarget(node, path, "references (@objectRef)", target, out);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// @references — identity.reference's FK target. May be bare ("Entity",
|
|
285
|
+
// defaulting to the primary identity) or dotted ("Entity.field" /
|
|
286
|
+
// "Entity.fieldA,fieldB", an explicit field/compound target) — the dotted
|
|
287
|
+
// field half is a deprecation-relevant target in its own right (#305 review:
|
|
288
|
+
// `@references: "Team.code"` where only `code` is `@deprecated` was missed
|
|
289
|
+
// when this checked the entity head alone).
|
|
290
|
+
if (node.type === TYPE_IDENTITY && node.subType === IDENTITY_SUBTYPE_REFERENCE) {
|
|
291
|
+
const raw = node.attr(IDENTITY_REFERENCE_ATTR_REFERENCES);
|
|
292
|
+
if (typeof raw === "string" && raw !== "") {
|
|
293
|
+
const owner = owningObject(node);
|
|
294
|
+
checkEntityFieldPath(root, node, path, "references (@references)", raw, owner !== undefined ? packageOf(owner) : "", out);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// origin.* — @from / @of / @via.
|
|
299
|
+
if (node.type === TYPE_ORIGIN) {
|
|
300
|
+
checkOrigin(root, node, path, out);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Lint the loaded model for references INTO a `deprecated` node, across every
|
|
306
|
+
* reference kind the metamodel resolves: `extends`, `@objectRef`, `@references`,
|
|
307
|
+
* and an origin's `@from` / `@of` / `@via`. Returns `[]` for a model with no
|
|
308
|
+
* `deprecated` attr anywhere reachable from a reference (additive, opt-in).
|
|
309
|
+
*/
|
|
310
|
+
export function lintDeprecatedReferences(root: MetaData): Diagnostic[] {
|
|
311
|
+
const out: Diagnostic[] = [];
|
|
312
|
+
const walk = (n: MetaData, prefix: string): void => {
|
|
313
|
+
for (const c of n.ownChildren()) {
|
|
314
|
+
// origin carries no name of its own (always a singleton child of a field) —
|
|
315
|
+
// report against the FIELD it belongs to, not a path ending in a blank segment.
|
|
316
|
+
const path = c.type === TYPE_ORIGIN
|
|
317
|
+
? prefix
|
|
318
|
+
: prefix === ""
|
|
319
|
+
? c.resolutionKey()
|
|
320
|
+
: `${prefix}.${c.name}`;
|
|
321
|
+
checkNode(root, c, path, out);
|
|
322
|
+
walk(c, path);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
walk(root, "");
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// server/typescript/packages/cli/src/lib/fmt-engine.ts
|
|
2
|
+
//
|
|
3
|
+
// `meta fmt` (#304) orchestration: resolve the project's own metadata files
|
|
4
|
+
// (resolveCollection — never a hardcoded `metaobjects/`), format each one
|
|
5
|
+
// standalone (@metaobjectsdev/metadata's formatMetadataFile — own-mode,
|
|
6
|
+
// declared-here layer only), and gate every rewrite behind a whole-project
|
|
7
|
+
// reload-and-compare safety check before it ever touches disk.
|
|
8
|
+
//
|
|
9
|
+
// The safety check (design rule 4): swap a candidate in via an in-memory
|
|
10
|
+
// source carrying the SAME file id the real load would use, reload the WHOLE
|
|
11
|
+
// project, and require both that it loads with no errors AND that its
|
|
12
|
+
// canonical serialization is byte-identical to the untouched baseline. Only
|
|
13
|
+
// then is the candidate written — so `--check` (which skips the write) and a
|
|
14
|
+
// real run share one code path, and a bug in the formatter can only ever
|
|
15
|
+
// leave a file unchanged plus an error, never a corrupted file.
|
|
16
|
+
import { basename, extname, resolve as resolvePath } from "node:path";
|
|
17
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
|
|
19
|
+
import {
|
|
20
|
+
composeRegistry,
|
|
21
|
+
MetaDataLoader,
|
|
22
|
+
InMemoryStringSource,
|
|
23
|
+
canonicalSerialize,
|
|
24
|
+
formatMetadataFile,
|
|
25
|
+
} from "@metaobjectsdev/metadata";
|
|
26
|
+
import { FileSource } from "@metaobjectsdev/metadata/core";
|
|
27
|
+
import { resolveCollection, defaultLoadMemoryProviders, type Collection } from "@metaobjectsdev/sdk";
|
|
28
|
+
import { loadMetaobjectsConfig, loadMemoryOptionsFrom } from "./load-metaobjects-config.js";
|
|
29
|
+
|
|
30
|
+
export type FmtFileStatus =
|
|
31
|
+
| "formatted"
|
|
32
|
+
| "would-format"
|
|
33
|
+
| "unchanged"
|
|
34
|
+
| "skipped-yaml"
|
|
35
|
+
| "skipped-overlay"
|
|
36
|
+
| "error";
|
|
37
|
+
|
|
38
|
+
export interface FmtFileReport {
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly status: FmtFileStatus;
|
|
41
|
+
readonly detail?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FmtRunResult {
|
|
45
|
+
readonly files: readonly FmtFileReport[];
|
|
46
|
+
/** Set when the run could not even start — an unresolvable collection, an
|
|
47
|
+
* explicit file argument outside the project's sources, or metadata that
|
|
48
|
+
* does not currently load cleanly. Nothing was attempted. */
|
|
49
|
+
readonly fatal?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FmtRunOptions {
|
|
53
|
+
readonly projectRoot: string;
|
|
54
|
+
readonly check: boolean;
|
|
55
|
+
/** Explicit paths (cwd-relative or absolute) narrowing the run. Omit/empty
|
|
56
|
+
* for every file the project owns. */
|
|
57
|
+
readonly files?: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function projectProviders(collection: Collection): Promise<readonly MetaDataTypeProvider[]> {
|
|
61
|
+
try {
|
|
62
|
+
const cfg = await loadMetaobjectsConfig(collection.configDir);
|
|
63
|
+
return loadMemoryOptionsFrom(cfg).providers ?? [];
|
|
64
|
+
} catch {
|
|
65
|
+
// No metaobjects.config.ts (common for a non-TS-server project running
|
|
66
|
+
// `meta fmt` just for schema-neutral formatting) — format against the
|
|
67
|
+
// core vocabulary only, exactly like a project with no custom providers.
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function runFmt(opts: FmtRunOptions): Promise<FmtRunResult> {
|
|
73
|
+
let collection: Collection;
|
|
74
|
+
try {
|
|
75
|
+
collection = await resolveCollection(opts.projectRoot);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { files: [], fatal: err instanceof Error ? err.message : String(err) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const providers = await projectProviders(collection);
|
|
81
|
+
const registry = composeRegistry([...defaultLoadMemoryProviders, ...providers]);
|
|
82
|
+
|
|
83
|
+
let targets: readonly string[];
|
|
84
|
+
if (opts.files !== undefined && opts.files.length > 0) {
|
|
85
|
+
const want = new Set(opts.files.map((f) => resolvePath(opts.projectRoot, f)));
|
|
86
|
+
const owned = new Set(collection.ownFiles);
|
|
87
|
+
const missing = [...want].filter((f) => !owned.has(f));
|
|
88
|
+
if (missing.length > 0) {
|
|
89
|
+
return {
|
|
90
|
+
files: [],
|
|
91
|
+
fatal:
|
|
92
|
+
`not among this project's resolved metadata sources: ${missing.join(", ")}\n` +
|
|
93
|
+
"meta fmt only formats files resolveCollection() already resolves — pass no " +
|
|
94
|
+
"arguments to format every one of them.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
targets = collection.ownFiles.filter((f) => want.has(f));
|
|
98
|
+
} else {
|
|
99
|
+
targets = collection.ownFiles;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sourceId = (p: string): string => collection.fileIds.get(p) ?? basename(p);
|
|
103
|
+
|
|
104
|
+
const libSources =
|
|
105
|
+
collection.libraries.length > 0
|
|
106
|
+
? (await import("@metaobjectsdev/metadata/library")).librarySources([...collection.libraries])
|
|
107
|
+
: [];
|
|
108
|
+
|
|
109
|
+
const buildSources = (overridePath?: string, overrideText?: string) => [
|
|
110
|
+
...libSources,
|
|
111
|
+
...collection.files.map((p) => {
|
|
112
|
+
const id = sourceId(p);
|
|
113
|
+
return overridePath !== undefined && p === overridePath
|
|
114
|
+
? new InMemoryStringSource(overrideText ?? "", { id })
|
|
115
|
+
: new FileSource(p, { id });
|
|
116
|
+
}),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const baseline = await new MetaDataLoader({ registry }).load(buildSources());
|
|
120
|
+
if (baseline.errors.length > 0) {
|
|
121
|
+
return {
|
|
122
|
+
files: [],
|
|
123
|
+
fatal:
|
|
124
|
+
"this project's metadata does not currently load cleanly — fix the error(s) below, " +
|
|
125
|
+
"then re-run fmt:\n" +
|
|
126
|
+
baseline.errors.map((e) => ` ${e.message}`).join("\n"),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const baselineCanonical = canonicalSerialize(baseline.root);
|
|
130
|
+
|
|
131
|
+
const reports: FmtFileReport[] = [];
|
|
132
|
+
for (const path of targets) {
|
|
133
|
+
const ext = extname(path).toLowerCase();
|
|
134
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
135
|
+
reports.push({
|
|
136
|
+
path,
|
|
137
|
+
status: "skipped-yaml",
|
|
138
|
+
detail: "no canonical YAML emitter exists (ADR-0006: JSON is the canonical interchange form) — left untouched",
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const content = await readFile(path, "utf8");
|
|
144
|
+
const formatted = formatMetadataFile(content, { registry, sourceId: sourceId(path) });
|
|
145
|
+
if (!formatted.ok) {
|
|
146
|
+
reports.push(
|
|
147
|
+
formatted.overlay
|
|
148
|
+
? { path, status: "skipped-overlay", detail: formatted.message }
|
|
149
|
+
: { path, status: "error", detail: formatted.message },
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (formatted.text === content) {
|
|
155
|
+
reports.push({ path, status: "unchanged" });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const testLoad = await new MetaDataLoader({ registry }).load(buildSources(path, formatted.text));
|
|
160
|
+
const safe = testLoad.errors.length === 0 && canonicalSerialize(testLoad.root) === baselineCanonical;
|
|
161
|
+
if (!safe) {
|
|
162
|
+
reports.push({
|
|
163
|
+
path,
|
|
164
|
+
status: "error",
|
|
165
|
+
detail: "formatting this file would change the loaded model's meaning — left unchanged",
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (opts.check) {
|
|
171
|
+
reports.push({ path, status: "would-format" });
|
|
172
|
+
} else {
|
|
173
|
+
await writeFile(path, formatted.text, "utf8");
|
|
174
|
+
reports.push({ path, status: "formatted" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { files: reports };
|
|
179
|
+
}
|