@legionworks/facet 1.9.0 → 1.10.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/README.md +50 -21
- package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
- package/dist/gallery/{chunk-c6s9k9ty.js → chunk-48emrxg6.js} +71 -3
- package/dist/gallery/frame/frame.css +8 -1
- package/dist/gallery/frame/runtime/chart.js +23 -1
- package/dist/gallery/index.html +4 -2
- package/docs/reference/cli.md +29 -1
- package/docs/reference/export.md +4 -0
- package/docs/reference/mcp.md +16 -8
- package/docs/reference/security.md +7 -0
- package/docs/reference/storage.md +6 -1
- package/docs/reference/tsx.md +14 -0
- package/docs/reference/validation.md +34 -6
- package/package.json +4 -4
- package/skills/facet/SKILL.md +24 -1
- package/src/cli/commands/promote.ts +1 -0
- package/src/cli/commands/templates.ts +25 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/parser.ts +4 -0
- package/src/cli/presenter.ts +48 -0
- package/src/gallery-web/app.ts +45 -1
- package/src/gallery-web/favicon.ts +1 -0
- package/src/gallery-web/frame/renderers/chart.ts +31 -1
- package/src/gallery-web/frame/styles/frame.css +8 -1
- package/src/gallery-web/frame-html.ts +2 -1
- package/src/gallery-web/index.html +3 -1
- package/src/gallery-web/styles/verdict.css +19 -4
- package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
- package/src/harness-adapters/mcp/main.ts +2 -0
- package/src/harness-adapters/mcp/server.ts +92 -69
- package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
- package/src/service/dispatcher.ts +10 -0
- package/src/service/router-guards.ts +2 -0
- package/src/service/store/migrations.ts +12 -2
- package/src/service/store/repository-lifecycle.ts +91 -19
- package/src/service/store/repository.ts +60 -2
- package/src/service/store/schema.ts +4 -0
- package/src/service/stored-verdict.ts +3 -0
- package/src/shared/contracts/artifact.ts +1 -0
- package/src/shared/contracts/commands/index.ts +10 -0
- package/src/shared/contracts/commands/names.ts +2 -0
- package/src/shared/contracts/commands/requests.ts +7 -0
- package/src/shared/contracts/commands/results.ts +20 -1
- package/src/shared/contracts/promotion.ts +16 -0
- package/src/shared/contracts/validation.ts +7 -0
- package/src/shared/errors/facet-error.ts +2 -0
- package/src/shared/errors/store-error.ts +4 -0
- package/src/shared/html/artifact-main.ts +3 -0
- package/src/shared/storage-version.ts +1 -1
- package/src/validation/tier0/dom-shim.ts +3 -18
- package/src/validation/tier0/markdown.ts +28 -5
- package/src/validation/tier0/mermaid.ts +17 -8
- package/src/validation/tier0/worker-dispatch.ts +1 -1
- package/src/validation/tier1/entries/tsx.ts +1 -0
- package/src/validation/tier1/harness.ts +2 -1
- package/src/validation/tier1/isolated-probe.ts +4 -0
- package/src/validation/tier1/protocol-probe.ts +37 -0
- package/src/validation/tier1/runner.ts +21 -4
- package/src/validation/tier1/verdict.ts +24 -39
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import { Tier1ResultSchema, VerdictSchema } from "../validation";
|
|
3
|
+
import { Tier1ResultSchema, VerdictSchema, RenderStatusSchema } from "../validation";
|
|
4
4
|
import { ArtifactTypeSchema, RendererSchema } from "../artifact";
|
|
5
5
|
import { EvidenceImageFormatSchema } from "../../evidence-image";
|
|
6
6
|
import { ExportFormatSchema } from "./requests";
|
|
@@ -64,6 +64,25 @@ export const ListResultSchema = BaseResultSchema.extend({
|
|
|
64
64
|
});
|
|
65
65
|
export type ListResult = z.infer<typeof ListResultSchema>;
|
|
66
66
|
|
|
67
|
+
export const TemplatesResultSchema = BaseResultSchema.extend({
|
|
68
|
+
command: z.literal("templates"),
|
|
69
|
+
templates: z.array(
|
|
70
|
+
z.object({
|
|
71
|
+
name: z.string().min(1),
|
|
72
|
+
artifactId: z.string().min(1),
|
|
73
|
+
revisionId: z.string().min(1),
|
|
74
|
+
revisionSha: z.string().regex(/^[a-f0-9]{64}$/),
|
|
75
|
+
promotedBy: z.string().min(1),
|
|
76
|
+
promotedAt: z.string().datetime({ offset: true }),
|
|
77
|
+
promotionOverride: z.string().nullable(),
|
|
78
|
+
sourceVerdict: z
|
|
79
|
+
.object({ status: RenderStatusSchema, tier: z.union([z.literal(0), z.literal(1)]) })
|
|
80
|
+
.nullable(),
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
});
|
|
84
|
+
export type TemplatesResult = z.infer<typeof TemplatesResultSchema>;
|
|
85
|
+
|
|
67
86
|
/**
|
|
68
87
|
* Read-back result embeds the canonical `VerdictSchema` from
|
|
69
88
|
* `validation.ts`. The two definitions are the SAME object so a
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RenderStatus } from "./validation";
|
|
2
|
+
|
|
3
|
+
export const PROMOTION_GATE = {
|
|
4
|
+
ok: "allow",
|
|
5
|
+
error: "refuse",
|
|
6
|
+
"partial:layout_unverified": "allow",
|
|
7
|
+
"partial:opaque_content": "allow",
|
|
8
|
+
"partial:external_resources": "allow",
|
|
9
|
+
"partial:unstable": "allow",
|
|
10
|
+
"partial:empty_render": "refuse",
|
|
11
|
+
tampered: "refuse",
|
|
12
|
+
timeout: "refuse",
|
|
13
|
+
shim_only: "refuse",
|
|
14
|
+
probe_only: "refuse",
|
|
15
|
+
"insecure:unvalidated": "refuse",
|
|
16
|
+
} as const satisfies Record<RenderStatus, "allow" | "refuse">;
|
|
@@ -36,6 +36,7 @@ export const RenderStatusSchema = z.enum([
|
|
|
36
36
|
"partial:opaque_content",
|
|
37
37
|
"partial:external_resources",
|
|
38
38
|
"partial:unstable",
|
|
39
|
+
"partial:empty_render",
|
|
39
40
|
"tampered",
|
|
40
41
|
"timeout",
|
|
41
42
|
"shim_only",
|
|
@@ -134,9 +135,14 @@ export type LexicalCounters = z.infer<typeof LexicalCountersSchema>;
|
|
|
134
135
|
const ObservedCountShape = Object.fromEntries(
|
|
135
136
|
OBSERVED_COUNT_KEYS.map((key) => [key, z.number().int().nonnegative()]),
|
|
136
137
|
) as Record<(typeof OBSERVED_COUNT_KEYS)[number], z.ZodNumber>;
|
|
138
|
+
const RenderedContentShape = {
|
|
139
|
+
// Legacy stored observations lack this TSX-only probe; absence never proves an empty render.
|
|
140
|
+
emptyRendererRoot: z.boolean().optional(),
|
|
141
|
+
};
|
|
137
142
|
|
|
138
143
|
export const VerdictObservedSchema = z.object({
|
|
139
144
|
...ObservedCountShape,
|
|
145
|
+
...RenderedContentShape,
|
|
140
146
|
html: HtmlStructureCountsSchema.optional(),
|
|
141
147
|
viewBoxes: z.array(z.string()).optional(),
|
|
142
148
|
errorCount: z.number().int().nonnegative(),
|
|
@@ -288,6 +294,7 @@ export type Tier1Result = z.infer<typeof Tier1ResultSchema>;
|
|
|
288
294
|
*/
|
|
289
295
|
export const ProtocolObservationSchema = z.object({
|
|
290
296
|
...ObservedCountShape,
|
|
297
|
+
...RenderedContentShape,
|
|
291
298
|
html: HtmlStructureCountsSchema.optional(),
|
|
292
299
|
viewBoxes: z.array(z.string()),
|
|
293
300
|
errorCount: z.number().int().nonnegative(),
|
|
@@ -12,6 +12,7 @@ export const FacetErrorCodes = {
|
|
|
12
12
|
database_busy: true,
|
|
13
13
|
disk_full: true,
|
|
14
14
|
duplicate_revision: true,
|
|
15
|
+
template_name_taken: true,
|
|
15
16
|
foreign_key: true,
|
|
16
17
|
immutable_revision: true,
|
|
17
18
|
migration_failed: true,
|
|
@@ -27,6 +28,7 @@ export const FacetErrorCodes = {
|
|
|
27
28
|
artifact_not_found: true,
|
|
28
29
|
revision_not_found: true,
|
|
29
30
|
template_not_found: true,
|
|
31
|
+
promotion_refused: true,
|
|
30
32
|
evidence_unavailable: true,
|
|
31
33
|
output_unwritable: true,
|
|
32
34
|
revision_capacity_pinned: true,
|
|
@@ -18,6 +18,7 @@ export type StoreErrorCode = Extract<
|
|
|
18
18
|
| "database_busy"
|
|
19
19
|
| "disk_full"
|
|
20
20
|
| "duplicate_revision"
|
|
21
|
+
| "template_name_taken"
|
|
21
22
|
| "foreign_key"
|
|
22
23
|
| "immutable_revision"
|
|
23
24
|
| "migration_failed"
|
|
@@ -62,6 +63,9 @@ export function asStoreError(error: unknown): FacetStoreError {
|
|
|
62
63
|
return new FacetStoreError("foreign_key", message, { cause: error });
|
|
63
64
|
}
|
|
64
65
|
if (lower.includes("unique constraint")) {
|
|
66
|
+
if (lower.includes("templates.name")) {
|
|
67
|
+
return new FacetStoreError("template_name_taken", message, { cause: error });
|
|
68
|
+
}
|
|
65
69
|
return new FacetStoreError("duplicate_revision", message, { cause: error });
|
|
66
70
|
}
|
|
67
71
|
return new FacetStoreError("constraint", message, { cause: error });
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Latest schema migration applied by the service. */
|
|
2
|
-
export const CURRENT_STORAGE_VERSION =
|
|
2
|
+
export const CURRENT_STORAGE_VERSION = 10 as const;
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* linkedom-based DOM shim for the Tier 0 worker.
|
|
3
3
|
*
|
|
4
|
-
* Mermaid
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* none of these by default, so a naive `import mermaid from "mermaid"`
|
|
8
|
-
* throws `DOMPurify.addHook is not a function` (or its equivalent)
|
|
9
|
-
* before the parser ever sees source.
|
|
4
|
+
* Mermaid needs a minimal DOM for import and parsing in the worker.
|
|
5
|
+
* Tier 0 does not render labels; their sanitization is a render concern
|
|
6
|
+
* verified by Tier 1.
|
|
10
7
|
*
|
|
11
8
|
* This module installs the shim at IMPORT TIME (top-level) so any
|
|
12
9
|
* subsequent `import "mermaid"` sees a DOM. It is structural only —
|
|
@@ -24,17 +21,6 @@ function installDomShim(): void {
|
|
|
24
21
|
installed = true;
|
|
25
22
|
const { document, window } = parseHTML("<!DOCTYPE html><html><body></body></html>");
|
|
26
23
|
domShimDocument = document as unknown as Document;
|
|
27
|
-
// DOMPurify's isSupported check looks for `implementation.createHTMLDocument`.
|
|
28
|
-
// linkedom ships without an implementation object; we attach a minimal shim
|
|
29
|
-
// that returns a freshly-parsed document. This is structural only —
|
|
30
|
-
// we never serialize against this DOM.
|
|
31
|
-
const fakeImpl = {
|
|
32
|
-
createHTMLDocument: (html: string): Document => {
|
|
33
|
-
const r = parseHTML(html);
|
|
34
|
-
return r.document as unknown as Document;
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
Object.defineProperty(document, "implementation", { value: fakeImpl, configurable: true });
|
|
38
24
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
39
25
|
g["document"] = document;
|
|
40
26
|
g["window"] = window;
|
|
@@ -43,7 +29,6 @@ function installDomShim(): void {
|
|
|
43
29
|
g["Node"] = window.Node;
|
|
44
30
|
g["DocumentFragment"] = window.DocumentFragment;
|
|
45
31
|
g["HTMLTemplateElement"] = window.HTMLTemplateElement;
|
|
46
|
-
g["NodeFilter"] = window.NodeFilter;
|
|
47
32
|
}
|
|
48
33
|
|
|
49
34
|
// Top-level install — runs synchronously when this module is first
|
|
@@ -30,6 +30,7 @@ import { Lexer, type Token, type Tokens } from "marked";
|
|
|
30
30
|
|
|
31
31
|
import type { DiscriminativeError, VerdictObserved } from "../../shared/contracts/validation";
|
|
32
32
|
import { countMermaidNodeDeclarations } from "../../shared/util/mermaid-nodes";
|
|
33
|
+
import { parseMermaidText } from "./mermaid";
|
|
33
34
|
|
|
34
35
|
export interface MarkdownParseOk {
|
|
35
36
|
readonly status: "ok";
|
|
@@ -54,6 +55,7 @@ interface MarkdownCounts {
|
|
|
54
55
|
hasScript: boolean;
|
|
55
56
|
hasOnHandler: boolean;
|
|
56
57
|
hasExternalRef: boolean;
|
|
58
|
+
mermaidBodies: string[];
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function isExternalHttpsUrl(value: string): boolean {
|
|
@@ -102,6 +104,7 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
|
|
|
102
104
|
counts.totalFenced += 1;
|
|
103
105
|
const lang = (code.lang ?? "").trim().toLowerCase();
|
|
104
106
|
if (lang === "mermaid") {
|
|
107
|
+
counts.mermaidBodies.push(code.text);
|
|
105
108
|
counts.mermaidFenced += 1;
|
|
106
109
|
counts.rendererRoots += 1;
|
|
107
110
|
const nodes = countMermaidNodeDeclarations(code.text);
|
|
@@ -161,12 +164,11 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* one) but never interpreted.
|
|
167
|
+
* Tokenize Markdown and grammar-check Mermaid fences without rendering
|
|
168
|
+
* or executing source. Raw HTML is counted and checked for structural
|
|
169
|
+
* red flags, but never interpreted.
|
|
168
170
|
*/
|
|
169
|
-
export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
171
|
+
export async function parseMarkdown(bytes: Uint8Array): Promise<MarkdownParseResult> {
|
|
170
172
|
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
171
173
|
const counts: MarkdownCounts = {
|
|
172
174
|
totalFenced: 0,
|
|
@@ -178,6 +180,7 @@ export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
|
178
180
|
hasScript: false,
|
|
179
181
|
hasOnHandler: false,
|
|
180
182
|
hasExternalRef: false,
|
|
183
|
+
mermaidBodies: [],
|
|
181
184
|
};
|
|
182
185
|
let lexError: unknown = null;
|
|
183
186
|
let tokens: Token[] = [];
|
|
@@ -189,6 +192,26 @@ export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
|
189
192
|
lexError = error;
|
|
190
193
|
}
|
|
191
194
|
|
|
195
|
+
for (let i = 0; i < counts.mermaidBodies.length; i += 1) {
|
|
196
|
+
const message = await parseMermaidText(counts.mermaidBodies[i]!);
|
|
197
|
+
if (message !== null) {
|
|
198
|
+
// Fence grammar errors take precedence over lexer and hostile-HTML errors.
|
|
199
|
+
return {
|
|
200
|
+
status: "error",
|
|
201
|
+
observed: {
|
|
202
|
+
rendererRootSvgCount: counts.rendererRoots,
|
|
203
|
+
graphCount: counts.mermaidFenced,
|
|
204
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
205
|
+
visibleSvgCount: 0,
|
|
206
|
+
externalImageCount: counts.externalImageCount,
|
|
207
|
+
errorCount: 1,
|
|
208
|
+
opaqueRegionCount: 0,
|
|
209
|
+
},
|
|
210
|
+
errors: [{ code: "mermaid_parse_error", message, location: `mermaid fence ${i}` }],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
if (lexError !== null) {
|
|
193
216
|
const message = lexError instanceof Error ? lexError.message : String(lexError);
|
|
194
217
|
return {
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* DOM (DOMPurify, etc.) at IMPORT TIME.
|
|
9
9
|
*
|
|
10
10
|
* `dom-shim.ts` installs a linkedom-based structural DOM stub BEFORE
|
|
11
|
-
* this module imports mermaid, so the library
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* this module imports mermaid, so the library can initialize. DOMPurify
|
|
12
|
+
* deliberately reports itself unsupported under the stub and passes label
|
|
13
|
+
* text through unsanitized: Tier 0 never renders or keeps that text, and
|
|
14
|
+
* sanitization is verified at Tier 1 in a real browser. The shim never
|
|
15
|
+
* executes artifact source against the DOM. netns ensures any
|
|
16
|
+
* library-initiated network egress cannot reach a host.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import "./dom-shim";
|
|
@@ -33,6 +35,15 @@ export interface MermaidParseFail {
|
|
|
33
35
|
|
|
34
36
|
export type MermaidParseResult = MermaidParseOk | MermaidParseFail;
|
|
35
37
|
|
|
38
|
+
export async function parseMermaidText(text: string): Promise<string | null> {
|
|
39
|
+
try {
|
|
40
|
+
await (mermaid as { parse: (s: string) => Promise<MermaidResolved> }).parse(text);
|
|
41
|
+
return null;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
/**
|
|
37
48
|
* Count node declarations across the source. This is the lexical
|
|
38
49
|
* counter the parser agrees with when the parse succeeds and disagrees
|
|
@@ -58,10 +69,8 @@ export async function parseMermaid(bytes: Uint8Array): Promise<MermaidParseResul
|
|
|
58
69
|
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
59
70
|
const lexicalNodes = countMermaidNodes(bytes);
|
|
60
71
|
try {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
)) as MermaidResolved;
|
|
64
|
-
void resolved;
|
|
72
|
+
const message = await parseMermaidText(text);
|
|
73
|
+
if (message !== null) throw new Error(message);
|
|
65
74
|
return {
|
|
66
75
|
status: "ok",
|
|
67
76
|
observed: {
|
|
@@ -15,7 +15,7 @@ export async function runParser(input: WorkerInput): Promise<Tier0WorkerResult>
|
|
|
15
15
|
};
|
|
16
16
|
switch (input.artifactType) {
|
|
17
17
|
case "markdown": {
|
|
18
|
-
const result = parseMarkdown(input.source);
|
|
18
|
+
const result = await parseMarkdown(input.source);
|
|
19
19
|
const externalImageCount = result.observed.externalImageCount;
|
|
20
20
|
return {
|
|
21
21
|
...base,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "../../../gallery-web/frame/styles/artifact.css";
|
|
1
2
|
import { renderTsx } from "../../../gallery-web/frame/renderers/tsx";
|
|
2
3
|
import { createRendererRegistry } from "../../../gallery-web/frame/renderers/registry";
|
|
3
4
|
import { startTier1Harness } from "../harness-entry";
|
|
@@ -24,6 +24,7 @@ import { ARTIFACT_TYPES, type ArtifactType } from "../../gallery-web/frame/rende
|
|
|
24
24
|
import frameChromeStyles from "../../gallery-web/frame/styles/frame.css" with { type: "text" };
|
|
25
25
|
import { freshHarnessNonce } from "./nonce";
|
|
26
26
|
import { frameBundlePlugins } from "../../shared/build/frame-bundle-plugins";
|
|
27
|
+
import { artifactMainAttributes } from "../../shared/html/artifact-main";
|
|
27
28
|
import { FROZEN_CSP_TEMPLATE as HARNESS_CSP } from "../../shared/security/frozen-csp";
|
|
28
29
|
|
|
29
30
|
export { FROZEN_CSP_TEMPLATE as HARNESS_CSP } from "../../shared/security/frozen-csp";
|
|
@@ -135,7 +136,7 @@ export async function buildHarnessSrcdoc(artifactType: string): Promise<{
|
|
|
135
136
|
`<style>${frameChromeStyles.replace(/<\/style/gi, "<\\/style")}</style>` +
|
|
136
137
|
(styles.length === 0 ? "" : `<style>${styles.replace(/<\/style/gi, "<\\/style")}</style>`) +
|
|
137
138
|
"</head><body>" +
|
|
138
|
-
`<main
|
|
139
|
+
`<main ${artifactMainAttributes(rendererType)} data-facet-nonce="${nonce}"></main>` +
|
|
139
140
|
`<script type="module" nonce="${nonce}">${escaped}</script>` +
|
|
140
141
|
"</body></html>";
|
|
141
142
|
return { srcdoc, nonce, bundleBytes: bytes };
|
|
@@ -26,6 +26,7 @@ import type { VerifierCdpSession } from "./browser-process";
|
|
|
26
26
|
export async function probeIsolatedCounts(
|
|
27
27
|
session: VerifierCdpSession,
|
|
28
28
|
executionContextId: number,
|
|
29
|
+
observeContent = false,
|
|
29
30
|
): Promise<ProtocolObservation | null> {
|
|
30
31
|
try {
|
|
31
32
|
const selectors = Object.fromEntries(
|
|
@@ -47,6 +48,8 @@ export async function probeIsolatedCounts(
|
|
|
47
48
|
" var svgRoots = roots.filter(function(root){ return String(root.nodeName).toLowerCase() === 'svg'; });",
|
|
48
49
|
" var graphRoots = svgRoots.filter(function(root){ return root.getAttribute('data-facet-renderer-graph') === 'true'; });",
|
|
49
50
|
" var htmlRoots = roots.filter(function(root){ return String(root.nodeName).toLowerCase() !== 'svg'; });",
|
|
51
|
+
` var observeContent = ${JSON.stringify(observeContent)};`,
|
|
52
|
+
" var emptyRendererRoot = observeContent && htmlRoots.length === 1 ? Array.prototype.every.call(htmlRoots[0].childNodes, function(node){ return node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim()); }) : undefined;",
|
|
50
53
|
` var selectors = ${JSON.stringify(selectors)};`,
|
|
51
54
|
" var html = htmlRoots.length === 0 ? null : {rendererRootCount:htmlRoots.length,headingCount:0,tableCount:0,listCount:0,imageCount:0,canvasCount:0,externalImageCount:0};",
|
|
52
55
|
" var externalImageCount = 0;",
|
|
@@ -82,6 +85,7 @@ export async function probeIsolatedCounts(
|
|
|
82
85
|
" opaqueRegionCount: opaqueRegionCount,",
|
|
83
86
|
" externalImageCount: externalImageCount,",
|
|
84
87
|
" discriminativeErrors: [],",
|
|
88
|
+
" ...(emptyRendererRoot === undefined ? {} : {emptyRendererRoot: emptyRendererRoot}),",
|
|
85
89
|
" ...(html === null ? {} : {html: html})",
|
|
86
90
|
" };",
|
|
87
91
|
"})()",
|
|
@@ -30,6 +30,8 @@ export interface SnapshotDocument {
|
|
|
30
30
|
readonly frameId: number;
|
|
31
31
|
readonly nodes: {
|
|
32
32
|
readonly nodeName: number[];
|
|
33
|
+
readonly nodeType?: readonly number[];
|
|
34
|
+
readonly nodeValue?: readonly number[];
|
|
33
35
|
/** Parent node index for every entry in nodeName. */
|
|
34
36
|
readonly parentIndex: readonly number[];
|
|
35
37
|
/**
|
|
@@ -44,6 +46,23 @@ export interface SnapshotDocument {
|
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
function snapshotRootEmpty(snapshot: SnapshotResponse, documentIndex: number): boolean | undefined {
|
|
50
|
+
const document = snapshot.documents[documentIndex];
|
|
51
|
+
if (document === undefined) return undefined;
|
|
52
|
+
const roots = htmlRootIndexes(snapshot, documentIndex);
|
|
53
|
+
if (roots.length !== 1) return undefined;
|
|
54
|
+
const root = roots[0];
|
|
55
|
+
if (root === undefined) return undefined;
|
|
56
|
+
for (let index = 0; index < document.nodes.nodeName.length; index += 1) {
|
|
57
|
+
if (document.nodes.parentIndex[index] !== root) continue;
|
|
58
|
+
const type = document.nodes.nodeType?.[index];
|
|
59
|
+
if (type === 1) return false;
|
|
60
|
+
if (type === 3 && readString(snapshot.strings, document.nodes.nodeValue?.[index] ?? -1).trim())
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
export interface SnapshotResponse {
|
|
48
67
|
readonly documents: readonly SnapshotDocument[];
|
|
49
68
|
readonly strings: readonly string[];
|
|
@@ -328,6 +347,7 @@ function findDocumentIndex(snapshot: SnapshotResponse, childFrameId: string): nu
|
|
|
328
347
|
export async function probeProtocolSnapshot(
|
|
329
348
|
session: VerifierCdpSession,
|
|
330
349
|
childFrame: ResolvedChildFrame,
|
|
350
|
+
observeContent = false,
|
|
331
351
|
): Promise<ProtocolObservation> {
|
|
332
352
|
const snapshot = (await session.send("DOMSnapshot.captureSnapshot", {
|
|
333
353
|
computedStyles: [],
|
|
@@ -352,6 +372,7 @@ export async function probeProtocolSnapshot(
|
|
|
352
372
|
const discriminativeErrors = collectDiscriminativeErrors(snapshot, documentIndex);
|
|
353
373
|
const errorCount = discriminativeErrors.length;
|
|
354
374
|
const htmlCounts = countSnapshotHtml(snapshot, documentIndex);
|
|
375
|
+
const emptyRendererRoot = observeContent ? snapshotRootEmpty(snapshot, documentIndex) : undefined;
|
|
355
376
|
return {
|
|
356
377
|
rendererRootSvgCount: rendererRoots.length,
|
|
357
378
|
graphCount: graphRoots.length,
|
|
@@ -360,6 +381,7 @@ export async function probeProtocolSnapshot(
|
|
|
360
381
|
opaqueRegionCount: countByName(snapshot, documentIndex, "canvas"),
|
|
361
382
|
externalImageCount: htmlCounts?.externalImageCount ?? 0,
|
|
362
383
|
...(htmlCounts === undefined ? {} : { html: htmlCounts }),
|
|
384
|
+
...(emptyRendererRoot === undefined ? {} : { emptyRendererRoot }),
|
|
363
385
|
viewBoxes,
|
|
364
386
|
errorCount,
|
|
365
387
|
discriminativeErrors: discriminativeErrors.map((entry) => ({
|
|
@@ -378,6 +400,7 @@ export async function probeProtocolSnapshot(
|
|
|
378
400
|
export async function probeProtocolGetDocument(
|
|
379
401
|
session: VerifierCdpSession,
|
|
380
402
|
childFrame: ResolvedChildFrame,
|
|
403
|
+
observeContent = false,
|
|
381
404
|
): Promise<ProtocolObservation> {
|
|
382
405
|
const result = (await session.send("DOM.getDocument", {
|
|
383
406
|
depth: -1,
|
|
@@ -402,6 +425,7 @@ export async function probeProtocolGetDocument(
|
|
|
402
425
|
let opaqueRegionCount = 0;
|
|
403
426
|
let visibleSvgCount = 0;
|
|
404
427
|
let html: HtmlStructureCounts | undefined;
|
|
428
|
+
let emptyRendererRoot: boolean | undefined;
|
|
405
429
|
const viewBoxes: string[] = [];
|
|
406
430
|
const visit = (
|
|
407
431
|
node: unknown,
|
|
@@ -413,6 +437,8 @@ export async function probeProtocolGetDocument(
|
|
|
413
437
|
if (node === null || typeof node !== "object") return;
|
|
414
438
|
const record = node as {
|
|
415
439
|
nodeName?: string;
|
|
440
|
+
nodeType?: number;
|
|
441
|
+
nodeValue?: string;
|
|
416
442
|
// DOM.Node attributes arrive as a FLAT string array
|
|
417
443
|
// [name1, value1, name2, value2, …] — not {name, value} objects.
|
|
418
444
|
attributes?: string[];
|
|
@@ -435,6 +461,16 @@ export async function probeProtocolGetDocument(
|
|
|
435
461
|
const htmlRoot = name !== "svg" && markedRoot && !withinMarkedRoot;
|
|
436
462
|
const graphRoot = rendererRoot && findAttr("data-facet-renderer-graph") === "true";
|
|
437
463
|
if (htmlRoot) {
|
|
464
|
+
if (observeContent) {
|
|
465
|
+
const empty = !(record.children ?? []).some((child) => {
|
|
466
|
+
const direct = child as ProtocolDomNode & { nodeType?: number; nodeValue?: string };
|
|
467
|
+
return (
|
|
468
|
+
direct.nodeType === 1 ||
|
|
469
|
+
(direct.nodeType === 3 && (direct.nodeValue ?? "").trim().length > 0)
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
emptyRendererRoot = emptyRendererRoot === undefined ? empty : false;
|
|
473
|
+
}
|
|
438
474
|
html ??= {
|
|
439
475
|
rendererRootCount: 0,
|
|
440
476
|
headingCount: 0,
|
|
@@ -511,6 +547,7 @@ export async function probeProtocolGetDocument(
|
|
|
511
547
|
opaqueRegionCount,
|
|
512
548
|
externalImageCount: html?.externalImageCount ?? 0,
|
|
513
549
|
...(html === undefined ? {} : { html }),
|
|
550
|
+
...(emptyRendererRoot === undefined ? {} : { emptyRendererRoot }),
|
|
514
551
|
viewBoxes,
|
|
515
552
|
errorCount,
|
|
516
553
|
discriminativeErrors:
|
|
@@ -327,6 +327,7 @@ async function runTier1Attempt(
|
|
|
327
327
|
artifactFrame,
|
|
328
328
|
isolated.executionContextId,
|
|
329
329
|
interactiveTsx ? runtimeExceptions!.errorsForFrame(artifactFrame.frameId) : [],
|
|
330
|
+
input.artifactType === "tsx",
|
|
330
331
|
);
|
|
331
332
|
const secondObservation = interactiveTsx
|
|
332
333
|
? await waitForStabilityObservation(
|
|
@@ -334,6 +335,7 @@ async function runTier1Attempt(
|
|
|
334
335
|
artifactFrame,
|
|
335
336
|
isolated.executionContextId,
|
|
336
337
|
() => runtimeExceptions!.errorsForFrame(artifactFrame.frameId),
|
|
338
|
+
true,
|
|
337
339
|
)
|
|
338
340
|
: firstObservation;
|
|
339
341
|
const protocolObservation = secondObservation.protocol;
|
|
@@ -350,6 +352,7 @@ async function runTier1Attempt(
|
|
|
350
352
|
bootReady: shim.bootReady,
|
|
351
353
|
renderComplete: shim.renderComplete,
|
|
352
354
|
interactive: interactiveTsx,
|
|
355
|
+
tsx: input.artifactType === "tsx",
|
|
353
356
|
channelDivergence,
|
|
354
357
|
structureChanged:
|
|
355
358
|
interactiveTsx && countsDiffer(firstObservation.protocol, secondObservation.protocol),
|
|
@@ -390,6 +393,9 @@ async function runTier1Attempt(
|
|
|
390
393
|
errorCount: observed.errorCount,
|
|
391
394
|
opaqueRegionCount: observed.opaqueRegionCount,
|
|
392
395
|
externalImageCount: observed.externalImageCount,
|
|
396
|
+
...(observed.emptyRendererRoot === undefined
|
|
397
|
+
? {}
|
|
398
|
+
: { emptyRendererRoot: observed.emptyRendererRoot }),
|
|
393
399
|
...(observed.html === undefined ? {} : { html: observed.html }),
|
|
394
400
|
discriminativeErrors: observed.discriminativeErrors,
|
|
395
401
|
},
|
|
@@ -604,6 +610,12 @@ function mergeProtocol(
|
|
|
604
610
|
message: `DOMSnapshot.externalImageCount=${snapshot.externalImageCount} vs DOM.getDocument.externalImageCount=${getDocument.externalImageCount}`,
|
|
605
611
|
});
|
|
606
612
|
}
|
|
613
|
+
if (snapshot.emptyRendererRoot !== getDocument.emptyRendererRoot) {
|
|
614
|
+
errors.push({
|
|
615
|
+
code: "protocol_divergence",
|
|
616
|
+
message: `DOMSnapshot.emptyRendererRoot=${snapshot.emptyRendererRoot} vs DOM.getDocument.emptyRendererRoot=${getDocument.emptyRendererRoot}`,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
607
619
|
const observedCounts = Object.fromEntries(
|
|
608
620
|
OBSERVED_COUNT_KEYS.map((key) => [key, snapshot[key]]),
|
|
609
621
|
) as Pick<ProtocolObservation, ObservedCountKey>;
|
|
@@ -612,6 +624,9 @@ function mergeProtocol(
|
|
|
612
624
|
viewBoxes: snapshot.viewBoxes,
|
|
613
625
|
errorCount: snapshot.errorCount,
|
|
614
626
|
...(snapshot.html === undefined ? {} : { html: snapshot.html }),
|
|
627
|
+
...(snapshot.emptyRendererRoot === undefined
|
|
628
|
+
? {}
|
|
629
|
+
: { emptyRendererRoot: snapshot.emptyRendererRoot }),
|
|
615
630
|
discriminativeErrors: errors,
|
|
616
631
|
};
|
|
617
632
|
}
|
|
@@ -717,10 +732,11 @@ async function observeArtifact(
|
|
|
717
732
|
frame: { readonly frameId: string; readonly url: string },
|
|
718
733
|
executionContextId: number,
|
|
719
734
|
runtimeErrors: readonly { readonly code: string; readonly message: string }[],
|
|
735
|
+
observeContent = false,
|
|
720
736
|
): Promise<ArtifactObservation> {
|
|
721
|
-
const snapshot = await probeProtocolSnapshot(session, frame);
|
|
722
|
-
const document = await probeProtocolGetDocument(session, frame);
|
|
723
|
-
const isolated = await probeIsolatedCounts(session, executionContextId);
|
|
737
|
+
const snapshot = await probeProtocolSnapshot(session, frame, observeContent);
|
|
738
|
+
const document = await probeProtocolGetDocument(session, frame, observeContent);
|
|
739
|
+
const isolated = await probeIsolatedCounts(session, executionContextId, observeContent);
|
|
724
740
|
return { protocol: mergeProtocol(snapshot, document, runtimeErrors), isolated };
|
|
725
741
|
}
|
|
726
742
|
|
|
@@ -729,9 +745,10 @@ async function waitForStabilityObservation(
|
|
|
729
745
|
frame: { readonly frameId: string; readonly url: string },
|
|
730
746
|
executionContextId: number,
|
|
731
747
|
runtimeErrors: () => readonly { readonly code: string; readonly message: string }[],
|
|
748
|
+
observeContent = false,
|
|
732
749
|
): Promise<ArtifactObservation> {
|
|
733
750
|
await Bun.sleep(TSX_STABILITY_WINDOW_MS);
|
|
734
|
-
return observeArtifact(session, frame, executionContextId, runtimeErrors());
|
|
751
|
+
return observeArtifact(session, frame, executionContextId, runtimeErrors(), observeContent);
|
|
735
752
|
}
|
|
736
753
|
|
|
737
754
|
interface EvidenceCapture {
|
|
@@ -44,12 +44,12 @@ export type { ProtocolObservation };
|
|
|
44
44
|
*/
|
|
45
45
|
export type PageShim = Pick<
|
|
46
46
|
ProtocolObservation,
|
|
47
|
-
(typeof COUNT_COMPARISON_KEYS)[number] | "html" | "errorCount"
|
|
47
|
+
(typeof COUNT_COMPARISON_KEYS)[number] | "html" | "errorCount" | "emptyRendererRoot"
|
|
48
48
|
>;
|
|
49
49
|
|
|
50
50
|
export type CountsLike = Pick<
|
|
51
51
|
ProtocolObservation,
|
|
52
|
-
(typeof COUNT_COMPARISON_KEYS)[number] | "html" | "errorCount"
|
|
52
|
+
(typeof COUNT_COMPARISON_KEYS)[number] | "html" | "errorCount" | "emptyRendererRoot"
|
|
53
53
|
>;
|
|
54
54
|
|
|
55
55
|
const COUNT_COMPARISON_KEYS = OBSERVED_COUNT_KEYS;
|
|
@@ -88,47 +88,21 @@ export interface LifecycleSummary {
|
|
|
88
88
|
readonly channelDivergence?: boolean;
|
|
89
89
|
/** Interactive TSX has no lexical HTML prediction and no trusted outer shim. */
|
|
90
90
|
readonly interactive?: boolean;
|
|
91
|
+
/** Only TSX has a renderer-root emptiness claim from Tier 1. */
|
|
92
|
+
readonly tsx?: boolean;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/**
|
|
94
|
-
* Compute the final `RenderStatus`.
|
|
96
|
+
* Compute the final `RenderStatus`. Precedence, highest first:
|
|
97
|
+
* timeout → channel divergence/tampered → missing channels → protocol
|
|
98
|
+
* errors or lexical mismatch → missing TSX content observation →
|
|
99
|
+
* unstable → empty TSX root → missing declared opaque content/error →
|
|
100
|
+
* opaque content → external resources → unobservable SVG layout → ok.
|
|
95
101
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* 5. Channel availability: only shim missing → `probe_only`
|
|
101
|
-
* 6. Channel availability: only isolated missing → `shim_only`
|
|
102
|
-
* 7. Opaque content: expected > 0 but protocol observed 0 → `error`
|
|
103
|
-
* 8. Opaque content: protocol observed > 0 → `partial:opaque_content`
|
|
104
|
-
* 9. External resources: expected external images > 0 → `partial:external_resources`
|
|
105
|
-
* 10. Layout observability (non-HTML only): protocol visibleSvgCount === 0
|
|
106
|
-
* AND every viewBox is zeroed → `partial:layout_unverified`. The
|
|
107
|
-
* branch is gated on `expected.html === undefined` because HTML
|
|
108
|
-
* artifacts carry no viewBox axis — they have no SVG layout to
|
|
109
|
-
* verify against, so `partial:layout_unverified` is structurally
|
|
110
|
-
* unreachable for HTML. A clean HTML artifact with zero
|
|
111
|
-
* `visibleSvgCount` and zero `viewBoxes` returns `ok` when its
|
|
112
|
-
* counts match.
|
|
113
|
-
* 11. Counts: protocol discriminativeErrors non-empty → `error`
|
|
114
|
-
* 12. Counts: protocol observed !== expected lexical → `error`
|
|
115
|
-
* 13. Otherwise → `ok`
|
|
116
|
-
*
|
|
117
|
-
* Tampered wins over partial: a forge attempt that hides layout
|
|
118
|
-
* observability (no viewBoxes) is still a forge attempt.
|
|
119
|
-
*
|
|
120
|
-
* D11 addition: `partial:unstable` slots between step 6 and step 7,
|
|
121
|
-
* i.e. AFTER the catastrophic statuses (timeout, tampered, channel
|
|
122
|
-
* availability) and the count-mismatch error, but BEFORE the
|
|
123
|
-
* single-snapshot partials (opaque_content, external_resources,
|
|
124
|
-
* layout_unverified). The reasoning: when structure is changing
|
|
125
|
-
* between the two observation snapshots, the verifier cannot
|
|
126
|
-
* honestly claim "this artifact has structure X" — every
|
|
127
|
-
* single-snapshot claim is moot. Unstable is a meta-claim about the
|
|
128
|
-
* page's runtime behavior that dominates the structural claims.
|
|
129
|
-
* Tampered stays above it because channel divergence (the page
|
|
130
|
-
* contradicting protocol authority) is the more catastrophic
|
|
131
|
-
* reading of the page's behavior.
|
|
102
|
+
* A changing page cannot support any single-snapshot content claim,
|
|
103
|
+
* including emptiness. HTML/TSX has no SVG viewBox axis, while TSX
|
|
104
|
+
* emptiness needs agreement from the two protocol paths and isolated
|
|
105
|
+
* world; the outer page shim has no authority to make that claim.
|
|
132
106
|
*/
|
|
133
107
|
export function deriveVerdict(
|
|
134
108
|
expected: LexicalCounters,
|
|
@@ -164,11 +138,19 @@ export function deriveVerdict(
|
|
|
164
138
|
|
|
165
139
|
if (protocolObservation.discriminativeErrors.length > 0) return "error";
|
|
166
140
|
if (!lifecycle.interactive && !matchesExpected(expected, protocolObservation)) return "error";
|
|
141
|
+
if (
|
|
142
|
+
lifecycle.tsx &&
|
|
143
|
+
(protocolObservation.emptyRendererRoot === undefined ||
|
|
144
|
+
isolatedObservation?.emptyRendererRoot === undefined)
|
|
145
|
+
)
|
|
146
|
+
return "probe_only";
|
|
167
147
|
// D11: structure changed between the barrier and the stability
|
|
168
148
|
// window. This is the only path that does not also depend on a
|
|
169
149
|
// single observation — it depends on TWO observations, so it
|
|
170
150
|
// dominates the single-snapshot partial statuses below.
|
|
171
151
|
if (lifecycle.structureChanged === true) return "partial:unstable";
|
|
152
|
+
if (lifecycle.tsx && protocolObservation.emptyRendererRoot === true)
|
|
153
|
+
return "partial:empty_render";
|
|
172
154
|
if (expected.opaqueRegionCount > 0 && protocolObservation.opaqueRegionCount === 0) {
|
|
173
155
|
return "error";
|
|
174
156
|
}
|
|
@@ -224,6 +206,9 @@ function htmlCountsDiffer(
|
|
|
224
206
|
export function countsDiffer(left: CountsLike, right: CountsLike): boolean {
|
|
225
207
|
return (
|
|
226
208
|
COUNT_COMPARISON_KEYS.some((key) => left[key] !== right[key]) ||
|
|
209
|
+
(left.emptyRendererRoot !== undefined &&
|
|
210
|
+
right.emptyRendererRoot !== undefined &&
|
|
211
|
+
left.emptyRendererRoot !== right.emptyRendererRoot) ||
|
|
227
212
|
left.errorCount !== right.errorCount ||
|
|
228
213
|
htmlCountsDiffer(left.html, right.html)
|
|
229
214
|
);
|