From ec152d072dcf72cfa60d82ada5a9edc24d6097de Mon Sep 17 00:00:00 2001 From: lukachi Date: Wed, 2 Sep 2026 21:09:58 +0300 Subject: [PATCH] feat(web): add offline manifest inspection tooling --- .github/workflows/tx-manifest-check.yml | 30 +- apps/web/package.json | 2 + apps/web/src/App.tsx | 48 +++- apps/web/src/app/format/index.test.tsx | 87 ++++++ apps/web/src/app/format/index.tsx | 126 +++++++++ apps/web/src/app/format/positions.ts | 26 ++ apps/web/src/app/home/index.tsx | 31 ++- .../components/ConstructTable.test.tsx | 113 ++++++++ .../manifest/components/ConstructTable.tsx | 134 +++++++++ .../components/ContractSourceList.tsx | 88 ++++++ .../manifest/components/RewriteList.test.tsx | 47 ++++ .../app/manifest/components/RewriteList.tsx | 42 +++ .../app/manifest/components/Verdict.test.tsx | 230 ++++++++++++++++ .../src/app/manifest/components/Verdict.tsx | 185 +++++++++++++ .../manifest/components/groupByState.test.ts | 136 +++++++++ .../app/manifest/components/groupByState.ts | 85 ++++++ .../src/app/manifest/contractSources.test.ts | 67 +++++ apps/web/src/app/manifest/contractSources.ts | 48 ++++ apps/web/src/app/manifest/index.test.tsx | 54 ++++ apps/web/src/app/manifest/index.tsx | 188 +++++++++++++ .../web/src/app/manifest/readDocument.test.ts | 157 +++++++++++ apps/web/src/app/manifest/readDocument.ts | 73 +++++ apps/web/src/bun-test-env.d.ts | 6 + apps/web/tsconfig.app.json | 8 +- apps/web/tsconfig.tooling.json | 22 ++ bun.lock | 1 + .../tx-manifest/src/document/inspect.test.ts | 258 ++++++++++++++++++ packages/tx-manifest/src/document/inspect.ts | 128 +++++++++ packages/tx-manifest/src/document/refuse.ts | 133 +++++++++ .../tx-manifest/src/document/registry.test.ts | 83 ++++++ packages/tx-manifest/src/document/registry.ts | 147 +++++++++- packages/tx-manifest/src/document/sites.ts | 22 ++ packages/tx-manifest/src/index.ts | 27 ++ 33 files changed, 2810 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/app/format/index.test.tsx create mode 100644 apps/web/src/app/format/index.tsx create mode 100644 apps/web/src/app/format/positions.ts create mode 100644 apps/web/src/app/manifest/components/ConstructTable.test.tsx create mode 100644 apps/web/src/app/manifest/components/ConstructTable.tsx create mode 100644 apps/web/src/app/manifest/components/ContractSourceList.tsx create mode 100644 apps/web/src/app/manifest/components/RewriteList.test.tsx create mode 100644 apps/web/src/app/manifest/components/RewriteList.tsx create mode 100644 apps/web/src/app/manifest/components/Verdict.test.tsx create mode 100644 apps/web/src/app/manifest/components/Verdict.tsx create mode 100644 apps/web/src/app/manifest/components/groupByState.test.ts create mode 100644 apps/web/src/app/manifest/components/groupByState.ts create mode 100644 apps/web/src/app/manifest/contractSources.test.ts create mode 100644 apps/web/src/app/manifest/contractSources.ts create mode 100644 apps/web/src/app/manifest/index.test.tsx create mode 100644 apps/web/src/app/manifest/index.tsx create mode 100644 apps/web/src/app/manifest/readDocument.test.ts create mode 100644 apps/web/src/app/manifest/readDocument.ts create mode 100644 apps/web/src/bun-test-env.d.ts create mode 100644 apps/web/tsconfig.tooling.json create mode 100644 packages/tx-manifest/src/document/inspect.test.ts create mode 100644 packages/tx-manifest/src/document/inspect.ts create mode 100644 packages/tx-manifest/src/document/registry.test.ts diff --git a/.github/workflows/tx-manifest-check.yml b/.github/workflows/tx-manifest-check.yml index 140057a..f3b6de0 100644 --- a/.github/workflows/tx-manifest-check.yml +++ b/.github/workflows/tx-manifest-check.yml @@ -1,6 +1,7 @@ name: tx-manifest / smplx check -# The gate for the tx-manifest package and the smplx adapter, and only for those. +# The gate for the tx-manifest package, the smplx adapter and the offline developer +# tooling in the web app, and only for those. # # Deliberately not `bun run check`. On a clean frozen install against the pinned # submodules, the repository-wide typecheck fails in existing UI code on duplicate React @@ -73,6 +74,25 @@ jobs: apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts \ apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts + # The web half of this slice: the manifest inspector and the format support page, which + # read the manifest runtime's TypeScript rather than only a JSON fixture from it. + # + # `tsconfig.tooling.json` narrows what is checked, not what is read: it starts from these + # two directories and follows every UI component and package they import under the app's + # own settings, with nothing stubbed or skipped. The whole-app and root typechecks still + # fail on the duplicate type families described above, and are restored in the activation + # slice — gating on them here would report that against every change to this tooling. + - name: Typecheck the manifest tooling + run: bun --filter='./apps/web' run typecheck:tooling + + # The production module graph, bundled. It is what says the two new views are reachable + # through the App/Home navigation and that everything they pull in resolves for a browser. + # Vite directly rather than the app's `build` script, which runs the whole-app typecheck + # first: bundling succeeds where that typecheck does not, and this step is about the bundle. + - name: Build the web app + working-directory: apps/web + run: bunx vite build + # Both are repository-wide and both pass on this branch, so they are run as they are # rather than narrowed to these paths. - name: Lint @@ -81,5 +101,11 @@ jobs: - name: Check formatting run: bun run format:check + # `apps/web` carries the server-rendered assertions for the inspector and the format + # page. They render each view to a string with no wallet, no provider and no document, + # which is the strongest available check that those pages stand alone — so they have to + # run here rather than only in a full-repository gate this job deliberately skips. - name: Test - run: bun test packages/tx-manifest apps/extension/src/core/chains/liquid/adapters/smplx + run: | + bun test packages/tx-manifest apps/web \ + apps/extension/src/core/chains/liquid/adapters/smplx diff --git a/apps/web/package.json b/apps/web/package.json index 57be9d2..cc48b75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,12 +7,14 @@ "dev": "vite", "build": "tsc -b && vite build", "typecheck": "tsc --noEmit", + "typecheck:tooling": "tsc -p tsconfig.tooling.json --noEmit --pretty false", "preview": "vite preview", "cleanup": "rm -rf node_modules out dist" }, "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@humid/appkit-injected-adapter": "workspace:*", + "@humid/tx-manifest": "workspace:*", "@reown/appkit": "^1.8.19", "@reown/appkit-common": "^1.8.19", "@reown/appkit-controllers": "^1.8.21", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7719dce..68b3764 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,31 +2,53 @@ import { ChevronLeftIcon } from "lucide-react"; import { useState } from "react"; import Dashboard from "@/app/dashboard"; +import FormatSupport from "@/app/format"; import Home from "@/app/home"; +import ManifestInspector from "@/app/manifest"; import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -type View = "home" | "developer"; +type View = "developer" | "format" | "home" | "manifest"; export function App() { const [view, setView] = useState("home"); return ( - {view === "home" ? ( - setView("developer")} /> - ) : ( -
-
- + {(() => { + if (view === "home") { + return ( + setView("developer")} + onOpenFormatSupport={() => setView("format")} + onOpenManifestInspector={() => setView("manifest")} + /> + ); + } + + return ( +
+
+ +
+ {(() => { + if (view === "developer") { + return ; + } + + if (view === "format") { + return ; + } + + return ; + })()}
- -
- )} + ); + })()} ); diff --git a/apps/web/src/app/format/index.test.tsx b/apps/web/src/app/format/index.test.tsx new file mode 100644 index 0000000..6ffc35d --- /dev/null +++ b/apps/web/src/app/format/index.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; + +import { describeRegistry } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import FormatSupport from "./index"; +import { WHERE_IT_SITS } from "./positions"; + +// The page's whole content is the runtime's own construct table, so what is checked here is +// that all of it arrives, that what the wallet cannot do leads, and that every gap carries its +// reason — the part no document can ever show, because no published protocol uses any of them. + +function render(): string { + return renderToStaticMarkup(); +} + +describe("what this wallet does not implement", () => { + test("leads with it, before anything the wallet does read", () => { + const html = render(); + + expect(html.indexOf("Not implemented")).toBeLessThan( + html.indexOf("Read, and it changes what gets signed"), + ); + }); + + test("names every construct the runtime does not act on, with its reason", () => { + const html = render(); + + for (const entry of describeRegistry().filter((candidate) => candidate.reason !== undefined)) { + expect(html).toContain(entry.key); + expect(html).toContain(escaped(entry.reason ?? "")); + } + }); + + // The count is what an engineer came for and the one thing that must not be written down by + // hand: a sentence saying "eight" survives a ninth being added. + test("counts what is missing from the table rather than from a sentence", () => { + const unimplemented = describeRegistry().filter((entry) => entry.state === "unimplemented"); + + expect(render()).toContain(`>${unimplemented.length}`); + }); +}); + +describe("the whole table, not a sample of it", () => { + test("renders every construct the runtime registers", () => { + const html = render(); + + for (const entry of describeRegistry()) { + expect(html).toContain(entry.key); + } + }); + + test("says how much of the format this is, counted rather than stated", () => { + const entries = describeRegistry(); + const positioned = entries.filter((entry) => entry.site !== undefined); + + expect(render()).toContain(`${positioned.length} fields at`); + }); + + test("says where each one sits in words a reader can use", () => { + const html = render(); + + for (const where of Object.values(WHERE_IT_SITS)) { + expect(html).toContain(where); + } + }); +}); + +describe("the page stands alone", () => { + // It holds no wallet context and reads no document: every other surface in this app reads a + // wallet context, and reading a missing one would throw. + test("renders with no wallet, no provider, no network and nothing pasted", () => { + const html = render(); + + expect(html).toContain("What this wallet reads of the format"); + expect(html).not.toContain("", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/web/src/app/format/index.tsx b/apps/web/src/app/format/index.tsx new file mode 100644 index 0000000..721407b --- /dev/null +++ b/apps/web/src/app/format/index.tsx @@ -0,0 +1,126 @@ +import { type ConstructRegistryEntry, describeRegistry } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +import { WHERE_IT_SITS } from "./positions"; + +/** + * What this wallet reads of the transaction-manifest format, and what it does not. + * + * The manifest page answers a question about one document. This one answers a question no + * document can: a construct nobody has published is invisible in every document there is, and + * every construct the format defines and this wallet does not implement is in that position. + * Every published protocol therefore inspects clean while they stand, which is why this is a + * page rather than a section beside a box someone pastes into. + * + * It reads nothing from that page and nothing from anywhere else. Its whole content is the + * runtime's own construct table, so it cannot describe a wallet that differs from the one that + * runs — including the reason beside each gap, which is data the table refuses to compile + * without rather than a sentence written here. + */ +export default function FormatSupport() { + const entries = describeRegistry(); + + return ( +
+ + + What this wallet reads of the format + + Every field the transaction-manifest format defines, against what this wallet does with + it. Nothing here depends on a document — it is the same table the wallet decides by, + printed. + + + +

{summaryOf(entries)}

+
+
+ +
entry.state === "unimplemented")} + /> +
entry.state === "never-read")} + /> +
entry.state === "shown")} + /> +
entry.state === "acted-on")} + /> +
+ ); +} + +function Section({ + description, + entries, + title, +}: { + description: string; + entries: ConstructRegistryEntry[]; + title: string; +}) { + if (entries.length === 0) { + return null; + } + + return ( + + + + {title} + {entries.length} + + {description} + + +
+ + + {entries.map((entry) => ( + + + + + + ))} + +
{entry.key} + {WHERE_IT_SITS[entry.site ?? "everywhere"]} + {entry.reason}
+
+
+
+ ); +} + +/** + * How much of the format this is, said before any of it is read. + * + * Counted from the table rather than written down, so the sentence cannot fall behind the thing + * it describes — which is the same reason this page exists at all. + */ +function summaryOf(entries: ConstructRegistryEntry[]): string { + const positioned = entries.filter((entry) => entry.site !== undefined); + const kinds = new Set(positioned.map((entry) => entry.site)).size; + const everywhere = entries.length - positioned.length; + + return ( + `${positioned.length} fields at ${kinds} kinds of position, plus ${everywhere} that any ` + + "JSON document may carry anywhere. Each one this wallet does not act on says why." + ); +} diff --git a/apps/web/src/app/format/positions.ts b/apps/web/src/app/format/positions.ts new file mode 100644 index 0000000..a4be1b5 --- /dev/null +++ b/apps/web/src/app/format/positions.ts @@ -0,0 +1,26 @@ +import type { ConstructSiteKind } from "@humid/tx-manifest"; + +/** + * Where a field sits, in the words a person would use for it. + * + * A translation and not a claim: the runtime keys its table by these names and this says the + * same thing in English, so nothing here can be true while the runtime says otherwise. It is + * typed against the runtime's own set, so a kind of position added there and forgotten here + * fails to compile rather than rendering a key nobody can read. + * + * `everywhere` is not one of the runtime's kinds. It stands for the keys any JSON document may + * carry at any depth, which the runtime answers once rather than listing at every position. + */ +export const WHERE_IT_SITS: Record = { + action: "on an action", + everywhere: "anywhere", + input: "on an input", + manifest: "on the document", + output: "on an output", + param: "on a parameter", + script: "on a contract", + ui: "in display metadata", + utxoType: "on a kind of holding", + validation: "on a rule", + witness: "on a witness", +}; diff --git a/apps/web/src/app/home/index.tsx b/apps/web/src/app/home/index.tsx index d9e65ff..41e763d 100644 --- a/apps/web/src/app/home/index.tsx +++ b/apps/web/src/app/home/index.tsx @@ -8,7 +8,15 @@ import { HomeActions } from "./components/HomeActions"; * The product Home: an identity-first hero (network, "signed in as", balance) with a row of primary * actions. A thin consumer of {@link useHumidContext} — all wallet plumbing lives in the context. */ -export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void }) { +export default function Home({ + onOpenDeveloper, + onOpenFormatSupport, + onOpenManifestInspector, +}: { + onOpenDeveloper: () => void; + onOpenFormatSupport: () => void; + onOpenManifestInspector: () => void; +}) { const { hasProvider, isConnected } = useHumidContext(); return ( @@ -21,7 +29,10 @@ export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void {hasProvider && isConnected ? : null} -
+ {/* The offline pages sit beside Developer rather than inside it: the cards there are all + ways of driving a wallet and disappear when none is installed, which is exactly when + reading a document — or the table the wallet reads one by — is most useful. */} +
+ +
); diff --git a/apps/web/src/app/manifest/components/ConstructTable.test.tsx b/apps/web/src/app/manifest/components/ConstructTable.test.tsx new file mode 100644 index 0000000..04445c9 --- /dev/null +++ b/apps/web/src/app/manifest/components/ConstructTable.test.tsx @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; + +import type { ConstructReport, ConstructSiteKind, ConstructState } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ConstructTable } from "./ConstructTable"; + +// The five states and the positions come from the package and are tested there; what is checked +// here is that a reader is shown the state, the field, where it sits, how many places that is, +// and — the part a state name alone does not carry — what that state means for them. + +function report( + state: ConstructState, + key: string = state, + at = "manifest", + site: ConstructSiteKind = "manifest", +): ConstructReport { + return { at, key, site, state }; +} + +function render(constructs: ConstructReport[]): string { + return renderToStaticMarkup(); +} + +describe("what a reader is told about each field", () => { + test("shows the field, where it sits, and its state", () => { + const html = render([report("unimplemented", "args", "action Pay", "action")]); + + expect(html).toContain("args"); + expect(html).toContain("action Pay"); + expect(html).toContain("unimplemented"); + }); + + test("explains what each state means rather than only naming it", () => { + expect(render([report("acted-on")])).toContain("changes what gets signed"); + expect(render([report("shown")])).toContain("It decides nothing"); + expect(render([report("unimplemented")])).toContain("does not implement it"); + expect(render([report("unrecognised")])).toContain("No specification this wallet knows"); + expect(render([report("never-read")])).toContain("read by nothing"); + }); + + test("a document declaring nothing says so rather than drawing an empty table", () => { + const html = render([]); + + expect(html).toContain("declares no fields"); + expect(html).not.toContain(" { + expect(render([report("acted-on")])).not.toContain("never-read"); + }); +}); + +describe("a key that recurs draws one row", () => { + test("counts the positions instead of repeating the field", () => { + const html = render([ + report("unimplemented", "args", "action Pay", "action"), + report("unimplemented", "args", "action Refund", "action"), + report("unimplemented", "args", "action Close", "action"), + ]); + + expect(html.match(/args/g)).toHaveLength(1); + expect(html).toContain("3 positions"); + }); + + test("still names every position, so nothing is only counted", () => { + const html = render([ + report("unimplemented", "args", "action Pay", "action"), + report("unimplemented", "args", "action Refund", "action"), + ]); + + expect(html).toContain("action Pay"); + expect(html).toContain("action Refund"); + }); + + test("names the one position outright when a field sits at exactly one", () => { + const html = render([report("unimplemented", "args", "action Pay", "action")]); + + expect(html).toContain("action Pay"); + expect(html).not.toContain("1 positions"); + }); +}); + +describe("what is working opens closed", () => { + // Not hidden and not dropped: the count is visible without clicking and the rows are one + // click away. What is removed is meeting hundreds of rows that say a field works before + // reaching the few that say anything else. + test("puts the states that mean nothing is wrong behind a disclosure", () => { + const html = render([report("acted-on", "chain"), report("shown", "description")]); + + expect(html.match(/
{ + const html = render([ + report("unrecognised", "wat"), + report("unimplemented", "args", "action Pay", "action"), + report("never-read", "source"), + ]); + + expect(html).not.toContain(" { + const html = render([ + report("acted-on", "chain"), + report("acted-on", "amount_sat", "action Pay / output a", "output"), + report("acted-on", "amount_sat", "action Pay / output b", "output"), + ]); + + expect(html).toContain("2 fields, at 3 positions"); + }); +}); diff --git a/apps/web/src/app/manifest/components/ConstructTable.tsx b/apps/web/src/app/manifest/components/ConstructTable.tsx new file mode 100644 index 0000000..c427fc7 --- /dev/null +++ b/apps/web/src/app/manifest/components/ConstructTable.tsx @@ -0,0 +1,134 @@ +import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; + +import { type ConstructGroup, groupByState } from "./groupByState"; + +/** + * What each state means, in the words a protocol author would use. + * + * The state names are the runtime's; these sentences are what a person reading the table + * actually needs, and they say what happens rather than what the field is called. + */ +const MEANING: Record = { + "acted-on": { badge: "default", sentence: "Read, and it changes what gets signed." }, + "never-read": { + badge: "ghost", + sentence: "Known to the format and read by nothing, here or in the reference implementation.", + }, + shown: { badge: "secondary", sentence: "Read, and shown to a person. It decides nothing." }, + unimplemented: { + badge: "destructive", + sentence: "The format defines it and this wallet does not implement it.", + }, + unrecognised: { + badge: "destructive", + sentence: "No specification this wallet knows describes this field here.", + }, +}; + +type BadgeVariant = "default" | "destructive" | "ghost" | "secondary"; + +/** + * Every construct this document declares, once each, against what the runtime does with it. + * + * One row per construct rather than per position, because a key genuinely recurs — dozens of + * places in a deployed protocol — and a row per place is hundreds of rows saying a few dozen + * things. The places are still all here, under the row that counts them. + * + * The two states that mean nothing is wrong open collapsed. That is the whole of what was + * unreadable: not that the information was present, but that hundreds of rows of "this field + * works" came before the ones that said anything else. + */ +export function ConstructTable({ constructs }: { constructs: ConstructReport[] }) { + if (constructs.length === 0) { + return

This document declares no fields.

; + } + + return ( +
+ {groupByState(constructs).map((group) => ( + + ))} +
+ ); +} + +function Group({ group }: { group: ConstructGroup }) { + const heading = ( +
+ {group.state} + {MEANING[group.state].sentence} + {countOf(group)} +
+ ); + + if (!group.nothingWrong) { + return ( +
+ {heading} + +
+ ); + } + + return ( +
+ {heading} +
+ +
+
+ ); +} + +function Rows({ group }: { group: ConstructGroup }) { + return ( +
+ + + {group.rows.map((row) => ( + + + + + ))} + +
{row.key} + {whereOf(row)} + {row.at.length > 1 && {row.at.join(" · ")}} +
+
+ ); +} + +/** + * Where one construct sits, said as a place when there is one and as a count when there are + * many. The places themselves follow underneath either way, so the count is a headline rather + * than a substitute. + */ +function whereOf(row: { at: string[] }): string { + if (row.at.length === 1) { + return row.at[0] ?? ""; + } + + return `${row.at.length} positions`; +} + +/** + * How much this group holds, said before it is opened. + * + * A collapsed group whose size is unknown is a page hiding something; a collapsed group that + * says how many constructs and how many positions it holds is a page that has already answered + * the only question the reader had about it. + */ +function countOf(group: ConstructGroup): string { + const positions = group.rows.reduce((total, row) => total + row.at.length, 0); + const constructs = `${group.rows.length} ${group.rows.length === 1 ? "field" : "fields"}`; + + if (positions === group.rows.length) { + return constructs; + } + + return `${constructs}, at ${positions} positions`; +} diff --git a/apps/web/src/app/manifest/components/ContractSourceList.tsx b/apps/web/src/app/manifest/components/ContractSourceList.tsx new file mode 100644 index 0000000..b3a4451 --- /dev/null +++ b/apps/web/src/app/manifest/components/ContractSourceList.tsx @@ -0,0 +1,88 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +import type { SuppliedSource } from "../contractSources"; + +/** + * The contracts a document references, and which of them this page has been handed. + * + * A version this wallet does not ship can be asked for in two places, and one of them is + * inside the contract source. Nothing about a document says what its contracts contain, so + * this is the only way the second half of that check can run at all — and until it does, the + * page says so rather than reporting the check as done. + * + * The files never leave the page. They are read in the browser, the same way the document in + * the textarea is, which is what lets this ask for them at all. + */ +export function ContractSourceList({ + contracts, + onClear, + onSupply, + supplied, + unmatched, +}: { + contracts: readonly string[]; + onClear: () => void; + onSupply: (sources: SuppliedSource[]) => void; + supplied: Record; + unmatched: readonly string[]; +}) { + return ( +
+
+ + { + const chosen = [...(event.target.files ?? [])]; + + onSupply( + await Promise.all( + chosen.map(async (file) => ({ name: file.name, text: await file.text() })), + ), + ); + }} + /> +
+ + {contracts.length === 0 ? ( +

+ This document references no contract sources, so the compiler check has only the + document’s own declaration to read. +

+ ) : ( +
    + {contracts.map((path) => ( +
  • + + {path in supplied ? "read" : "not read"} + + {path} +
  • + ))} +
+ )} + + {unmatched.length > 0 && ( +

+ This document references nothing by the name {unmatched.join(", ")}, so it was not given + to the reader. A source is checked under the path the document asks for it by, and nothing + else. +

+ )} + + {Object.keys(supplied).length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/manifest/components/RewriteList.test.tsx b/apps/web/src/app/manifest/components/RewriteList.test.tsx new file mode 100644 index 0000000..5b49109 --- /dev/null +++ b/apps/web/src/app/manifest/components/RewriteList.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; + +import type { NormalisationNote } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { RewriteList } from "./RewriteList"; + +// Three things per rewrite — where, the name it now carries, the name it +// had — now sitting with the fields rather than in a region of their own. The statement that a +// document needed no rewriting moved to the verdict, so this renders nothing at all for a clean +// document: the page says it once, where the answer is. + +function render(rewrites: NormalisationNote[]): string { + return renderToStaticMarkup(); +} + +describe("what a reader is told about older spellings", () => { + test("shows the name found, the name it now carries, and where", () => { + const html = render([{ at: "action Pay", canonical: "is_constructor", found: "deploy" }]); + + expect(html).toContain("deploy"); + expect(html).toContain("is_constructor"); + expect(html).toContain("action Pay"); + }); + + // The verdict carries this now, in one sentence beside the answer it belongs to. A second + // statement here would be the page saying the same thing twice at different weights. + test("a clean document draws nothing here at all", () => { + expect(render([])).toBe(""); + }); + + test("says what a rewrite means: the document is from an earlier generation", () => { + const html = render([{ at: "manifest", canonical: "params", found: "compile_params" }]); + + expect(html).toContain("earlier generation"); + }); + + test("shows every rewrite, not only the first", () => { + const html = render([ + { at: "manifest", canonical: "manifest_version", found: "compose_version" }, + { at: "manifest", canonical: "params", found: "compile_params" }, + ]); + + expect(html).toContain("compose_version"); + expect(html).toContain("compile_params"); + }); +}); diff --git a/apps/web/src/app/manifest/components/RewriteList.tsx b/apps/web/src/app/manifest/components/RewriteList.tsx new file mode 100644 index 0000000..284fe43 --- /dev/null +++ b/apps/web/src/app/manifest/components/RewriteList.tsx @@ -0,0 +1,42 @@ +import type { NormalisationNote } from "@humid/tx-manifest"; + +/** + * The renamings themselves, against the fields they renamed. + * + * What is worth knowing from a rewrite is that the document belongs to an earlier generation of + * the format, and that is one sentence, which lives in the verdict. What is left here is a + * lookup, for someone who has the document open and wants to know which of its keys the runtime + * knows by another name. + * + * Nothing is rendered when nothing was renamed. The verdict has already said so, and a second + * statement of it here would be the page repeating itself at the reader. + */ +export function RewriteList({ rewrites }: { rewrites: NormalisationNote[] }) { + if (rewrites.length === 0) { + return null; + } + + return ( +
+

Renamed on the way in

+

+ The wallet accepted these older spellings and read them under the current name. A document + needing this is from an earlier generation of the format — it still works, and nothing about + it says which generation it is. +

+
+ + + {rewrites.map((note) => ( + + + + + + ))} + +
{note.found}{note.canonical}{note.at}
+
+
+ ); +} diff --git a/apps/web/src/app/manifest/components/Verdict.test.tsx b/apps/web/src/app/manifest/components/Verdict.test.tsx new file mode 100644 index 0000000..bb4e16e --- /dev/null +++ b/apps/web/src/app/manifest/components/Verdict.test.tsx @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { Verdict } from "./Verdict"; + +// The claims this page makes, at the only place they can be checked: the text a reader actually +// meets. Rendered to a string rather than to a DOM, because this repository has no DOM in its +// tests and react-dom is already here — the assertions below are about words on a screen, and a +// string carries those. + +function render(inspection: Parameters[0]["inspection"]): string { + return renderToStaticMarkup(); +} + +const NOTHING_ASKED: Pick< + Parameters[0]["inspection"], + "constructs" | "partial" | "rewrites" | "skipped" | "unreachable" +> = { + constructs: [], + partial: [], + rewrites: [], + skipped: [], + unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], +}; + +describe("the answer this page came to give", () => { + test("says what the wallet would do before it says anything else", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: 'This protocol is for "bitcoin".', reject: "foreign-chain" }, + }); + + expect(html.indexOf("would refuse to build an action")).toBeLessThan( + html.indexOf("Not decidable from a document at all"), + ); + }); + + test("leads with the reader's own sentence, which names where in the document", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { + reason: 'This protocol uses "args" at action Pay, which this wallet does not implement.', + reject: "unimplemented-construct", + }, + }); + + expect(html).toContain("action Pay"); + expect(html.indexOf("action Pay")).toBeLessThan(html.indexOf("unimplemented-construct")); + }); + + // The single most misreadable thing on the page. A document can be flawless in every way a + // document can be judged and still be unbuildable for want of money. + test("never lets no-refusal read as a promise that the wallet would build", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("Nothing a document alone can decide refuses this one"); + expect(html).toContain("not a statement that the wallet would build"); + }); +}); + +describe("what was never asked, beside the answer", () => { + test("names the unreachable checks whether or not a refusal was found", () => { + for (const refusal of [undefined, { reason: "…", reject: "foreign-chain" as const }]) { + const html = render({ ...NOTHING_ASKED, refusal }); + + expect(html).toContain("covenant-mismatch"); + expect(html).toContain("shortfall"); + expect(html).toContain("no-fee-rate"); + expect(html).toContain("Not decidable from a document at all"); + } + }); + + test("says why the unreachable ones are unreachable, and how many", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("3 of this wallet's refusals"); + expect(html).toContain("money"); + expect(html).toContain("chain read"); + }); + + // Nothing that says a check was not made may hide behind a click: the absence of a + // refusal is only honest beside the list of what was never asked. + test("puts nothing unchecked inside a disclosure", () => { + const html = render({ + constructs: [], + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + rewrites: [], + skipped: ["foreign-compiler"], + unreachable: ["shortfall"], + }); + + expect(html).not.toContain(" { + const html = render({ + constructs: [], + partial: [], + refusal: undefined, + rewrites: [], + skipped: ["foreign-compiler"], + unreachable: ["shortfall"], + }); + + expect(html).toContain("Not checked, because this page has not been given what they need"); + expect(html).toContain("foreign-compiler"); + expect(html).toContain("Not decidable from a document at all"); + }); + + // Between skipped and done there is a third answer, and the page has to carry it or a check + // that read one of its two places is read as one that passed. + test("keeps a half-answered check apart from both a skipped one and a passed one", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("Checked in one of the two places that decide it"); + expect(html).toContain("./p2pk.simf"); + expect(html).not.toContain("Not checked, because"); + }); + + test("says which sources went unread rather than that some did", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./lending.simf", "./script_auth.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("./lending.simf"); + expect(html).toContain("./script_auth.simf"); + }); + + test("says nothing about a half-answered check when every check was answered in full", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Checked in one of"); + }); + + test("says nothing about skipped checks when none were skipped", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Not checked, because"); + }); + + test("tells a reader who can still answer that they can", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined, skipped: ["foreign-compiler"] }); + + expect(html).toContain("Name it above and the check runs"); + }); +}); + +describe("the runtime's own names for its refusals", () => { + // A person cannot act on a reject token; they can act on the sentence beside it. The + // token stays for whoever is chasing one into the code, and stops being what they meet first. + test("never puts a token where the heading goes", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: "This protocol is for bitcoin.", reject: "foreign-chain" }, + }); + + const headings = [...html.matchAll(/]*>([^<]*)<\/h3>/g)].map((match) => match[1]); + + expect(headings.length).toBeGreaterThan(0); + + for (const heading of headings) { + for (const token of ["foreign-chain", ...NOTHING_ASKED.unreachable]) { + expect(heading).not.toContain(token); + } + } + + expect(html.indexOf("This protocol is for bitcoin.")).toBeLessThan( + html.indexOf("foreign-chain"), + ); + }); +}); + +describe("older spellings, said once", () => { + // A renaming that succeeded changed nothing about the answer, so what is + // worth saying is that the document belongs to an earlier generation — one sentence, here. + test("counts them and says they changed nothing about the answer", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: undefined, + rewrites: [ + { at: "manifest", canonical: "manifest_version", found: "compose_version" }, + { at: "manifest", canonical: "params", found: "compile_params" }, + ], + }); + + expect(html).toContain("2 older spellings"); + expect(html).toContain("changed nothing about the answer"); + }); + + test("says so when a document needed none, rather than leaving it unsaid", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("current spelling"); + }); +}); + +describe("more than one field would refuse", () => { + // A protocol refusing on one decorative field reads as hopeless when the field table below + // says a few fixable gaps, and the runtime names only the first by design. + test("says how many fields would refuse, not only which one the wallet names", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [ + { at: "manifest", key: "$schema", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "contract_templates", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "simplicity_hl", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "description", site: "manifest", state: "shown" }, + ], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).toContain("3 fields in this document would refuse"); + expect(html).toContain("The other 2"); + }); + + test("does not count when the wallet's one refusal is the whole of it", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [{ at: "manifest", key: "$schema", site: "manifest", state: "unrecognised" }], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).not.toContain("would refuse, and the wallet names"); + }); +}); diff --git a/apps/web/src/app/manifest/components/Verdict.tsx b/apps/web/src/app/manifest/components/Verdict.tsx new file mode 100644 index 0000000..804adac --- /dev/null +++ b/apps/web/src/app/manifest/components/Verdict.tsx @@ -0,0 +1,185 @@ +import type { ManifestInspection, RejectToken } from "@humid/tx-manifest"; + +/** + * What this wallet would do with the document, and — always beside it — what was never asked. + * + * The answer leads. Everything the reader computed is available further down the page, but a + * person holding a document is deciding one thing, and a page that opens with an inventory + * makes them assemble the answer themselves out of parts that all look equally important. + * + * The absence of a refusal is the most misreadable thing here. A document can be flawless in + * every way a document can be judged and still be unbuildable for want of money, a fee rate, + * or the covenant actually being where the state file says. So the unreached checks are not a + * footnote and never collapse: they are rendered in this same region, whether or not a refusal + * was found, and a tab or a disclosure would put back exactly the misreading they prevent. + * + * The runtime's own names for its refusals stay reachable and stop being headlines. A person + * cannot act on `unbuildable-utxo-type`; they can act on the sentence beside it, which names + * the position in the document. A dozen of those names set as badges is the page shouting its + * vocabulary at someone who came to ask a question. + * + * The second most misreadable thing is that the runtime returns one refusal and does so + * deliberately: a person deciding whether to trust a site is not helped by a list of a dozen + * field names. A developer diagnosing coverage is misled by it — a protocol refusing on one + * decorative field reads as hopeless when the truth is a few fixable gaps. Saying how many + * fields are in that class is not disagreeing with the runtime's choice; it is this page + * declining to let one stand in for all of them. + */ +export function Verdict({ + inspection, +}: { + inspection: Pick< + ManifestInspection, + "constructs" | "partial" | "refusal" | "rewrites" | "skipped" | "unreachable" + >; +}) { + const wouldRefuse = inspection.constructs.filter( + (report) => report.state === "unimplemented" || report.state === "unrecognised", + ); + + return ( +
+ {(() => { + if (!inspection.refusal) { + return ( +
+

+ Nothing a document alone can decide refuses this one. +

+

+ This is not a statement that the wallet would build an action from it. Read it with + what was not checked, below. +

+
+ ); + } + + return ( +
+

+ This wallet would refuse to build an action from this document. +

+

{inspection.refusal.reason}

+ {wouldRefuse.length > 1 && ( +

+ {wouldRefuse.length} fields in this document would refuse, and the wallet names the + first. The other {wouldRefuse.length - 1} are in the field table below, under + unrecognised and unimplemented — fixing this one uncovers them rather than + finishing. +

+ )} + +
+ ); + })()} + +

+ {spellingSentence(inspection.rewrites.length)} +

+ + {inspection.skipped.length > 0 && ( + + )} + + {inspection.partial.length > 0 && ( +
+

Checked in one of the two places that decide it

+

+ A compiler version is declared twice: by the document, and by a directive inside each + contract source. The document’s own declaration was checked. These sources were + not read, so what they ask for is unknown — which is not the same as agreeing. Open them + above and the check completes. +

+ {inspection.partial.map((check) => ( +

+ {check.reject} · {check.unread.join(" · ")} +

+ ))} +
+ )} + + +
+ ); +} + +/** + * What the older spellings amount to, said once and in the verdict's own region. + * + * A renaming that succeeded changed nothing about the answer above it, which is precisely why + * a panel of its own would be unreadable: it would report, at the weight of a finding, that + * nothing had happened. What is worth knowing is that the document belongs to an earlier + * generation of the format, and that is one sentence. A document needing none says so, because + * an absent sentence and a document nobody checked look the same. + */ +function spellingSentence(count: number): string { + if (count === 0) { + return "This document is written in the format's current spelling, so nothing was renamed on the way in."; + } + + return ( + `${count} older spellings were accepted and renamed on the way in. They changed nothing about ` + + "the answer above; the renamings themselves are listed with the fields below." + ); +} + +/** + * Why each unrun check was not run, in the reader's own terms. + * + * Where the answer is the reader's to give, the sentence says so — an explanation that only + * states what is absent leaves the page looking broken rather than waiting. + */ +function whyUnasked(skipped: readonly RejectToken[]): string[] { + const explanations: string[] = []; + + if (skipped.includes("foreign-compiler")) { + explanations.push( + "The compiler check needs the single SimplicityHL version a wallet ships, and this page holds no wallet. Name it above and the check runs.", + ); + } + + return explanations; +} + +function Unasked({ + explanations, + heading, + tokens, +}: { + explanations: readonly string[]; + heading: string; + tokens: readonly string[]; +}) { + return ( +
+

{heading}

+ {explanations.map((explanation) => ( +

+ {explanation} +

+ ))} + +
+ ); +} + +/** + * The runtime's own names for the checks just described. + * + * Present because a developer chasing one of these into the code needs the exact string, and + * subordinate because nobody decides anything from it. Never a heading, never a badge, and + * never collapsed — the sentence above is what is being said, and this is the address of it. + */ +function Names({ tokens }: { tokens: readonly string[] }) { + return

{tokens.join(" · ")}

; +} diff --git a/apps/web/src/app/manifest/components/groupByState.test.ts b/apps/web/src/app/manifest/components/groupByState.test.ts new file mode 100644 index 0000000..13c0159 --- /dev/null +++ b/apps/web/src/app/manifest/components/groupByState.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; + +import { + type ConstructReport, + type ConstructSiteKind, + type ConstructState, + inspectManifestDocument, +} from "@humid/tx-manifest"; +import dexManifest from "@humid/tx-manifest/fixtures/current/dex.manifest.json"; +import lendingV3Manifest from "@humid/tx-manifest/fixtures/current/lending_v3.manifest.json"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; + +import { groupByState } from "./groupByState"; + +// What each field is comes from the package and is tested there; the order a person meets them +// in and how many rows that is are this surface's own decisions, and both are invisible when +// wrong — a table still renders, with the handful of fields worth reading buried under hundreds +// that are working. + +function report( + state: ConstructState, + key: string = state, + at = "manifest", + site: ConstructSiteKind = "manifest", +): ConstructReport { + return { at, key, site, state }; +} + +function rowsFor(document: unknown): number { + const inspection = inspectManifestDocument(document); + + if (!inspection.ok) { + throw new Error("expected a readable document"); + } + + return groupByState(inspection.constructs).reduce((total, group) => total + group.rows.length, 0); +} + +describe("the order fields are shown in", () => { + test("leads with what no specification describes, and trails with what is working", () => { + const grouped = groupByState([ + report("never-read"), + report("shown"), + report("acted-on"), + report("unimplemented"), + report("unrecognised"), + ]); + + expect(grouped.map((group) => group.state)).toEqual([ + "unrecognised", + "unimplemented", + "never-read", + "shown", + "acted-on", + ]); + }); + + test("collapses only the states that mean nothing is wrong", () => { + const grouped = groupByState([ + report("unrecognised"), + report("unimplemented"), + report("never-read"), + report("shown"), + report("acted-on"), + ]); + + expect(grouped.filter((group) => group.nothingWrong).map((group) => group.state)).toEqual([ + "shown", + "acted-on", + ]); + }); + + test("shows no heading for a state this document does not use", () => { + const grouped = groupByState([report("acted-on")]); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.state).toBe("acted-on"); + }); + + test("a document declaring nothing groups into nothing", () => { + expect(groupByState([])).toEqual([]); + }); +}); + +describe("one row per construct, not per position", () => { + test("gathers every position a key was found at into its one row", () => { + const grouped = groupByState([ + report("acted-on", "amount_sat", "action Pay / output p2pk_out", "output"), + report("acted-on", "amount_sat", "action Refund / output refund_out", "output"), + ]); + + expect(grouped[0]?.rows).toHaveLength(1); + expect(grouped[0]?.rows[0]?.at).toEqual([ + "action Pay / output p2pk_out", + "action Refund / output refund_out", + ]); + }); + + // The same key at two kinds of position is two constructs and can be in two states. Merging + // them by name alone would print one row whose state is whichever the loop met last. + test("keeps the same key apart when it sits at different kinds of position", () => { + const grouped = groupByState([ + report("shown", "description", "action Pay", "action"), + report("shown", "description", "action Pay / output p2pk_out", "output"), + ]); + + expect(grouped[0]?.rows).toHaveLength(2); + }); + + test("loses no position, so the whole document is still reachable", () => { + const positions = groupByState([ + report("acted-on", "chain"), + report("acted-on", "utxo_types"), + report("shown", "description"), + ]).flatMap((group) => group.rows.flatMap((row) => row.at)); + + expect(positions).toHaveLength(3); + }); +}); + +// Real numbers, taken from the published protocols rather than from a document written to make +// the assertion pass. The second figure in each name is what a row per position would draw, +// which is what makes the collapse worth having rather than a preference. +describe("what the published protocols draw", () => { + test("the deployed lending protocol: 56 rows rather than 619", () => { + expect(rowsFor(lendingV3Manifest)).toBe(56); + }); + + test("the exchange protocol: 50 rows rather than 235", () => { + expect(rowsFor(dexManifest)).toBe(50); + }); + + test("the simplest published protocol: 40 rows rather than 69", () => { + expect(rowsFor(p2pkManifest)).toBe(40); + }); +}); diff --git a/apps/web/src/app/manifest/components/groupByState.ts b/apps/web/src/app/manifest/components/groupByState.ts new file mode 100644 index 0000000..a5729e1 --- /dev/null +++ b/apps/web/src/app/manifest/components/groupByState.ts @@ -0,0 +1,85 @@ +import type { ConstructReport, ConstructSiteKind, ConstructState } from "@humid/tx-manifest"; + +/** + * The order a reader wants: what stops the build first, then what merely is. + * + * `unrecognised` leads because it is the one state that means nobody has ever specified this + * field here. `acted-on` trails, because it is the state of a field that works — for the + * deployed lending protocol that is most of the document, and putting them before the rest + * buries the few worth reading. + */ +const ORDER: ConstructState[] = [ + "unrecognised", + "unimplemented", + "never-read", + "shown", + "acted-on", +]; + +/** + * The states that mean nothing is wrong, and are therefore collapsed until asked for. + * + * Not hidden and not dropped: a reader who wants the whole document is one click away and the + * count is visible without clicking. What is removed is the default of meeting hundreds of rows + * that each say "this field works" before reaching the few that say anything else. + */ +const NOTHING_WRONG = new Set(["shown", "acted-on"]); + +/** One construct, and every position in this document that declares it. */ +export type FieldRow = { + /** Where it was found, in the document's own terms, in the order the document lists them. */ + at: string[]; + key: string; + site: ConstructSiteKind; +}; + +export type ConstructGroup = { + /** Whether this state means nothing is wrong, and so opens collapsed. */ + nothingWrong: boolean; + rows: FieldRow[]; + state: ConstructState; +}; + +/** + * Groups one document's fields by what the wallet does with them, in reading order, and + * collapses each construct into one row carrying every position it was found at. + * + * A row per position is one row per key per place that key appears, which for the published + * protocols is hundreds of rows over a few dozen distinct keys. Nothing there is wrong and + * nothing is readable, because the repetition is inherent to the shape of the data rather than + * to anything the document did. + * + * A construct is a key at a kind of position, which is how the runtime's own table is keyed: + * `description` on an action and `description` on an output are two constructs and can be in + * two different states. Aggregating by key alone would merge them into one row whose state is + * whichever the loop met last. + * + * A function rather than a few lines inside the component because it is the only decision that + * surface makes: everything else there is layout, and a decision left inside JSX is a decision + * nothing can check. + */ +export function groupByState(constructs: ConstructReport[]): ConstructGroup[] { + return ORDER.map((state) => ({ + nothingWrong: NOTHING_WRONG.has(state), + rows: rowsOf(constructs.filter((report) => report.state === state)), + state, + })).filter((group) => group.rows.length > 0); +} + +function rowsOf(reports: ConstructReport[]): FieldRow[] { + const rows = new Map(); + + for (const report of reports) { + const identity = `${report.site}/${report.key}`; + const row = rows.get(identity); + + if (row) { + row.at.push(report.at); + continue; + } + + rows.set(identity, { at: [report.at], key: report.key, site: report.site }); + } + + return [...rows.values()].toSorted((left, right) => left.key.localeCompare(right.key)); +} diff --git a/apps/web/src/app/manifest/contractSources.test.ts b/apps/web/src/app/manifest/contractSources.test.ts new file mode 100644 index 0000000..aecdd34 --- /dev/null +++ b/apps/web/src/app/manifest/contractSources.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; + +import { matchContractSources } from "./contractSources"; + +// A document references a contract by a path relative to itself and a person hands over a file. +// What must never happen here is a file reaching the reader under a path the document did not +// ask for: the compiler check would then be answered by a source nothing in the document names. + +describe("matching supplied files onto the paths a document uses", () => { + test("puts a file under the path whose last segment is its name", () => { + const { sources } = matchContractSources( + ["./p2pk.simf"], + [{ name: "p2pk.simf", text: "fn main() {}" }], + ); + + expect(sources).toEqual({ "./p2pk.simf": "fn main() {}" }); + }); + + test("matches a path with no directory in it at all", () => { + const { sources } = matchContractSources( + ["lending.simf"], + [{ name: "lending.simf", text: "x" }], + ); + + expect(sources).toEqual({ "lending.simf": "x" }); + }); + + test("a file the document does not reference reaches the reader under no path", () => { + const { sources, unmatched } = matchContractSources( + ["./p2pk.simf"], + [{ name: "something_else.simf", text: "x" }], + ); + + expect(sources).toEqual({}); + expect(unmatched).toEqual(["something_else.simf"]); + }); + + // A name that merely appears inside another is not the same file, and treating it as one + // would answer a check with the wrong source. + test("does not match a name that is only a suffix of the real one", () => { + const { sources, unmatched } = matchContractSources( + ["./asset_auth_vault.simf"], + [{ name: "auth_vault.simf", text: "x" }], + ); + + expect(sources).toEqual({}); + expect(unmatched).toEqual(["auth_vault.simf"]); + }); + + test("takes several files at once, and reports both sides", () => { + const { sources, unmatched } = matchContractSources( + ["./lending.simf", "./script_auth.simf"], + [ + { name: "lending.simf", text: "one" }, + { name: "script_auth.simf", text: "two" }, + { name: "notes.txt", text: "three" }, + ], + ); + + expect(sources).toEqual({ "./lending.simf": "one", "./script_auth.simf": "two" }); + expect(unmatched).toEqual(["notes.txt"]); + }); + + test("nothing supplied is nothing matched, which is not an error", () => { + expect(matchContractSources(["./p2pk.simf"], [])).toEqual({ sources: {}, unmatched: [] }); + }); +}); diff --git a/apps/web/src/app/manifest/contractSources.ts b/apps/web/src/app/manifest/contractSources.ts new file mode 100644 index 0000000..0fa2a77 --- /dev/null +++ b/apps/web/src/app/manifest/contractSources.ts @@ -0,0 +1,48 @@ +/** One contract source a person handed to this page, under the name it had on their disk. */ +export type SuppliedSource = { + name: string; + text: string; +}; + +export type MatchedSources = { + /** What the reader is given: sources under the paths the document references them by. */ + sources: Record; + /** Names that matched nothing this document references, which is worth saying rather than ignoring. */ + unmatched: string[]; +}; + +/** + * Puts supplied files under the paths the document references them by. + * + * A document references a contract by a path relative to itself — `./p2pk.simf` — and a person + * has a file, not a path. Matching on the name at the end of the path is what closes that gap + * without asking anyone to retype a path they can read on screen. + * + * It matches rather than guesses: a file the document does not reference goes to `unmatched` + * and reaches the reader under no path at all. Handing it over under an invented key would put + * a source into a check that nothing in the document asked for. + */ +export function matchContractSources( + referenced: readonly string[], + supplied: readonly SuppliedSource[], +): MatchedSources { + const sources: Record = {}; + const unmatched: string[] = []; + + for (const file of supplied) { + const path = referenced.find((candidate) => endsWithName(candidate, file.name)); + + if (path === undefined) { + unmatched.push(file.name); + continue; + } + + sources[path] = file.text; + } + + return { sources, unmatched }; +} + +function endsWithName(path: string, name: string): boolean { + return path === name || path.endsWith(`/${name}`); +} diff --git a/apps/web/src/app/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx new file mode 100644 index 0000000..84ddd0a --- /dev/null +++ b/apps/web/src/app/manifest/index.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import ManifestInspector from "./index"; + +// The claim is that this opens with no wallet installed, no connection and no network, and the +// strongest available check of it is that rendering the whole view touches no wallet context at +// all: every other surface in this app reads one, and reading a missing one here would throw +// rather than degrade. + +describe("the inspector with nothing around it", () => { + test("renders with no wallet context, no provider and no network", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Manifest inspector"); + expect(html).toContain(" { + const html = renderToStaticMarkup(); + + expect(html).toContain("Nothing is sent anywhere"); + expect(html).toContain("no wallet is needed"); + }); + + test("shows no result panels until something is pasted", () => { + const html = renderToStaticMarkup(); + + expect(html).not.toContain("What this wallet would do"); + expect(html).not.toContain("What each field is"); + }); + + // The file picker asks for an input, not a result, and until a document says which contracts + // it references there is nothing to ask for. So it appears with the document rather than + // beside the answer. + test("asks for contract sources only once a document has named some", () => { + expect(renderToStaticMarkup()).not.toContain("Contract sources"); + }); + + test("offers a document to start from, so the empty box is not the only way in", () => { + expect(renderToStaticMarkup()).toContain("Load the p2pk example"); + }); + + // The one thing the page asks for, and it opens without an answer: a default here would be a + // guess that decides whether a document is refused. + test("asks which SimplicityHL version, and opens with none given", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("SimplicityHL version"); + expect(html).toContain("Not given"); + expect(html).toContain("reported as not run"); + }); +}); diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx new file mode 100644 index 0000000..7062791 --- /dev/null +++ b/apps/web/src/app/manifest/index.tsx @@ -0,0 +1,188 @@ +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +import { ConstructTable } from "./components/ConstructTable"; +import { ContractSourceList } from "./components/ContractSourceList"; +import { RewriteList } from "./components/RewriteList"; +import { Verdict } from "./components/Verdict"; +import { matchContractSources, type SuppliedSource } from "./contractSources"; +import { readDocument } from "./readDocument"; + +/** + * What this wallet would do with a txManifest document, without building anything from it. + * + * The page answers one question and answers it first: would this wallet refuse, and why. + * Everything else is under it — an account of everything the reader computed, one region per + * field of its return value, is a dump of a data structure rather than an answer, and leaves + * the person holding the document to work out which part of it bore on anything. + * + * What the reader was never able to check sits inside the verdict rather than below it, because + * the absence of a refusal is only honest beside the list of what was never asked; see + * {@link Verdict}. + * + * Everything shown comes from `@humid/tx-manifest` — the same package the wallet itself reads a + * document with — so this page cannot describe a parser that differs from the one that runs. + * + * It connects to nothing. There is no wallet here, no chain read and no request, which is both + * the point and the limit. + * + * The compiler version and the contract sources are asked for in the input card rather than + * reported as results, because that is what they are: this page holds no wallet, so it holds + * neither the version one ships nor the sources a document references. Unanswered is a real + * state and the one this opens in — a check needing one of them is reported as not run, which + * is not the same as passing. + */ +export default function ManifestInspector() { + const [text, setText] = useState(""); + const [compilerVersion, setCompilerVersion] = useState(""); + const [suppliedSources, setSuppliedSources] = useState([]); + + // Read twice, because a file arrives under the name it has on a disk and the reader wants it + // under the path the document references it by — and only the document says what those paths + // are. The first read asks that question, which no supplied source can change the answer to, + // and the second is the one the page reports. + const { document, matched } = useMemo(() => { + const referenced = readDocument(text, { compilerVersion }); + const byReferencedPath = matchContractSources( + referenced.kind === "read" && referenced.ok ? referenced.contracts : [], + suppliedSources, + ); + + return { + document: readDocument(text, { + compilerVersion, + contractSources: byReferencedPath.sources, + }), + matched: byReferencedPath, + }; + }, [text, compilerVersion, suppliedSources]); + + return ( +
+ + + Manifest inspector + + Paste a txManifest document. Nothing is sent anywhere and no wallet is needed — this + runs the same reader the wallet uses, here in the page. + + + +
+ + setCompilerVersion(event.target.value)} + placeholder="Not given" + spellCheck={false} + className="w-72 font-mono" + /> +

+ The single version a reading wallet ships. This page holds no wallet, so there is + nothing here to read it from — and left blank, the compiler check is reported as not + run rather than answered against a stand-in. +

+
+