@cloudcrane/blackbox 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.
- package/dist/cli.js +4 -1
- package/dist/commands.d.ts +7 -1
- package/dist/commands.js +69 -6
- package/dist/csp.d.ts +2 -0
- package/dist/csp.js +9 -0
- package/dist/extract.d.ts +53 -3
- package/dist/extract.js +149 -11
- package/dist/readers/gaps.d.ts +11 -0
- package/dist/readers/gaps.js +89 -0
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { build, diff, extract, preview, validate } from "./commands.js";
|
|
3
|
+
import { build, coverage, diff, extract, preview, validate } from "./commands.js";
|
|
4
4
|
// blackbox: write a CloudCrane app spec from an app's code, see what a code
|
|
5
5
|
// change touched, and validate, build and preview a spec.
|
|
6
6
|
const USAGE = `blackbox <command> <path> [options]
|
|
@@ -8,6 +8,7 @@ const USAGE = `blackbox <command> <path> [options]
|
|
|
8
8
|
extract <repo> [--hints FILE] [--out DIR] write a spec from an app's code and its blackbox.hints.json
|
|
9
9
|
diff <repo> [--module ID] [--range A..B] which parts of the spec a code change touched
|
|
10
10
|
validate <spec.json> check a spec: schema, A2UI surface, rules, leaks
|
|
11
|
+
coverage <spec.json...> what the specs are drawn with: catalog components, and the native widgets coded components replace
|
|
11
12
|
build <spec.json> [--out FILE] build the app page (the same bytes every time)
|
|
12
13
|
preview <spec.json> [--port N] serve the app here, under CloudCrane's security policy`;
|
|
13
14
|
const print = (o) => {
|
|
@@ -43,6 +44,8 @@ async function main(argv) {
|
|
|
43
44
|
return print(diff(file, values.module, values.range));
|
|
44
45
|
if (command === "validate")
|
|
45
46
|
return print(validate(file));
|
|
47
|
+
if (command === "coverage")
|
|
48
|
+
return print(coverage(positionals.slice(1)));
|
|
46
49
|
if (command === "build")
|
|
47
50
|
return print(build(file, values.out));
|
|
48
51
|
if (command === "preview") {
|
package/dist/commands.d.ts
CHANGED
|
@@ -3,8 +3,14 @@ export type Outcome = {
|
|
|
3
3
|
ok: boolean;
|
|
4
4
|
lines: string[];
|
|
5
5
|
};
|
|
6
|
-
/** blackbox validate <spec.json>: every problem, or that it's ready. */
|
|
6
|
+
/** blackbox validate <spec.json>: every problem, or that it's ready; and what would make it better, either way. */
|
|
7
7
|
export declare function validate(file: string): Outcome;
|
|
8
|
+
/**
|
|
9
|
+
* blackbox coverage <spec.json...>: what the specs' screens are drawn with, added up: each catalog component and how
|
|
10
|
+
* often it's placed, and each coded component by the native widget it replaces, most needed first, so the catalog
|
|
11
|
+
* grows where ports fall back to code. Specs that don't validate are counted as they're written, and named.
|
|
12
|
+
*/
|
|
13
|
+
export declare function coverage(files: string[]): Outcome;
|
|
8
14
|
/** blackbox build <spec.json> [--out app.html]: the app page and its hash. */
|
|
9
15
|
export declare function build(file: string, out?: string): Outcome;
|
|
10
16
|
/** blackbox preview <spec.json> [--port 5420]: the app as a phone gets it, on this machine, under CloudCrane's policy. */
|
package/dist/commands.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import { validateSpec } from "@cloudcrane/app-spec";
|
|
3
|
+
import { catalogUse, featuresOf, validateSpec } from "@cloudcrane/app-spec";
|
|
4
4
|
import { compile, CompileError } from "@cloudcrane/app-runtime";
|
|
5
|
-
import { appCsp } from "./csp.js";
|
|
5
|
+
import { appCsp, appPermissions } from "./csp.js";
|
|
6
6
|
import { extract as extractSpec } from "./extract.js";
|
|
7
7
|
import { touchedBy } from "./diff.js";
|
|
8
8
|
import { mkdirSync } from "node:fs";
|
|
@@ -15,15 +15,75 @@ const readSpec = (file) => {
|
|
|
15
15
|
throw new Error(`Couldn't read ${file} as JSON: ${e.message}`);
|
|
16
16
|
}
|
|
17
17
|
};
|
|
18
|
-
/** blackbox validate <spec.json>: every problem, or that it's ready. */
|
|
18
|
+
/** blackbox validate <spec.json>: every problem, or that it's ready; and what would make it better, either way. */
|
|
19
19
|
export function validate(file) {
|
|
20
|
-
const { spec, problems } = validateSpec(readSpec(file));
|
|
20
|
+
const { spec, problems, warnings } = validateSpec(readSpec(file));
|
|
21
|
+
const better = warnings.length
|
|
22
|
+
? [` ${warnings.length} thing${warnings.length === 1 ? "" : "s"} to make it better:`, ...warnings.map((w) => ` ${w.at}: ${w.problem}`)]
|
|
23
|
+
: [];
|
|
21
24
|
if (spec)
|
|
22
25
|
return {
|
|
23
26
|
ok: true,
|
|
24
|
-
lines: [
|
|
27
|
+
lines: [
|
|
28
|
+
`${file}: ready. ${spec.journeys.length} journeys, ${spec.promises.length} promises, ${Object.keys(spec.logic.functions).length} rules.`,
|
|
29
|
+
...gapLines(spec),
|
|
30
|
+
...better,
|
|
31
|
+
],
|
|
25
32
|
};
|
|
26
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
lines: [`${file}: ${problems.length} problem${problems.length === 1 ? "" : "s"}`, ...problems.map((p) => ` ${p.at}: ${p.problem}`), ...better],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const gapLines = (spec) => (spec.gaps?.length ? [` Doesn't do what the original does: ${spec.gaps.map((g) => g.what.toLowerCase()).join("; ")}.`] : []);
|
|
39
|
+
/**
|
|
40
|
+
* blackbox coverage <spec.json...>: what the specs' screens are drawn with, added up: each catalog component and how
|
|
41
|
+
* often it's placed, and each coded component by the native widget it replaces, most needed first, so the catalog
|
|
42
|
+
* grows where ports fall back to code. Specs that don't validate are counted as they're written, and named.
|
|
43
|
+
*/
|
|
44
|
+
export function coverage(files) {
|
|
45
|
+
const catalog = {};
|
|
46
|
+
const fallbacks = {};
|
|
47
|
+
const unreadable = [];
|
|
48
|
+
let coded = 0;
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const raw = readSpec(file);
|
|
51
|
+
let use;
|
|
52
|
+
try {
|
|
53
|
+
use = catalogUse({ surface: raw.surface, components: raw.components ?? {} });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
unreadable.push(file);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const [c, n] of Object.entries(use.catalog))
|
|
60
|
+
catalog[c] = (catalog[c] ?? 0) + n;
|
|
61
|
+
for (const c of use.coded) {
|
|
62
|
+
coded++;
|
|
63
|
+
const key = c.replaces ?? (c.kit === "page" ? "(a whole page)" : "(a game, or replaces not said)");
|
|
64
|
+
const f = (fallbacks[key] ??= { specs: new Set(), placed: 0 });
|
|
65
|
+
f.specs.add(file);
|
|
66
|
+
f.placed += c.placed;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const byCount = (entries, n) => entries.sort(([a, x], [b, y]) => n(y) - n(x) || (a < b ? -1 : 1));
|
|
70
|
+
const placed = Object.values(catalog).reduce((a, b) => a + b, 0);
|
|
71
|
+
const share = placed + coded ? Math.round((placed / (placed + coded)) * 100) : 0;
|
|
72
|
+
return {
|
|
73
|
+
ok: !unreadable.length,
|
|
74
|
+
lines: [
|
|
75
|
+
`${files.length - unreadable.length} spec${files.length === 1 ? "" : "s"}: ${placed} catalog components placed, ${coded} coded component${coded === 1 ? "" : "s"} (${share}% drawn by the catalog).`,
|
|
76
|
+
"",
|
|
77
|
+
"Coded components, by what they replace (most needed in the catalog first):",
|
|
78
|
+
...(coded
|
|
79
|
+
? byCount(Object.entries(fallbacks), (f) => f.specs.size).map(([what, f]) => ` ${what}: ${f.specs.size} spec${f.specs.size === 1 ? "" : "s"}, placed ${f.placed} time${f.placed === 1 ? "" : "s"}`)
|
|
80
|
+
: [" none"]),
|
|
81
|
+
"",
|
|
82
|
+
"Catalog components used:",
|
|
83
|
+
...byCount(Object.entries(catalog), (n) => n).map(([c, n]) => ` ${c}: ${n}`),
|
|
84
|
+
...unreadable.map((f) => `${f}: its surface couldn't be read`),
|
|
85
|
+
],
|
|
86
|
+
};
|
|
27
87
|
}
|
|
28
88
|
/** blackbox build <spec.json> [--out app.html]: the app page and its hash. */
|
|
29
89
|
export function build(file, out) {
|
|
@@ -44,6 +104,8 @@ export function preview(file, port = 5420) {
|
|
|
44
104
|
const spec = readSpec(file);
|
|
45
105
|
const { html } = compile(spec);
|
|
46
106
|
const csp = appCsp((spec.network ?? []).map((n) => n.site));
|
|
107
|
+
// And the phone features it declares, nothing else, as CloudCrane serves it.
|
|
108
|
+
const permissions = appPermissions(featuresOf(spec.uses ?? []));
|
|
47
109
|
const server = createServer((req, res) => {
|
|
48
110
|
if (req.url !== "/" && req.url !== "/index.html") {
|
|
49
111
|
res.writeHead(404).end();
|
|
@@ -52,6 +114,7 @@ export function preview(file, port = 5420) {
|
|
|
52
114
|
res.writeHead(200, {
|
|
53
115
|
"content-type": "text/html; charset=utf-8",
|
|
54
116
|
"content-security-policy": csp,
|
|
117
|
+
"permissions-policy": permissions,
|
|
55
118
|
"cache-control": "no-store",
|
|
56
119
|
"x-content-type-options": "nosniff",
|
|
57
120
|
});
|
package/dist/csp.d.ts
CHANGED
package/dist/csp.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
// the app will on a phone: no scripts, styles or fonts from other sites, no eval,
|
|
4
4
|
// and only the sites the spec lists reachable.
|
|
5
5
|
const SITE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
6
|
+
/** Features no app may have, whatever it declares (CloudCrane's ALWAYS_OFF). */
|
|
7
|
+
const ALWAYS_OFF = ["payment", "usb", "serial", "hid", "midi", "display-capture"];
|
|
8
|
+
/** Every feature an app could ask for: each is allowed only when the spec's uses declare it (CloudCrane's permissionsPolicy). */
|
|
9
|
+
const FEATURES = ["camera", "microphone", "geolocation", "accelerometer", "gyroscope", "magnetometer", "screen-wake-lock", "gamepad", "bluetooth"];
|
|
10
|
+
/** The Permissions-Policy CloudCrane serves an app with: what its uses declare, and nothing else. */
|
|
11
|
+
export function appPermissions(allowed) {
|
|
12
|
+
const on = new Set(allowed);
|
|
13
|
+
return [...FEATURES.map((f) => `${f}=${on.has(f) ? "*" : "()"}`), ...ALWAYS_OFF.map((f) => `${f}=()`)].join(", ");
|
|
14
|
+
}
|
|
6
15
|
export function appCsp(sites) {
|
|
7
16
|
const listed = [...new Set(sites.map((s) => s.toLowerCase()))].filter((s) => SITE.test(s));
|
|
8
17
|
const https = listed.map((s) => ` https://${s}`).join("");
|
package/dist/extract.d.ts
CHANGED
|
@@ -1,23 +1,42 @@
|
|
|
1
1
|
import { type Problem, type Spec } from "@cloudcrane/app-spec";
|
|
2
|
-
export declare const GENERATOR = "blackbox@0.
|
|
2
|
+
export declare const GENERATOR = "blackbox@0.7.0";
|
|
3
3
|
/** blackbox.hints.json: where things are, and what the readers can't find yet (as spec parts). */
|
|
4
4
|
export type Hints = {
|
|
5
5
|
module: Spec["module"];
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* roles: a theme colour's name → the token path(s) it sets, like ["color", "primary"] or [["color", "on-background"], ["color", "on-surface"]].
|
|
8
|
+
* mode: one scheme only. Left out, a theme with both gives the light colours, and the dark ones as tokens.dark.
|
|
9
|
+
*/
|
|
7
10
|
theme?: {
|
|
8
11
|
file: string;
|
|
9
12
|
mode?: "dark" | "light";
|
|
10
13
|
roles?: Record<string, string[] | string[][]>;
|
|
11
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* files: modules of pure functions, translated to CEL; tests: their tests, read for examples; recorded: JSON Lines
|
|
17
|
+
* files of calls recorded while the original app ran ({"fn", "in", "out"} each), kept as examples `from: "app"`.
|
|
18
|
+
*/
|
|
12
19
|
rules?: {
|
|
13
20
|
files: string[];
|
|
14
21
|
tests?: string[];
|
|
22
|
+
recorded?: string[];
|
|
15
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* What the original does that this one doesn't. Left out, it's suggested from the packages the app depends on
|
|
26
|
+
* (`manifests`, by default pubspec.yaml and package.json at the repo's root); given, it's the list as written.
|
|
27
|
+
*/
|
|
28
|
+
gaps?: Spec["gaps"];
|
|
29
|
+
manifests?: string[];
|
|
16
30
|
tokens?: Record<string, unknown>;
|
|
17
31
|
surface: Spec["surface"];
|
|
18
32
|
data: Spec["data"];
|
|
19
33
|
logic?: Partial<Spec["logic"]>;
|
|
34
|
+
/** What it can reach: each site with why and what it's sent; the device features it uses. */
|
|
20
35
|
network?: Spec["network"];
|
|
36
|
+
uses?: Spec["uses"];
|
|
37
|
+
/** What it does: each screen with its empty, loading, offline and error states; the orientation. */
|
|
38
|
+
screens?: Spec["screens"];
|
|
39
|
+
screen?: Spec["screen"];
|
|
21
40
|
journeys: Spec["journeys"];
|
|
22
41
|
promises?: Spec["promises"];
|
|
23
42
|
tests?: Spec["tests"];
|
|
@@ -27,14 +46,45 @@ export type Citation = {
|
|
|
27
46
|
file: string;
|
|
28
47
|
lines: [number, number];
|
|
29
48
|
hash: string;
|
|
30
|
-
how: "flutter-theme" | "ts-pure" | "ts-vectors" | "hint";
|
|
49
|
+
how: "flutter-theme" | "ts-pure" | "ts-vectors" | "app-recording" | "manifest" | "hint";
|
|
31
50
|
symbol?: string;
|
|
32
51
|
};
|
|
52
|
+
/** How much of a spec was read from the app, rather than written in hints: the parts a reviewer checks by hand are the rest. */
|
|
53
|
+
export type Coverage = {
|
|
54
|
+
/** Design tokens and rules read from the code, of all of them, as a whole percentage. */
|
|
55
|
+
fromCode: number;
|
|
56
|
+
tokens: {
|
|
57
|
+
code: number;
|
|
58
|
+
hints: number;
|
|
59
|
+
};
|
|
60
|
+
rules: {
|
|
61
|
+
code: number;
|
|
62
|
+
hints: number;
|
|
63
|
+
};
|
|
64
|
+
/** Rules with at least one example to reproduce, of all rules; and the examples by where they came from. */
|
|
65
|
+
checked: {
|
|
66
|
+
rules: number;
|
|
67
|
+
of: number;
|
|
68
|
+
examples: {
|
|
69
|
+
tests: number;
|
|
70
|
+
app: number;
|
|
71
|
+
hints: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/** What the screens are drawn with: catalog components placed, coded components defined, and what each replaces. */
|
|
75
|
+
screens: {
|
|
76
|
+
catalog: number;
|
|
77
|
+
coded: number;
|
|
78
|
+
replaces: string[];
|
|
79
|
+
};
|
|
80
|
+
gaps: number;
|
|
81
|
+
};
|
|
33
82
|
export type Provenance = {
|
|
34
83
|
generator: string;
|
|
35
84
|
spec: string;
|
|
36
85
|
source: string;
|
|
37
86
|
elements: Record<string, Citation[]>;
|
|
87
|
+
coverage?: Coverage;
|
|
38
88
|
};
|
|
39
89
|
export type Extracted = {
|
|
40
90
|
spec: Spec | null;
|
package/dist/extract.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { canonical, validateSpec } from "@cloudcrane/app-spec";
|
|
4
|
+
import { canonical, catalogUse, validateSpec } from "@cloudcrane/app-spec";
|
|
5
5
|
import { RUNTIME_VERSION } from "@cloudcrane/app-runtime";
|
|
6
6
|
import { citeHash } from "veridic/citations";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
7
8
|
import { readFlutterTheme, ROLES } from "./readers/flutter-theme.js";
|
|
9
|
+
import { gapsIn } from "./readers/gaps.js";
|
|
8
10
|
import { translateModule } from "./readers/ts-pure.js";
|
|
9
11
|
import { harvestVectors } from "./readers/ts-vectors.js";
|
|
10
12
|
import { shingleLeaks } from "./shingles.js";
|
|
@@ -14,7 +16,7 @@ import { shingleLeaks } from "./shingles.js";
|
|
|
14
16
|
// Pointer, with the file, lines and a hash of those lines (Veridic's citeHash),
|
|
15
17
|
// and how it was found. The spec itself holds no code and no paths: the
|
|
16
18
|
// validator and the leak gates refuse any.
|
|
17
|
-
export const GENERATOR = "blackbox@0.
|
|
19
|
+
export const GENERATOR = "blackbox@0.7.0";
|
|
18
20
|
const escapePointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
19
21
|
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
20
22
|
export function extract(repo, hints) {
|
|
@@ -26,13 +28,16 @@ export function extract(repo, hints) {
|
|
|
26
28
|
return { file, lines: [a, b], hash: citeHash(lines), how, ...(symbol ? { symbol } : {}) };
|
|
27
29
|
};
|
|
28
30
|
const elements = {};
|
|
31
|
+
// Files read as data (recorded calls, package manifests): part of the source's fingerprint, but not code, so not
|
|
32
|
+
// held to the copied-code check, which recorded examples would trip by design.
|
|
33
|
+
const data = new Set();
|
|
29
34
|
const add = (pointer, c) => (elements[pointer] ??= []).push(c);
|
|
30
35
|
const notes = [];
|
|
31
36
|
const cited = new Set();
|
|
32
37
|
// Tokens: the theme's colours by role, its font and radius; the hints may add or override.
|
|
33
38
|
const tokens = {};
|
|
34
39
|
if (hints.theme) {
|
|
35
|
-
const { file, mode
|
|
40
|
+
const { file, mode, roles = {} } = hints.theme;
|
|
36
41
|
cited.add(file);
|
|
37
42
|
const theme = readFlutterTheme(read(file));
|
|
38
43
|
const map = { ...ROLES, ...roles };
|
|
@@ -44,6 +49,12 @@ export function extract(repo, hints) {
|
|
|
44
49
|
for (const [group, name] of paths) {
|
|
45
50
|
(tokens[group] ??= { $type: "color" })[name] = { $value: mode === "dark" ? c.dark : c.light };
|
|
46
51
|
add(`/tokens/${escapePointer(group)}/${escapePointer(name)}`, cite(file, [c.line, c.line], "flutter-theme", c.name));
|
|
52
|
+
// Both schemes, unless one was asked for: the dark colour as tokens.dark, where it differs.
|
|
53
|
+
if (!mode && c.dark !== c.light) {
|
|
54
|
+
const dark = (tokens["dark"] ??= {});
|
|
55
|
+
(dark[group] ??= { $type: "color" })[name] = { $value: c.dark };
|
|
56
|
+
add(`/tokens/dark/${escapePointer(group)}/${escapePointer(name)}`, cite(file, [c.line, c.line], "flutter-theme", c.name));
|
|
57
|
+
}
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
if (theme.font) {
|
|
@@ -95,6 +106,46 @@ export function extract(repo, hints) {
|
|
|
95
106
|
if (leftOut)
|
|
96
107
|
notes.push(`- ${leftOut} test call${leftOut === 1 ? "" : "s"} in ${file.split("/").pop()} not read as vectors (in a loop, or not plain values)`);
|
|
97
108
|
}
|
|
109
|
+
// Examples recorded from the original app as it ran: the strongest proof a rule does what the app did.
|
|
110
|
+
const MAX_APP_EXAMPLES = 20;
|
|
111
|
+
for (const file of hints.rules?.recorded ?? []) {
|
|
112
|
+
data.add(file);
|
|
113
|
+
const perFn = {};
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
let unknown = 0;
|
|
116
|
+
let unread = 0;
|
|
117
|
+
read(file)
|
|
118
|
+
.split("\n")
|
|
119
|
+
.forEach((line, i) => {
|
|
120
|
+
if (!line.trim())
|
|
121
|
+
return;
|
|
122
|
+
let call;
|
|
123
|
+
try {
|
|
124
|
+
call = JSON.parse(line);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
unread++;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// At most 8 arguments, as a spec's examples allow.
|
|
131
|
+
if (typeof call.fn !== "string" || !Array.isArray(call.in) || call.in.length > 8 || !("out" in call))
|
|
132
|
+
return void unread++;
|
|
133
|
+
if (!(call.fn in functions) && !(call.fn in (hints.logic?.functions ?? {})))
|
|
134
|
+
return void unknown++;
|
|
135
|
+
const key = JSON.stringify([call.fn, call.in, call.out]);
|
|
136
|
+
if (seen.has(key) || (perFn[call.fn] ?? 0) >= MAX_APP_EXAMPLES)
|
|
137
|
+
return;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
perFn[call.fn] = (perFn[call.fn] ?? 0) + 1;
|
|
140
|
+
add(`/logic/vectors/${vectors.length}`, cite(file, [i + 1, i + 1], "app-recording", call.fn));
|
|
141
|
+
vectors.push({ fn: call.fn, in: call.in, out: call.out, from: "app" });
|
|
142
|
+
});
|
|
143
|
+
const name = file.split("/").pop();
|
|
144
|
+
if (unknown)
|
|
145
|
+
notes.push(`- ${unknown} recorded call${unknown === 1 ? "" : "s"} in ${name} to functions the spec doesn't have`);
|
|
146
|
+
if (unread)
|
|
147
|
+
notes.push(`- ${unread} line${unread === 1 ? "" : "s"} in ${name} not read as a call ({"fn", "in", "out"})`);
|
|
148
|
+
}
|
|
98
149
|
for (const [name, f] of Object.entries(hints.logic?.functions ?? {})) {
|
|
99
150
|
if (name in functions)
|
|
100
151
|
notes.push(`- ${name}: the hints' version replaces the translated one`);
|
|
@@ -107,9 +158,31 @@ export function extract(repo, hints) {
|
|
|
107
158
|
}
|
|
108
159
|
for (const pointer of Object.keys(hints.logic?.computed ?? {}))
|
|
109
160
|
add(`/logic/computed/${escapePointer(pointer)}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
161
|
+
// Gaps: as the hints say, or suggested from the packages the app depends on.
|
|
162
|
+
let gaps = hints.gaps ?? [];
|
|
163
|
+
const manifests = hints.manifests ?? ["pubspec.yaml", "package.json"].filter((f) => existsSync(join(repo, f)));
|
|
164
|
+
const suggested = [];
|
|
165
|
+
for (const file of manifests) {
|
|
166
|
+
data.add(file);
|
|
167
|
+
for (const g of gapsIn(file, read(file))) {
|
|
168
|
+
if (hints.gaps) {
|
|
169
|
+
if (!hints.gaps.some((h) => h.kind === g.kind))
|
|
170
|
+
suggested.push(`- ${g.what} (${g.package}, ${file})`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (gaps.length >= 20)
|
|
174
|
+
break;
|
|
175
|
+
add(`/gaps/${gaps.length}`, cite(file, [g.line, g.line], "manifest", g.package));
|
|
176
|
+
gaps.push({ kind: g.kind, what: g.what });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (hints.gaps)
|
|
180
|
+
add("/gaps", { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
181
|
+
gaps = gaps.slice(0, 20);
|
|
110
182
|
// The source's fingerprint: every cited file's content, by name, hashed. A hash, never the code.
|
|
111
183
|
const files = [...cited].sort();
|
|
112
|
-
const
|
|
184
|
+
const all = [...new Set([...cited, ...data])].sort();
|
|
185
|
+
const fingerprint = sha(all.map((f) => `${f}\0${sha(readFileSync(join(repo, f)))}`).join("\n"));
|
|
113
186
|
const draft = {
|
|
114
187
|
v: 1,
|
|
115
188
|
manifest: { runtime: RUNTIME_VERSION, generator: GENERATOR, source: { fingerprint: `sha256:${fingerprint}` } },
|
|
@@ -119,18 +192,23 @@ export function extract(repo, hints) {
|
|
|
119
192
|
data: hints.data,
|
|
120
193
|
logic: { consts: { ...consts, ...(hints.logic?.consts ?? {}) }, functions, computed: hints.logic?.computed ?? {}, vectors },
|
|
121
194
|
network: hints.network ?? [],
|
|
195
|
+
...(hints.uses ? { uses: hints.uses } : {}),
|
|
196
|
+
...(hints.screens ? { screens: hints.screens } : {}),
|
|
197
|
+
...(hints.screen ? { screen: hints.screen } : {}),
|
|
198
|
+
...(gaps.length ? { gaps } : {}),
|
|
122
199
|
journeys: hints.journeys,
|
|
123
200
|
promises: hints.promises ?? [],
|
|
124
201
|
tests: hints.tests ?? [],
|
|
125
202
|
mods: hints.mods ?? { open: [], locked: [] },
|
|
126
203
|
};
|
|
127
|
-
for (const part of ["surface", "data", "journeys", "promises", "tests", "mods", "network", "module"])
|
|
204
|
+
for (const part of ["surface", "data", "journeys", "promises", "tests", "mods", "network", "uses", "screens", "screen", "module"])
|
|
128
205
|
if (part in hints)
|
|
129
206
|
add(`/${part}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
130
|
-
const { spec, problems } = validateSpec(draft);
|
|
207
|
+
const { spec, problems, warnings } = validateSpec(draft);
|
|
131
208
|
// The second gate: nothing in the spec (outside its CEL rules) repeats the source's own words.
|
|
132
209
|
const shingles = shingleLeaks(draft, files.map(read));
|
|
133
|
-
const
|
|
210
|
+
const refused = [...problems, ...shingles];
|
|
211
|
+
const coverage = coverageOf(draft, elements);
|
|
134
212
|
const counts = {};
|
|
135
213
|
for (const cs of Object.values(elements))
|
|
136
214
|
for (const c of cs)
|
|
@@ -138,7 +216,7 @@ export function extract(repo, hints) {
|
|
|
138
216
|
const report = [
|
|
139
217
|
`# ${hints.module.name}: what Blackbox read`,
|
|
140
218
|
"",
|
|
141
|
-
`Spec ${
|
|
219
|
+
`Spec ${refused.length ? `**not ready**: ${refused.length} problem${refused.length === 1 ? "" : "s"}` : "ready"}. Generated by ${GENERATOR} for runtime ${RUNTIME_VERSION}, from ${all.length} source files (fingerprint ${fingerprint.slice(0, 12)}).`,
|
|
142
220
|
"",
|
|
143
221
|
"## Where each part came from",
|
|
144
222
|
"",
|
|
@@ -149,12 +227,72 @@ export function extract(repo, hints) {
|
|
|
149
227
|
`Read from the code: ${Object.keys(tokens).length ? "design tokens, " : ""}${Object.keys(functions).length - Object.keys(hints.logic?.functions ?? {}).length} rules, ${vectors.filter((v) => v.from === "recorded").length} vectors from the module's own tests.`,
|
|
150
228
|
"From hints: the screens, the saved-data shape, composed rules and computed values, journeys and promises.",
|
|
151
229
|
"",
|
|
230
|
+
"## How much came from the code",
|
|
231
|
+
"",
|
|
232
|
+
...coverageLines(coverage),
|
|
233
|
+
"",
|
|
234
|
+
"## What it doesn't do that the original does",
|
|
235
|
+
"",
|
|
236
|
+
...(gaps.length ? gaps.map((g) => `- ${g.what} (${g.kind})`) : ["- nothing found; say so in the hints' gaps if the original does more"]),
|
|
237
|
+
...(suggested.length ? ["", "Its packages suggest these too, which the hints' gaps don't list:", ...suggested] : []),
|
|
238
|
+
"",
|
|
152
239
|
"## Left out",
|
|
153
240
|
"",
|
|
154
241
|
...(notes.length ? notes : ["- nothing"]),
|
|
155
242
|
"",
|
|
156
|
-
...(
|
|
243
|
+
...(refused.length ? ["## Problems", "", ...refused.map((p) => `- ${p.at}: ${p.problem}`), ""] : []),
|
|
244
|
+
...(warnings.length ? ["## To make it better", "", ...warnings.map((w) => `- ${w.at}: ${w.problem}`), ""] : []),
|
|
157
245
|
].join("\n");
|
|
158
|
-
const provenance = { generator: GENERATOR, spec: spec ? sha(canonical(spec)) : "", source: `sha256:${fingerprint}`, elements };
|
|
159
|
-
return { spec:
|
|
246
|
+
const provenance = { generator: GENERATOR, spec: spec ? sha(canonical(spec)) : "", source: `sha256:${fingerprint}`, elements, coverage };
|
|
247
|
+
return { spec: refused.length ? null : spec, draft, provenance, report, problems: refused };
|
|
248
|
+
}
|
|
249
|
+
/** Read from the code or written in hints, by element: an element read from the code has a citation that isn't a hint. */
|
|
250
|
+
function coverageOf(spec, elements) {
|
|
251
|
+
const fromCode = (pointer) => (elements[pointer] ?? []).some((c) => c.how !== "hint");
|
|
252
|
+
const under = (prefix, depth) => Object.keys(elements).filter((p) => p.startsWith(prefix) && p.split("/").length === depth);
|
|
253
|
+
// Tokens: each colour /tokens/<group>/<name> (and dark ones, one deeper), or a whole token like /tokens/font-family.
|
|
254
|
+
const tokenPointers = Object.keys(elements).filter((p) => p.startsWith("/tokens/"));
|
|
255
|
+
const tokens = { code: tokenPointers.filter(fromCode).length, hints: tokenPointers.filter((p) => !fromCode(p)).length };
|
|
256
|
+
const fns = under("/logic/functions/", 4);
|
|
257
|
+
const rules = { code: fns.filter(fromCode).length, hints: fns.filter((p) => !fromCode(p)).length };
|
|
258
|
+
const vectors = spec.logic?.vectors ?? [];
|
|
259
|
+
const names = Object.keys(spec.logic?.functions ?? {});
|
|
260
|
+
const withExamples = names.filter((n) => vectors.some((v) => v.fn === n)).length;
|
|
261
|
+
const examples = {
|
|
262
|
+
tests: vectors.filter((v) => v.from === "recorded").length,
|
|
263
|
+
app: vectors.filter((v) => v.from === "app").length,
|
|
264
|
+
hints: vectors.filter((v) => v.from === "hint").length,
|
|
265
|
+
};
|
|
266
|
+
let screens = { catalog: 0, coded: 0, replaces: [] };
|
|
267
|
+
try {
|
|
268
|
+
const use = catalogUse({ surface: spec.surface, components: spec.components ?? {} });
|
|
269
|
+
screens = {
|
|
270
|
+
catalog: Object.values(use.catalog).reduce((a, b) => a + b, 0),
|
|
271
|
+
coded: use.coded.length,
|
|
272
|
+
replaces: use.coded.flatMap((c) => (c.replaces ? [c.replaces] : [])),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// A surface too malformed to read is reported as a problem; coverage just leaves it at nothing.
|
|
277
|
+
}
|
|
278
|
+
const read = tokens.code + rules.code;
|
|
279
|
+
const total = read + tokens.hints + rules.hints;
|
|
280
|
+
return {
|
|
281
|
+
fromCode: total ? Math.round((read / total) * 100) : 0,
|
|
282
|
+
tokens,
|
|
283
|
+
rules,
|
|
284
|
+
checked: { rules: withExamples, of: names.length, examples },
|
|
285
|
+
screens,
|
|
286
|
+
gaps: spec.gaps?.length ?? 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function coverageLines(c) {
|
|
290
|
+
const of = (part) => `${part.code} of ${part.code + part.hints}`;
|
|
291
|
+
return [
|
|
292
|
+
`**${c.fromCode}%** of the design tokens and rules were read from the code; the rest came from hints, to check by hand.`,
|
|
293
|
+
"",
|
|
294
|
+
`- Design tokens: ${of(c.tokens)} read from the code`,
|
|
295
|
+
`- Rules: ${of(c.rules)} read from the code; ${c.checked.rules} of ${c.checked.of} checked by examples (${c.checked.examples.app} recorded from the app, ${c.checked.examples.tests} from its tests, ${c.checked.examples.hints} from hints)`,
|
|
296
|
+
`- Screens: from hints, with ${c.screens.catalog} catalog components and ${c.screens.coded} coded component${c.screens.coded === 1 ? "" : "s"}${c.screens.replaces.length ? ` (standing in for ${c.screens.replaces.join(", ")})` : ""}`,
|
|
297
|
+
];
|
|
160
298
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type GapKind = "background" | "widget" | "notifications" | "purchases" | "accounts" | "sync" | "device" | "other";
|
|
2
|
+
export type FoundGap = {
|
|
3
|
+
kind: GapKind;
|
|
4
|
+
what: string;
|
|
5
|
+
package: string;
|
|
6
|
+
line: number;
|
|
7
|
+
};
|
|
8
|
+
/** Package name (exact, or a prefix ending in "*") → what the app likely does with it that a web app can't yet. */
|
|
9
|
+
export declare const KNOWN: [string, GapKind, string][];
|
|
10
|
+
/** The gaps a manifest suggests, once each by what, in the order the packages are listed. */
|
|
11
|
+
export declare function gapsIn(file: string, text: string): FoundGap[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// What an app does that a phone web app can't yet, read from the packages it depends on: Flutter's pubspec.yaml and
|
|
2
|
+
// a React Native or web app's package.json. Read, never run. Each finding is a suggestion for the spec's gaps, which the
|
|
3
|
+
// partner keeps, rewords or drops in blackbox.hints.json: a package can be listed and never used.
|
|
4
|
+
/** Package name (exact, or a prefix ending in "*") → what the app likely does with it that a web app can't yet. */
|
|
5
|
+
export const KNOWN = [
|
|
6
|
+
// Flutter (pub.dev)
|
|
7
|
+
["workmanager", "background", "Work that runs in the background while the app is closed"],
|
|
8
|
+
["background_fetch", "background", "Work that runs in the background while the app is closed"],
|
|
9
|
+
["flutter_background_service", "background", "Work that runs in the background while the app is closed"],
|
|
10
|
+
["android_alarm_manager_plus", "background", "Alarms that run code while the app is closed"],
|
|
11
|
+
["home_widget", "widget", "A home-screen widget"],
|
|
12
|
+
["firebase_messaging", "notifications", "Push notifications"],
|
|
13
|
+
["flutter_local_notifications", "notifications", "Scheduled notifications"],
|
|
14
|
+
["awesome_notifications", "notifications", "Scheduled notifications"],
|
|
15
|
+
["onesignal_flutter", "notifications", "Push notifications"],
|
|
16
|
+
["in_app_purchase", "purchases", "In-app purchases or subscriptions"],
|
|
17
|
+
["purchases_flutter", "purchases", "In-app purchases or subscriptions"],
|
|
18
|
+
["firebase_auth", "accounts", "Signing in to an account"],
|
|
19
|
+
["google_sign_in", "accounts", "Signing in with Google"],
|
|
20
|
+
["sign_in_with_apple", "accounts", "Signing in with Apple"],
|
|
21
|
+
["supabase_flutter", "accounts", "Signing in and keeping data on a server"],
|
|
22
|
+
["cloud_firestore", "sync", "Data kept on a server and synced across devices"],
|
|
23
|
+
["firebase_database", "sync", "Data kept on a server and synced across devices"],
|
|
24
|
+
["health", "device", "Reading Apple Health or Health Connect"],
|
|
25
|
+
["flutter_blue_plus", "device", "Bluetooth devices beyond what the browser allows"],
|
|
26
|
+
["local_auth", "device", "Face ID or fingerprint inside the app"],
|
|
27
|
+
["geofence_service", "background", "Reacting to places while the app is closed"],
|
|
28
|
+
// React Native and web (npm)
|
|
29
|
+
["react-native-background-fetch", "background", "Work that runs in the background while the app is closed"],
|
|
30
|
+
["react-native-background-actions", "background", "Work that runs in the background while the app is closed"],
|
|
31
|
+
["expo-background-fetch", "background", "Work that runs in the background while the app is closed"],
|
|
32
|
+
["expo-task-manager", "background", "Work that runs in the background while the app is closed"],
|
|
33
|
+
["react-native-widget-extension", "widget", "A home-screen widget"],
|
|
34
|
+
["@react-native-firebase/messaging", "notifications", "Push notifications"],
|
|
35
|
+
["expo-notifications", "notifications", "Push or scheduled notifications"],
|
|
36
|
+
["@notifee/react-native", "notifications", "Scheduled notifications"],
|
|
37
|
+
["react-native-iap", "purchases", "In-app purchases or subscriptions"],
|
|
38
|
+
["react-native-purchases", "purchases", "In-app purchases or subscriptions"],
|
|
39
|
+
["expo-in-app-purchases", "purchases", "In-app purchases or subscriptions"],
|
|
40
|
+
["@react-native-firebase/auth", "accounts", "Signing in to an account"],
|
|
41
|
+
["expo-auth-session", "accounts", "Signing in to an account"],
|
|
42
|
+
["@react-native-firebase/firestore", "sync", "Data kept on a server and synced across devices"],
|
|
43
|
+
["react-native-health", "device", "Reading Apple Health"],
|
|
44
|
+
["react-native-health-connect", "device", "Reading Health Connect"],
|
|
45
|
+
["expo-local-authentication", "device", "Face ID or fingerprint inside the app"],
|
|
46
|
+
];
|
|
47
|
+
const matches = (name, pattern) => (pattern.endsWith("*") ? name.startsWith(pattern.slice(0, -1)) : name === pattern);
|
|
48
|
+
/** The dependency names in a pubspec.yaml (under dependencies only, not dev_dependencies), with their lines. */
|
|
49
|
+
function pubspecDeps(text) {
|
|
50
|
+
const out = [];
|
|
51
|
+
let inDeps = false;
|
|
52
|
+
text.split("\n").forEach((raw, i) => {
|
|
53
|
+
const line = raw.replace(/#.*$/, "");
|
|
54
|
+
if (/^\S/.test(line))
|
|
55
|
+
inDeps = /^dependencies\s*:/.test(line);
|
|
56
|
+
else if (inDeps) {
|
|
57
|
+
const m = /^ {2}([a-z0-9_]+)\s*:/.exec(line);
|
|
58
|
+
if (m)
|
|
59
|
+
out.push({ name: m[1], line: i + 1 });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** The dependency names in a package.json (dependencies only), with their lines. */
|
|
65
|
+
function packageDeps(text) {
|
|
66
|
+
let deps = {};
|
|
67
|
+
try {
|
|
68
|
+
deps = JSON.parse(text).dependencies ?? {};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const lines = text.split("\n");
|
|
74
|
+
return Object.keys(deps).map((name) => ({ name, line: Math.max(1, lines.findIndex((l) => l.includes(`"${name}"`)) + 1) }));
|
|
75
|
+
}
|
|
76
|
+
/** The gaps a manifest suggests, once each by what, in the order the packages are listed. */
|
|
77
|
+
export function gapsIn(file, text) {
|
|
78
|
+
const deps = file.endsWith("pubspec.yaml") ? pubspecDeps(text) : file.endsWith("package.json") ? packageDeps(text) : [];
|
|
79
|
+
const out = [];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const d of deps) {
|
|
82
|
+
const known = KNOWN.find(([pattern]) => matches(d.name, pattern));
|
|
83
|
+
if (!known || seen.has(known[2]))
|
|
84
|
+
continue;
|
|
85
|
+
seen.add(known[2]);
|
|
86
|
+
out.push({ kind: known[1], what: known[2], package: d.name, line: d.line });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudcrane/blackbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Turns an app's code into a CloudCrane app spec with no code in it, checks it, and builds or previews the app it describes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudcrane",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"typescript": "~5.9.3",
|
|
44
44
|
"veridic": "^0.14.0",
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@cloudcrane/app-runtime": "^0.
|
|
47
|
-
"@cloudcrane/app-spec": "^0.
|
|
46
|
+
"@cloudcrane/app-runtime": "^0.8.0",
|
|
47
|
+
"@cloudcrane/app-spec": "^0.7.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"tsx": "4.23.15"
|