@cloudcrane/app-spec 0.4.0 → 0.7.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.
@@ -61,6 +61,8 @@ export const CodedComponent = z
61
61
  kind: z.literal("coded"),
62
62
  /** game: a canvas, a loop and touch, the code a script; page: a whole HTML page, drawn full screen (what an app written as HTML becomes). */
63
63
  kit: z.enum(["game", "page"]),
64
+ /** Optional: the native widget or screen it stands in for, like "CupertinoSlidingSegmentedControl", so `blackbox coverage` can say which the catalog lacks. */
65
+ replaces: Text(120).optional(),
64
66
  inputs: z.record(CelName, CodedType).default({}),
65
67
  events: z.record(CelName, z.record(CelName, CodedType)).default({}),
66
68
  code: z
@@ -80,6 +82,8 @@ export const CodedComponent = z
80
82
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["code"], message: "a game kit's code is at most 64 KB" });
81
83
  })
82
84
  .refine((c) => Object.keys(c.inputs).length <= 10 && Object.keys(c.events).length <= 10, "at most 10 inputs and 10 events");
85
+ /** The kinds of thing a ported app may not do that its original did. */
86
+ export const GAP_KINDS = ["background", "widget", "notifications", "purchases", "accounts", "sync", "device", "other"];
83
87
  export const SpecSchema = z
84
88
  .object({
85
89
  v: z.literal(1),
@@ -138,6 +142,11 @@ export const SpecSchema = z
138
142
  .default([]),
139
143
  /** Each step turns version `from` into `to`, as a CEL expression over `saved`. */
140
144
  migrations: z.array(z.object({ from: z.number().int().min(1), to: z.number().int().min(2), cel: Cel }).strict()),
145
+ /**
146
+ * What it keeps: the saved fields that are personal (health, money, people's names), as pointers into the saved
147
+ * data, like /days or /people. They stay on the phone; CloudCrane never puts them in a remix, screenshot or report.
148
+ */
149
+ personal: z.array(Pointer).max(20).optional(),
141
150
  })
142
151
  .strict(),
143
152
  logic: z
@@ -147,11 +156,21 @@ export const SpecSchema = z
147
156
  /** Values the screens read, kept up to date: a data-model pointer and the CEL that computes it. */
148
157
  computed: z.record(Pointer, Cel).default({}),
149
158
  /** Examples each function must reproduce: recorded from the source's own tests, or given. */
150
- vectors: z.array(z.object({ fn: CelName, in: z.array(z.unknown()).max(8), out: z.unknown(), from: z.enum(["recorded", "hint"]) }).strict()).default([]),
159
+ /**
160
+ * Examples each function must reproduce: `recorded` from the module's own tests, `app` from calls recorded while
161
+ * the original app ran, `hint` written by hand.
162
+ */
163
+ vectors: z
164
+ .array(z.object({ fn: CelName, in: z.array(z.unknown()).max(8), out: z.unknown(), from: z.enum(["recorded", "app", "hint"]) }).strict())
165
+ .default([]),
151
166
  })
152
167
  .strict(),
168
+ /**
169
+ * What it can reach: each site, why, and what it's sent, in the person's words ("the city you typed"). Shown to the
170
+ * person before the app opens; a site the code names but this doesn't list is refused.
171
+ */
153
172
  network: z
154
- .array(z.object({ site: z.string().regex(/^[a-z0-9.-]+\.[a-z]{2,}$/), why: Text(300) }).strict())
173
+ .array(z.object({ site: z.string().regex(/^[a-z0-9.-]+\.[a-z]{2,}$/), why: Text(300), sends: Text(200) }).strict())
155
174
  .max(5)
156
175
  .default([]),
157
176
  /** The device features the app uses, by CloudCrane's names, each once with why; anything not listed is blocked. */
@@ -159,6 +178,29 @@ export const SpecSchema = z
159
178
  .array(z.object({ what: z.enum(USE_NAMES), why: Text(300) }).strict())
160
179
  .max(USE_NAMES.length)
161
180
  .default([]),
181
+ /**
182
+ * What it does: each screen in words, and what it shows when there's nothing yet, while it loads, offline, and when
183
+ * something fails. Optional; without it the blueprint describes the one surface from its words.
184
+ */
185
+ screens: z
186
+ .array(z
187
+ .object({
188
+ id: Id,
189
+ says: Text(300),
190
+ states: z
191
+ .object({ empty: Text(200).optional(), loading: Text(200).optional(), offline: Text(200).optional(), error: Text(200).optional() })
192
+ .strict()
193
+ .optional(),
194
+ })
195
+ .strict())
196
+ .min(1)
197
+ .max(20)
198
+ .optional(),
199
+ /** How it looks on the phone: the orientation it holds and whether it fills the screen (Android honours both). */
200
+ screen: z
201
+ .object({ orientation: z.enum(["portrait", "landscape", "any"]).optional(), fullscreen: z.boolean().optional() })
202
+ .strict()
203
+ .optional(),
162
204
  journeys: z
163
205
  .array(z
164
206
  .object({
@@ -176,6 +218,16 @@ export const SpecSchema = z
176
218
  .record(Id, CodedComponent)
177
219
  .refine((c) => Object.keys(c).length <= 5, "at most 5 components")
178
220
  .default({}),
221
+ /** The page-kit component that is the whole app: the spec is then that page, compiled to its HTML as written (see page.ts). */
222
+ page: Id.optional(),
223
+ /**
224
+ * What the original app does that this one doesn't, said plainly so no one is promised it: a native feature with
225
+ * no web equivalent yet (background jobs, home-screen widgets, push notifications, in-app purchases...).
226
+ */
227
+ gaps: z
228
+ .array(z.object({ kind: z.enum(GAP_KINDS), what: Text(120), why: Text(300).optional() }).strict())
229
+ .max(20)
230
+ .optional(),
179
231
  /** What a remix may change and what stays as the partner made it. */
180
232
  mods: z
181
233
  .object({ open: z.array(PointerPattern), locked: z.array(PointerPattern) })
@@ -3,4 +3,5 @@ import { type Spec } from "./schema.js";
3
3
  export declare function validateSpec(input: unknown, catalog?: Catalog): {
4
4
  spec: Spec | null;
5
5
  problems: Problem[];
6
+ warnings: Problem[];
6
7
  };
@@ -3,8 +3,15 @@ import { catalogFor } from "./catalog.js";
3
3
  import { leakProblems } from "./leak.js";
4
4
  import { logicProblems } from "./logic.js";
5
5
  import { SpecSchema } from "./schema.js";
6
+ import { pageProblems } from "./page.js";
7
+ import { designWarnings, statesWarnings } from "./design.js";
8
+ import { memoryProblems } from "./memory.js";
9
+ import { reachProblems } from "./reach.js";
10
+ import { rulesWarnings } from "./fidelity.js";
6
11
  // Everything wrong with a spec, collected rather than stopping at the first: the
7
- // envelope's shape, then the A2UI surface, then references between parts.
12
+ // envelope's shape, then the A2UI surface, then references between parts, what it keeps (memory.ts) and what its code
13
+ // reaches (reach.ts). Problems refuse it. Warnings (how it looks and what each screen shows when empty, offline or
14
+ // failing: design.ts) don't: they say what would make it better.
8
15
  const pathOf = (path) => path
9
16
  .map((p) => (typeof p === "number" ? `[${p}]` : `.${p}`))
10
17
  .join("")
@@ -12,12 +19,12 @@ const pathOf = (path) => path
12
19
  export function validateSpec(input, catalog) {
13
20
  const parsed = SpecSchema.safeParse(input);
14
21
  if (!parsed.success)
15
- return { spec: null, problems: parsed.error.issues.map((i) => ({ at: pathOf(i.path) || "spec", problem: i.message })) };
22
+ return { spec: null, problems: parsed.error.issues.map((i) => ({ at: pathOf(i.path) || "spec", problem: i.message })), warnings: [] };
16
23
  const spec = parsed.data;
17
24
  // The surface is checked against the catalog it names (CloudCrane's, or A2UI's basic one).
18
25
  const named = catalog ?? catalogFor(spec.surface.catalog);
19
26
  if (!named)
20
- return { spec: null, problems: [{ at: "surface.catalog", problem: "not a catalog this runtime has" }] };
27
+ return { spec: null, problems: [{ at: "surface.catalog", problem: "not a catalog this runtime has" }], warnings: [] };
21
28
  const problems = [...surfaceProblems(spec.surface.messages, named)];
22
29
  const catalogId = spec.surface.messages[0].createSurface?.catalogId;
23
30
  if (catalogId && catalogId !== spec.surface.catalog)
@@ -46,11 +53,12 @@ export function validateSpec(input, catalog) {
46
53
  for (const m of spec.data.migrations)
47
54
  if (m.to !== m.from + 1 || m.to > spec.data.version)
48
55
  problems.push({ at: `data.migrations ${m.from}`, problem: "steps go one version at a time, up to data.version" });
49
- problems.push(...codedProblems(spec), ...logicProblems(spec), ...leakProblems(spec));
56
+ problems.push(...codedProblems(spec), ...pageProblems(spec), ...logicProblems(spec), ...leakProblems(spec), ...memoryProblems(spec), ...reachProblems(spec));
50
57
  for (const locked of spec.mods.locked)
51
58
  if (spec.mods.open.includes(locked))
52
59
  problems.push({ at: `mods ${locked}`, problem: "both open and locked" });
53
- return { spec: problems.length ? null : spec, problems };
60
+ const warnings = [...designWarnings(spec), ...statesWarnings(spec), ...rulesWarnings(spec)];
61
+ return { spec: problems.length ? null : spec, problems, warnings };
54
62
  }
55
63
  /** Each Coded placement names a component the spec defines, and only its declared inputs and events; each component is placed. */
56
64
  function codedProblems(spec) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcrane/app-spec",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "The CloudCrane app spec: its schema, the expression language its rules are written in, and the checks that keep partner code out of it.",
5
5
  "keywords": [
6
6
  "cloudcrane",
@@ -204,6 +204,14 @@
204
204
  ],
205
205
  "additionalProperties": false
206
206
  }
207
+ },
208
+ "personal": {
209
+ "type": "array",
210
+ "items": {
211
+ "type": "string",
212
+ "pattern": "^(\\/([^~/]|~[01])*)*$"
213
+ },
214
+ "maxItems": 20
207
215
  }
208
216
  },
209
217
  "required": [
@@ -293,6 +301,7 @@
293
301
  "type": "string",
294
302
  "enum": [
295
303
  "recorded",
304
+ "app",
296
305
  "hint"
297
306
  ]
298
307
  }
@@ -322,11 +331,17 @@
322
331
  "type": "string",
323
332
  "minLength": 1,
324
333
  "maxLength": 300
334
+ },
335
+ "sends": {
336
+ "type": "string",
337
+ "minLength": 1,
338
+ "maxLength": 200
325
339
  }
326
340
  },
327
341
  "required": [
328
342
  "site",
329
- "why"
343
+ "why",
344
+ "sends"
330
345
  ],
331
346
  "additionalProperties": false
332
347
  },
@@ -367,6 +382,73 @@
367
382
  "maxItems": 9,
368
383
  "default": []
369
384
  },
385
+ "screens": {
386
+ "type": "array",
387
+ "items": {
388
+ "type": "object",
389
+ "properties": {
390
+ "id": {
391
+ "type": "string",
392
+ "pattern": "^[a-z][a-zA-Z0-9_.-]{0,63}$"
393
+ },
394
+ "says": {
395
+ "type": "string",
396
+ "minLength": 1,
397
+ "maxLength": 300
398
+ },
399
+ "states": {
400
+ "type": "object",
401
+ "properties": {
402
+ "empty": {
403
+ "type": "string",
404
+ "minLength": 1,
405
+ "maxLength": 200
406
+ },
407
+ "loading": {
408
+ "type": "string",
409
+ "minLength": 1,
410
+ "maxLength": 200
411
+ },
412
+ "offline": {
413
+ "type": "string",
414
+ "minLength": 1,
415
+ "maxLength": 200
416
+ },
417
+ "error": {
418
+ "type": "string",
419
+ "minLength": 1,
420
+ "maxLength": 200
421
+ }
422
+ },
423
+ "additionalProperties": false
424
+ }
425
+ },
426
+ "required": [
427
+ "id",
428
+ "says"
429
+ ],
430
+ "additionalProperties": false
431
+ },
432
+ "minItems": 1,
433
+ "maxItems": 20
434
+ },
435
+ "screen": {
436
+ "type": "object",
437
+ "properties": {
438
+ "orientation": {
439
+ "type": "string",
440
+ "enum": [
441
+ "portrait",
442
+ "landscape",
443
+ "any"
444
+ ]
445
+ },
446
+ "fullscreen": {
447
+ "type": "boolean"
448
+ }
449
+ },
450
+ "additionalProperties": false
451
+ },
370
452
  "journeys": {
371
453
  "type": "array",
372
454
  "items": {
@@ -466,6 +548,11 @@
466
548
  "page"
467
549
  ]
468
550
  },
551
+ "replaces": {
552
+ "type": "string",
553
+ "minLength": 1,
554
+ "maxLength": 120
555
+ },
469
556
  "inputs": {
470
557
  "type": "object",
471
558
  "additionalProperties": {
@@ -524,6 +611,47 @@
524
611
  },
525
612
  "default": {}
526
613
  },
614
+ "page": {
615
+ "type": "string",
616
+ "pattern": "^[a-z][a-zA-Z0-9_.-]{0,63}$"
617
+ },
618
+ "gaps": {
619
+ "type": "array",
620
+ "items": {
621
+ "type": "object",
622
+ "properties": {
623
+ "kind": {
624
+ "type": "string",
625
+ "enum": [
626
+ "background",
627
+ "widget",
628
+ "notifications",
629
+ "purchases",
630
+ "accounts",
631
+ "sync",
632
+ "device",
633
+ "other"
634
+ ]
635
+ },
636
+ "what": {
637
+ "type": "string",
638
+ "minLength": 1,
639
+ "maxLength": 120
640
+ },
641
+ "why": {
642
+ "type": "string",
643
+ "minLength": 1,
644
+ "maxLength": 300
645
+ }
646
+ },
647
+ "required": [
648
+ "kind",
649
+ "what"
650
+ ],
651
+ "additionalProperties": false
652
+ },
653
+ "maxItems": 20
654
+ },
527
655
  "mods": {
528
656
  "type": "object",
529
657
  "properties": {