@mapled/cli 0.1.0 → 0.3.0

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/pin.js ADDED
@@ -0,0 +1,297 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ import { typeLabel } from "./manifest.js";
5
+ import { schemaHash } from "./schema.js";
6
+ /** mapled/schema.json — the repository's pin of the schema it was last
7
+ synced with (§21.5). `mapled schema diff` compares it with the live
8
+ schema and says which changes a site that READS the content must
9
+ care about; `schema pull` and `types generate` move the pin. The
10
+ file is deterministic: no timestamps, ids or counters. */
11
+ export const SCHEMA_FILE = "mapled/schema.json";
12
+ function pinSubField(s) {
13
+ const out = { key: s.key, displayName: s.displayName, type: s.type, required: s.required === true };
14
+ out.helpText = s.helpText ?? null;
15
+ out.options = s.options ?? null;
16
+ out.sensitive = s.sensitive === true;
17
+ return out;
18
+ }
19
+ function pinField(f) {
20
+ const out = {
21
+ key: f.key,
22
+ displayName: f.displayName,
23
+ type: f.type,
24
+ required: f.required === true,
25
+ helpText: f.helpText ?? null,
26
+ options: f.options ?? null,
27
+ relation: f.relation
28
+ ? {
29
+ target: f.relation.target,
30
+ cardinality: f.relation.cardinality,
31
+ ...(f.relation.onDelete ? { onDelete: f.relation.onDelete } : {}),
32
+ }
33
+ : null,
34
+ sensitive: f.sensitive === true,
35
+ group: f.group
36
+ ? {
37
+ repeatable: f.group.repeatable,
38
+ ...(f.group.maxItems ? { maxItems: f.group.maxItems } : {}),
39
+ fields: f.group.fields.map(pinSubField),
40
+ }
41
+ : null,
42
+ };
43
+ if (f.validation !== undefined && f.validation !== null)
44
+ out.validation = f.validation;
45
+ if (f.defaultValue !== undefined && f.defaultValue !== null)
46
+ out.defaultValue = f.defaultValue;
47
+ return out;
48
+ }
49
+ /** The schema reduced to what the pin keeps — the live answer of
50
+ GET /v1/agent/schema without ids, positions and counters. */
51
+ export function pinSchema(schema, project) {
52
+ const collections = schema.collections.map((c) => ({
53
+ key: c.key,
54
+ displayName: c.displayName,
55
+ kind: c.kind,
56
+ accessClass: c.accessClass,
57
+ mode: c.mode,
58
+ fields: c.fields.map(pinField),
59
+ }));
60
+ return { project, hash: schemaHash({ collections }), collections };
61
+ }
62
+ export function parsePin(raw, file) {
63
+ let data;
64
+ try {
65
+ data = JSON.parse(raw);
66
+ }
67
+ catch {
68
+ throw new CliError(`${file} isn't valid JSON — delete it and run \`mapled schema pull\` again.`);
69
+ }
70
+ const obj = data && typeof data === "object" && !Array.isArray(data) ? data : null;
71
+ const collections = obj?.collections;
72
+ if (!obj ||
73
+ typeof obj.project !== "string" ||
74
+ typeof obj.hash !== "string" ||
75
+ !Array.isArray(collections) ||
76
+ collections.some((c) => !c || typeof c !== "object" || typeof c.key !== "string" || !Array.isArray(c.fields))) {
77
+ throw new CliError(`${file} isn't a schema pin written by \`mapled schema pull\` — delete it and pull again.`);
78
+ }
79
+ return pinSchema({ collections: collections }, obj.project);
80
+ }
81
+ export async function readPin(dir) {
82
+ const file = path.join(dir, SCHEMA_FILE);
83
+ let raw;
84
+ try {
85
+ raw = await readFile(file, "utf8");
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ return { file, pin: parsePin(raw, SCHEMA_FILE) };
91
+ }
92
+ export async function writePin(dir, pin) {
93
+ const file = path.join(dir, SCHEMA_FILE);
94
+ await mkdir(path.dirname(file), { recursive: true });
95
+ await writeFile(file, JSON.stringify(pin, null, 2) + "\n");
96
+ return file;
97
+ }
98
+ const q = (s) => `“${s}”`;
99
+ function describeType(f, names) {
100
+ if (f.type === "relation" && "relation" in f && f.relation) {
101
+ const target = names.get(f.relation.target) ?? f.relation.target;
102
+ return `relation to ${target}${f.relation.cardinality === "many" ? " (many)" : ""}`;
103
+ }
104
+ if (f.type === "group" && "group" in f && f.group) {
105
+ return `group of ${f.group.fields.length} field${f.group.fields.length === 1 ? "" : "s"}${f.group.repeatable ? ", repeatable" : ""}`;
106
+ }
107
+ return typeLabel(f.type);
108
+ }
109
+ function diffCommon(before, after, names, push) {
110
+ if (before.type !== after.type) {
111
+ push("changed", `${describeType(before, names)} → ${describeType(after, names)}`, "breaking");
112
+ }
113
+ if (before.required === true && after.required !== true) {
114
+ push("changed", "required → optional (may be empty now)", "breaking");
115
+ }
116
+ else if (before.required !== true && after.required === true) {
117
+ push("changed", "optional → required", "safe");
118
+ }
119
+ if (before.displayName !== after.displayName)
120
+ push("changed", `name ${q(before.displayName)} → ${q(after.displayName)}`, "safe");
121
+ if ((before.helpText ?? null) !== (after.helpText ?? null))
122
+ push("changed", "help text changed", "safe");
123
+ const beforeOptions = before.options ?? [];
124
+ const afterOptions = after.options ?? [];
125
+ for (const o of beforeOptions)
126
+ if (!afterOptions.includes(o))
127
+ push("changed", `option ${q(o)} removed`, "breaking");
128
+ for (const o of afterOptions)
129
+ if (!beforeOptions.includes(o))
130
+ push("changed", `option ${q(o)} added`, "safe");
131
+ if (before.sensitive !== true && after.sensitive === true) {
132
+ push("changed", "now sensitive — no longer delivered to the site", "breaking");
133
+ }
134
+ else if (before.sensitive === true && after.sensitive !== true) {
135
+ push("changed", "no longer sensitive — delivered to the site now", "safe");
136
+ }
137
+ }
138
+ function sameJson(a, b) {
139
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
140
+ }
141
+ /** What changed between two schemas, judged for a site that reads the
142
+ content: anything that can make a read fail or a value disappear is
143
+ breaking; additions and renames of labels are safe. */
144
+ export function diffSchema(before, after) {
145
+ const changes = [];
146
+ const names = new Map();
147
+ for (const c of before)
148
+ names.set(c.key, c.displayName);
149
+ for (const c of after)
150
+ names.set(c.key, c.displayName);
151
+ const afterByKey = new Map(after.map((c) => [c.key, c]));
152
+ const beforeByKey = new Map(before.map((c) => [c.key, c]));
153
+ for (const b of before) {
154
+ const a = afterByKey.get(b.key);
155
+ const base = { collection: b.key, collectionName: a?.displayName ?? b.displayName };
156
+ if (!a) {
157
+ changes.push({ ...base, scope: "collection", op: "removed", detail: "removed", severity: "breaking" });
158
+ continue;
159
+ }
160
+ const col = (detail, severity) => changes.push({ ...base, scope: "collection", op: "changed", detail, severity });
161
+ if (b.kind !== a.kind)
162
+ col(`${b.kind} → ${a.kind}`, "breaking");
163
+ if (b.accessClass !== a.accessClass)
164
+ col(`access ${b.accessClass} → ${a.accessClass}`, "breaking");
165
+ if (b.mode !== a.mode)
166
+ col(`mode ${b.mode} → ${a.mode}`, "breaking");
167
+ if (b.displayName !== a.displayName)
168
+ col(`name ${q(b.displayName)} → ${q(a.displayName)}`, "safe");
169
+ const afterFields = new Map(a.fields.map((f) => [f.key, f]));
170
+ const beforeFields = new Map(b.fields.map((f) => [f.key, f]));
171
+ for (const bf of b.fields) {
172
+ const af = afterFields.get(bf.key);
173
+ if (!af) {
174
+ changes.push({ ...base, scope: "field", field: bf.key, op: "removed", detail: `removed — ${describeType(bf, names)}`, severity: "breaking" });
175
+ continue;
176
+ }
177
+ const push = (op, detail, severity) => changes.push({ ...base, scope: "field", field: bf.key, op, detail, severity });
178
+ diffCommon(bf, af, names, push);
179
+ if (bf.type === "relation" && af.type === "relation" && bf.relation && af.relation) {
180
+ if (bf.relation.target !== af.relation.target) {
181
+ push("changed", `relation to ${names.get(bf.relation.target) ?? bf.relation.target} → ${names.get(af.relation.target) ?? af.relation.target}`, "breaking");
182
+ }
183
+ if (bf.relation.cardinality !== af.relation.cardinality)
184
+ push("changed", `${bf.relation.cardinality} → ${af.relation.cardinality}`, "breaking");
185
+ if ((bf.relation.onDelete ?? null) !== (af.relation.onDelete ?? null)) {
186
+ push("changed", `on delete ${bf.relation.onDelete ?? "default"} → ${af.relation.onDelete ?? "default"}`, "safe");
187
+ }
188
+ }
189
+ if (bf.type === "group" && af.type === "group" && bf.group && af.group) {
190
+ if (bf.group.repeatable !== af.group.repeatable) {
191
+ push("changed", bf.group.repeatable ? "repeatable → single item" : "single item → repeatable", "breaking");
192
+ }
193
+ if ((bf.group.maxItems ?? null) !== (af.group.maxItems ?? null)) {
194
+ const limit = (n) => (n ? `up to ${n} items` : "no item limit");
195
+ push("changed", `${limit(bf.group.maxItems)} → ${limit(af.group.maxItems)}`, "safe");
196
+ }
197
+ const afterSubs = new Map(af.group.fields.map((s) => [s.key, s]));
198
+ const beforeSubs = new Map(bf.group.fields.map((s) => [s.key, s]));
199
+ for (const bs of bf.group.fields) {
200
+ const as = afterSubs.get(bs.key);
201
+ const sub = `${bf.key}.${bs.key}`;
202
+ if (!as) {
203
+ changes.push({ ...base, scope: "subfield", field: sub, op: "removed", detail: `removed — ${describeType(bs, names)}`, severity: "breaking" });
204
+ continue;
205
+ }
206
+ diffCommon(bs, as, names, (op, detail, severity) => changes.push({ ...base, scope: "subfield", field: sub, op, detail, severity }));
207
+ }
208
+ for (const as of af.group.fields) {
209
+ if (beforeSubs.has(as.key))
210
+ continue;
211
+ changes.push({
212
+ ...base,
213
+ scope: "subfield",
214
+ field: `${bf.key}.${as.key}`,
215
+ op: "added",
216
+ detail: `added — ${describeType(as, names)}, ${as.required ? "required" : "optional"}`,
217
+ severity: "safe",
218
+ });
219
+ }
220
+ }
221
+ if (!sameJson(bf.validation, af.validation))
222
+ push("changed", "validation changed", "safe");
223
+ if (!sameJson(bf.defaultValue, af.defaultValue))
224
+ push("changed", "default value changed", "safe");
225
+ }
226
+ for (const af of a.fields) {
227
+ if (beforeFields.has(af.key))
228
+ continue;
229
+ changes.push({
230
+ ...base,
231
+ scope: "field",
232
+ field: af.key,
233
+ op: "added",
234
+ detail: `added — ${describeType(af, names)}, ${af.required ? "required" : "optional"}`,
235
+ severity: "safe",
236
+ });
237
+ }
238
+ }
239
+ for (const a of after) {
240
+ if (beforeByKey.has(a.key))
241
+ continue;
242
+ changes.push({
243
+ collection: a.key,
244
+ collectionName: a.displayName,
245
+ scope: "collection",
246
+ op: "added",
247
+ detail: `new ${a.kind} — ${a.fields.length} field${a.fields.length === 1 ? "" : "s"}`,
248
+ severity: "safe",
249
+ });
250
+ }
251
+ return changes;
252
+ }
253
+ const GLYPH = { added: "+", removed: "-", changed: "~" };
254
+ /** The diff as terminal lines: one heading per collection, one line per
255
+ change, the severity in the last column. */
256
+ export function formatChanges(changes, paint = (_, t) => t) {
257
+ const lines = [];
258
+ const order = [];
259
+ const byCollection = new Map();
260
+ for (const c of changes) {
261
+ if (!byCollection.has(c.collection)) {
262
+ byCollection.set(c.collection, []);
263
+ order.push(c.collection);
264
+ }
265
+ byCollection.get(c.collection).push(c);
266
+ }
267
+ const width = Math.max(0, ...changes.filter((c) => c.field).map((c) => c.field.length));
268
+ const detailWidth = Math.max(0, ...changes.map((c) => c.detail.length));
269
+ for (const key of order) {
270
+ const list = byCollection.get(key);
271
+ const name = list[0].collectionName;
272
+ const whole = list.find((c) => c.scope === "collection" && c.op !== "changed");
273
+ if (whole) {
274
+ lines.push(`${GLYPH[whole.op]} ${name} (${key}) ${whole.detail.padEnd(detailWidth)} ${paint(whole.severity, whole.severity)}`);
275
+ continue;
276
+ }
277
+ lines.push(`${name} (${key})`);
278
+ for (const c of list) {
279
+ const label = c.scope === "collection" ? "(collection)" : c.field;
280
+ lines.push(` ${GLYPH[c.op]} ${label.padEnd(width)} ${c.detail.padEnd(detailWidth)} ${paint(c.severity, c.severity)}`);
281
+ }
282
+ }
283
+ return lines;
284
+ }
285
+ export function summarizeChanges(changes) {
286
+ const breaking = changes.filter((c) => c.severity === "breaking").length;
287
+ const safe = changes.length - breaking;
288
+ const p = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
289
+ if (changes.length === 0)
290
+ return "No changes";
291
+ const parts = [];
292
+ if (breaking > 0)
293
+ parts.push(`${p(breaking, "breaking change")}`);
294
+ if (safe > 0)
295
+ parts.push(`${p(safe, "safe change")}`);
296
+ return parts.join(", ");
297
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type * as TS from "typescript";
2
+ import { type Manifest, type ManifestBinding, type ManifestPage } from "./manifest.js";
3
+ import type { Schema } from "./schema.js";
4
+ /** `mapled scan` (§19.5, §21.4 — the standalone scanner of wave 2): reads
5
+ the site's source and writes down what the code reads from Mapled —
6
+ the pages (from the framework's file conventions) and one binding per
7
+ place a collection or field is used. The parser is the repository's
8
+ own TypeScript (AST, per §19.6); without it a tokenizer finds the
9
+ reads but not the fields. Source code never leaves the machine: only
10
+ the manifest does, and only when `bindings push` is run. */
11
+ export type TypeScriptModule = typeof TS;
12
+ export type ScanOptions = {
13
+ framework?: string | null;
14
+ schema: Schema;
15
+ ts: TypeScriptModule | null;
16
+ /** Files to read at most (a note is added past it). */
17
+ maxFiles?: number;
18
+ };
19
+ export type Read = {
20
+ method: string;
21
+ collection: string;
22
+ fields: Set<string>;
23
+ /** Where the read happens (the file and the function it sits in). */
24
+ file: string;
25
+ component: string | null;
26
+ /** Fields traced into other components: field → { component, file }. */
27
+ fieldSites: Map<string, {
28
+ component: string | null;
29
+ file: string;
30
+ }>;
31
+ bySlug: boolean;
32
+ };
33
+ export type ScanResult = {
34
+ framework: string | null;
35
+ parser: "typescript" | "tokens";
36
+ files: number;
37
+ pages: ManifestPage[];
38
+ bindings: ManifestBinding[];
39
+ notes: string[];
40
+ };
41
+ export declare const SDK_METHODS: Set<string>;
42
+ /** The repository's TypeScript, when it has one; the CLI ships none. */
43
+ export declare function loadTypeScript(dir: string): TypeScriptModule | null;
44
+ export type RouteMap = {
45
+ pages: Map<string, string>;
46
+ layouts: Map<string, string>;
47
+ };
48
+ export declare function detectRoutes(files: string[], framework: string | null): RouteMap;
49
+ export type Token = {
50
+ kind: "ident" | "string" | "punct";
51
+ value: string;
52
+ };
53
+ /** JavaScript tokens: identifiers, string literals and punctuation, with
54
+ comments and template bodies dropped. A regex literal after an
55
+ operator is skipped; JSX text is read as code, so an apostrophe in
56
+ prose can swallow a stretch — a false negative, never a crash. */
57
+ export declare function tokenize(text: string): Token[];
58
+ export declare function routeKey(route: string): string;
59
+ export declare function scanRepository(dir: string, opts: ScanOptions): Promise<ScanResult>;
60
+ export type MergeResult = {
61
+ manifest: Manifest;
62
+ added: string[];
63
+ changed: string[];
64
+ unchanged: string[];
65
+ /** Bindings the scan didn't produce: kept (or dropped with prune). */
66
+ kept: string[];
67
+ dropped: string[];
68
+ };
69
+ /** The scan's bindings replace the ones with the same key; bindings the
70
+ scanner can't see (an agent's, hand-written) stay unless pruned. */
71
+ export declare function mergeManifest(existing: Manifest | null, scan: ScanResult, opts: {
72
+ prune: boolean;
73
+ }): MergeResult;