@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.
@@ -1,16 +1,25 @@
1
1
  import type { Spec } from "./schema.js";
2
+ import { type KeptType } from "./memory.js";
2
3
  export type BlueprintV1 = {
3
4
  v: 1;
4
5
  purpose: string;
5
6
  screens: {
6
7
  id: string;
7
8
  says: string;
9
+ states?: {
10
+ empty?: string;
11
+ loading?: string;
12
+ offline?: string;
13
+ error?: string;
14
+ };
8
15
  }[];
9
16
  data: {
10
17
  version: number;
11
18
  keeps: {
12
19
  key: string;
13
20
  what: string;
21
+ shape?: Record<string, KeptType>;
22
+ personal?: boolean;
14
23
  }[];
15
24
  };
16
25
  journeys: Spec["journeys"];
@@ -20,9 +29,14 @@ export type BlueprintV1 = {
20
29
  icon?: string;
21
30
  category?: string;
22
31
  tags?: string[];
32
+ screen?: {
33
+ orientation?: "portrait" | "landscape" | "any";
34
+ fullscreen?: boolean;
35
+ };
23
36
  design: {
24
37
  palette: string[];
25
38
  notes: string;
39
+ tokens?: Record<string, string>;
26
40
  };
27
41
  };
28
42
  export declare function deriveBlueprint(spec: Spec): BlueprintV1;
@@ -1,3 +1,41 @@
1
+ import { keptShape } from "./memory.js";
2
+ import { rgbOf } from "./design.js";
3
+ /** A blueprint's tokens are at most this many, by role (CloudCrane's blueprint takes 40). */
4
+ const MAX_TOKENS = 40;
5
+ /**
6
+ * The design tokens by role, as CloudCrane's blueprint holds them: colours as #hex (light as color.<role>, dark as
7
+ * color.dark.<role>), the corner radius and the font. Names as the spec has them, lower-case.
8
+ */
9
+ function rolesOf(tokens) {
10
+ const out = {};
11
+ const hex = (v) => {
12
+ const rgb = rgbOf(v);
13
+ return rgb ? `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}` : null;
14
+ };
15
+ const tree = (tokens ?? {});
16
+ const colours = (group, prefix) => {
17
+ if (!group || typeof group !== "object")
18
+ return;
19
+ for (const [role, node] of Object.entries(group)) {
20
+ if (role.startsWith("$") || !node || typeof node !== "object" || !("$value" in node))
21
+ continue;
22
+ const h = hex(node.$value);
23
+ if (h && /^[a-z][a-z0-9-]*$/.test(role))
24
+ out[`${prefix}.${role}`] = h;
25
+ }
26
+ };
27
+ colours(tree["color"], "color");
28
+ colours(tree["dark"]?.["color"], "color.dark");
29
+ const radius = tree["border-radius"]?.["$value"];
30
+ if (radius && typeof radius.value === "number" && typeof radius.unit === "string")
31
+ out["border-radius"] = `${radius.value}${radius.unit}`;
32
+ const font = tree["font-family"]?.["$value"];
33
+ if (typeof font === "string")
34
+ out["font-family"] = font.slice(0, 100);
35
+ else if (Array.isArray(font))
36
+ out["font-family"] = font.map(String).join(", ").slice(0, 100);
37
+ return Object.fromEntries(Object.entries(out).slice(0, MAX_TOKENS));
38
+ }
1
39
  const literal = (v) => (typeof v === "string" ? v.replace(/\$\{[^}]*\}/g, "…").trim() : null);
2
40
  const list = (xs, max = 6) => {
3
41
  const u = [...new Set(xs.filter(Boolean))];
@@ -43,13 +81,23 @@ function palette(tokens) {
43
81
  }
44
82
  export function deriveBlueprint(spec) {
45
83
  const saved = spec.data.initial && typeof spec.data.initial === "object" ? Object.keys(spec.data.initial) : [];
84
+ const shape = keptShape(spec.data.schema);
85
+ const tokens = rolesOf(spec.tokens);
46
86
  return {
47
87
  v: 1,
48
88
  purpose: spec.module.purpose,
49
- screens: [{ id: spec.module.id, says: screenWords(spec) }],
89
+ // The screens as the spec describes them, with their states; else one, in the surface's own words.
90
+ screens: spec.screens ?? [{ id: spec.module.id, says: screenWords(spec) }],
50
91
  data: {
51
92
  version: spec.data.version,
52
- keeps: [{ key: spec.data.key, what: saved.length ? `On this phone: ${list(saved)}` : "What you enter, on this phone" }],
93
+ keeps: [
94
+ {
95
+ key: spec.data.key,
96
+ what: saved.length ? `On this phone: ${list(saved)}` : "What you enter, on this phone",
97
+ ...(Object.keys(shape).length ? { shape } : {}),
98
+ ...(spec.data.personal?.length ? { personal: true } : {}),
99
+ },
100
+ ],
53
101
  },
54
102
  journeys: spec.journeys,
55
103
  promises: spec.promises,
@@ -58,6 +106,11 @@ export function deriveBlueprint(spec) {
58
106
  ...(spec.module.icon ? { icon: spec.module.icon } : {}),
59
107
  ...(spec.module.category ? { category: spec.module.category } : {}),
60
108
  ...(spec.module.tags?.length ? { tags: spec.module.tags } : {}),
61
- design: { palette: palette(spec.tokens), notes: `Built from a CloudCrane spec, runtime ${spec.manifest.runtime}.` },
109
+ ...(spec.screen ? { screen: spec.screen } : {}),
110
+ design: {
111
+ palette: palette(spec.tokens),
112
+ notes: `Built from a CloudCrane spec, runtime ${spec.manifest.runtime}.`,
113
+ ...(Object.keys(tokens).length ? { tokens } : {}),
114
+ },
62
115
  };
63
116
  }
@@ -0,0 +1,16 @@
1
+ import type { Problem } from "./a2ui.js";
2
+ import type { Spec } from "./schema.js";
3
+ /** The colour roles every screen uses: the page, and its main action. */
4
+ export declare const CORE_ROLES: readonly ["background", "on-background", "primary", "on-primary"];
5
+ /** Each "on" colour and what it sits on, read as text. */
6
+ export declare const TEXT_PAIRS: [string, string][];
7
+ /** WCAG 2.2 minimum contrast for normal-size text. */
8
+ export declare const TEXT_CONTRAST = 4.5;
9
+ /** A colour token's value as [r, g, b] 0-255, from #rgb, #rrggbb (or #rrggbbaa) or DTCG srgb components; null otherwise. */
10
+ export declare function rgbOf(value: unknown): [number, number, number] | null;
11
+ /** WCAG contrast ratio between two colours, 1 to 21. */
12
+ export declare function contrast(a: [number, number, number], b: [number, number, number]): number;
13
+ /** What could look better: the colour roles, their contrast in light and dark, and token types the runtime can't draw. */
14
+ export declare function designWarnings(spec: Spec): Problem[];
15
+ /** What could work better: each screen's empty, offline and error states, said. */
16
+ export declare function statesWarnings(spec: Spec): Problem[];
@@ -0,0 +1,137 @@
1
+ // Warnings, not refusals: what makes an app look good and work well, measured from the spec alone.
2
+ //
3
+ // How it looks: the colour roles a screen needs are there, and each text colour reads on what it sits on (WCAG 2.2
4
+ // contrast, 4.5:1 for text), in light and, when the spec has them, dark colours (tokens.dark). Token types the
5
+ // runtime can't draw are said rather than silently dropped.
6
+ // What it does: a screen that lists saved things says what it shows when there are none, and an app that talks to a
7
+ // site says what it shows offline and when the site fails.
8
+ /** The colour roles every screen uses: the page, and its main action. */
9
+ export const CORE_ROLES = ["background", "on-background", "primary", "on-primary"];
10
+ /** Each "on" colour and what it sits on, read as text. */
11
+ export const TEXT_PAIRS = [
12
+ ["on-background", "background"],
13
+ ["on-surface", "surface"],
14
+ ["on-primary", "primary"],
15
+ ["on-input", "input"],
16
+ ["error", "background"],
17
+ ];
18
+ /** WCAG 2.2 minimum contrast for normal-size text. */
19
+ export const TEXT_CONTRAST = 4.5;
20
+ /** DTCG types the runtime has no CSS for. */
21
+ const UNDRAWN = new Set(["shadow", "typography", "gradient", "border", "transition", "strokeStyle", "cubicBezier"]);
22
+ /** A colour token's value as [r, g, b] 0-255, from #rgb, #rrggbb (or #rrggbbaa) or DTCG srgb components; null otherwise. */
23
+ export function rgbOf(value) {
24
+ if (typeof value === "string") {
25
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value.trim());
26
+ if (!hex)
27
+ return null;
28
+ const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join("") : hex[1].slice(0, 6);
29
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
30
+ }
31
+ if (value && typeof value === "object") {
32
+ const o = value;
33
+ if (typeof o["hex"] === "string")
34
+ return rgbOf(o["hex"]);
35
+ if (o["colorSpace"] === "srgb" && Array.isArray(o["components"]) && o["components"].length >= 3)
36
+ return o["components"].slice(0, 3).map((c) => Math.round(Math.max(0, Math.min(1, Number(c))) * 255));
37
+ }
38
+ return null;
39
+ }
40
+ /** WCAG relative luminance. */
41
+ const luminance = ([r, g, b]) => {
42
+ const [lr, lg, lb] = [r, g, b].map((c) => {
43
+ const x = c / 255;
44
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
45
+ });
46
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
47
+ };
48
+ /** WCAG contrast ratio between two colours, 1 to 21. */
49
+ export function contrast(a, b) {
50
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
51
+ return (hi + 0.05) / (lo + 0.05);
52
+ }
53
+ /** The color.* tokens of a tree, by role, as written. */
54
+ function colours(tree) {
55
+ const out = new Map();
56
+ const color = tree && typeof tree === "object" ? tree["color"] : undefined;
57
+ if (!color || typeof color !== "object")
58
+ return out;
59
+ for (const [k, v] of Object.entries(color))
60
+ if (!k.startsWith("$") && v && typeof v === "object" && "$value" in v)
61
+ out.set(k, v["$value"]);
62
+ return out;
63
+ }
64
+ /** Every token's $type that the runtime can't draw, with where it is. */
65
+ function undrawn(tree, path = [], type) {
66
+ if (!tree || typeof tree !== "object")
67
+ return [];
68
+ const node = tree;
69
+ const t = typeof node["$type"] === "string" ? node["$type"] : type;
70
+ if ("$value" in node)
71
+ return t && UNDRAWN.has(t) ? [`${path.join(".")} (${t})`] : [];
72
+ return Object.entries(node).flatMap(([k, v]) => (k.startsWith("$") ? [] : undrawn(v, [...path, k], t)));
73
+ }
74
+ function themeWarnings(scheme, roles) {
75
+ const at = scheme === "light" ? "tokens.color" : "tokens.dark.color";
76
+ const out = [];
77
+ for (const [text, under] of TEXT_PAIRS) {
78
+ const a = roles.get(text);
79
+ const b = roles.get(under);
80
+ if (a === undefined || b === undefined)
81
+ continue;
82
+ const fg = rgbOf(a);
83
+ const bg = rgbOf(b);
84
+ if (!fg || !bg)
85
+ continue;
86
+ const ratio = contrast(fg, bg);
87
+ if (ratio < TEXT_CONTRAST)
88
+ out.push({ at: `${at}.${text}`, problem: `${text} on ${under} is ${ratio.toFixed(1)}:1; text needs at least ${TEXT_CONTRAST}:1 to read (WCAG)` });
89
+ }
90
+ return out;
91
+ }
92
+ /** What could look better: the colour roles, their contrast in light and dark, and token types the runtime can't draw. */
93
+ export function designWarnings(spec) {
94
+ const out = [];
95
+ const light = colours(spec.tokens);
96
+ for (const role of CORE_ROLES)
97
+ if (!light.has(role))
98
+ out.push({ at: "tokens.color", problem: `no color.${role}: every screen uses it` });
99
+ out.push(...themeWarnings("light", light));
100
+ const darkTree = spec.tokens["dark"];
101
+ if (darkTree) {
102
+ // Dark colours replace the light ones they name; the rest stay as they are.
103
+ const dark = new Map([...light, ...colours(darkTree)]);
104
+ out.push(...themeWarnings("dark", dark));
105
+ }
106
+ else {
107
+ out.push({ at: "tokens", problem: "no dark colours (tokens.dark): the app stays light when the phone is set to dark" });
108
+ }
109
+ for (const where of undrawn(spec.tokens))
110
+ out.push({ at: `tokens.${where.split(" ")[0]}`, problem: `the runtime can't draw ${where} yet; it's left out of the page` });
111
+ return out;
112
+ }
113
+ /** Whether a surface shows a list of things (an A2UI List, or a view of saved data). */
114
+ function listsThings(spec) {
115
+ if (spec.data.views.length)
116
+ return true;
117
+ return spec.surface.messages.some((m) => (m.updateComponents?.components ?? []).some((c) => c.component === "List"));
118
+ }
119
+ /** What could work better: each screen's empty, offline and error states, said. */
120
+ export function statesWarnings(spec) {
121
+ const out = [];
122
+ const online = spec.network.length > 0;
123
+ if (!spec.screens) {
124
+ if (listsThings(spec) || online)
125
+ out.push({ at: "screens", problem: "no screens described: say what each shows when it's empty, offline or something fails" });
126
+ return out;
127
+ }
128
+ // Said once for the app, not per screen: not every screen lists things or goes online.
129
+ const has = (state) => spec.screens.some((s) => s.states?.[state]);
130
+ if (listsThings(spec) && !has("empty"))
131
+ out.push({ at: "screens", problem: "no empty state: say what a screen shows before anything is saved" });
132
+ if (online && !has("offline"))
133
+ out.push({ at: "screens", problem: "no offline state: say what it shows without a connection" });
134
+ if (online && !has("error"))
135
+ out.push({ at: "screens", problem: `no error state: say what it shows when ${spec.network[0].site} fails` });
136
+ return out;
137
+ }
@@ -0,0 +1,17 @@
1
+ import type { Problem } from "./a2ui.js";
2
+ import type { Spec } from "./schema.js";
3
+ /** The functions with no example to reproduce, in one warning that names them. */
4
+ export declare function rulesWarnings(spec: Pick<Spec, "logic">): Problem[];
5
+ export type CatalogUse = {
6
+ /** Each catalog component the screens place, and how many times. */
7
+ catalog: Record<string, number>;
8
+ /** Each coded component the screens place, its kit, and the native widget it replaces when the spec says. */
9
+ coded: {
10
+ name: string;
11
+ kit: "game" | "page";
12
+ replaces: string | null;
13
+ placed: number;
14
+ }[];
15
+ };
16
+ /** What a spec's screens are drawn with. A page spec (the whole app as one page) counts as its one coded component. */
17
+ export declare function catalogUse(spec: Pick<Spec, "surface" | "components">): CatalogUse;
@@ -0,0 +1,36 @@
1
+ // How closely a spec can be shown to copy the app it came from.
2
+ // - Rules with no examples: nothing checks they do what the original does (a warning; the compiler checks every example
3
+ // a rule has, so one recorded from the original app is proof).
4
+ // - What the screens are drawn with: the catalog's components, and the coded components standing in for what the
5
+ // catalog can't draw, with the native widget each replaces. `blackbox coverage` adds these up across specs, so the
6
+ // catalog grows where ports need it most.
7
+ /** The functions with no example to reproduce, in one warning that names them. */
8
+ export function rulesWarnings(spec) {
9
+ const shown = new Set(spec.logic.vectors.map((v) => v.fn));
10
+ const bare = Object.keys(spec.logic.functions).filter((fn) => !shown.has(fn));
11
+ if (!bare.length)
12
+ return [];
13
+ const [one] = bare;
14
+ return [
15
+ {
16
+ at: bare.length === 1 ? `/logic/functions/${one}` : "/logic/functions",
17
+ problem: `${bare.length === 1 ? `${one} has` : `${bare.join(", ")} have`} no examples: nothing checks ${bare.length === 1 ? "it does" : "they do"} what the original does. Record some from the app (from: app) or its tests`,
18
+ },
19
+ ];
20
+ }
21
+ /** What a spec's screens are drawn with. A page spec (the whole app as one page) counts as its one coded component. */
22
+ export function catalogUse(spec) {
23
+ const catalog = {};
24
+ const placed = {};
25
+ for (const m of spec.surface.messages)
26
+ for (const c of m.updateComponents?.components ?? []) {
27
+ if (typeof c.component !== "string")
28
+ continue;
29
+ if (c.component === "Coded" && typeof c.use === "string")
30
+ placed[c.use] = (placed[c.use] ?? 0) + 1;
31
+ else
32
+ catalog[c.component] = (catalog[c.component] ?? 0) + 1;
33
+ }
34
+ const coded = Object.entries(spec.components).map(([name, c]) => ({ name, kit: c.kit, replaces: c.replaces ?? null, placed: placed[name] ?? 0 }));
35
+ return { catalog, coded };
36
+ }
@@ -1,5 +1,5 @@
1
1
  export { A2UI_VERSION, BASIC_CATALOG, surfaceProblems, type Catalog, type Problem } from "./a2ui.js";
2
- export { Id, Pointer, PointerPattern, SpecSchema, Tokens, type Spec, type TokenNode } from "./schema.js";
2
+ export { GAP_KINDS, Id, Pointer, PointerPattern, SpecSchema, Tokens, type Spec, type TokenNode } from "./schema.js";
3
3
  export { validateSpec } from "./validate.js";
4
4
  export { canonical } from "./canonical.js";
5
5
  export { specJsonSchema } from "./jsonschema.js";
@@ -8,3 +8,8 @@ export { leakProblems } from "./leak.js";
8
8
  export { CLOUDCRANE_CATALOG, CLOUDCRANE_CATALOG_ID, catalogFor, cloudcraneCatalog } from "./catalog.js";
9
9
  export { deriveBlueprint, type BlueprintV1 } from "./blueprint.js";
10
10
  export { USES, USE_NAMES, NEEDS_ORIGIN, featuresOf, type Use } from "./uses.js";
11
+ export { pageOf, pageProblems, wrapPage, type PageApp } from "./page.js";
12
+ export { designWarnings, statesWarnings, contrast, rgbOf, CORE_ROLES, TEXT_PAIRS, TEXT_CONTRAST } from "./design.js";
13
+ export { memoryProblems, keptShape, type KeptType } from "./memory.js";
14
+ export { reachProblems, hostsNamedIn, buildsAddressAtRuntime } from "./reach.js";
15
+ export { rulesWarnings, catalogUse, type CatalogUse } from "./fidelity.js";
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The CloudCrane app spec: its schema, the A2UI surface check, canonical JSON
2
2
  // and the JSON Schema export. See the repo README.
3
3
  export { A2UI_VERSION, BASIC_CATALOG, surfaceProblems } from "./a2ui.js";
4
- export { Id, Pointer, PointerPattern, SpecSchema, Tokens } from "./schema.js";
4
+ export { GAP_KINDS, Id, Pointer, PointerPattern, SpecSchema, Tokens } from "./schema.js";
5
5
  export { validateSpec } from "./validate.js";
6
6
  export { canonical } from "./canonical.js";
7
7
  export { specJsonSchema } from "./jsonschema.js";
@@ -10,3 +10,8 @@ export { leakProblems } from "./leak.js";
10
10
  export { CLOUDCRANE_CATALOG, CLOUDCRANE_CATALOG_ID, catalogFor, cloudcraneCatalog } from "./catalog.js";
11
11
  export { deriveBlueprint } from "./blueprint.js";
12
12
  export { USES, USE_NAMES, NEEDS_ORIGIN, featuresOf } from "./uses.js";
13
+ export { pageOf, pageProblems, wrapPage } from "./page.js";
14
+ export { designWarnings, statesWarnings, contrast, rgbOf, CORE_ROLES, TEXT_PAIRS, TEXT_CONTRAST } from "./design.js";
15
+ export { memoryProblems, keptShape } from "./memory.js";
16
+ export { reachProblems, hostsNamedIn, buildsAddressAtRuntime } from "./reach.js";
17
+ export { rulesWarnings, catalogUse } from "./fidelity.js";
package/dist/src/leak.js CHANGED
@@ -44,6 +44,15 @@ const RULES = [
44
44
  test: re(/\b(sk|pk|rk)[-_](live|test|proj)?[-_]?[A-Za-z0-9]{20,}\b|\bAIza[0-9A-Za-z_-]{35}\b|\bgh[pousr]_[A-Za-z0-9]{36}\b|\bxox[abpr]-[A-Za-z0-9-]{10,}/),
45
45
  },
46
46
  { kind: "secret", why: "a JSON Web Token", test: re(/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\./) },
47
+ // After secretlint's recommended preset: the formats it knows, beyond the ones above.
48
+ { kind: "secret", why: "an AWS secret access key", test: re(/aws_?secret_?access_?key["'\s:=]+[A-Za-z0-9/+=]{40}\b/i) },
49
+ { kind: "secret", why: "an npm token", test: re(/\bnpm_[A-Za-z0-9]{36}\b/) },
50
+ { kind: "secret", why: "a GitHub token", test: re(/\bgithub_pat_[A-Za-z0-9_]{60,}\b/) },
51
+ { kind: "secret", why: "an Anthropic or OpenAI key", test: re(/\bsk-(ant-(api|admin)\d{2}-|proj-)[A-Za-z0-9_-]{20,}/) },
52
+ { kind: "secret", why: "a Slack webhook", test: re(/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/) },
53
+ { kind: "secret", why: "a SendGrid key", test: re(/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/) },
54
+ { kind: "secret", why: "a Shopify token", test: re(/\bshp(at|ss|ca|pa)_[a-fA-F0-9]{32}\b/) },
55
+ { kind: "secret", why: "a password in an address", test: re(/\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{3,}@[^\s/]+/i) },
47
56
  { kind: "secret", why: "a private key", test: re(/-----BEGIN [A-Z ]*PRIVATE KEY-----/) },
48
57
  { kind: "secret", why: "a long random-looking string", test: highEntropy },
49
58
  { kind: "personal", why: "an email address", test: re(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/) },
package/dist/src/logic.js CHANGED
@@ -172,7 +172,20 @@ export function logicProblems(spec) {
172
172
  const cycle = cycleIn(spec.logic.functions);
173
173
  if (cycle)
174
174
  return [{ at: "/logic/functions", problem: `functions call each other in a cycle: ${cycle.join(" → ")}` }];
175
- const rules = new Rules(spec.logic);
175
+ let rules;
176
+ try {
177
+ rules = new Rules(spec.logic);
178
+ }
179
+ catch (e) {
180
+ // A function named like one CEL already has (double, int, size...) can't be registered: say which, don't crash.
181
+ const taken = Object.keys(spec.logic.functions).find((n) => e.message.includes(`'${n}(`) || e.message.includes(` ${n}(`));
182
+ return [
183
+ {
184
+ at: taken ? `/logic/functions/${token(taken)}` : "/logic/functions",
185
+ problem: `${taken ? `"${taken}" is` : "a function name is"} already a CEL function: name it something else`,
186
+ },
187
+ ];
188
+ }
176
189
  const each = [
177
190
  ...Object.entries(spec.logic.functions).map(([n, f]) => [`/logic/functions/${token(n)}/cel`, f.cel]),
178
191
  ...Object.entries(spec.logic.computed).map(([p, cel]) => [`/logic/computed/${token(p)}`, cel]),
@@ -0,0 +1,7 @@
1
+ import type { Problem } from "./a2ui.js";
2
+ import type { Spec } from "./schema.js";
3
+ /** CloudCrane's kept-field types (its blueprint's keeps[].shape). */
4
+ export type KeptType = "text" | "number" | "yes/no" | "date" | "list" | "object";
5
+ export declare function memoryProblems(spec: Spec): Problem[];
6
+ /** Each top-level saved field and its type, in CloudCrane's words. */
7
+ export declare function keptShape(schema: Record<string, unknown>): Record<string, KeptType>;
@@ -0,0 +1,78 @@
1
+ import { Ajv2020 } from "ajv/dist/2020.js";
2
+ const ajv = () => new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
3
+ /** The schema as Ajv takes it: any $schema line names a draft this one reads the same for what specs use. */
4
+ const plain = (schema) => {
5
+ const { $schema: _s, ...rest } = schema;
6
+ return rest;
7
+ };
8
+ export function memoryProblems(spec) {
9
+ const problems = [];
10
+ let validate;
11
+ try {
12
+ validate = ajv().compile(plain(spec.data.schema));
13
+ }
14
+ catch (err) {
15
+ return [{ at: "data.schema", problem: `isn't a JSON Schema: ${err instanceof Error ? err.message : String(err)}` }];
16
+ }
17
+ if (spec.data.initial !== undefined && !validate(spec.data.initial))
18
+ for (const e of validate.errors ?? [])
19
+ problems.push({ at: `data.initial${e.instancePath.replace(/\//g, ".")}`, problem: `doesn't fit data.schema: ${e.message ?? "invalid"}` });
20
+ // Every step from 1 to the current version, once each, so any older phone can come up to date.
21
+ const steps = new Map();
22
+ for (const m of spec.data.migrations)
23
+ steps.set(m.from, (steps.get(m.from) ?? 0) + 1);
24
+ for (let from = 1; from < spec.data.version; from++) {
25
+ const n = steps.get(from) ?? 0;
26
+ if (n === 0)
27
+ problems.push({ at: "data.migrations", problem: `no step from version ${from} to ${from + 1}: a phone still on ${from} would lose its data` });
28
+ if (n > 1)
29
+ problems.push({ at: "data.migrations", problem: `${n} steps from version ${from}; there must be one` });
30
+ }
31
+ // Personal fields name something the saved data has.
32
+ for (const pointer of spec.data.personal ?? [])
33
+ if (!pointsInto(spec.data.schema, pointer))
34
+ problems.push({ at: `data.personal ${pointer}`, problem: "the saved data has no such field (data.schema)" });
35
+ return problems;
36
+ }
37
+ /** Whether a JSON Pointer names a field the schema describes (properties all the way down; arrays and maps by their items). */
38
+ function pointsInto(schema, pointer) {
39
+ if (pointer === "")
40
+ return true;
41
+ let node = schema;
42
+ for (const raw of pointer.split("/").slice(1)) {
43
+ const part = raw.replace(/~1/g, "/").replace(/~0/g, "~");
44
+ if (!node || typeof node !== "object")
45
+ return false;
46
+ const props = node["properties"];
47
+ if (props && part in props)
48
+ node = props[part];
49
+ else if (node["items"] && /^\d+$|^\*$/.test(part))
50
+ node = node["items"];
51
+ else if (node["additionalProperties"] && typeof node["additionalProperties"] === "object")
52
+ node = node["additionalProperties"];
53
+ else
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+ /** Each top-level saved field and its type, in CloudCrane's words. */
59
+ export function keptShape(schema) {
60
+ const props = (schema["properties"] ?? {});
61
+ const out = {};
62
+ for (const [name, p] of Object.entries(props).slice(0, 40)) {
63
+ const type = Array.isArray(p["type"]) ? p["type"].find((t) => t !== "null") : p["type"];
64
+ out[name.slice(0, 40)] =
65
+ type === "string"
66
+ ? p["format"] === "date" || p["format"] === "date-time"
67
+ ? "date"
68
+ : "text"
69
+ : type === "number" || type === "integer"
70
+ ? "number"
71
+ : type === "boolean"
72
+ ? "yes/no"
73
+ : type === "array"
74
+ ? "list"
75
+ : "object";
76
+ }
77
+ return out;
78
+ }
@@ -0,0 +1,35 @@
1
+ import type { Spec } from "./schema.js";
2
+ /** The spec as the page it is, or null when it isn't one. */
3
+ export declare function pageOf(spec: Spec): string | null;
4
+ type Problem = {
5
+ at: string;
6
+ problem: string;
7
+ };
8
+ /** What a spec that says it's a page may hold: the page, full screen, and no logic of its own. */
9
+ export declare function pageProblems(spec: Spec): Problem[];
10
+ /** What an HTML app says about itself, to make its spec from. */
11
+ export type PageApp = {
12
+ id: string;
13
+ name: string;
14
+ purpose: string;
15
+ html: string;
16
+ /** What made the spec, like "cloudcrane, from an HTML app" (the leak scan refuses one shaped like a call). */
17
+ generator: string;
18
+ /** The runtime version the spec is for; a page needs none, but the spec names the compiler's. */
19
+ runtime?: string;
20
+ data: {
21
+ version: number;
22
+ key: string;
23
+ };
24
+ journeys: Spec["journeys"];
25
+ promises?: Spec["promises"];
26
+ network?: Spec["network"];
27
+ uses?: Spec["uses"];
28
+ tests?: Spec["tests"];
29
+ icon?: string;
30
+ category?: string;
31
+ tags?: string[];
32
+ };
33
+ /** An HTML app as a spec whose one component is the page, full screen. Check it with validateSpec. */
34
+ export declare function wrapPage(app: PageApp): Record<string, unknown>;
35
+ export {};
@@ -0,0 +1,72 @@
1
+ import { CLOUDCRANE_CATALOG_ID } from "./catalog.js";
2
+ // An app written as one HTML page, as a spec. The page is its one page-kit
3
+ // component, and `page` names it: the spec is then the page, and the compiler
4
+ // gives back its HTML as written, with no runtime. It runs, saves and is
5
+ // tested exactly as it would on its own (the same storage keys, the same
6
+ // address, the same tests), while it's described, versioned and checked like
7
+ // any spec. Without `page`, the same component runs in a frame among the
8
+ // spec's own screens.
9
+ /** The spec as the page it is, or null when it isn't one. */
10
+ export function pageOf(spec) {
11
+ if (!spec.page)
12
+ return null;
13
+ const c = spec.components[spec.page];
14
+ return c?.kit === "page" ? c.code : null;
15
+ }
16
+ /** What a spec that says it's a page may hold: the page, full screen, and no logic of its own. */
17
+ export function pageProblems(spec) {
18
+ if (!spec.page)
19
+ return [];
20
+ const c = spec.components[spec.page];
21
+ if (c?.kit !== "page")
22
+ return [{ at: "page", problem: "names no page-kit component" }];
23
+ const problems = [];
24
+ if (Object.keys(spec.components).length !== 1)
25
+ problems.push({ at: "page", problem: "the page is the spec's only component" });
26
+ if (Object.keys(c.inputs).length || Object.keys(c.events).length)
27
+ problems.push({ at: "page", problem: "the page takes no inputs and sends no events" });
28
+ const placed = spec.surface.messages.flatMap((m) => (m["updateComponents"]?.components ?? []));
29
+ if (placed.length !== 1 || placed[0]["component"] !== "Coded" || placed[0]["use"] !== spec.page)
30
+ problems.push({ at: "page", problem: "the screen can hold nothing but the page" });
31
+ const { functions, computed, vectors } = spec.logic;
32
+ if (Object.keys(functions).length || Object.keys(computed).length || vectors.length || spec.data.views.length || spec.data.migrations.length)
33
+ problems.push({ at: "page", problem: "a page has no logic, views or migrations of the spec's own: its code keeps its data" });
34
+ return problems;
35
+ }
36
+ // A page draws itself, so these only satisfy the spec's shape; they're never shown.
37
+ const TOKENS = {
38
+ color: { $type: "color", background: { $value: "#ffffff" }, "on-background": { $value: "#111111" }, primary: { $value: "#0f766e" } },
39
+ };
40
+ /** An HTML app as a spec whose one component is the page, full screen. Check it with validateSpec. */
41
+ export function wrapPage(app) {
42
+ return {
43
+ v: 1,
44
+ manifest: { runtime: app.runtime ?? "0.0.0", generator: app.generator, source: { fingerprint: `sha256:${"0".repeat(64)}` } },
45
+ module: {
46
+ id: app.id,
47
+ name: app.name,
48
+ purpose: app.purpose,
49
+ ...(app.icon ? { icon: app.icon } : {}),
50
+ ...(app.category ? { category: app.category } : {}),
51
+ ...(app.tags?.length ? { tags: app.tags } : {}),
52
+ },
53
+ tokens: TOKENS,
54
+ surface: {
55
+ catalog: CLOUDCRANE_CATALOG_ID,
56
+ messages: [
57
+ { version: "v0.9.1", createSurface: { surfaceId: app.id, catalogId: CLOUDCRANE_CATALOG_ID } },
58
+ { version: "v0.9.1", updateComponents: { surfaceId: app.id, components: [{ id: "root", component: "Coded", use: "page", label: app.name }] } },
59
+ ],
60
+ },
61
+ data: { version: app.data.version, key: app.data.key, persist: "/saved", views: [], schema: { type: "object" }, initial: {}, migrations: [] },
62
+ logic: { consts: {}, functions: {}, computed: {}, vectors: [] },
63
+ network: app.network ?? [],
64
+ uses: app.uses ?? [],
65
+ journeys: app.journeys,
66
+ promises: app.promises ?? [],
67
+ tests: app.tests ?? [],
68
+ components: { page: { kind: "coded", kit: "page", inputs: {}, events: {}, code: app.html } },
69
+ page: "page",
70
+ mods: { open: [], locked: [] },
71
+ };
72
+ }
@@ -0,0 +1,7 @@
1
+ import type { Problem } from "./a2ui.js";
2
+ import type { Spec } from "./schema.js";
3
+ /** Every host an https:// or wss:// address in the code names, lower-case, once each. */
4
+ export declare function hostsNamedIn(code: string): string[];
5
+ /** Whether a request's address is put together while the app runs, so no one can read which site it reaches. */
6
+ export declare function buildsAddressAtRuntime(code: string): boolean;
7
+ export declare function reachProblems(spec: Spec): Problem[];