@cloudcrane/app-spec 0.5.0 → 0.7.1
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/blueprint.d.ts +14 -0
- package/dist/src/blueprint.js +56 -3
- package/dist/src/design.d.ts +16 -0
- package/dist/src/design.js +137 -0
- package/dist/src/fidelity.d.ts +17 -0
- package/dist/src/fidelity.js +36 -0
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +5 -1
- package/dist/src/leak.js +9 -0
- package/dist/src/logic.js +14 -1
- package/dist/src/memory.d.ts +7 -0
- package/dist/src/memory.js +81 -0
- package/dist/src/reach.d.ts +7 -0
- package/dist/src/reach.js +102 -0
- package/dist/src/schema.d.ts +205 -9
- package/dist/src/schema.js +52 -2
- package/dist/src/validate.d.ts +1 -0
- package/dist/src/validate.js +12 -5
- package/package.json +1 -1
- package/schema/app-spec.v1.json +125 -1
package/dist/src/blueprint.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/blueprint.js
CHANGED
|
@@ -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
|
|
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: [
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -9,3 +9,7 @@ export { CLOUDCRANE_CATALOG, CLOUDCRANE_CATALOG_ID, catalogFor, cloudcraneCatalo
|
|
|
9
9
|
export { deriveBlueprint, type BlueprintV1 } from "./blueprint.js";
|
|
10
10
|
export { USES, USE_NAMES, NEEDS_ORIGIN, featuresOf, type Use } from "./uses.js";
|
|
11
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";
|
|
@@ -11,3 +11,7 @@ export { CLOUDCRANE_CATALOG, CLOUDCRANE_CATALOG_ID, catalogFor, cloudcraneCatalo
|
|
|
11
11
|
export { deriveBlueprint } from "./blueprint.js";
|
|
12
12
|
export { USES, USE_NAMES, NEEDS_ORIGIN, featuresOf } from "./uses.js";
|
|
13
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
|
-
|
|
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,81 @@
|
|
|
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. A page spec (an HTML
|
|
21
|
+
// app as a spec) moves its own data in its own code, as the page it is, so it has no migrations to check.
|
|
22
|
+
if (spec.page)
|
|
23
|
+
return [...problems, ...personalProblems(spec)];
|
|
24
|
+
const steps = new Map();
|
|
25
|
+
for (const m of spec.data.migrations)
|
|
26
|
+
steps.set(m.from, (steps.get(m.from) ?? 0) + 1);
|
|
27
|
+
for (let from = 1; from < spec.data.version; from++) {
|
|
28
|
+
const n = steps.get(from) ?? 0;
|
|
29
|
+
if (n === 0)
|
|
30
|
+
problems.push({ at: "data.migrations", problem: `no step from version ${from} to ${from + 1}: a phone still on ${from} would lose its data` });
|
|
31
|
+
if (n > 1)
|
|
32
|
+
problems.push({ at: "data.migrations", problem: `${n} steps from version ${from}; there must be one` });
|
|
33
|
+
}
|
|
34
|
+
return [...problems, ...personalProblems(spec)];
|
|
35
|
+
}
|
|
36
|
+
/** Personal fields name something the saved data has. */
|
|
37
|
+
function personalProblems(spec) {
|
|
38
|
+
return (spec.data.personal ?? []).flatMap((pointer) => pointsInto(spec.data.schema, pointer) ? [] : [{ at: `data.personal ${pointer}`, problem: "the saved data has no such field (data.schema)" }]);
|
|
39
|
+
}
|
|
40
|
+
/** Whether a JSON Pointer names a field the schema describes (properties all the way down; arrays and maps by their items). */
|
|
41
|
+
function pointsInto(schema, pointer) {
|
|
42
|
+
if (pointer === "")
|
|
43
|
+
return true;
|
|
44
|
+
let node = schema;
|
|
45
|
+
for (const raw of pointer.split("/").slice(1)) {
|
|
46
|
+
const part = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
47
|
+
if (!node || typeof node !== "object")
|
|
48
|
+
return false;
|
|
49
|
+
const props = node["properties"];
|
|
50
|
+
if (props && part in props)
|
|
51
|
+
node = props[part];
|
|
52
|
+
else if (node["items"] && /^\d+$|^\*$/.test(part))
|
|
53
|
+
node = node["items"];
|
|
54
|
+
else if (node["additionalProperties"] && typeof node["additionalProperties"] === "object")
|
|
55
|
+
node = node["additionalProperties"];
|
|
56
|
+
else
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** Each top-level saved field and its type, in CloudCrane's words. */
|
|
62
|
+
export function keptShape(schema) {
|
|
63
|
+
const props = (schema["properties"] ?? {});
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [name, p] of Object.entries(props).slice(0, 40)) {
|
|
66
|
+
const type = Array.isArray(p["type"]) ? p["type"].find((t) => t !== "null") : p["type"];
|
|
67
|
+
out[name.slice(0, 40)] =
|
|
68
|
+
type === "string"
|
|
69
|
+
? p["format"] === "date" || p["format"] === "date-time"
|
|
70
|
+
? "date"
|
|
71
|
+
: "text"
|
|
72
|
+
: type === "number" || type === "integer"
|
|
73
|
+
? "number"
|
|
74
|
+
: type === "boolean"
|
|
75
|
+
? "yes/no"
|
|
76
|
+
: type === "array"
|
|
77
|
+
? "list"
|
|
78
|
+
: "object";
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
@@ -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[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { USE_NAMES } from "./uses.js";
|
|
2
|
+
// What it can reach, checked against the code: a coded or page component's code may name only the sites in `network`
|
|
3
|
+
// and use only the device features in `uses`, so what the person is shown before the app opens is everything it does.
|
|
4
|
+
// Refusals, not warnings. The same reading CloudCrane's checks give an app's code (its apps.rules.ts and
|
|
5
|
+
// blueprints/capabilities.ts), so a spec that passes here passes there.
|
|
6
|
+
/** Example domains and schema.org name no site an app talks to. */
|
|
7
|
+
const EXAMPLE_DOMAIN = /^(?:[a-z0-9-]+\.)*example\.(?:com|org|net)$|^(?:[a-z0-9-]+\.)+(?:example|test|invalid|localhost)$/i;
|
|
8
|
+
const notASite = (host) => EXAMPLE_DOMAIN.test(host.replace(/:\d+$/, "")) || /^(?:[a-z0-9-]+\.)*schema\.org(?::\d+)?$/.test(host);
|
|
9
|
+
/** Links and the page's own tags carry nothing with them: their addresses aren't sites the code talks to. */
|
|
10
|
+
const OWN_TAG = /<(a|script|link|iframe|embed|object|form)\b[^>]*>/gi;
|
|
11
|
+
const ADDRESS_ATTR = /(?<=\s)(href|src|data|action)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
12
|
+
/** Every host an https:// or wss:// address in the code names, lower-case, once each. */
|
|
13
|
+
export function hostsNamedIn(code) {
|
|
14
|
+
const text = code.replace(OWN_TAG, (tag) => tag.replace(ADDRESS_ATTR, '$1=""'));
|
|
15
|
+
const hosts = new Set();
|
|
16
|
+
for (const m of text.matchAll(/\b(?:https|wss):\/\/([^\s/?#"'`<>\\()[\]{},;$]+)/gi)) {
|
|
17
|
+
const authority = m[1];
|
|
18
|
+
const host = authority
|
|
19
|
+
.slice(authority.lastIndexOf("@") + 1)
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/\.$/, "")
|
|
22
|
+
.replace(/:443$/, "");
|
|
23
|
+
if (host && !notASite(host))
|
|
24
|
+
hosts.add(host);
|
|
25
|
+
}
|
|
26
|
+
return [...hosts];
|
|
27
|
+
}
|
|
28
|
+
/** Calls that reach the network, and where their address starts. */
|
|
29
|
+
const REQUESTS = [
|
|
30
|
+
/(?<![\w$])fetch\s*\(\s*/g,
|
|
31
|
+
/\bnew\s+(?:WebSocket|EventSource)\s*\(\s*/g,
|
|
32
|
+
/\.sendBeacon\s*\(\s*/g,
|
|
33
|
+
/\.open\s*\(\s*(["'`])[A-Za-z]+\1\s*,\s*/g,
|
|
34
|
+
];
|
|
35
|
+
/** Whether a request's address is put together while the app runs, so no one can read which site it reaches. */
|
|
36
|
+
export function buildsAddressAtRuntime(code) {
|
|
37
|
+
for (const pattern of REQUESTS) {
|
|
38
|
+
if (pattern.source.startsWith("\\.open") && !/XMLHttpRequest/.test(code))
|
|
39
|
+
continue;
|
|
40
|
+
for (const m of code.matchAll(pattern)) {
|
|
41
|
+
const rest = code.slice(m.index + m[0].length, m.index + m[0].length + 300);
|
|
42
|
+
const quote = rest[0];
|
|
43
|
+
if (quote !== '"' && quote !== "'" && quote !== "`")
|
|
44
|
+
return true;
|
|
45
|
+
const end = rest.indexOf(quote, 1);
|
|
46
|
+
let start = rest.slice(1, end === -1 ? undefined : end);
|
|
47
|
+
if (quote === "`" && start.includes("${"))
|
|
48
|
+
start = start.slice(0, start.indexOf("${"));
|
|
49
|
+
if (!start)
|
|
50
|
+
return true;
|
|
51
|
+
const absolute = /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(start);
|
|
52
|
+
if (absolute && !/^(?:[a-z][a-z0-9+.-]*:)?\/\/[a-z0-9-]+(\.[a-z0-9-]+)*/i.test(start))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/** How code uses each device feature a spec can declare. */
|
|
59
|
+
const USED_BY = {
|
|
60
|
+
camera: [/\bgetUserMedia\b/],
|
|
61
|
+
microphone: [/\bgetUserMedia\b/],
|
|
62
|
+
location: [/\bgeolocation\b/],
|
|
63
|
+
motion: [/\bDevice(Motion|Orientation)Event\b/, /\bdevice(motion|orientation)\b/],
|
|
64
|
+
"screen-awake": [/\bwakeLock\b/],
|
|
65
|
+
gamepad: [/\bgetGamepads\b/, /\bgamepad(dis)?connected\b/],
|
|
66
|
+
voice: [/\b(webkit)?SpeechRecognition\b/],
|
|
67
|
+
contacts: [/\bnavigator\.contacts\b/, /\bContactsManager\b/],
|
|
68
|
+
nfc: [/\bNDEF(Reader|Message|Record)\b/],
|
|
69
|
+
};
|
|
70
|
+
/** Features a spec can't declare, so code that uses them is refused. */
|
|
71
|
+
const NOT_FOR_SPECS = [
|
|
72
|
+
["Bluetooth", /\bnavigator\.bluetooth\b/],
|
|
73
|
+
["direct connections to other phones (RTCPeerConnection)", /\b(webkit)?RTCPeerConnection\b/],
|
|
74
|
+
["USB, serial or HID devices", /\bnavigator\.(usb|serial|hid)\b/],
|
|
75
|
+
["payments", /\bPaymentRequest\b/],
|
|
76
|
+
];
|
|
77
|
+
export function reachProblems(spec) {
|
|
78
|
+
const problems = [];
|
|
79
|
+
const sites = new Set(spec.network.map((n) => n.site));
|
|
80
|
+
const declared = new Set(spec.uses.map((u) => u.what));
|
|
81
|
+
for (const [name, component] of Object.entries(spec.components)) {
|
|
82
|
+
const at = `components.${name}.code`;
|
|
83
|
+
const code = component.code;
|
|
84
|
+
for (const host of hostsNamedIn(code))
|
|
85
|
+
if (!sites.has(host))
|
|
86
|
+
problems.push({ at, problem: `talks to ${host}, which network doesn't list: add it with why and what it's sent, or take it out` });
|
|
87
|
+
if (buildsAddressAtRuntime(code))
|
|
88
|
+
problems.push({ at, problem: "builds a request's address while it runs, so no one can tell which site it reaches: write each address out in full" });
|
|
89
|
+
// camera and microphone share getUserMedia: either one declared covers it.
|
|
90
|
+
for (const what of USE_NAMES) {
|
|
91
|
+
if (declared.has(what) || !USED_BY[what].some((p) => p.test(code)))
|
|
92
|
+
continue;
|
|
93
|
+
if ((what === "camera" || what === "microphone") && (declared.has("camera") || declared.has("microphone")))
|
|
94
|
+
continue;
|
|
95
|
+
problems.push({ at, problem: `uses ${what} but uses doesn't declare it: add it with why, or take it out` });
|
|
96
|
+
}
|
|
97
|
+
for (const [what, pattern] of NOT_FOR_SPECS)
|
|
98
|
+
if (pattern.test(code))
|
|
99
|
+
problems.push({ at, problem: `uses ${what}, which a spec can't declare` });
|
|
100
|
+
}
|
|
101
|
+
return problems;
|
|
102
|
+
}
|