@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.
Files changed (59) hide show
  1. package/README.md +50 -21
  2. package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
  3. package/dist/gallery/{chunk-c6s9k9ty.js → chunk-48emrxg6.js} +71 -3
  4. package/dist/gallery/frame/frame.css +8 -1
  5. package/dist/gallery/frame/runtime/chart.js +23 -1
  6. package/dist/gallery/index.html +4 -2
  7. package/docs/reference/cli.md +29 -1
  8. package/docs/reference/export.md +4 -0
  9. package/docs/reference/mcp.md +16 -8
  10. package/docs/reference/security.md +7 -0
  11. package/docs/reference/storage.md +6 -1
  12. package/docs/reference/tsx.md +14 -0
  13. package/docs/reference/validation.md +34 -6
  14. package/package.json +4 -4
  15. package/skills/facet/SKILL.md +24 -1
  16. package/src/cli/commands/promote.ts +1 -0
  17. package/src/cli/commands/templates.ts +25 -0
  18. package/src/cli/main.ts +4 -0
  19. package/src/cli/parser.ts +4 -0
  20. package/src/cli/presenter.ts +48 -0
  21. package/src/gallery-web/app.ts +45 -1
  22. package/src/gallery-web/favicon.ts +1 -0
  23. package/src/gallery-web/frame/renderers/chart.ts +31 -1
  24. package/src/gallery-web/frame/styles/frame.css +8 -1
  25. package/src/gallery-web/frame-html.ts +2 -1
  26. package/src/gallery-web/index.html +3 -1
  27. package/src/gallery-web/styles/verdict.css +19 -4
  28. package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
  29. package/src/harness-adapters/mcp/main.ts +2 -0
  30. package/src/harness-adapters/mcp/server.ts +92 -69
  31. package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
  32. package/src/service/dispatcher.ts +10 -0
  33. package/src/service/router-guards.ts +2 -0
  34. package/src/service/store/migrations.ts +12 -2
  35. package/src/service/store/repository-lifecycle.ts +91 -19
  36. package/src/service/store/repository.ts +60 -2
  37. package/src/service/store/schema.ts +4 -0
  38. package/src/service/stored-verdict.ts +3 -0
  39. package/src/shared/contracts/artifact.ts +1 -0
  40. package/src/shared/contracts/commands/index.ts +10 -0
  41. package/src/shared/contracts/commands/names.ts +2 -0
  42. package/src/shared/contracts/commands/requests.ts +7 -0
  43. package/src/shared/contracts/commands/results.ts +20 -1
  44. package/src/shared/contracts/promotion.ts +16 -0
  45. package/src/shared/contracts/validation.ts +7 -0
  46. package/src/shared/errors/facet-error.ts +2 -0
  47. package/src/shared/errors/store-error.ts +4 -0
  48. package/src/shared/html/artifact-main.ts +3 -0
  49. package/src/shared/storage-version.ts +1 -1
  50. package/src/validation/tier0/dom-shim.ts +3 -18
  51. package/src/validation/tier0/markdown.ts +28 -5
  52. package/src/validation/tier0/mermaid.ts +17 -8
  53. package/src/validation/tier0/worker-dispatch.ts +1 -1
  54. package/src/validation/tier1/entries/tsx.ts +1 -0
  55. package/src/validation/tier1/harness.ts +2 -1
  56. package/src/validation/tier1/isolated-probe.ts +4 -0
  57. package/src/validation/tier1/protocol-probe.ts +37 -0
  58. package/src/validation/tier1/runner.ts +21 -4
  59. 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 });
@@ -0,0 +1,3 @@
1
+ export function artifactMainAttributes(artifactType: string): string {
2
+ return `id="artifact" data-facet-artifact-type="${artifactType}"`;
3
+ }
@@ -1,2 +1,2 @@
1
1
  /** Latest schema migration applied by the service. */
2
- export const CURRENT_STORAGE_VERSION = 9 as const;
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 and other browser-shaped libraries perform import-time
5
- * initialization against `globalThis.document`, `window`, and the
6
- * `implementation.createHTMLDocument` API. Bun's worker process has
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
- * Parse the source bytes as markdown. Pure tokenization — no DOM, no
165
- * render, no script execution. Raw HTML is counted (it must NOT
166
- * execute, and we surface structural red flags if it tries to smuggle
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's import-time
12
- * DOMPurify check succeeds. The shim is structural: it never executes
13
- * artifact source against the DOM. netns ensures any library-initiated
14
- * network egress cannot reach a host.
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 resolved = (await (mermaid as { parse: (s: string) => Promise<MermaidResolved> }).parse(
62
- text,
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 id="artifact" data-facet-nonce="${nonce}"></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`. Order of precedence:
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
- * 1. Lifecycle: `renderComplete === false` → `timeout`
97
- * 2. Trust: shim disagrees with protocol → `tampered`
98
- * 3. Trust: isolated disagrees with protocol → `tampered`
99
- * 4. Channel availability: both shim AND isolated missing → `probe_only`
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
  );