@metaobjectsdev/cli 1.0.11 → 1.0.13

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.
@@ -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
+ }