@cloudcrane/blackbox 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +26 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +92 -0
- package/dist/csp.d.ts +1 -0
- package/dist/csp.js +24 -0
- package/dist/diff.d.ts +8 -0
- package/dist/diff.js +28 -0
- package/dist/extract.d.ts +46 -0
- package/dist/extract.js +160 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/readers/flutter-theme.d.ts +20 -0
- package/dist/readers/flutter-theme.js +68 -0
- package/dist/readers/ts-pure.d.ts +20 -0
- package/dist/readers/ts-pure.js +176 -0
- package/dist/readers/ts-vectors.d.ts +11 -0
- package/dist/readers/ts-vectors.js +86 -0
- package/dist/shingles.d.ts +3 -0
- package/dist/shingles.js +46 -0
- package/package.json +57 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chirag Phor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cloudcrane/blackbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Blackbox CLI: write a CloudCrane app spec from your app's code, check it,
|
|
4
|
+
and build or preview the app it describes. See the
|
|
5
|
+
[Blackbox repo](https://github.com/cloudcrane-dev/blackbox) for what a spec is.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx @cloudcrane/blackbox extract . --hints blackbox.hints.json --out blackbox/plans
|
|
9
|
+
npx @cloudcrane/blackbox validate blackbox/plans/spec.json
|
|
10
|
+
npx @cloudcrane/blackbox build blackbox/plans/spec.json --out app.html
|
|
11
|
+
npx @cloudcrane/blackbox preview blackbox/plans/spec.json
|
|
12
|
+
npx @cloudcrane/blackbox diff . --range main..HEAD
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Command | What it does |
|
|
16
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `extract <repo>` | Writes `spec.json`, `provenance.json` and `report.md` from your code and `blackbox.hints.json` |
|
|
18
|
+
| `diff <repo>` | Lists the parts of the spec whose cited code changed |
|
|
19
|
+
| `validate <spec.json>` | Checks the schema, the A2UI screens, the rules against their examples, and for leaked code |
|
|
20
|
+
| `build <spec.json>` | Builds the app page; the same bytes every time |
|
|
21
|
+
| `preview <spec.json>` | Serves the app locally under CloudCrane's content security policy |
|
|
22
|
+
|
|
23
|
+
Extraction runs on your machine. It reads your code; it never runs it and sends
|
|
24
|
+
nothing anywhere. Keep `provenance.json` and `blackbox.hints.json` to yourself:
|
|
25
|
+
they name your files.
|
|
26
|
+
|
|
27
|
+
Node 22.5 or later. MIT licensed.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { build, diff, extract, preview, validate } from "./commands.js";
|
|
4
|
+
// blackbox: write a CloudCrane app spec from an app's code, see what a code
|
|
5
|
+
// change touched, and validate, build and preview a spec.
|
|
6
|
+
const USAGE = `blackbox <command> <path> [options]
|
|
7
|
+
|
|
8
|
+
extract <repo> [--hints FILE] [--out DIR] write a spec from an app's code and its blackbox.hints.json
|
|
9
|
+
diff <repo> [--module ID] [--range A..B] which parts of the spec a code change touched
|
|
10
|
+
validate <spec.json> check a spec: schema, A2UI surface, rules, leaks
|
|
11
|
+
build <spec.json> [--out FILE] build the app page (the same bytes every time)
|
|
12
|
+
preview <spec.json> [--port N] serve the app here, under CloudCrane's security policy`;
|
|
13
|
+
const print = (o) => {
|
|
14
|
+
for (const l of o.lines)
|
|
15
|
+
(o.ok ? console.log : console.error)(l);
|
|
16
|
+
return o.ok ? 0 : 1;
|
|
17
|
+
};
|
|
18
|
+
async function main(argv) {
|
|
19
|
+
const { positionals, values } = parseArgs({
|
|
20
|
+
args: argv,
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: {
|
|
23
|
+
out: { type: "string" },
|
|
24
|
+
port: { type: "string" },
|
|
25
|
+
hints: { type: "string" },
|
|
26
|
+
module: { type: "string" },
|
|
27
|
+
range: { type: "string" },
|
|
28
|
+
help: { type: "boolean", short: "h" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const [command, file] = positionals;
|
|
32
|
+
if (values.help || !command) {
|
|
33
|
+
console.log(USAGE);
|
|
34
|
+
return command ? 0 : 1;
|
|
35
|
+
}
|
|
36
|
+
if (!file) {
|
|
37
|
+
console.error(`${command} needs a spec file.\n\n${USAGE}`);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (command === "extract")
|
|
41
|
+
return print(extract(file, values.hints, values.out));
|
|
42
|
+
if (command === "diff")
|
|
43
|
+
return print(diff(file, values.module, values.range));
|
|
44
|
+
if (command === "validate")
|
|
45
|
+
return print(validate(file));
|
|
46
|
+
if (command === "build")
|
|
47
|
+
return print(build(file, values.out));
|
|
48
|
+
if (command === "preview") {
|
|
49
|
+
const { url } = await preview(file, values.port ? Number(values.port) : 5420);
|
|
50
|
+
console.log(`Previewing at ${url} (Ctrl+C to stop)`);
|
|
51
|
+
return new Promise(() => { });
|
|
52
|
+
}
|
|
53
|
+
console.error(`Unknown command: ${command}\n\n${USAGE}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
|
|
57
|
+
console.error(e.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
export type Outcome = {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
lines: string[];
|
|
5
|
+
};
|
|
6
|
+
/** blackbox validate <spec.json>: every problem, or that it's ready. */
|
|
7
|
+
export declare function validate(file: string): Outcome;
|
|
8
|
+
/** blackbox build <spec.json> [--out app.html]: the app page and its hash. */
|
|
9
|
+
export declare function build(file: string, out?: string): Outcome;
|
|
10
|
+
/** blackbox preview <spec.json> [--port 5420]: the app as a phone gets it, on this machine, under CloudCrane's policy. */
|
|
11
|
+
export declare function preview(file: string, port?: number): Promise<{
|
|
12
|
+
server: Server;
|
|
13
|
+
url: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** blackbox extract <repo> [--hints FILE] [--out DIR]: the spec, its provenance and a report, from an app's code and hints. */
|
|
16
|
+
export declare function extract(repo: string, hintsFile?: string, outDir?: string): Outcome;
|
|
17
|
+
/** blackbox diff <repo> [--module ID] [--range A..B]: which parts of the spec a change to the code touched. */
|
|
18
|
+
export declare function diff(repo: string, module?: string, range?: string): Outcome;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { validateSpec } from "@cloudcrane/app-spec";
|
|
4
|
+
import { compile, CompileError } from "@cloudcrane/app-runtime";
|
|
5
|
+
import { appCsp } from "./csp.js";
|
|
6
|
+
import { extract as extractSpec } from "./extract.js";
|
|
7
|
+
import { touchedBy } from "./diff.js";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const readSpec = (file) => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
throw new Error(`Couldn't read ${file} as JSON: ${e.message}`);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** blackbox validate <spec.json>: every problem, or that it's ready. */
|
|
19
|
+
export function validate(file) {
|
|
20
|
+
const { spec, problems } = validateSpec(readSpec(file));
|
|
21
|
+
if (spec)
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
lines: [`${file}: ready. ${spec.journeys.length} journeys, ${spec.promises.length} promises, ${Object.keys(spec.logic.functions).length} rules.`],
|
|
25
|
+
};
|
|
26
|
+
return { ok: false, lines: [`${file}: ${problems.length} problem${problems.length === 1 ? "" : "s"}`, ...problems.map((p) => ` ${p.at}: ${p.problem}`)] };
|
|
27
|
+
}
|
|
28
|
+
/** blackbox build <spec.json> [--out app.html]: the app page and its hash. */
|
|
29
|
+
export function build(file, out) {
|
|
30
|
+
try {
|
|
31
|
+
const { html, sha256, runtime } = compile(readSpec(file));
|
|
32
|
+
if (out)
|
|
33
|
+
writeFileSync(out, html);
|
|
34
|
+
return { ok: true, lines: [`${out ?? "(not written)"}: ${Buffer.byteLength(html)} bytes, sha256 ${sha256}, ${runtime}`] };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e instanceof CompileError)
|
|
38
|
+
return { ok: false, lines: [`${file} can't be built:`, ...e.problems.map((p) => ` ${p.at}: ${p.problem}`)] };
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** blackbox preview <spec.json> [--port 5420]: the app as a phone gets it, on this machine, under CloudCrane's policy. */
|
|
43
|
+
export function preview(file, port = 5420) {
|
|
44
|
+
const spec = readSpec(file);
|
|
45
|
+
const { html } = compile(spec);
|
|
46
|
+
const csp = appCsp((spec.network ?? []).map((n) => n.site));
|
|
47
|
+
const server = createServer((req, res) => {
|
|
48
|
+
if (req.url !== "/" && req.url !== "/index.html") {
|
|
49
|
+
res.writeHead(404).end();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
res.writeHead(200, {
|
|
53
|
+
"content-type": "text/html; charset=utf-8",
|
|
54
|
+
"content-security-policy": csp,
|
|
55
|
+
"cache-control": "no-store",
|
|
56
|
+
"x-content-type-options": "nosniff",
|
|
57
|
+
});
|
|
58
|
+
res.end(html);
|
|
59
|
+
});
|
|
60
|
+
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/` })));
|
|
61
|
+
}
|
|
62
|
+
/** blackbox extract <repo> [--hints FILE] [--out DIR]: the spec, its provenance and a report, from an app's code and hints. */
|
|
63
|
+
export function extract(repo, hintsFile = join(repo, "blackbox.hints.json"), outDir) {
|
|
64
|
+
const hints = readSpec(hintsFile);
|
|
65
|
+
const r = extractSpec(repo, hints);
|
|
66
|
+
const dir = outDir ?? join(repo, "blackbox", hints.module.id);
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
// canonical-ish: stable key order and two-space indents, so a re-run with nothing changed writes the same bytes.
|
|
69
|
+
const stable = (v) => `${JSON.stringify(v, (_k, x) => (x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x), 2)}\n`;
|
|
70
|
+
writeFileSync(join(dir, "spec.json"), stable(r.spec ?? r.draft));
|
|
71
|
+
writeFileSync(join(dir, "provenance.json"), stable(r.provenance));
|
|
72
|
+
writeFileSync(join(dir, "report.md"), r.report);
|
|
73
|
+
return {
|
|
74
|
+
ok: !!r.spec,
|
|
75
|
+
lines: [r.report.split("\n")[2], `Wrote ${dir}/spec.json, provenance.json and report.md`, ...r.problems.map((p) => ` ${p.at}: ${p.problem}`)],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** blackbox diff <repo> [--module ID] [--range A..B]: which parts of the spec a change to the code touched. */
|
|
79
|
+
export function diff(repo, module, range) {
|
|
80
|
+
const id = module ?? readSpec(join(repo, "blackbox.hints.json")).module.id;
|
|
81
|
+
const provenance = readSpec(join(repo, "blackbox", id, "provenance.json"));
|
|
82
|
+
const touched = touchedBy(repo, provenance, range);
|
|
83
|
+
if (!touched.length)
|
|
84
|
+
return { ok: true, lines: [`Nothing in the ${id} spec is touched${range ? ` by ${range}` : " by the uncommitted changes"}.`] };
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
lines: [
|
|
88
|
+
`${touched.length} part${touched.length === 1 ? "" : "s"} of the ${id} spec touched; run blackbox extract to regenerate:`,
|
|
89
|
+
...touched.map((t) => ` ${t.pointer} (${t.why}: ${t.file}:${t.lines.join("-")})`),
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/csp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function appCsp(sites: readonly string[]): string;
|
package/dist/csp.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The security policy CloudCrane's apps host serves every app with (appCsp in
|
|
2
|
+
// CloudCrane's apps-host/host.rules.ts), word for word, so a preview behaves as
|
|
3
|
+
// the app will on a phone: no scripts, styles or fonts from other sites, no eval,
|
|
4
|
+
// and only the sites the spec lists reachable.
|
|
5
|
+
const SITE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
6
|
+
export function appCsp(sites) {
|
|
7
|
+
const listed = [...new Set(sites.map((s) => s.toLowerCase()))].filter((s) => SITE.test(s));
|
|
8
|
+
const https = listed.map((s) => ` https://${s}`).join("");
|
|
9
|
+
const connect = listed.map((s) => ` https://${s} wss://${s}`).join("");
|
|
10
|
+
return [
|
|
11
|
+
"default-src 'none'",
|
|
12
|
+
"script-src 'self' 'unsafe-inline'",
|
|
13
|
+
"style-src 'self' 'unsafe-inline'",
|
|
14
|
+
`img-src 'self' data: blob:${https}`,
|
|
15
|
+
"media-src 'self' data: blob:",
|
|
16
|
+
"font-src 'self' data:",
|
|
17
|
+
`connect-src 'self'${connect}`,
|
|
18
|
+
"manifest-src 'self'",
|
|
19
|
+
"worker-src 'self'",
|
|
20
|
+
"form-action 'self'",
|
|
21
|
+
"frame-ancestors 'none'",
|
|
22
|
+
"base-uri 'none'",
|
|
23
|
+
].join("; ");
|
|
24
|
+
}
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Provenance } from "./extract.js";
|
|
2
|
+
export type Touched = {
|
|
3
|
+
pointer: string;
|
|
4
|
+
file: string;
|
|
5
|
+
lines: [number, number];
|
|
6
|
+
why: "changed" | "deleted" | "renamed";
|
|
7
|
+
};
|
|
8
|
+
export declare function touchedBy(repo: string, provenance: Provenance, range?: string): Touched[];
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { parseDiff, touches } from "veridic/staleness";
|
|
3
|
+
export function touchedBy(repo, provenance, range) {
|
|
4
|
+
const files = new Set(Object.values(provenance.elements).flatMap((cs) => cs.filter((c) => c.how !== "hint").map((c) => c.file)));
|
|
5
|
+
if (!files.size)
|
|
6
|
+
return [];
|
|
7
|
+
const args = ["diff", "--unified=0", "-M", "--no-color", "--no-ext-diff", ...(range ? [range] : []), "--", ...[...files].sort()];
|
|
8
|
+
const changes = parseDiff(execFileSync("git", args, { cwd: repo, encoding: "utf8" }));
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const [pointer, cites] of Object.entries(provenance.elements).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
11
|
+
for (const c of cites) {
|
|
12
|
+
if (c.how === "hint")
|
|
13
|
+
continue;
|
|
14
|
+
const change = changes.get(c.file);
|
|
15
|
+
if (!change)
|
|
16
|
+
continue;
|
|
17
|
+
if (change.status === "deleted" || change.status === "renamed") {
|
|
18
|
+
out.push({ pointer, file: c.file, lines: c.lines, why: change.status });
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
if (change.hunks.some((h) => touches(h, c.lines[0], c.lines[1]))) {
|
|
22
|
+
out.push({ pointer, file: c.file, lines: c.lines, why: "changed" });
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Problem, type Spec } from "@cloudcrane/app-spec";
|
|
2
|
+
export declare const GENERATOR = "blackbox@0.1.0";
|
|
3
|
+
/** blackbox.hints.json: where things are, and what the readers can't find yet (as spec parts). */
|
|
4
|
+
export type Hints = {
|
|
5
|
+
module: Spec["module"];
|
|
6
|
+
/** roles: a theme colour's name → the token path(s) it sets, like ["color", "primary"] or [["color", "on-background"], ["color", "on-surface"]]. */
|
|
7
|
+
theme?: {
|
|
8
|
+
file: string;
|
|
9
|
+
mode?: "dark" | "light";
|
|
10
|
+
roles?: Record<string, string[] | string[][]>;
|
|
11
|
+
};
|
|
12
|
+
rules?: {
|
|
13
|
+
files: string[];
|
|
14
|
+
tests?: string[];
|
|
15
|
+
};
|
|
16
|
+
tokens?: Record<string, unknown>;
|
|
17
|
+
surface: Spec["surface"];
|
|
18
|
+
data: Spec["data"];
|
|
19
|
+
logic?: Partial<Spec["logic"]>;
|
|
20
|
+
network?: Spec["network"];
|
|
21
|
+
journeys: Spec["journeys"];
|
|
22
|
+
promises?: Spec["promises"];
|
|
23
|
+
tests?: Spec["tests"];
|
|
24
|
+
mods?: Spec["mods"];
|
|
25
|
+
};
|
|
26
|
+
export type Citation = {
|
|
27
|
+
file: string;
|
|
28
|
+
lines: [number, number];
|
|
29
|
+
hash: string;
|
|
30
|
+
how: "flutter-theme" | "ts-pure" | "ts-vectors" | "hint";
|
|
31
|
+
symbol?: string;
|
|
32
|
+
};
|
|
33
|
+
export type Provenance = {
|
|
34
|
+
generator: string;
|
|
35
|
+
spec: string;
|
|
36
|
+
source: string;
|
|
37
|
+
elements: Record<string, Citation[]>;
|
|
38
|
+
};
|
|
39
|
+
export type Extracted = {
|
|
40
|
+
spec: Spec | null;
|
|
41
|
+
draft: unknown;
|
|
42
|
+
provenance: Provenance;
|
|
43
|
+
report: string;
|
|
44
|
+
problems: Problem[];
|
|
45
|
+
};
|
|
46
|
+
export declare function extract(repo: string, hints: Hints): Extracted;
|
package/dist/extract.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonical, validateSpec } from "@cloudcrane/app-spec";
|
|
5
|
+
import { RUNTIME_VERSION } from "@cloudcrane/app-runtime";
|
|
6
|
+
import { citeHash } from "veridic/citations";
|
|
7
|
+
import { readFlutterTheme, ROLES } from "./readers/flutter-theme.js";
|
|
8
|
+
import { translateModule } from "./readers/ts-pure.js";
|
|
9
|
+
import { harvestVectors } from "./readers/ts-vectors.js";
|
|
10
|
+
import { shingleLeaks } from "./shingles.js";
|
|
11
|
+
// blackbox extract: a CloudCrane spec from an app's own code, plus the partner's
|
|
12
|
+
// hints for what can't be read yet. Read, never run. What's read is cited in
|
|
13
|
+
// provenance.json (stays in the partner's repo): each spec element, by JSON
|
|
14
|
+
// Pointer, with the file, lines and a hash of those lines (Veridic's citeHash),
|
|
15
|
+
// and how it was found. The spec itself holds no code and no paths: the
|
|
16
|
+
// validator and the leak gates refuse any.
|
|
17
|
+
export const GENERATOR = "blackbox@0.1.0";
|
|
18
|
+
const escapePointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
19
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
20
|
+
export function extract(repo, hints) {
|
|
21
|
+
const read = (file) => readFileSync(join(repo, file), "utf8");
|
|
22
|
+
const cite = (file, [a, b], how, symbol) => {
|
|
23
|
+
const lines = read(file)
|
|
24
|
+
.split("\n")
|
|
25
|
+
.slice(a - 1, b);
|
|
26
|
+
return { file, lines: [a, b], hash: citeHash(lines), how, ...(symbol ? { symbol } : {}) };
|
|
27
|
+
};
|
|
28
|
+
const elements = {};
|
|
29
|
+
const add = (pointer, c) => (elements[pointer] ??= []).push(c);
|
|
30
|
+
const notes = [];
|
|
31
|
+
const cited = new Set();
|
|
32
|
+
// Tokens: the theme's colours by role, its font and radius; the hints may add or override.
|
|
33
|
+
const tokens = {};
|
|
34
|
+
if (hints.theme) {
|
|
35
|
+
const { file, mode = "dark", roles = {} } = hints.theme;
|
|
36
|
+
cited.add(file);
|
|
37
|
+
const theme = readFlutterTheme(read(file));
|
|
38
|
+
const map = { ...ROLES, ...roles };
|
|
39
|
+
for (const c of theme.colors) {
|
|
40
|
+
const role = map[c.name];
|
|
41
|
+
if (!role)
|
|
42
|
+
continue;
|
|
43
|
+
const paths = (Array.isArray(role[0]) ? role : [role]);
|
|
44
|
+
for (const [group, name] of paths) {
|
|
45
|
+
(tokens[group] ??= { $type: "color" })[name] = { $value: mode === "dark" ? c.dark : c.light };
|
|
46
|
+
add(`/tokens/${escapePointer(group)}/${escapePointer(name)}`, cite(file, [c.line, c.line], "flutter-theme", c.name));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (theme.font) {
|
|
50
|
+
tokens["font-family"] = { $type: "fontFamily", $value: [theme.font.family, "system-ui", "sans-serif"] };
|
|
51
|
+
add("/tokens/font-family", cite(file, [theme.font.line, theme.font.line], "flutter-theme"));
|
|
52
|
+
}
|
|
53
|
+
if (theme.radius) {
|
|
54
|
+
tokens["border-radius"] = { $type: "dimension", $value: { value: theme.radius.value, unit: "px" } };
|
|
55
|
+
add("/tokens/border-radius", cite(file, [theme.radius.line, theme.radius.line], "flutter-theme"));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [group, value] of Object.entries(hints.tokens ?? {})) {
|
|
59
|
+
if (value && typeof value === "object" && !("$value" in value)) {
|
|
60
|
+
tokens[group] = { ...(tokens[group] ?? {}), ...value };
|
|
61
|
+
for (const k of Object.keys(value))
|
|
62
|
+
if (!k.startsWith("$"))
|
|
63
|
+
add(`/tokens/${escapePointer(group)}/${escapePointer(k)}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
tokens[group] = value;
|
|
67
|
+
add(`/tokens/${escapePointer(group)}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Rules: the pure functions translated to CEL, vectors from the module's own tests.
|
|
71
|
+
const consts = {};
|
|
72
|
+
const functions = {};
|
|
73
|
+
const vectors = [];
|
|
74
|
+
for (const file of hints.rules?.files ?? []) {
|
|
75
|
+
cited.add(file);
|
|
76
|
+
const t = translateModule(read(file), file);
|
|
77
|
+
for (const c of t.consts) {
|
|
78
|
+
consts[c.name] = c.value;
|
|
79
|
+
add(`/logic/consts/${c.name}`, cite(file, c.lines, "ts-pure", c.name));
|
|
80
|
+
}
|
|
81
|
+
for (const f of t.functions) {
|
|
82
|
+
functions[f.name] = { params: f.params, cel: f.cel };
|
|
83
|
+
add(`/logic/functions/${f.name}`, cite(file, f.lines, "ts-pure", f.name));
|
|
84
|
+
}
|
|
85
|
+
for (const s of t.skipped)
|
|
86
|
+
notes.push(`- ${s.name} (${file.split("/").pop()}): not translated, ${s.why}`);
|
|
87
|
+
}
|
|
88
|
+
for (const file of hints.rules?.tests ?? []) {
|
|
89
|
+
cited.add(file);
|
|
90
|
+
const { vectors: found, leftOut } = harvestVectors(read(file), new Set(Object.keys(functions)), file);
|
|
91
|
+
for (const v of found) {
|
|
92
|
+
add(`/logic/vectors/${vectors.length}`, cite(file, [v.line, v.line], "ts-vectors", v.fn));
|
|
93
|
+
vectors.push({ fn: v.fn, in: v.in, out: v.out, from: "recorded" });
|
|
94
|
+
}
|
|
95
|
+
if (leftOut)
|
|
96
|
+
notes.push(`- ${leftOut} test call${leftOut === 1 ? "" : "s"} in ${file.split("/").pop()} not read as vectors (in a loop, or not plain values)`);
|
|
97
|
+
}
|
|
98
|
+
for (const [name, f] of Object.entries(hints.logic?.functions ?? {})) {
|
|
99
|
+
if (name in functions)
|
|
100
|
+
notes.push(`- ${name}: the hints' version replaces the translated one`);
|
|
101
|
+
functions[name] = f;
|
|
102
|
+
elements[`/logic/functions/${name}`] = [{ file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" }];
|
|
103
|
+
}
|
|
104
|
+
for (const v of hints.logic?.vectors ?? []) {
|
|
105
|
+
add(`/logic/vectors/${vectors.length}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
106
|
+
vectors.push(v);
|
|
107
|
+
}
|
|
108
|
+
for (const pointer of Object.keys(hints.logic?.computed ?? {}))
|
|
109
|
+
add(`/logic/computed/${escapePointer(pointer)}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
110
|
+
// The source's fingerprint: every cited file's content, by name, hashed. A hash, never the code.
|
|
111
|
+
const files = [...cited].sort();
|
|
112
|
+
const fingerprint = sha(files.map((f) => `${f}\0${sha(readFileSync(join(repo, f)))}`).join("\n"));
|
|
113
|
+
const draft = {
|
|
114
|
+
v: 1,
|
|
115
|
+
manifest: { runtime: RUNTIME_VERSION, generator: GENERATOR, source: { fingerprint: `sha256:${fingerprint}` } },
|
|
116
|
+
module: hints.module,
|
|
117
|
+
tokens,
|
|
118
|
+
surface: hints.surface,
|
|
119
|
+
data: hints.data,
|
|
120
|
+
logic: { consts: { ...consts, ...(hints.logic?.consts ?? {}) }, functions, computed: hints.logic?.computed ?? {}, vectors },
|
|
121
|
+
network: hints.network ?? [],
|
|
122
|
+
journeys: hints.journeys,
|
|
123
|
+
promises: hints.promises ?? [],
|
|
124
|
+
tests: hints.tests ?? [],
|
|
125
|
+
mods: hints.mods ?? { open: [], locked: [] },
|
|
126
|
+
};
|
|
127
|
+
for (const part of ["surface", "data", "journeys", "promises", "tests", "mods", "network", "module"])
|
|
128
|
+
if (part in hints)
|
|
129
|
+
add(`/${part}`, { file: "blackbox.hints.json", lines: [0, 0], hash: "", how: "hint" });
|
|
130
|
+
const { spec, problems } = validateSpec(draft);
|
|
131
|
+
// The second gate: nothing in the spec (outside its CEL rules) repeats the source's own words.
|
|
132
|
+
const shingles = shingleLeaks(draft, files.map(read));
|
|
133
|
+
const all = [...problems, ...shingles];
|
|
134
|
+
const counts = {};
|
|
135
|
+
for (const cs of Object.values(elements))
|
|
136
|
+
for (const c of cs)
|
|
137
|
+
counts[c.how] = (counts[c.how] ?? 0) + 1;
|
|
138
|
+
const report = [
|
|
139
|
+
`# ${hints.module.name}: what Blackbox read`,
|
|
140
|
+
"",
|
|
141
|
+
`Spec ${all.length ? `**not ready**: ${all.length} problem${all.length === 1 ? "" : "s"}` : "ready"}. Generated by ${GENERATOR} for runtime ${RUNTIME_VERSION}, from ${files.length} source files (fingerprint ${fingerprint.slice(0, 12)}).`,
|
|
142
|
+
"",
|
|
143
|
+
"## Where each part came from",
|
|
144
|
+
"",
|
|
145
|
+
...Object.entries(counts)
|
|
146
|
+
.sort()
|
|
147
|
+
.map(([how, n]) => `- ${how}: ${n}`),
|
|
148
|
+
"",
|
|
149
|
+
`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
|
+
"From hints: the screens, the saved-data shape, composed rules and computed values, journeys and promises.",
|
|
151
|
+
"",
|
|
152
|
+
"## Left out",
|
|
153
|
+
"",
|
|
154
|
+
...(notes.length ? notes : ["- nothing"]),
|
|
155
|
+
"",
|
|
156
|
+
...(all.length ? ["## Problems", "", ...all.map((p) => `- ${p.at}: ${p.problem}`), ""] : []),
|
|
157
|
+
].join("\n");
|
|
158
|
+
const provenance = { generator: GENERATOR, spec: spec ? sha(canonical(spec)) : "", source: `sha256:${fingerprint}`, elements };
|
|
159
|
+
return { spec: all.length ? null : spec, draft, provenance, report, problems: all };
|
|
160
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ThemeColor = {
|
|
2
|
+
name: string;
|
|
3
|
+
dark: string;
|
|
4
|
+
light: string;
|
|
5
|
+
line: number;
|
|
6
|
+
};
|
|
7
|
+
export type FlutterTheme = {
|
|
8
|
+
colors: ThemeColor[];
|
|
9
|
+
font: {
|
|
10
|
+
family: string;
|
|
11
|
+
line: number;
|
|
12
|
+
} | null;
|
|
13
|
+
radius: {
|
|
14
|
+
value: number;
|
|
15
|
+
line: number;
|
|
16
|
+
} | null;
|
|
17
|
+
};
|
|
18
|
+
export declare function readFlutterTheme(source: string): FlutterTheme;
|
|
19
|
+
/** Common Flutter colour names → the A2UI theme variable they set (tokens' paths). A hint can add or override. */
|
|
20
|
+
export declare const ROLES: Record<string, string[]>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// A Flutter theme's colours, font and corner radius, read from its Dart source:
|
|
2
|
+
// `static Color get name => dark ? const Color(0xAARRGGBB) : const Color(...)`
|
|
3
|
+
// (or one colour, or Colors.white / Colors.black), `GoogleFonts.<family>(`, and
|
|
4
|
+
// `BorderRadius.circular(<n>)` / `double radius = <n>`. Reading text, not
|
|
5
|
+
// running Dart. Each value keeps its line, for provenance.
|
|
6
|
+
const NAMED = { white: "#ffffff", black: "#000000", transparent: "#00000000" };
|
|
7
|
+
/** 0xAARRGGBB as #rrggbb (or #rrggbbaa when not opaque). */
|
|
8
|
+
function hexOf(argb) {
|
|
9
|
+
const v = argb.toLowerCase().replace(/^0x/, "").padStart(8, "f");
|
|
10
|
+
const [a, rgb] = [v.slice(0, 2), v.slice(2)];
|
|
11
|
+
return a === "ff" ? `#${rgb}` : `#${rgb}${a}`;
|
|
12
|
+
}
|
|
13
|
+
const colorIn = (s) => {
|
|
14
|
+
const hex = /Color\(\s*(0x[0-9a-fA-F]{6,8})\s*\)/.exec(s);
|
|
15
|
+
if (hex)
|
|
16
|
+
return hexOf(hex[1]);
|
|
17
|
+
const named = /Colors\.(\w+)\b/.exec(s);
|
|
18
|
+
return named && NAMED[named[1]] ? NAMED[named[1]] : null;
|
|
19
|
+
};
|
|
20
|
+
/** "atkinsonHyperlegible" → "Atkinson Hyperlegible". */
|
|
21
|
+
const familyName = (camel) => camel.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
|
|
22
|
+
export function readFlutterTheme(source) {
|
|
23
|
+
const colors = [];
|
|
24
|
+
let font = null;
|
|
25
|
+
let radius = null;
|
|
26
|
+
source.split("\n").forEach((text, i) => {
|
|
27
|
+
const line = i + 1;
|
|
28
|
+
const getter = /static\s+(?:const\s+)?Color\s+get\s+(\w+)\s*=>\s*(.+);/.exec(text) ?? /static\s+(?:const\s+)?Color\s+(\w+)\s*=\s*(.+);/.exec(text);
|
|
29
|
+
if (getter) {
|
|
30
|
+
const [, name, value] = getter;
|
|
31
|
+
const branch = /^(.*?)\?(.*):(.*)$/.exec(value);
|
|
32
|
+
const dark = branch ? colorIn(branch[2]) : colorIn(value);
|
|
33
|
+
const light = branch ? colorIn(branch[3]) : dark;
|
|
34
|
+
if (dark && light)
|
|
35
|
+
colors.push({ name: name, dark, light, line });
|
|
36
|
+
}
|
|
37
|
+
const f = /GoogleFonts\.(\w+?)(?:TextTheme)?\(/.exec(text);
|
|
38
|
+
if (f && !font)
|
|
39
|
+
font = { family: familyName(f[1]), line };
|
|
40
|
+
const r = /double\s+radius\s*=\s*(\d+(?:\.\d+)?)/.exec(text) ?? /BorderRadius\.circular\(\s*(\d+(?:\.\d+)?)\s*\)/.exec(text);
|
|
41
|
+
if (r && !radius)
|
|
42
|
+
radius = { value: Number(r[1]), line };
|
|
43
|
+
});
|
|
44
|
+
return { colors, font, radius };
|
|
45
|
+
}
|
|
46
|
+
/** Common Flutter colour names → the A2UI theme variable they set (tokens' paths). A hint can add or override. */
|
|
47
|
+
export const ROLES = {
|
|
48
|
+
primaryColor: ["color", "primary"],
|
|
49
|
+
primary: ["color", "primary"],
|
|
50
|
+
primaryForeground: ["color", "on-primary"],
|
|
51
|
+
onPrimary: ["color", "on-primary"],
|
|
52
|
+
background: ["color", "background"],
|
|
53
|
+
scaffoldBackgroundColor: ["color", "background"],
|
|
54
|
+
cardColor: ["color", "surface"],
|
|
55
|
+
surfaceColor: ["color", "surface"],
|
|
56
|
+
surface: ["color", "surface"],
|
|
57
|
+
cardBorder: ["color", "border"],
|
|
58
|
+
borderColor: ["color", "border"],
|
|
59
|
+
foreground: ["color", "on-background"],
|
|
60
|
+
onSurface: ["color", "on-surface"],
|
|
61
|
+
mutedForeground: ["text", "caption-color"],
|
|
62
|
+
inputColor: ["color", "input"],
|
|
63
|
+
successColor: ["color", "success"],
|
|
64
|
+
amber: ["color", "warning"],
|
|
65
|
+
warningColor: ["color", "warning"],
|
|
66
|
+
destructive: ["color", "error"],
|
|
67
|
+
errorColor: ["color", "error"],
|
|
68
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Translated = {
|
|
2
|
+
consts: {
|
|
3
|
+
name: string;
|
|
4
|
+
value: number | string | boolean;
|
|
5
|
+
lines: [number, number];
|
|
6
|
+
}[];
|
|
7
|
+
functions: {
|
|
8
|
+
name: string;
|
|
9
|
+
params: string[];
|
|
10
|
+
cel: string;
|
|
11
|
+
lines: [number, number];
|
|
12
|
+
}[];
|
|
13
|
+
skipped: {
|
|
14
|
+
name: string;
|
|
15
|
+
why: string;
|
|
16
|
+
lines: [number, number];
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
/** The pure rules in a TypeScript module, as CEL, with what couldn't be translated and why. */
|
|
20
|
+
export declare function translateModule(source: string, file?: string): Translated;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
class Untranslatable extends Error {
|
|
3
|
+
}
|
|
4
|
+
const no = (why) => {
|
|
5
|
+
throw new Untranslatable(why);
|
|
6
|
+
};
|
|
7
|
+
const BINARY = {
|
|
8
|
+
[ts.SyntaxKind.PlusToken]: "+",
|
|
9
|
+
[ts.SyntaxKind.MinusToken]: "-",
|
|
10
|
+
[ts.SyntaxKind.AsteriskToken]: "*",
|
|
11
|
+
[ts.SyntaxKind.PercentToken]: "%",
|
|
12
|
+
[ts.SyntaxKind.LessThanToken]: "<",
|
|
13
|
+
[ts.SyntaxKind.LessThanEqualsToken]: "<=",
|
|
14
|
+
[ts.SyntaxKind.GreaterThanToken]: ">",
|
|
15
|
+
[ts.SyntaxKind.GreaterThanEqualsToken]: ">=",
|
|
16
|
+
[ts.SyntaxKind.EqualsEqualsEqualsToken]: "==",
|
|
17
|
+
[ts.SyntaxKind.EqualsEqualsToken]: "==",
|
|
18
|
+
[ts.SyntaxKind.ExclamationEqualsEqualsToken]: "!=",
|
|
19
|
+
[ts.SyntaxKind.ExclamationEqualsToken]: "!=",
|
|
20
|
+
[ts.SyntaxKind.AmpersandAmpersandToken]: "&&",
|
|
21
|
+
[ts.SyntaxKind.BarBarToken]: "||",
|
|
22
|
+
};
|
|
23
|
+
function expr(n, s) {
|
|
24
|
+
if (ts.isParenthesizedExpression(n))
|
|
25
|
+
return `(${expr(n.expression, s)})`;
|
|
26
|
+
if (ts.isNumericLiteral(n))
|
|
27
|
+
return n.text;
|
|
28
|
+
if (ts.isStringLiteral(n))
|
|
29
|
+
return JSON.stringify(n.text);
|
|
30
|
+
if (n.kind === ts.SyntaxKind.TrueKeyword)
|
|
31
|
+
return "true";
|
|
32
|
+
if (n.kind === ts.SyntaxKind.FalseKeyword)
|
|
33
|
+
return "false";
|
|
34
|
+
if (n.kind === ts.SyntaxKind.NullKeyword)
|
|
35
|
+
return "null";
|
|
36
|
+
if (ts.isIdentifier(n)) {
|
|
37
|
+
if (n.text === "undefined")
|
|
38
|
+
return "null";
|
|
39
|
+
if (s.fields.has(n.text))
|
|
40
|
+
return s.fields.get(n.text);
|
|
41
|
+
if (s.params.has(n.text) || s.locals.has(n.text) || s.known.has(n.text))
|
|
42
|
+
return n.text;
|
|
43
|
+
return no(`uses ${n.text}, which isn't a parameter or something in this file`);
|
|
44
|
+
}
|
|
45
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && s.params.has(n.expression.text))
|
|
46
|
+
return `${n.expression.text}.${n.name.text}`;
|
|
47
|
+
if (ts.isPrefixUnaryExpression(n)) {
|
|
48
|
+
if (n.operator === ts.SyntaxKind.ExclamationToken)
|
|
49
|
+
return `!${expr(n.operand, s)}`;
|
|
50
|
+
if (n.operator === ts.SyntaxKind.MinusToken)
|
|
51
|
+
return `-${expr(n.operand, s)}`;
|
|
52
|
+
return no("a ++ or -- changes a value");
|
|
53
|
+
}
|
|
54
|
+
if (ts.isBinaryExpression(n)) {
|
|
55
|
+
if (n.operatorToken.kind === ts.SyntaxKind.SlashToken)
|
|
56
|
+
return no("division: CEL divides whole numbers differently");
|
|
57
|
+
const op = BINARY[n.operatorToken.kind];
|
|
58
|
+
if (!op)
|
|
59
|
+
return no(`the ${ts.tokenToString(n.operatorToken.kind) ?? "operator"} operator`);
|
|
60
|
+
return `${expr(n.left, s)} ${op} ${expr(n.right, s)}`;
|
|
61
|
+
}
|
|
62
|
+
if (ts.isConditionalExpression(n))
|
|
63
|
+
return `${expr(n.condition, s)} ? ${expr(n.whenTrue, s)} : ${expr(n.whenFalse, s)}`;
|
|
64
|
+
if (ts.isCallExpression(n)) {
|
|
65
|
+
const args = n.arguments.map((a) => expr(a, s));
|
|
66
|
+
const callee = n.expression;
|
|
67
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "Math") {
|
|
68
|
+
const m = callee.name.text;
|
|
69
|
+
if ((m === "max" || m === "min") && args.length === 2) {
|
|
70
|
+
const [a, b] = args;
|
|
71
|
+
return `(${a} ${m === "max" ? ">" : "<"} ${b} ? ${a} : ${b})`;
|
|
72
|
+
}
|
|
73
|
+
return no(`Math.${m}`);
|
|
74
|
+
}
|
|
75
|
+
if (ts.isIdentifier(callee) && s.known.has(callee.text))
|
|
76
|
+
return `${callee.text}(${args.join(", ")})`;
|
|
77
|
+
return no(`calls ${callee.getText()}, which isn't in this file`);
|
|
78
|
+
}
|
|
79
|
+
return no(`a ${ts.SyntaxKind[n.kind]}`);
|
|
80
|
+
}
|
|
81
|
+
/** A function body as one CEL expression, built from its last statement back. */
|
|
82
|
+
function body(statements, s) {
|
|
83
|
+
const [first, ...rest] = statements;
|
|
84
|
+
if (!first)
|
|
85
|
+
return no("it can end without returning");
|
|
86
|
+
if (ts.isReturnStatement(first)) {
|
|
87
|
+
if (!first.expression)
|
|
88
|
+
return no("it returns nothing");
|
|
89
|
+
return expr(first.expression, s);
|
|
90
|
+
}
|
|
91
|
+
if (ts.isIfStatement(first)) {
|
|
92
|
+
const thenReturn = ts.isBlock(first.thenStatement) ? first.thenStatement.statements : [first.thenStatement];
|
|
93
|
+
if (first.elseStatement) {
|
|
94
|
+
const elseReturn = ts.isBlock(first.elseStatement) ? first.elseStatement.statements : [first.elseStatement];
|
|
95
|
+
return `${expr(first.expression, s)} ? ${body(thenReturn, s)} : ${body([...elseReturn, ...rest], s)}`;
|
|
96
|
+
}
|
|
97
|
+
return `${expr(first.expression, s)} ? ${body(thenReturn, s)} : ${body(rest, s)}`;
|
|
98
|
+
}
|
|
99
|
+
if (ts.isVariableStatement(first) && first.declarationList.flags & ts.NodeFlags.Const) {
|
|
100
|
+
const [d] = first.declarationList.declarations;
|
|
101
|
+
if (!d || first.declarationList.declarations.length !== 1 || !d.initializer)
|
|
102
|
+
return no("a declaration it can't follow");
|
|
103
|
+
// const { a, b } = opts; → a and b read opts.a and opts.b
|
|
104
|
+
if (ts.isObjectBindingPattern(d.name) && ts.isIdentifier(d.initializer) && s.params.has(d.initializer.text)) {
|
|
105
|
+
for (const el of d.name.elements) {
|
|
106
|
+
if (!ts.isIdentifier(el.name) || el.initializer || el.dotDotDotToken)
|
|
107
|
+
return no("a destructuring it can't follow");
|
|
108
|
+
const field = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
|
|
109
|
+
s.fields.set(el.name.text, `${d.initializer.text}.${field}`);
|
|
110
|
+
}
|
|
111
|
+
return body(rest, s);
|
|
112
|
+
}
|
|
113
|
+
if (!ts.isIdentifier(d.name))
|
|
114
|
+
return no("a declaration it can't follow");
|
|
115
|
+
const value = expr(d.initializer, s);
|
|
116
|
+
s.locals.add(d.name.text);
|
|
117
|
+
return `cel.bind(${d.name.text}, ${value}, ${body(rest, s)})`;
|
|
118
|
+
}
|
|
119
|
+
return no(`a ${ts.SyntaxKind[first.kind]} statement`);
|
|
120
|
+
}
|
|
121
|
+
const linesOf = (sf, n) => [
|
|
122
|
+
sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
|
|
123
|
+
sf.getLineAndCharacterOfPosition(n.getEnd()).line + 1,
|
|
124
|
+
];
|
|
125
|
+
/** The pure rules in a TypeScript module, as CEL, with what couldn't be translated and why. */
|
|
126
|
+
export function translateModule(source, file = "module.ts") {
|
|
127
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
128
|
+
const out = { consts: [], functions: [], skipped: [] };
|
|
129
|
+
const known = new Set();
|
|
130
|
+
for (const st of sf.statements) {
|
|
131
|
+
if (ts.isFunctionDeclaration(st) && st.name)
|
|
132
|
+
known.add(st.name.text);
|
|
133
|
+
if (ts.isVariableStatement(st))
|
|
134
|
+
for (const d of st.declarationList.declarations)
|
|
135
|
+
if (ts.isIdentifier(d.name))
|
|
136
|
+
known.add(d.name.text);
|
|
137
|
+
}
|
|
138
|
+
for (const st of sf.statements) {
|
|
139
|
+
if (ts.isVariableStatement(st) && st.declarationList.flags & ts.NodeFlags.Const) {
|
|
140
|
+
for (const d of st.declarationList.declarations) {
|
|
141
|
+
if (!ts.isIdentifier(d.name) || !d.initializer)
|
|
142
|
+
continue;
|
|
143
|
+
const i = d.initializer;
|
|
144
|
+
const literal = ts.isNumericLiteral(i)
|
|
145
|
+
? Number(i.text)
|
|
146
|
+
: ts.isStringLiteral(i)
|
|
147
|
+
? i.text
|
|
148
|
+
: i.kind === ts.SyntaxKind.TrueKeyword
|
|
149
|
+
? true
|
|
150
|
+
: i.kind === ts.SyntaxKind.FalseKeyword
|
|
151
|
+
? false
|
|
152
|
+
: undefined;
|
|
153
|
+
if (literal !== undefined)
|
|
154
|
+
out.consts.push({ name: d.name.text, value: literal, lines: linesOf(sf, st) });
|
|
155
|
+
else
|
|
156
|
+
out.skipped.push({ name: d.name.text, why: "a constant that isn't a plain value", lines: linesOf(sf, st) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (ts.isFunctionDeclaration(st) && st.name && st.body) {
|
|
160
|
+
const name = st.name.text;
|
|
161
|
+
try {
|
|
162
|
+
if (st.asteriskToken || st.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword))
|
|
163
|
+
no("it's async or a generator");
|
|
164
|
+
const params = st.parameters.map((p) => ts.isIdentifier(p.name) && !p.initializer && !p.dotDotDotToken ? p.name.text : no("a parameter with a default, rest or pattern"));
|
|
165
|
+
const scope = { params: new Set(params), fields: new Map(), locals: new Set(), known };
|
|
166
|
+
out.functions.push({ name, params, cel: body(st.body.statements, scope), lines: linesOf(sf, st) });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (!(e instanceof Untranslatable))
|
|
170
|
+
throw e;
|
|
171
|
+
out.skipped.push({ name, why: e.message, lines: linesOf(sf, st) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Vector = {
|
|
2
|
+
fn: string;
|
|
3
|
+
in: unknown[];
|
|
4
|
+
out: unknown;
|
|
5
|
+
line: number;
|
|
6
|
+
};
|
|
7
|
+
/** Literal vectors for `functions` found in a test file, and how many calls had to be left out. */
|
|
8
|
+
export declare function harvestVectors(source: string, functions: Set<string>, file?: string): {
|
|
9
|
+
vectors: Vector[];
|
|
10
|
+
leftOut: number;
|
|
11
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
function literal(n) {
|
|
3
|
+
if (ts.isParenthesizedExpression(n))
|
|
4
|
+
return literal(n.expression);
|
|
5
|
+
if (ts.isNumericLiteral(n))
|
|
6
|
+
return { ok: true, value: Number(n.text) };
|
|
7
|
+
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n))
|
|
8
|
+
return { ok: true, value: n.text };
|
|
9
|
+
if (n.kind === ts.SyntaxKind.TrueKeyword)
|
|
10
|
+
return { ok: true, value: true };
|
|
11
|
+
if (n.kind === ts.SyntaxKind.FalseKeyword)
|
|
12
|
+
return { ok: true, value: false };
|
|
13
|
+
if (n.kind === ts.SyntaxKind.NullKeyword)
|
|
14
|
+
return { ok: true, value: null };
|
|
15
|
+
if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(n.operand))
|
|
16
|
+
return { ok: true, value: -Number(n.operand.text) };
|
|
17
|
+
if (ts.isArrayLiteralExpression(n)) {
|
|
18
|
+
const items = n.elements.map((e) => literal(e));
|
|
19
|
+
return items.every((i) => i.ok) ? { ok: true, value: items.map((i) => i.value) } : { ok: false };
|
|
20
|
+
}
|
|
21
|
+
if (ts.isObjectLiteralExpression(n)) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const p of n.properties) {
|
|
24
|
+
if (!ts.isPropertyAssignment(p) || !(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)))
|
|
25
|
+
return { ok: false };
|
|
26
|
+
const v = literal(p.initializer);
|
|
27
|
+
if (!v.ok)
|
|
28
|
+
return { ok: false };
|
|
29
|
+
out[p.name.text] = v.value;
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, value: out };
|
|
32
|
+
}
|
|
33
|
+
return { ok: false };
|
|
34
|
+
}
|
|
35
|
+
const inLoop = (n) => {
|
|
36
|
+
for (let p = n.parent; p; p = p.parent)
|
|
37
|
+
if (ts.isForStatement(p) || ts.isForOfStatement(p) || ts.isForInStatement(p) || ts.isWhileStatement(p) || ts.isDoStatement(p))
|
|
38
|
+
return true;
|
|
39
|
+
return false;
|
|
40
|
+
};
|
|
41
|
+
/** Literal vectors for `functions` found in a test file, and how many calls had to be left out. */
|
|
42
|
+
export function harvestVectors(source, functions, file = "test.ts") {
|
|
43
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
44
|
+
const vectors = [];
|
|
45
|
+
let leftOut = 0;
|
|
46
|
+
const visit = (n) => {
|
|
47
|
+
if (ts.isCallExpression(n)) {
|
|
48
|
+
let actual;
|
|
49
|
+
let expected;
|
|
50
|
+
const callee = n.expression;
|
|
51
|
+
// assert.equal(actual, expected) and friends
|
|
52
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
53
|
+
ts.isIdentifier(callee.expression) &&
|
|
54
|
+
callee.expression.text === "assert" &&
|
|
55
|
+
/^(equal|strictEqual|deepEqual|deepStrictEqual)$/.test(callee.name.text)) {
|
|
56
|
+
[actual, expected] = n.arguments;
|
|
57
|
+
}
|
|
58
|
+
// expect(actual).toBe(expected) / toEqual / toStrictEqual
|
|
59
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
60
|
+
/^(toBe|toEqual|toStrictEqual)$/.test(callee.name.text) &&
|
|
61
|
+
ts.isCallExpression(callee.expression) &&
|
|
62
|
+
ts.isIdentifier(callee.expression.expression) &&
|
|
63
|
+
callee.expression.expression.text === "expect") {
|
|
64
|
+
actual = callee.expression.arguments[0];
|
|
65
|
+
expected = n.arguments[0];
|
|
66
|
+
}
|
|
67
|
+
if (actual && expected && ts.isCallExpression(actual) && ts.isIdentifier(actual.expression) && functions.has(actual.expression.text)) {
|
|
68
|
+
const args = actual.arguments.map(literal);
|
|
69
|
+
const out = literal(expected);
|
|
70
|
+
if (!inLoop(n) && args.every((a) => a.ok) && out.ok) {
|
|
71
|
+
vectors.push({
|
|
72
|
+
fn: actual.expression.text,
|
|
73
|
+
in: args.map((a) => a.value),
|
|
74
|
+
out: out.value,
|
|
75
|
+
line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
leftOut++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
ts.forEachChild(n, visit);
|
|
83
|
+
};
|
|
84
|
+
visit(sf);
|
|
85
|
+
return { vectors, leftOut };
|
|
86
|
+
}
|
package/dist/shingles.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The second leak gate, against the partner's own source: no string in a spec
|
|
2
|
+
// may contain a run of 8 consecutive tokens (words, numbers, punctuation) that
|
|
3
|
+
// also appears in a source file it was read from. The static scan in app-spec
|
|
4
|
+
// catches code by its shape; this catches it by being copied, whatever its
|
|
5
|
+
// shape. CEL rules are exempt: they're translated from the source by design and
|
|
6
|
+
// checked by vectors instead. A2UI bindings (${/path}) are the standard's syntax.
|
|
7
|
+
const SIZE = 8;
|
|
8
|
+
const tokens = (s) => s.match(/[A-Za-z_$][\w$]*|\d+(?:\.\d+)?|[^\s\w]/g) ?? [];
|
|
9
|
+
function shinglesOf(text) {
|
|
10
|
+
const out = new Set();
|
|
11
|
+
const t = tokens(text);
|
|
12
|
+
for (let i = 0; i + SIZE <= t.length; i++)
|
|
13
|
+
out.add(t.slice(i, i + SIZE).join(" "));
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
const CEL_AT = [/^\/logic\/functions\/[^/]+\/cel$/, /^\/logic\/computed\/.+$/, /^\/data\/migrations\/\d+\/cel$/];
|
|
17
|
+
const escape = (k) => k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
18
|
+
function* strings(v, at = "") {
|
|
19
|
+
if (typeof v === "string")
|
|
20
|
+
yield [at, v];
|
|
21
|
+
else if (Array.isArray(v))
|
|
22
|
+
for (let i = 0; i < v.length; i++)
|
|
23
|
+
yield* strings(v[i], `${at}/${i}`);
|
|
24
|
+
else if (v && typeof v === "object")
|
|
25
|
+
for (const [k, x] of Object.entries(v))
|
|
26
|
+
yield* strings(x, `${at}/${escape(k)}`);
|
|
27
|
+
}
|
|
28
|
+
/** Strings in `spec` that repeat 8 tokens running from any of `sources`. */
|
|
29
|
+
export function shingleLeaks(spec, sources) {
|
|
30
|
+
const known = new Set();
|
|
31
|
+
for (const s of sources)
|
|
32
|
+
for (const sh of shinglesOf(s))
|
|
33
|
+
known.add(sh);
|
|
34
|
+
const problems = [];
|
|
35
|
+
for (const [at, raw] of strings(spec)) {
|
|
36
|
+
if (CEL_AT.some((r) => r.test(at)))
|
|
37
|
+
continue;
|
|
38
|
+
const text = raw.replace(/\$\{[^}]*\}/g, " ");
|
|
39
|
+
for (const sh of shinglesOf(text))
|
|
40
|
+
if (known.has(sh)) {
|
|
41
|
+
problems.push({ at, problem: `repeats the source word for word ("${sh.slice(0, 60)}")` });
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudcrane/blackbox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"cloudcrane",
|
|
7
|
+
"blackbox",
|
|
8
|
+
"app-spec",
|
|
9
|
+
"port",
|
|
10
|
+
"cli"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/cloudcrane-dev/blackbox/tree/main/packages/blackbox#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/cloudcrane-dev/blackbox/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/cloudcrane-dev/blackbox.git",
|
|
19
|
+
"directory": "packages/blackbox"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Chirag Phor",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.5"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"blackbox": "./dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"typescript": "~5.9.3",
|
|
44
|
+
"veridic": "^0.14.0",
|
|
45
|
+
"zod": "^3.25.76",
|
|
46
|
+
"@cloudcrane/app-runtime": "^0.1.0",
|
|
47
|
+
"@cloudcrane/app-spec": "^0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"tsx": "4.23.15"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
59
|
+
}
|
|
6
60
|
}
|