@fairfox/polly 0.77.3 → 0.79.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/polly.js +46 -3
- package/dist/cli/polly.js.map +3 -3
- package/dist/src/background/index.js.map +3 -3
- package/dist/src/background/message-router.js.map +3 -3
- package/dist/src/client/index.js +137 -32
- package/dist/src/client/index.js.map +6 -5
- package/dist/src/client/wrapper.d.ts +39 -2
- package/dist/src/elysia/index.js +22 -3
- package/dist/src/elysia/index.js.map +5 -5
- package/dist/src/elysia/route-match.d.ts +13 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +12 -2
- package/dist/src/index.js.map +7 -7
- package/dist/src/mesh.js +87 -46
- package/dist/src/mesh.js.map +12 -11
- package/dist/src/peer.js +7 -3
- package/dist/src/peer.js.map +6 -6
- package/dist/src/polly-ui/Badge.d.ts +5 -0
- package/dist/src/polly-ui/Button.d.ts +31 -6
- package/dist/src/polly-ui/Dropdown.d.ts +6 -0
- package/dist/src/polly-ui/Select.d.ts +11 -1
- package/dist/src/polly-ui/TextInput.d.ts +30 -0
- package/dist/src/polly-ui/index.css +10 -0
- package/dist/src/polly-ui/index.js +81 -32
- package/dist/src/polly-ui/index.js.map +10 -10
- package/dist/src/polly-ui/styles.css +10 -0
- package/dist/src/shared/adapters/index.js.map +3 -3
- package/dist/src/shared/lib/context-helpers.js.map +3 -3
- package/dist/src/shared/lib/mesh-client.d.ts +38 -0
- package/dist/src/shared/lib/mesh-signaling-client.d.ts +6 -5
- package/dist/src/shared/lib/mesh-state.d.ts +21 -0
- package/dist/src/shared/lib/message-bus.js.map +3 -3
- package/dist/src/shared/lib/peer-relay-adapter.d.ts +5 -0
- package/dist/src/shared/lib/peer-repo-server.d.ts +15 -0
- package/dist/src/shared/lib/resource.js +11 -2
- package/dist/src/shared/lib/resource.js.map +6 -6
- package/dist/src/shared/lib/state.d.ts +20 -0
- package/dist/src/shared/lib/state.js +11 -1
- package/dist/src/shared/lib/state.js.map +5 -5
- package/dist/src/shared/state/app-state.js +10 -1
- package/dist/src/shared/state/app-state.js.map +5 -5
- package/dist/tools/init/src/cli.js +23 -2
- package/dist/tools/init/src/cli.js.map +4 -4
- package/dist/tools/init/templates/pwa/package.json.template +1 -1
- package/dist/tools/init/templates/pwa/src/service-worker.ts.template +26 -15
- package/dist/tools/init/templates/pwa/src/shared-worker.ts.template +13 -3
- package/dist/tools/init/templates/pwa/tsconfig.json.template +2 -2
- package/dist/tools/init/templates/pwa/tsconfig.worker.json.template +17 -0
- package/dist/tools/test/src/browser/index.js +5 -2
- package/dist/tools/test/src/browser/index.js.map +3 -3
- package/dist/tools/test/src/contrast/index.js +20 -15
- package/dist/tools/test/src/contrast/index.js.map +3 -3
- package/dist/tools/test/src/e2e-cli/index.d.ts +10 -0
- package/dist/tools/test/src/e2e-cli/run-cli.d.ts +25 -0
- package/dist/tools/test/src/e2e-cli/with-temp-dir.d.ts +15 -0
- package/dist/tools/test/src/e2e-mesh/index.js +12 -7
- package/dist/tools/test/src/e2e-mesh/index.js.map +4 -4
- package/dist/tools/test/src/e2e-mesh/launch-peer.d.ts +7 -1
- package/dist/tools/test/src/e2e-relay/index.d.ts +12 -0
- package/dist/tools/test/src/e2e-relay/wait-for-relay-convergence.d.ts +27 -0
- package/dist/tools/test/src/e2e-relay/with-repo-server.d.ts +24 -0
- package/dist/tools/test/src/e2e-shared/assert.d.ts +18 -0
- package/dist/tools/test/src/e2e-shared/contract.d.ts +40 -0
- package/dist/tools/test/src/e2e-shared/index.d.ts +2 -0
- package/dist/tools/test/src/tiers/args.d.ts +23 -0
- package/dist/tools/test/src/tiers/cli.d.ts +2 -0
- package/dist/tools/test/src/tiers/cli.js +490 -0
- package/dist/tools/test/src/tiers/cli.js.map +16 -0
- package/dist/tools/test/src/tiers/detect.d.ts +12 -0
- package/dist/tools/test/src/tiers/discover.d.ts +2 -0
- package/dist/tools/test/src/tiers/engine.d.ts +3 -0
- package/dist/tools/test/src/tiers/index.d.ts +14 -0
- package/dist/tools/test/src/tiers/protocol.d.ts +10 -0
- package/dist/tools/test/src/tiers/reporter.d.ts +12 -0
- package/dist/tools/test/src/tiers/types.d.ts +94 -0
- package/dist/tools/test/src/tiers/worker.d.ts +2 -0
- package/dist/tools/test/src/tiers/worker.js +60 -0
- package/dist/tools/test/src/tiers/worker.js.map +12 -0
- package/dist/tools/verify/src/cli.js +322 -27
- package/dist/tools/verify/src/cli.js.map +13 -10
- package/dist/tools/verify/src/config.d.ts +10 -0
- package/dist/tools/verify/src/config.js.map +2 -2
- package/dist/tools/verify/src/stryker/index.js +20 -11
- package/dist/tools/verify/src/stryker/index.js.map +3 -3
- package/dist/tools/visualize/src/cli.js +8 -5
- package/dist/tools/visualize/src/cli.js.map +4 -4
- package/package.json +16 -6
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest types for the tiered test engine.
|
|
3
|
+
*
|
|
4
|
+
* The engine is deliberately pure: it knows how to *run* a plan of cases as
|
|
5
|
+
* isolated subprocesses, gate them on environment capabilities, time them, and
|
|
6
|
+
* report. It knows nothing about Polly's specific scripts — those are supplied
|
|
7
|
+
* as a {@link TierPlan} by a front-end (Polly's internal registry, or a
|
|
8
|
+
* consumer-discovery step behind `polly test`). This keeps the engine reusable
|
|
9
|
+
* and respects the repo's src → tools → cli → scripts import boundary.
|
|
10
|
+
*/
|
|
11
|
+
/** A capability a case needs from the host before it can run. */
|
|
12
|
+
export type Need = "docker" | "browser" | "network";
|
|
13
|
+
/** How a case is executed. Both forms run in their own subprocess. */
|
|
14
|
+
export type CaseExec = {
|
|
15
|
+
/**
|
|
16
|
+
* A module that may `export async function run(ctx)`. The worker imports
|
|
17
|
+
* it; if `run` is exported it is called for a structured result,
|
|
18
|
+
* otherwise the module's import side-effect (legacy top-level `main()`)
|
|
19
|
+
* runs and its `process.exitCode` becomes the result. This is what lets
|
|
20
|
+
* converted and unconverted scripts coexist.
|
|
21
|
+
*/
|
|
22
|
+
kind: "module";
|
|
23
|
+
modulePath: string;
|
|
24
|
+
} | {
|
|
25
|
+
/** An arbitrary command; success is exit code 0. */
|
|
26
|
+
kind: "command";
|
|
27
|
+
argv: string[];
|
|
28
|
+
cwd?: string;
|
|
29
|
+
};
|
|
30
|
+
/** One runnable unit within a tier. */
|
|
31
|
+
export interface CaseSpec {
|
|
32
|
+
/** Stable id, e.g. "mesh.blob-transfer". Used for --only matching. */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Human label for reports; defaults to id. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** Free-form tags for --only filtering (e.g. "mesh", "revocation"). */
|
|
37
|
+
tags?: string[];
|
|
38
|
+
/** Host capabilities required; unmet → skipped with a logged reason. */
|
|
39
|
+
needs?: Need[];
|
|
40
|
+
/** Per-case timeout. Defaults to the engine's tier default. */
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
exec: CaseExec;
|
|
43
|
+
}
|
|
44
|
+
/** An ordered group of cases sharing a realism level. */
|
|
45
|
+
export interface Tier {
|
|
46
|
+
name: string;
|
|
47
|
+
/** One-line description shown by --list. */
|
|
48
|
+
description?: string;
|
|
49
|
+
/** Run cases in this tier concurrently up to this many at once. Default 1. */
|
|
50
|
+
concurrency?: number;
|
|
51
|
+
/** Default per-case timeout for this tier. */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
cases: CaseSpec[];
|
|
54
|
+
}
|
|
55
|
+
/** The full thing handed to the engine. Tiers run in array order. */
|
|
56
|
+
export interface TierPlan {
|
|
57
|
+
tiers: Tier[];
|
|
58
|
+
}
|
|
59
|
+
export type CaseOutcome = "pass" | "fail" | "skip" | "timeout";
|
|
60
|
+
export interface CaseReport {
|
|
61
|
+
tier: string;
|
|
62
|
+
id: string;
|
|
63
|
+
label: string;
|
|
64
|
+
outcome: CaseOutcome;
|
|
65
|
+
durationMs: number;
|
|
66
|
+
message?: string;
|
|
67
|
+
/** Reason a case was skipped (unmet need). */
|
|
68
|
+
skipReason?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface RunReport {
|
|
71
|
+
cases: CaseReport[];
|
|
72
|
+
passed: number;
|
|
73
|
+
failed: number;
|
|
74
|
+
skipped: number;
|
|
75
|
+
durationMs: number;
|
|
76
|
+
ok: boolean;
|
|
77
|
+
}
|
|
78
|
+
/** Options controlling a single engine run. */
|
|
79
|
+
export interface EngineOptions {
|
|
80
|
+
/** Only run these tiers (by name). Empty = all tiers in the plan. */
|
|
81
|
+
tiers?: string[];
|
|
82
|
+
/** Only run cases whose id/label/tags match one of these substrings. */
|
|
83
|
+
only?: string[];
|
|
84
|
+
/** Stop after the first tier that has a failure. */
|
|
85
|
+
bail?: boolean;
|
|
86
|
+
/** Treat unmet-need skips as failures (CI-strict). Default false. */
|
|
87
|
+
strictNeeds?: boolean;
|
|
88
|
+
/** Forwarded into each case's environment. */
|
|
89
|
+
env?: Record<string, string | undefined>;
|
|
90
|
+
/** Default working directory for cases that don't set their own. */
|
|
91
|
+
cwd?: string;
|
|
92
|
+
/** Sink for engine-level progress lines. Defaults to console.log. */
|
|
93
|
+
log?: (message: string) => void;
|
|
94
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// tools/test/src/e2e-shared/contract.ts
|
|
21
|
+
function standaloneContext() {
|
|
22
|
+
return {
|
|
23
|
+
log: (message) => console.log(message),
|
|
24
|
+
env: process.env
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// tools/test/src/tiers/protocol.ts
|
|
29
|
+
var SENTINEL = "__TIER_RESULT__";
|
|
30
|
+
|
|
31
|
+
// tools/test/src/tiers/worker.ts
|
|
32
|
+
function emit(result) {
|
|
33
|
+
process.stdout.write(`
|
|
34
|
+
${SENTINEL}${JSON.stringify(result)}
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
async function runWorker() {
|
|
38
|
+
const modulePath = process.argv[2];
|
|
39
|
+
if (!modulePath) {
|
|
40
|
+
emit({ pass: false, message: "worker: no module path given" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import(modulePath);
|
|
45
|
+
if (typeof mod.run === "function") {
|
|
46
|
+
const result = await mod.run(standaloneContext());
|
|
47
|
+
emit({ pass: Boolean(result?.pass), message: result?.message, detail: result?.detail });
|
|
48
|
+
} else {
|
|
49
|
+
emit({ pass: (process.exitCode ?? 0) === 0 });
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
emit({ pass: false, message: err instanceof Error ? err.message : String(err) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (__require.main == __require.module) {
|
|
56
|
+
await runWorker();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//# debugId=0FA2FEFC484C1D5964756E2164756E21
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../tools/test/src/e2e-shared/contract.ts", "../tools/test/src/tiers/protocol.ts", "../tools/test/src/tiers/worker.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * The contract every tiered test case speaks.\n *\n * An e2e script is \"tier-aware\" when it exports an async `run(ctx)` that\n * returns a {@link TierResult} instead of signalling success through\n * `process.exitCode`. The engine prefers this export so it can collect a\n * structured result; scripts that do not export it still run (the engine\n * falls back to executing the file and reading its exit code), so the\n * migration is incremental and never breaks the suite.\n *\n * Standalone execution is preserved: a converted script keeps a\n * `if (import.meta.main) selfRun(run)` footer so `bun scripts/e2e-foo.ts`\n * behaves exactly as before.\n */\n\n/** Logger handed to a case so its output can be captured/prefixed by the engine. */\nexport type TierLog = (message: string) => void;\n\n/** Context passed to a case's `run()`. Kept small and forward-compatible. */\nexport interface TierContext {\n /** Emit a progress line. Defaults to `console.log` when run standalone. */\n log: TierLog;\n /** Extra environment the engine wants the case to honour (e.g. SIGNALING_URL). */\n env: Record<string, string | undefined>;\n}\n\n/** What a case reports back. `pass: false` with a `message` is a clean failure. */\nexport interface TierResult {\n pass: boolean;\n /** Human-readable failure reason; omitted on success. */\n message?: string;\n /** Optional structured detail surfaced in `--json` output. */\n detail?: Record<string, unknown>;\n}\n\nexport type TierRun = (ctx: TierContext) => Promise<TierResult>;\n\n/** Build a default context for standalone (`import.meta.main`) execution. */\nexport function standaloneContext(): TierContext {\n return {\n log: (message: string) => console.log(message),\n env: process.env,\n };\n}\n\n/**\n * Footer helper for converted scripts: runs the case standalone, prints a\n * PASS/FAIL line in the historical `[e2e] <capability>: PASS` format, and sets\n * the process exit code. Keeps the file runnable on its own.\n */\nexport async function selfRun(capability: string, run: TierRun): Promise<void> {\n const ctx = standaloneContext();\n try {\n const result = await run(ctx);\n if (result.pass) {\n ctx.log(`[e2e] ${capability}: PASS`);\n } else {\n ctx.log(`[e2e] ${capability}: FAIL — ${result.message ?? \"no reason given\"}`);\n process.exitCode = 1;\n }\n } catch (err) {\n ctx.log(`[e2e] ${capability}: FAIL — ${err instanceof Error ? err.message : String(err)}`);\n process.exitCode = 1;\n }\n}\n",
|
|
6
|
+
"/**\n * The one shared constant between the engine and its subprocess worker.\n *\n * It lives in its own leaf module so the engine never has to *import* the\n * worker (only spawn it by path). If the engine imported the worker, a\n * `splitting: false` bundle would inline the worker's `import.meta.main`\n * self-exec block into the CLI bundle and run it on startup. Keeping the\n * sentinel here avoids that.\n */\nexport const SENTINEL = \"__TIER_RESULT__\";\n",
|
|
7
|
+
"#!/usr/bin/env bun\n/**\n * Subprocess entry for a single \"module\" case.\n *\n * Spawned by the engine as `bun worker.ts <modulePath> <id>`. Running each case\n * in its own process is deliberate: the e2e scripts mutate globals (happy-dom),\n * bind ephemeral ports, and drive Puppeteer — they were written assuming\n * process isolation, and the engine preserves it.\n *\n * Protocol: the worker prints exactly one sentinel line\n * __TIER_RESULT__{\"pass\":true,...}\n * to stdout. Everything else on stdout is the case's own logging, forwarded by\n * the parent. If the case hard-exits (legacy `process.exit(1)`) before the\n * sentinel, the parent falls back to the worker's exit code.\n */\nimport { standaloneContext, type TierResult } from \"../e2e-shared/contract\";\nimport { SENTINEL } from \"./protocol\";\n\nfunction emit(result: TierResult): void {\n process.stdout.write(`\\n${SENTINEL}${JSON.stringify(result)}\\n`);\n}\n\nasync function runWorker(): Promise<void> {\n const modulePath = process.argv[2];\n if (!modulePath) {\n emit({ pass: false, message: \"worker: no module path given\" });\n return;\n }\n\n try {\n // Importing a converted script is side-effect-free (its self-run footer is\n // guarded by import.meta.main). Importing a legacy script runs its top-level\n // main() now, which sets process.exitCode.\n const mod: { run?: (ctx: unknown) => Promise<TierResult> } = await import(modulePath);\n\n if (typeof mod.run === \"function\") {\n const result = await mod.run(standaloneContext());\n emit({ pass: Boolean(result?.pass), message: result?.message, detail: result?.detail });\n } else {\n // Legacy: the import side-effect already ran the test; trust its exit code.\n emit({ pass: (process.exitCode ?? 0) === 0 });\n }\n } catch (err) {\n emit({ pass: false, message: err instanceof Error ? err.message : String(err) });\n }\n}\n\n// Only execute when invoked directly; the engine also imports SENTINEL from here.\nif (import.meta.main) {\n await runWorker();\n // Force exit so an open Puppeteer/server handle can't keep the worker alive;\n // the result has already been emitted and captured by the parent.\n process.exit(0);\n}\n"
|
|
8
|
+
],
|
|
9
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;AAsCO,SAAS,iBAAiB,GAAgB;AAAA,EAC/C,OAAO;AAAA,IACL,KAAK,CAAC,YAAoB,QAAQ,IAAI,OAAO;AAAA,IAC7C,KAAK,QAAQ;AAAA,EACf;AAAA;;;ACjCK,IAAM,WAAW;;;ACSxB,SAAS,IAAI,CAAC,QAA0B;AAAA,EACtC,QAAQ,OAAO,MAAM;AAAA,EAAK,WAAW,KAAK,UAAU,MAAM;AAAA,CAAK;AAAA;AAGjE,eAAe,SAAS,GAAkB;AAAA,EACxC,MAAM,aAAa,QAAQ,KAAK;AAAA,EAChC,IAAI,CAAC,YAAY;AAAA,IACf,KAAK,EAAE,MAAM,OAAO,SAAS,+BAA+B,CAAC;AAAA,IAC7D;AAAA,EACF;AAAA,EAEA,IAAI;AAAA,IAIF,MAAM,MAAuD,MAAa;AAAA,IAE1E,IAAI,OAAO,IAAI,QAAQ,YAAY;AAAA,MACjC,MAAM,SAAS,MAAM,IAAI,IAAI,kBAAkB,CAAC;AAAA,MAChD,KAAK,EAAE,MAAM,QAAQ,QAAQ,IAAI,GAAG,SAAS,QAAQ,SAAS,QAAQ,QAAQ,OAAO,CAAC;AAAA,IACxF,EAAO;AAAA,MAEL,KAAK,EAAE,OAAO,QAAQ,YAAY,OAAO,EAAE,CAAC;AAAA;AAAA,IAE9C,OAAO,KAAK;AAAA,IACZ,KAAK,EAAE,MAAM,OAAO,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE,CAAC;AAAA;AAAA;AAKnF,IAAI,oCAAkB;AAAA,EACpB,MAAM,UAAU;AAAA,EAGhB,QAAQ,KAAK,CAAC;AAChB;",
|
|
10
|
+
"debugId": "0FA2FEFC484C1D5964756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
|
@@ -249,6 +249,110 @@ var init_expression_validator = __esm(() => {
|
|
|
249
249
|
WEAK_NEGATION = /([a-zA-Z_][\w.]*(?:\.value\.[\w.]+|\.value\b|))?\s*!==?\s*("[^"]*"|'[^']*'|\d+|true|false|null)/;
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
// tools/verify/src/analysis/model-coverage.ts
|
|
253
|
+
var exports_model_coverage = {};
|
|
254
|
+
__export(exports_model_coverage, {
|
|
255
|
+
strictCoverageReasons: () => strictCoverageReasons,
|
|
256
|
+
computeModelCoverage: () => computeModelCoverage
|
|
257
|
+
});
|
|
258
|
+
function norm(field) {
|
|
259
|
+
return field.replace(/_/g, ".");
|
|
260
|
+
}
|
|
261
|
+
function computeModelCoverage(stateFields, handlers) {
|
|
262
|
+
const writersByField = new Map;
|
|
263
|
+
for (const field of stateFields) {
|
|
264
|
+
writersByField.set(norm(field), new Set);
|
|
265
|
+
}
|
|
266
|
+
const declaredOrder = stateFields.map((f) => ({ raw: f, key: norm(f) }));
|
|
267
|
+
for (const handler of handlers) {
|
|
268
|
+
for (const assignment of handler.assignments) {
|
|
269
|
+
const key = norm(assignment.field);
|
|
270
|
+
const writers = writersByField.get(key);
|
|
271
|
+
if (writers)
|
|
272
|
+
writers.add(handler.messageType);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const fieldCoverage = declaredOrder.map(({ raw, key }) => ({
|
|
276
|
+
field: raw,
|
|
277
|
+
writers: Array.from(writersByField.get(key) ?? []).sort()
|
|
278
|
+
}));
|
|
279
|
+
const unwrittenFields = fieldCoverage.filter((f) => f.writers.length === 0).map((f) => f.field);
|
|
280
|
+
const declaredKeys = new Set(declaredOrder.map((d) => d.key));
|
|
281
|
+
const unconstrainedMutators = [];
|
|
282
|
+
for (const handler of handlers) {
|
|
283
|
+
if (handler.postconditions.length > 0)
|
|
284
|
+
continue;
|
|
285
|
+
const fields = Array.from(new Set(handler.assignments.map((a) => norm(a.field)).filter((f) => declaredKeys.has(f)))).sort();
|
|
286
|
+
if (fields.length > 0) {
|
|
287
|
+
unconstrainedMutators.push({
|
|
288
|
+
handler: handler.messageType,
|
|
289
|
+
fields,
|
|
290
|
+
location: handler.location
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
fieldCoverage,
|
|
296
|
+
unwrittenFields,
|
|
297
|
+
unconstrainedMutators,
|
|
298
|
+
hasStrictViolation: unwrittenFields.length > 0
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function strictCoverageReasons(report, meshFindingCount) {
|
|
302
|
+
const reasons = [];
|
|
303
|
+
if (report.hasStrictViolation) {
|
|
304
|
+
reasons.push(`${report.unwrittenFields.length} declared state field(s) written by no modelled handler`);
|
|
305
|
+
}
|
|
306
|
+
if (meshFindingCount > 0) {
|
|
307
|
+
reasons.push(`${meshFindingCount} unverified $meshState/$peerState predicate(s)`);
|
|
308
|
+
}
|
|
309
|
+
return reasons;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// tools/verify/src/analysis/coupled-fields.ts
|
|
313
|
+
var exports_coupled_fields = {};
|
|
314
|
+
__export(exports_coupled_fields, {
|
|
315
|
+
checkCoupledFields: () => checkCoupledFields
|
|
316
|
+
});
|
|
317
|
+
function norm2(field) {
|
|
318
|
+
return field.replace(/_/g, ".");
|
|
319
|
+
}
|
|
320
|
+
function checkCoupledFields(coupledFields, handlers) {
|
|
321
|
+
const violations = [];
|
|
322
|
+
for (const rawGroup of coupledFields) {
|
|
323
|
+
const group = rawGroup.map(norm2);
|
|
324
|
+
const groupSet = new Set(group);
|
|
325
|
+
if (groupSet.size < 2)
|
|
326
|
+
continue;
|
|
327
|
+
for (const handler of handlers) {
|
|
328
|
+
const violation = subsetViolation(group, groupSet, handler);
|
|
329
|
+
if (violation)
|
|
330
|
+
violations.push(violation);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { valid: violations.length === 0, violations };
|
|
334
|
+
}
|
|
335
|
+
function writtenFields(group, handler) {
|
|
336
|
+
const written = new Set;
|
|
337
|
+
for (const assignment of handler.assignments) {
|
|
338
|
+
const field = norm2(assignment.field);
|
|
339
|
+
if (group.has(field))
|
|
340
|
+
written.add(field);
|
|
341
|
+
}
|
|
342
|
+
return written;
|
|
343
|
+
}
|
|
344
|
+
function subsetViolation(group, groupSet, handler) {
|
|
345
|
+
const written = writtenFields(groupSet, handler);
|
|
346
|
+
if (written.size === 0 || written.size === groupSet.size)
|
|
347
|
+
return null;
|
|
348
|
+
return {
|
|
349
|
+
group,
|
|
350
|
+
handler: handler.messageType,
|
|
351
|
+
written: group.filter((f) => written.has(f)),
|
|
352
|
+
missing: group.filter((f) => !written.has(f))
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
252
356
|
// tools/verify/src/analysis/non-interference.ts
|
|
253
357
|
var exports_non_interference = {};
|
|
254
358
|
__export(exports_non_interference, {
|
|
@@ -1253,28 +1357,35 @@ var init_tla = __esm(() => {
|
|
|
1253
1357
|
return key.replace(/[^A-Za-z0-9_]/g, "_");
|
|
1254
1358
|
}
|
|
1255
1359
|
fieldConfigInitialValue(_path, fieldConfig, _config) {
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
if (
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
return
|
|
1360
|
+
return this.typedFieldInitialValue(fieldConfig) ?? this.legacyValuesInitialValue(fieldConfig) ?? '"v1"';
|
|
1361
|
+
}
|
|
1362
|
+
typedFieldInitialValue(fieldConfig) {
|
|
1363
|
+
if (!("type" in fieldConfig))
|
|
1364
|
+
return;
|
|
1365
|
+
switch (fieldConfig.type) {
|
|
1366
|
+
case "boolean":
|
|
1367
|
+
return "FALSE";
|
|
1368
|
+
case "number":
|
|
1369
|
+
return String(typeof fieldConfig.min === "number" ? fieldConfig.min : 0);
|
|
1370
|
+
case "enum": {
|
|
1371
|
+
const values = fieldConfig.values;
|
|
1372
|
+
if (Array.isArray(values) && values.length > 0)
|
|
1373
|
+
return `"${String(values[0])}"`;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
case "string":
|
|
1377
|
+
return typeof fieldConfig.initial === "string" ? `"${fieldConfig.initial}"` : '""';
|
|
1378
|
+
case "array":
|
|
1379
|
+
return "<<>>";
|
|
1380
|
+
default:
|
|
1381
|
+
return;
|
|
1276
1382
|
}
|
|
1277
|
-
|
|
1383
|
+
}
|
|
1384
|
+
legacyValuesInitialValue(fieldConfig) {
|
|
1385
|
+
if (!("values" in fieldConfig) || !Array.isArray(fieldConfig.values))
|
|
1386
|
+
return;
|
|
1387
|
+
const [first] = fieldConfig.values;
|
|
1388
|
+
return first === undefined ? undefined : `"${first}"`;
|
|
1278
1389
|
}
|
|
1279
1390
|
defineValueTypes() {
|
|
1280
1391
|
this.line("\\* Generic value type for sequences and maps");
|
|
@@ -2678,6 +2789,9 @@ var init_tla = __esm(() => {
|
|
|
2678
2789
|
this.indent--;
|
|
2679
2790
|
this.indent--;
|
|
2680
2791
|
this.line("");
|
|
2792
|
+
if (config.capabilities && config.capabilities.length > 0) {
|
|
2793
|
+
this.addCapabilityInvariants(config.capabilities);
|
|
2794
|
+
}
|
|
2681
2795
|
if (this.extractedInvariants.length > 0) {
|
|
2682
2796
|
this.line("\\* Extracted invariants from code annotations");
|
|
2683
2797
|
this.line("");
|
|
@@ -2694,6 +2808,15 @@ var init_tla = __esm(() => {
|
|
|
2694
2808
|
this.line("\\* State constraint to bound state space");
|
|
2695
2809
|
this.addStateConstraint(config, _analysis);
|
|
2696
2810
|
}
|
|
2811
|
+
addCapabilityInvariants(capabilities) {
|
|
2812
|
+
for (const cap of capabilities) {
|
|
2813
|
+
this.extractedInvariants.push({
|
|
2814
|
+
name: `Capability_${cap.name}`,
|
|
2815
|
+
description: cap.message ? `polly#160 capability: ${cap.message}` : `polly#160 capability: ${cap.name} requires its precondition`,
|
|
2816
|
+
expression: `(!(${cap.enabledBy})) || (${cap.requires})`
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2697
2820
|
addTemporalConstraints(constraints) {
|
|
2698
2821
|
this.line("\\* Tier 2: Temporal constraint invariants");
|
|
2699
2822
|
this.line("\\* Enforce ordering requirements between message types");
|
|
@@ -3910,6 +4033,85 @@ function generateConfig(analysis, projectType = "chrome-extension") {
|
|
|
3910
4033
|
// tools/verify/src/config/parser.ts
|
|
3911
4034
|
import * as fs from "node:fs";
|
|
3912
4035
|
import * as path from "node:path";
|
|
4036
|
+
|
|
4037
|
+
// tools/verify/src/config/capability-validation.ts
|
|
4038
|
+
init_expression_validator();
|
|
4039
|
+
function validateCapabilities(capabilities, stateConfig) {
|
|
4040
|
+
if (!capabilities || capabilities.length === 0)
|
|
4041
|
+
return [];
|
|
4042
|
+
const issues = [];
|
|
4043
|
+
const configKeys = new Set(Object.keys(stateConfig));
|
|
4044
|
+
for (const cap of capabilities) {
|
|
4045
|
+
const name = cap.name?.trim();
|
|
4046
|
+
if (!name) {
|
|
4047
|
+
issues.push({
|
|
4048
|
+
type: "invalid_value",
|
|
4049
|
+
severity: "error",
|
|
4050
|
+
field: "capabilities",
|
|
4051
|
+
message: "Capability is missing a name.",
|
|
4052
|
+
suggestion: "Give each capability a unique name; it becomes the TLA+ invariant identifier."
|
|
4053
|
+
});
|
|
4054
|
+
continue;
|
|
4055
|
+
}
|
|
4056
|
+
issues.push(...validateExpression(name, "enabledBy", cap.enabledBy, configKeys, stateConfig), ...validateExpression(name, "requires", cap.requires, configKeys, stateConfig));
|
|
4057
|
+
}
|
|
4058
|
+
return issues;
|
|
4059
|
+
}
|
|
4060
|
+
function validateExpression(name, slot, expr, configKeys, stateConfig) {
|
|
4061
|
+
const field = `capabilities.${name}.${slot}`;
|
|
4062
|
+
if (!expr?.trim()) {
|
|
4063
|
+
return [
|
|
4064
|
+
{
|
|
4065
|
+
type: "capability_empty_expression",
|
|
4066
|
+
severity: "error",
|
|
4067
|
+
field,
|
|
4068
|
+
message: `Capability "${name}" has an empty ${slot} expression.`,
|
|
4069
|
+
suggestion: `Provide a state expression for ${slot}, e.g. "state.authenticated".`
|
|
4070
|
+
}
|
|
4071
|
+
];
|
|
4072
|
+
}
|
|
4073
|
+
const refs = extractFieldRefs(expr);
|
|
4074
|
+
if (refs.length === 0) {
|
|
4075
|
+
return [
|
|
4076
|
+
{
|
|
4077
|
+
type: "capability_empty_expression",
|
|
4078
|
+
severity: "error",
|
|
4079
|
+
field,
|
|
4080
|
+
message: `Capability "${name}" ${slot} expression "${expr}" references no state field.`,
|
|
4081
|
+
suggestion: 'Reference state via the state./.value form (e.g. "state.authReady"); a bare identifier produces a silently-vacuous invariant.'
|
|
4082
|
+
}
|
|
4083
|
+
];
|
|
4084
|
+
}
|
|
4085
|
+
return refs.filter((ref) => !fieldInConfig(ref, configKeys, stateConfig)).map((ref) => ({
|
|
4086
|
+
type: "capability_unknown_field",
|
|
4087
|
+
severity: "error",
|
|
4088
|
+
field,
|
|
4089
|
+
message: `Capability "${name}" ${slot} references "${ref}", which is not in the state config.`,
|
|
4090
|
+
suggestion: `Add "${ref}" to state, or correct the expression. Known fields: ${[...configKeys].join(", ")}`
|
|
4091
|
+
}));
|
|
4092
|
+
}
|
|
4093
|
+
function validateCoupledFields(coupledFields, stateConfig) {
|
|
4094
|
+
if (!coupledFields || coupledFields.length === 0)
|
|
4095
|
+
return [];
|
|
4096
|
+
const issues = [];
|
|
4097
|
+
const configKeys = new Set(Object.keys(stateConfig));
|
|
4098
|
+
coupledFields.forEach((group, i) => {
|
|
4099
|
+
for (const field of group) {
|
|
4100
|
+
if (!fieldInConfig(field, configKeys, stateConfig)) {
|
|
4101
|
+
issues.push({
|
|
4102
|
+
type: "coupled_unknown_field",
|
|
4103
|
+
severity: "error",
|
|
4104
|
+
field: `coupledFields[${i}]`,
|
|
4105
|
+
message: `Coupled field "${field}" is not in the state config.`,
|
|
4106
|
+
suggestion: `Use a declared state field. Known fields: ${[...configKeys].join(", ")}`
|
|
4107
|
+
});
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
});
|
|
4111
|
+
return issues;
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
// tools/verify/src/config/parser.ts
|
|
3913
4115
|
class ConfigValidator {
|
|
3914
4116
|
issues = [];
|
|
3915
4117
|
validate(configPath) {
|
|
@@ -4032,6 +4234,8 @@ class ConfigValidator {
|
|
|
4032
4234
|
if (config.subsystems) {
|
|
4033
4235
|
this.validateSubsystems(config.subsystems, config.state, config.messages);
|
|
4034
4236
|
}
|
|
4237
|
+
this.issues.push(...validateCapabilities(config.capabilities, config.state));
|
|
4238
|
+
this.issues.push(...validateCoupledFields(config.coupledFields, config.state));
|
|
4035
4239
|
}
|
|
4036
4240
|
findNullPlaceholders(obj, path2) {
|
|
4037
4241
|
if (obj === null || obj === undefined) {
|
|
@@ -7858,7 +8062,7 @@ function displayExpressionWarnings(result) {
|
|
|
7858
8062
|
function displayMeshOrPeerSignalWarnings(analysis, declaredMeshDocs) {
|
|
7859
8063
|
const findings = computeMeshOrPeerSignalFindings(analysis, declaredMeshDocs);
|
|
7860
8064
|
if (findings.length === 0)
|
|
7861
|
-
return;
|
|
8065
|
+
return 0;
|
|
7862
8066
|
const hasUndeclaredMesh = findings.some((f) => f.signalKind === "mesh");
|
|
7863
8067
|
const hasPeer = findings.some((f) => f.signalKind === "peer");
|
|
7864
8068
|
console.log(color(`
|
|
@@ -7881,6 +8085,61 @@ function displayMeshOrPeerSignalWarnings(analysis, declaredMeshDocs) {
|
|
|
7881
8085
|
console.log(color(` at ${f.location.file}:${f.location.line}`, COLORS.gray));
|
|
7882
8086
|
console.log();
|
|
7883
8087
|
}
|
|
8088
|
+
return findings.length;
|
|
8089
|
+
}
|
|
8090
|
+
function isStrictMode() {
|
|
8091
|
+
return process.argv.includes("--strict") || process.env.POLLY_VERIFY_STRICT === "1";
|
|
8092
|
+
}
|
|
8093
|
+
function displayModelCoverage(report) {
|
|
8094
|
+
const { unwrittenFields, unconstrainedMutators, fieldCoverage } = report;
|
|
8095
|
+
if (unwrittenFields.length === 0 && unconstrainedMutators.length === 0) {
|
|
8096
|
+
console.log(color(`✓ Model coverage: all ${fieldCoverage.length} declared field(s) written by a modelled handler`, COLORS.green));
|
|
8097
|
+
console.log();
|
|
8098
|
+
return;
|
|
8099
|
+
}
|
|
8100
|
+
if (unwrittenFields.length > 0) {
|
|
8101
|
+
console.log(color(`
|
|
8102
|
+
⚠️ ${unwrittenFields.length} declared state field(s) written by NO modelled handler (polly#160):`, COLORS.yellow));
|
|
8103
|
+
for (const f of unwrittenFields) {
|
|
8104
|
+
console.log(color(` • ${f}`, COLORS.yellow));
|
|
8105
|
+
}
|
|
8106
|
+
console.log(color(" The model carries a variable no transition can change. Either it is dead", COLORS.gray));
|
|
8107
|
+
console.log(color(" state (drop it from the verified surface) or its mutating path was never", COLORS.gray));
|
|
8108
|
+
console.log(color(" modelled — the omission class both TLC and mutation testing miss.", COLORS.gray));
|
|
8109
|
+
console.log();
|
|
8110
|
+
}
|
|
8111
|
+
if (unconstrainedMutators.length > 0) {
|
|
8112
|
+
console.log(color(`ℹ️ ${unconstrainedMutators.length} handler(s) mutate declared state with no ensures() postcondition:`, COLORS.gray));
|
|
8113
|
+
for (const m of unconstrainedMutators) {
|
|
8114
|
+
console.log(color(` • ${m.handler} writes {${m.fields.join(", ")}} — ${m.location.file}:${m.location.line}`, COLORS.gray));
|
|
8115
|
+
}
|
|
8116
|
+
console.log(color(" The checker explores these transitions but asserts nothing about their effect.", COLORS.gray));
|
|
8117
|
+
console.log();
|
|
8118
|
+
}
|
|
8119
|
+
}
|
|
8120
|
+
async function runModelCoverage(typedConfig, typedAnalysis, meshFindingCount) {
|
|
8121
|
+
const { computeModelCoverage: computeModelCoverage2, strictCoverageReasons: strictCoverageReasons2 } = await Promise.resolve().then(() => exports_model_coverage);
|
|
8122
|
+
const stateFields = Object.keys(typedConfig.state ?? {});
|
|
8123
|
+
const coverage = computeModelCoverage2(stateFields, typedAnalysis.handlers);
|
|
8124
|
+
displayModelCoverage(coverage);
|
|
8125
|
+
if (!isStrictMode())
|
|
8126
|
+
return;
|
|
8127
|
+
const reasons = strictCoverageReasons2(coverage, meshFindingCount);
|
|
8128
|
+
if (reasons.length === 0) {
|
|
8129
|
+
console.log(color("✓ Strict mode: model coverage complete", COLORS.green));
|
|
8130
|
+
console.log();
|
|
8131
|
+
return;
|
|
8132
|
+
}
|
|
8133
|
+
console.log(color(`❌ Strict mode: model coverage incomplete
|
|
8134
|
+
`, COLORS.red));
|
|
8135
|
+
for (const reason of reasons) {
|
|
8136
|
+
console.log(color(` • ${reason}`, COLORS.red));
|
|
8137
|
+
}
|
|
8138
|
+
console.log();
|
|
8139
|
+
console.log(color(" --strict fails closed on these so an unmodelled path cannot pass with a", COLORS.gray));
|
|
8140
|
+
console.log(color(" green check. Re-run without --strict to treat them as warnings.", COLORS.gray));
|
|
8141
|
+
console.log();
|
|
8142
|
+
process.exit(1);
|
|
7884
8143
|
}
|
|
7885
8144
|
async function verifyCommand() {
|
|
7886
8145
|
const configPath = path4.join(process.cwd(), "specs", "verification.config.ts");
|
|
@@ -7929,6 +8188,14 @@ Stack trace:`, COLORS.gray));
|
|
|
7929
8188
|
process.exit(1);
|
|
7930
8189
|
}
|
|
7931
8190
|
}
|
|
8191
|
+
function getMeshDocIds(config) {
|
|
8192
|
+
if (typeof config !== "object" || config === null || !("mesh" in config))
|
|
8193
|
+
return [];
|
|
8194
|
+
const mesh = config.mesh;
|
|
8195
|
+
if (typeof mesh !== "object" || mesh === null)
|
|
8196
|
+
return [];
|
|
8197
|
+
return Object.keys(mesh);
|
|
8198
|
+
}
|
|
7932
8199
|
function getTimeout(config) {
|
|
7933
8200
|
return config.verification?.timeout ?? 0;
|
|
7934
8201
|
}
|
|
@@ -7941,6 +8208,28 @@ function getMaxDepth(config) {
|
|
|
7941
8208
|
}
|
|
7942
8209
|
return;
|
|
7943
8210
|
}
|
|
8211
|
+
async function runCoupledFieldsLint(config, analysis) {
|
|
8212
|
+
const groups = config.coupledFields ?? [];
|
|
8213
|
+
if (groups.length === 0)
|
|
8214
|
+
return;
|
|
8215
|
+
const { checkCoupledFields: checkCoupledFields2 } = await Promise.resolve().then(() => exports_coupled_fields);
|
|
8216
|
+
const result = checkCoupledFields2(groups, analysis.handlers);
|
|
8217
|
+
if (result.valid) {
|
|
8218
|
+
console.log(color("✓ Coupled fields: verified (no partial-subset writes)", COLORS.green));
|
|
8219
|
+
console.log();
|
|
8220
|
+
return;
|
|
8221
|
+
}
|
|
8222
|
+
console.log(color(`⚠️ Coupled-field violations detected:
|
|
8223
|
+
`, COLORS.yellow));
|
|
8224
|
+
for (const v of result.violations) {
|
|
8225
|
+
console.log(color(` • Handler "${v.handler}" writes {${v.written.join(", ")}} but not {${v.missing.join(", ")}} of coupled group {${v.group.join(", ")}}`, COLORS.yellow));
|
|
8226
|
+
}
|
|
8227
|
+
console.log();
|
|
8228
|
+
console.log(color(" These fields are declared to move together; a capability granted without its", COLORS.yellow));
|
|
8229
|
+
console.log(color(" precondition is a likely cause. Co-write the full group in each handler, or model", COLORS.yellow));
|
|
8230
|
+
console.log(color(" the relationship with a `capabilities` invariant.", COLORS.yellow));
|
|
8231
|
+
console.log();
|
|
8232
|
+
}
|
|
7944
8233
|
async function runFullVerification(configPath) {
|
|
7945
8234
|
const config = await loadVerificationConfig(configPath);
|
|
7946
8235
|
const analysis = await runCodebaseAnalysis();
|
|
@@ -7951,8 +8240,10 @@ async function runFullVerification(configPath) {
|
|
|
7951
8240
|
if (exprValidation.warnings.length > 0) {
|
|
7952
8241
|
displayExpressionWarnings(exprValidation);
|
|
7953
8242
|
}
|
|
7954
|
-
const declaredMeshDocs = new Set(
|
|
7955
|
-
displayMeshOrPeerSignalWarnings(typedAnalysis, declaredMeshDocs);
|
|
8243
|
+
const declaredMeshDocs = new Set(getMeshDocIds(typedConfig));
|
|
8244
|
+
const meshFindingCount = displayMeshOrPeerSignalWarnings(typedAnalysis, declaredMeshDocs);
|
|
8245
|
+
await runCoupledFieldsLint(typedConfig, typedAnalysis);
|
|
8246
|
+
await runModelCoverage(typedConfig, typedAnalysis, meshFindingCount);
|
|
7956
8247
|
if (typedConfig.subsystems && Object.keys(typedConfig.subsystems).length > 0) {
|
|
7957
8248
|
await runSubsystemVerification(typedConfig, typedAnalysis);
|
|
7958
8249
|
return;
|
|
@@ -8222,8 +8513,7 @@ function findMeshSeedSpecDir() {
|
|
|
8222
8513
|
return null;
|
|
8223
8514
|
}
|
|
8224
8515
|
async function runMeshSeedGuard(docker, specDir, config) {
|
|
8225
|
-
|
|
8226
|
-
if (!mesh || Object.keys(mesh).length === 0)
|
|
8516
|
+
if (getMeshDocIds(config).length === 0)
|
|
8227
8517
|
return;
|
|
8228
8518
|
const sourceDir = findMeshSeedSpecDir();
|
|
8229
8519
|
if (!sourceDir) {
|
|
@@ -8330,6 +8620,11 @@ ${color("Commands:", COLORS.blue)}
|
|
|
8330
8620
|
${color("bun verify", COLORS.green)}
|
|
8331
8621
|
Run verification (validates config, generates specs, runs TLC)
|
|
8332
8622
|
|
|
8623
|
+
${color("bun verify --strict", COLORS.green)}
|
|
8624
|
+
Fail closed (non-zero exit) on model-coverage gaps: a declared state
|
|
8625
|
+
field no handler writes, or an unverified $meshState/$peerState predicate.
|
|
8626
|
+
Also via ${color("POLLY_VERIFY_STRICT=1", COLORS.yellow)}.
|
|
8627
|
+
|
|
8333
8628
|
${color("bun verify --setup", COLORS.green)}
|
|
8334
8629
|
Analyze codebase and generate configuration file
|
|
8335
8630
|
|
|
@@ -8393,4 +8688,4 @@ main().catch((error) => {
|
|
|
8393
8688
|
process.exit(1);
|
|
8394
8689
|
});
|
|
8395
8690
|
|
|
8396
|
-
//# debugId=
|
|
8691
|
+
//# debugId=9530CFE43867B27164756E2164756E21
|