@venlyfinance/settlement-mcp 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/frontend.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { verifyRuntimeContract } from "./verify-cli.js";
2
3
  export const REGISTRY_URL_TEMPLATE = "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json";
3
4
  const JOURNEYS = {
4
5
  auth: `# Auth (sign-in, 2FA, sign-up)
@@ -78,17 +79,400 @@ Registry items: venly-tokens, data-table, status-pill, side-panel, timeline.
78
79
  Hooks: useRampRequests, useFourEyesApproval (capability decides what renders), useRampLifecycle.
79
80
  States that must exist: queue with awaiting-approval items, detail with the decision context beside the figures, applied, stale-version (someone acted first - refetch and re-decide), creator-view (cannot approve own request - render the rule, not a disabled mystery button).
80
81
  Rules that must hold: the optimistic-locking version travels with every decision; a 409 means re-decide against fresh state, never auto-retry; reject requires a reason; the creator sees why they cannot approve.`,
82
+ "console-review-queue": `# Console review queue (the operator worklist)
83
+ Shell: left nav rail + thin top bar, full-width content, and a page-edge
84
+ environment banner naming mock mode. Not a consumer surface - density rules
85
+ apply.
86
+ Registry items: venly-tokens, data-table, status-pill, money, list-error. The
87
+ registry has no console block yet: compose these primitives.
88
+ Hooks: useAccounts, useParties. The queue's own state is DERIVED on every
89
+ render - never stored, never cached as a status.
90
+ Binding: sections are ACTORS, not statuses - your move, waiting on the customer,
91
+ waiting on a provider, then a collapsed closed section, so the reviewer's own
92
+ worklist is the top band by construction.
93
+ States that must exist: loading, your move, waiting on the customer, waiting on a provider, closed, empty queue, filtered to nothing, list error.
94
+ Rules that must hold: the whose-move value is a pure function of enum values on
95
+ the row - no clock reading, no threshold, no configuration and no default, and a
96
+ combination the mapping does not cover renders NO value plus an explicit
97
+ not-recognised line, which is a bug report rather than a guess; never a target
98
+ time, a breach colour or an overdue state, because the API publishes no targets
99
+ and an invented one is the same defect as an invented fee; an age column is
100
+ labelled for what it actually measures - a created-at delta is "Age", and only a
101
+ duration the API itself computes may be called time in state; empty sections are
102
+ still drawn as a zero header row, because nothing-to-do is information; loading
103
+ is a skeleton that preserves column geometry exactly, never prose; a row click
104
+ opens a side panel and never navigates; one status pill per row - the whose-move
105
+ value is plain text, since two pills read as two states.`,
106
+ "console-decision-detail": `# Console decision detail (evidence, ceremony, audit trail)
107
+ Shell: side panel about 30% wide over the queue - no scrim, the table stays
108
+ visible clipped at the panel edge and the source row stays tinted. Escalate to a
109
+ 35/65 split only when the evidence outgrows the panel. Evidence goes on the
110
+ LEFT: this is a judging task, not an authoring one.
111
+ Registry items: venly-tokens, side-panel, timeline, field-list, status-pill,
112
+ money, data-table.
113
+ Hooks: useAccount, useParty, useWallets, useTransfers,
114
+ useVirtualBankAccounts, useVenlyMock (the trail reads the mock's event log).
115
+ Two timeline columns, not one feed: the decision chain (who decided what, when,
116
+ in which seat) beside money movement on the same subject. Different actors,
117
+ different audiences; merging them is what makes an audit trail unreadable.
118
+ States that must exist: loading, evidence present, evidence unavailable, decision owed, decision applied, stale decision, terminal decision, frozen, empty trail.
119
+ Rules that must hold: every evidence row is either a real field path or a
120
+ labelled omission, and an omission is a FIRST-CLASS type in the component's
121
+ props, so a placeholder cannot be rendered where a gap belongs;
122
+ omission copy states only what is verified and never implies a result, a
123
+ pending state, or a clean one - and never mentions the API contract, which is
124
+ developer diagnostics rather than operator language; a field the API cannot
125
+ carry is captured anyway when the work needs it, and rendered with a visible
126
+ badge saying it is a console note rather than API state; every decision carries the
127
+ optimistic-locking version, and a conflict means refetch and re-decide against
128
+ fresh state, never auto-retry; every transition the console causes leaves a
129
+ timeline node with actor, role and a timezone-qualified absolute timestamp, so a
130
+ status change with no node is a bug; a store resync is a system line, not a
131
+ decision node; the panel footer carries row-stepping key chips so the reviewer
132
+ moves row to row without closing.`,
133
+ "console-pricing-config": `# Console pricing configuration
134
+ Shell: in-shell content column. A config screen, not a queue: no whose-move
135
+ value and no aging.
136
+ Registry items: venly-tokens, data-table, arithmetic-ladder, field-list.
137
+ Hooks: useCompanyFees.
138
+ Binding: the fee data the packages actually serve is a VOLUME-TIER model - tier
139
+ name, ramp direction, minimum and maximum volume, percentage, version - and it
140
+ is the same model the shipped withdrawal quote consumes, so this screen shows
141
+ where a real quote comes from. A second, richer per-rail configuration model
142
+ exists on an internal plane and is NOT served here; it renders as a labelled
143
+ omission, never as an empty form.
144
+ States that must exist: loading, tiers present, no tiers, configuration unavailable, worked example, save failed.
145
+ Rules that must hold: a worked arithmetic ladder is mandatory on the tier
146
+ section - a sample amount times the tier percentage, with the operator glyphs in
147
+ a left gutter - because a pricing screen that shows only stored numbers teaches
148
+ nothing; the ladder renders ONLY over data that exists, never over the omitted
149
+ section; the tier
150
+ a sample amount falls into is highlighted in the table so the row and the ladder
151
+ are visibly the same fact; a single-member enum renders as a disabled
152
+ single-value field that says so, not a select pretending at choice; a date
153
+ window that has not opened reads scheduled, never active; forms are single
154
+ column with the field width capped, label above input, helper text between them
155
+ and the error below.`,
156
+ "console-simulator": `# Sandbox simulator (play the counterparty)
157
+ Shell: its own chrome - a scrimmed right-hand drawer on a distinct surface with
158
+ a persistent sandbox label, reachable from ONE fixed affordance in the top bar.
159
+ It is the only scrimmed drawer in the console, so the surface change alone
160
+ signals the register change.
161
+ Registry items: venly-tokens, field-list, status-pill, money.
162
+ Hooks: useVenlyMock. Every control maps to exactly one call on the mock's
163
+ simulations namespace - no control without a call, and no call renamed.
164
+ Binding: inbound credits, provider progression and screening verdicts are things
165
+ OTHER parties do, so they live here rather than in the operator's workflow.
166
+ States that must exist: drawer closed, drawer open, sharing, not sharing, credit landed, verdict returned, payout advanced, books balanced, books do not balance, reset.
167
+ Rules that must hold: controls are phrased as events that happen to you, in the
168
+ third person, while operator controls elsewhere are imperative decisions - a
169
+ control phrased in the wrong voice is in the wrong surface; a simulated
170
+ transition emits the SAME event the real path emits, and the trail attributes it
171
+ to the simulator plainly rather than to an operator; the drawer is reachable
172
+ only from the top bar, never from a queue row or a decision panel, because those
173
+ paths make another party's action look like the operator's; the ledger check gets
174
+ a visible surface: it is the one control here that asserts something true, namely
175
+ that the simulated books balance; the channel footer states the adapter, session
176
+ and peer count, and says IN WORDS when the surface is not actually sharing - the
177
+ default channel shares nothing and cross-context sharing is same-origin only, so
178
+ without that line a two-context demo can prove nothing while looking correct.`,
81
179
  };
82
180
  const JOURNEY_KEYS = Object.keys(JOURNEYS);
181
+ const RUNTIME_PACKAGES_BY_BLOCK = {
182
+ activity: {
183
+ "@venlyfinance/react": "^0.4.0",
184
+ "@venlyfinance/sdk": "^0.5.0",
185
+ "@tanstack/react-query": "^5.0.0",
186
+ },
187
+ auth: { "@radix-ui/react-one-time-password-field": "^0.1.16" },
188
+ balances: {
189
+ "@venlyfinance/react": "^0.4.0",
190
+ "@venlyfinance/sdk": "^0.5.0",
191
+ "@tanstack/react-query": "^5.0.0",
192
+ },
193
+ "bank-accounts": {
194
+ "@venlyfinance/react": "^0.4.0",
195
+ "@venlyfinance/sdk": "^0.5.0",
196
+ "@tanstack/react-query": "^5.0.0",
197
+ },
198
+ onboarding: {
199
+ "@venlyfinance/react": "^0.4.0",
200
+ "@venlyfinance/sdk": "^0.5.0",
201
+ "@tanstack/react-query": "^5.0.0",
202
+ },
203
+ receive: {
204
+ "@venlyfinance/react": "^0.4.0",
205
+ "@venlyfinance/sdk": "^0.5.0",
206
+ "@tanstack/react-query": "^5.0.0",
207
+ },
208
+ reconciliation: {
209
+ "@venlyfinance/react": "^0.4.0",
210
+ "@venlyfinance/sdk": "^0.5.0",
211
+ "@tanstack/react-query": "^5.0.0",
212
+ },
213
+ send: {
214
+ "@venlyfinance/react": "^0.4.0",
215
+ "@venlyfinance/sdk": "^0.5.0",
216
+ "@tanstack/react-query": "^5.0.0",
217
+ },
218
+ team: { "@radix-ui/react-dialog": "^1.1.23" },
219
+ withdraw: {
220
+ "@venlyfinance/react": "^0.4.0",
221
+ "@venlyfinance/sdk": "^0.5.0",
222
+ "@tanstack/react-query": "^5.0.0",
223
+ },
224
+ };
225
+ /**
226
+ * The package set any hook-using screen needs, for journeys the registry has no
227
+ * composite block for yet - the console screens are built from primitives
228
+ * (data-table, side-panel, timeline …), and a primitive registry item declares
229
+ * no npm dependencies, so deriving `requiredPackages` from blocks alone would
230
+ * tell an agent that a surface living entirely on hooks needs no packages.
231
+ *
232
+ * The sdk range is the one the console screens themselves need: they render the
233
+ * mock's channel state and balances that move on a transfer, and both arrived in
234
+ * 0.6.0. Composite block registry items stamp their own range from
235
+ * ui/package.json, which is older; a console screen built against that range
236
+ * would describe states it cannot reach.
237
+ */
238
+ const DATA_PLANE_PACKAGES = {
239
+ "@venlyfinance/react": "^0.4.0",
240
+ "@venlyfinance/sdk": "^0.6.0",
241
+ "@tanstack/react-query": "^5.0.0",
242
+ };
243
+ const JOURNEY_RUNTIME = {
244
+ auth: {
245
+ blocks: ["auth"],
246
+ hooks: [],
247
+ demoBindings: [{ import: "createMockAuthAdapter", from: "registry:block/auth" }],
248
+ },
249
+ team: {
250
+ blocks: ["team"],
251
+ hooks: [],
252
+ demoBindings: [{ import: "createMockTeamAdapter", from: "registry:block/team" }],
253
+ },
254
+ "home-balances": { blocks: ["balances"], hooks: ["useAccounts", "useWallets"] },
255
+ receive: { blocks: ["receive"], hooks: ["useVirtualBankAccounts"] },
256
+ send: { blocks: ["send"], hooks: ["useStagedTransfer", "useFeeQuote"] },
257
+ activity: { blocks: ["activity"], hooks: ["useTransfers", "useRampRequests"] },
258
+ "onboarding-status": {
259
+ blocks: ["onboarding"],
260
+ hooks: ["useCreateParty", "useCreateAccount", "useParty", "useAccount"],
261
+ },
262
+ "withdraw-bank-accounts": {
263
+ blocks: ["bank-accounts", "withdraw"],
264
+ hooks: [
265
+ "useCompanyBankAccounts",
266
+ "useBankAccountConfig",
267
+ "useCreateCompanyBankAccount",
268
+ "useRampRequests",
269
+ "useRampRequest",
270
+ "useCreateRampRequest",
271
+ "useFeeQuote",
272
+ "useRampPairs",
273
+ "useReferenceData",
274
+ "useFourEyesApproval",
275
+ "useInitiateRamp",
276
+ "describeRampStatus",
277
+ ],
278
+ },
279
+ reconciliation: {
280
+ blocks: ["reconciliation"],
281
+ hooks: ["useVirtualBankAccounts", "useTransfers"],
282
+ },
283
+ "proof-of-segregation": { blocks: ["balances"], hooks: ["useWallets", "useAccount"] },
284
+ approvals: {
285
+ blocks: ["withdraw"],
286
+ hooks: ["useRampRequests", "useFourEyesApproval", "useRampLifecycle"],
287
+ },
288
+ "console-review-queue": {
289
+ blocks: [],
290
+ registryItems: ["venly-tokens", "data-table", "status-pill", "money", "list-error"],
291
+ dataPlane: true,
292
+ hooks: ["useAccounts", "useParties"],
293
+ extraForbidden: [
294
+ "a whose-move or needs-attention value computed from anything other than enum values on the row",
295
+ "a target time, breach threshold or overdue state (the API publishes no targets)",
296
+ "labelling a created-at delta \"time in state\" rather than \"Age\" (only an API-computed duration may use that phrase)",
297
+ ],
298
+ },
299
+ "console-decision-detail": {
300
+ blocks: [],
301
+ registryItems: [
302
+ "venly-tokens",
303
+ "side-panel",
304
+ "timeline",
305
+ "field-list",
306
+ "status-pill",
307
+ "money",
308
+ "data-table",
309
+ ],
310
+ dataPlane: true,
311
+ hooks: [
312
+ "useAccount",
313
+ "useParty",
314
+ "useWallets",
315
+ "useTransfers",
316
+ "useVirtualBankAccounts",
317
+ "useVenlyMock",
318
+ ],
319
+ extraForbidden: [
320
+ "a rendered placeholder where an unavailable field belongs (omission is a prop type, not a string)",
321
+ "a captured field the API cannot carry, rendered without the console-note badge",
322
+ "a status change that leaves no timeline node with actor, role and timezone-qualified stamp",
323
+ "auto-retry on a version conflict (refetch and let the operator re-decide)",
324
+ ],
325
+ },
326
+ "console-pricing-config": {
327
+ blocks: [],
328
+ registryItems: ["venly-tokens", "data-table", "arithmetic-ladder", "field-list"],
329
+ dataPlane: true,
330
+ hooks: ["useCompanyFees"],
331
+ extraForbidden: [
332
+ "an arithmetic ladder over figures the API does not serve",
333
+ "a single-member enum rendered as a select",
334
+ ],
335
+ },
336
+ "console-simulator": {
337
+ blocks: [],
338
+ registryItems: ["venly-tokens", "field-list", "status-pill", "money"],
339
+ dataPlane: true,
340
+ hooks: ["useVenlyMock"],
341
+ extraForbidden: [
342
+ "counterparty or provider simulation rendered inside operator chrome",
343
+ "a simulator control reachable from a queue row or a decision panel",
344
+ "a simulator control phrased as an imperative operator decision",
345
+ "a cross-context demo that does not state its channel adapter and peer count",
346
+ ],
347
+ },
348
+ };
349
+ const RUNTIME_CONTRACT_SCHEMA = z.object({
350
+ runtimeMode: z.enum(["mock", "staging", "production"]),
351
+ requiredPackages: z.record(z.string()),
352
+ requiredHooks: z.array(z.object({ import: z.string(), from: z.string() })),
353
+ demoBindings: z.array(z.object({ import: z.string(), from: z.string() })).optional(),
354
+ provider: z.object({
355
+ import: z.string(),
356
+ from: z.string(),
357
+ props: z.object({ environment: z.literal("mock") }),
358
+ }),
359
+ forbiddenPatterns: z.array(z.string()),
360
+ install: z.array(z.string()),
361
+ completionChecks: z.array(z.string()),
362
+ });
363
+ function runtimeContractForJourney(journey) {
364
+ const definition = JOURNEY_RUNTIME[journey];
365
+ // Base first, blocks last: a composite block's own stamped dependencies are
366
+ // what the registry will actually install, so they win where the two differ.
367
+ const requiredPackages = definition.dataPlane
368
+ ? { ...DATA_PLANE_PACKAGES }
369
+ : {};
370
+ for (const block of definition.blocks) {
371
+ Object.assign(requiredPackages, RUNTIME_PACKAGES_BY_BLOCK[block]);
372
+ }
373
+ const installItems = definition.blocks.length
374
+ ? definition.blocks.map((block) => `@venlyfinance/${block}`)
375
+ : (definition.registryItems ?? []).map((item) => `@venlyfinance/${item}`);
376
+ return {
377
+ runtimeMode: "mock",
378
+ requiredPackages,
379
+ requiredHooks: definition.hooks.map((name) => ({
380
+ import: name,
381
+ from: "@venlyfinance/react",
382
+ })),
383
+ ...(definition.demoBindings ? { demoBindings: definition.demoBindings } : {}),
384
+ provider: {
385
+ import: "VenlyProvider",
386
+ from: "@venlyfinance/react",
387
+ props: { environment: "mock" },
388
+ },
389
+ forbiddenPatterns: [
390
+ "in-memory store of transfer/balance/approval state",
391
+ "fetch()/axios to self-owned money routes that do not wrap @venlyfinance/sdk",
392
+ "useEffect polling loops for transfer status (useStagedTransfer/useRampLifecycle exist)",
393
+ "clientSecret in browser code (provider throws; use proxyClientOptions())",
394
+ ...(definition.extraForbidden ?? []),
395
+ ],
396
+ install: [
397
+ "npx shadcn@latest init -y -b radix -p nova",
398
+ 'add { "registries": { "@venlyfinance": "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json" } } to components.json',
399
+ `npx shadcn@latest add ${installItems.join(" ")} -y -o`,
400
+ ],
401
+ completionChecks: [
402
+ 'npx @venlyfinance/settlement-mcp review "src/**/*.tsx" exits 0',
403
+ 'npx @venlyfinance/settlement-mcp verify "src/**/*.{ts,tsx}" exits 0',
404
+ ],
405
+ };
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // Shared mechanics. Three cross-cutting behaviours every rule participates in:
409
+ //
410
+ // 1. Suppression: `venly-allow:<rule-id>` on the offending line or the line
411
+ // immediately above drops the finding silently - no counter, no second
412
+ // severity tier. A consumer's own API may legitimately return what ours
413
+ // does not; without this hatch the audit is uninstallable for them.
414
+ // 2. Comment lines are not copy: rules that judge words skip lines whose
415
+ // trimmed form starts with `*`, `//`, `/*` or `{/*` - otherwise the rule
416
+ // fires on the comment that documents the rule itself.
417
+ // 3. Findings carry the character index they fired at, so suppression can be
418
+ // resolved against the exact offending line.
419
+ // ---------------------------------------------------------------------------
420
+ const COMMENT_LINE = /^\s*(?:\*|\/\/|\/\*|\{\/\*)/;
421
+ function lineBoundsAt(source, idx) {
422
+ const at = Math.min(Math.max(idx, 0), source.length);
423
+ const start = source.lastIndexOf("\n", Math.max(0, at - 1)) + 1;
424
+ const nl = source.indexOf("\n", at);
425
+ return { start, end: nl === -1 ? source.length : nl };
426
+ }
427
+ function lineAt(source, idx) {
428
+ const { start, end } = lineBoundsAt(source, idx);
429
+ return source.slice(start, end);
430
+ }
431
+ function lineAboveAt(source, idx) {
432
+ const { start } = lineBoundsAt(source, idx);
433
+ if (start === 0)
434
+ return "";
435
+ const prevEnd = start - 1; // the \n terminating the previous line
436
+ const prevStart = source.lastIndexOf("\n", prevEnd - 1) + 1;
437
+ return source.slice(prevStart, prevEnd);
438
+ }
439
+ function isCommentLineAt(source, idx) {
440
+ return COMMENT_LINE.test(lineAt(source, idx));
441
+ }
442
+ function isSuppressedAt(source, idx, ruleId) {
443
+ const token = `venly-allow:${ruleId}`;
444
+ return lineAt(source, idx).includes(token) || lineAboveAt(source, idx).includes(token);
445
+ }
446
+ function lineNumberAt(source, idx) {
447
+ let line = 1;
448
+ for (let i = 0; i < idx && i < source.length; i++)
449
+ if (source[i] === "\n")
450
+ line++;
451
+ return line;
452
+ }
83
453
  /** Deterministic design audit. Text in, findings out - no model, no taste. */
84
- export function reviewScreenSource(source) {
454
+ export function reviewScreenSource(source, journey) {
85
455
  const findings = [];
86
- const push = (rule, severity, evidence, fix) => findings.push({ rule, severity, evidence: evidence.slice(0, 120), fix });
456
+ // Returns whether the finding was recorded, so rules that stop after the
457
+ // first hit can keep scanning past a suppressed occurrence instead of
458
+ // letting one venly-allow blind them to a later real violation.
459
+ const push = (rule, severity, evidence, fix, atIndex) => {
460
+ if (isSuppressedAt(source, atIndex, rule))
461
+ return false;
462
+ findings.push({
463
+ rule,
464
+ severity,
465
+ evidence: evidence.slice(0, 120),
466
+ fix,
467
+ line: lineNumberAt(source, atIndex),
468
+ });
469
+ return true;
470
+ };
87
471
  for (const match of source.matchAll(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g)) {
88
- push("raw-colour", "error", match[0], "Read colours from the venly-tokens custom properties; a reskin must be tokens.css and nothing else.");
472
+ push("raw-colour", "error", match[0], "Read colours from the venly-tokens custom properties; a reskin must be tokens.css and nothing else.", match.index ?? 0);
89
473
  }
90
474
  for (const match of source.matchAll(/-\d[\d,]*\.\d{2}\s*(?:[A-Z]{3}|€|\$|£)/g)) {
91
- push("hyphen-minus-amount", "error", match[0], "Use the true minus sign − before negative amounts (the Money primitive does this).");
475
+ push("hyphen-minus-amount", "error", match[0], "Use the true minus sign − before negative amounts (the Money primitive does this).", match.index ?? 0);
92
476
  }
93
477
  // Only a RENDERED cancelled state counts (a quoted/JSX label or a state
94
478
  // value), never the verb "cancel" in prose or a token file's comment; and
@@ -98,29 +482,238 @@ export function reviewScreenSource(source) {
98
482
  const idx = match.index ?? 0;
99
483
  const around = source.slice(Math.max(0, idx - 150), idx + 150);
100
484
  if (/✓/.test(around)) {
101
- push("success-on-cancelled", "error", around.trim().slice(0, 80), "A cancelled or failed terminal step must never carry a success check - grey ↺ or red ✕.");
102
- break;
485
+ if (push("success-on-cancelled", "error", around.trim().slice(0, 80), "A cancelled or failed terminal step must never carry a success check - grey ↺ or red ✕.", idx))
486
+ break;
103
487
  }
104
488
  }
105
- if (/review|confirm/i.test(source) && /[•*]{3,}/.test(source)) {
106
- push("masked-review-value", "error", source.match(/[•*]{3,}/)[0], "Never mask values on a review screen; its only job is legibility of what is about to happen.");
489
+ // The once-per-source rules below scan every occurrence and stop at the
490
+ // first RECORDED finding, so a venly-allow on one occurrence never hides a
491
+ // later unsuppressed one.
492
+ if (/review|confirm/i.test(source)) {
493
+ for (const masked of source.matchAll(/[•*]{3,}/g)) {
494
+ if (push("masked-review-value", "error", masked[0], "Never mask values on a review screen; its only job is legibility of what is about to happen.", masked.index ?? 0))
495
+ break;
496
+ }
107
497
  }
108
- if (/nth-child\(\s*(?:even|odd|2n)/.test(source)) {
109
- push("zebra-striping", "warn", "nth-child(even/odd) background", "No finance reference uses zebra striping - separate rows with hairlines and spacing.");
498
+ for (const zebra of source.matchAll(/nth-child\(\s*(?:even|odd|2n)/g)) {
499
+ if (push("zebra-striping", "warn", "nth-child(even/odd) background", "No finance reference uses zebra striping - separate rows with hairlines and spacing.", zebra.index ?? 0))
500
+ break;
110
501
  }
111
- if (/box-shadow/.test(source) && !/var\(--shadow-overlay\)/.test(source)) {
112
- push("shadow-outside-overlay", "warn", source.match(/box-shadow[^;"}]*/)?.[0] ?? "box-shadow", "Elevation is only for overlays, and only via the --shadow-overlay token; the base layer is flat.");
502
+ if (!/var\(--shadow-overlay\)/.test(source)) {
503
+ for (const shadow of source.matchAll(/box-shadow[^;"}]*/g)) {
504
+ if (push("shadow-outside-overlay", "warn", shadow[0], "Elevation is only for overlays, and only via the --shadow-overlay token; the base layer is flat.", shadow.index ?? 0))
505
+ break;
506
+ }
113
507
  }
114
- if (/linear-gradient|radial-gradient/.test(source)) {
115
- push("gradient-surface", "warn", source.match(/\w+-gradient\([^)]*\)/)?.[0] ?? "gradient", "Gradient balance heroes read as template, not product; surfaces are flat neutrals with one accent.");
508
+ for (const gradient of source.matchAll(/(?:linear|radial)-gradient(?:\([^)]*\))?/g)) {
509
+ if (push("gradient-surface", "warn", gradient[0], "Gradient balance heroes read as template, not product; surfaces are flat neutrals with one accent.", gradient.index ?? 0))
510
+ break;
116
511
  }
117
- if (/(?:status|state)/i.test(source) && /var\(--state-/.test(source)) {
118
- if (!/[✓✕↺⚠●○]|aria-hidden/.test(source)) {
119
- push("colour-only-state", "warn", "state colours present without any glyph", "Pair every state hue with a glyph or word so status survives greyscale.");
512
+ if (/(?:status|state)/i.test(source) && !/[✓✕↺⚠●○]|aria-hidden/.test(source)) {
513
+ for (const stateVar of source.matchAll(/var\(--state-/g)) {
514
+ if (push("colour-only-state", "warn", "state colours present without any glyph", "Pair every state hue with a glyph or word so status survives greyscale.", stateVar.index ?? 0))
515
+ break;
120
516
  }
121
517
  }
518
+ // --- New rule classes (invented timing copy, crypto currency formatting,
519
+ // required-rendered-optional, blueprint state coverage, fixture honesty)
520
+ // are registered below. Each judges text only, honours the suppression
521
+ // hatch, and skips comment lines wherever it judges copy.
522
+ checkInventedTimingClaim(source, push);
523
+ checkIntlCurrencyCrypto(source, push);
524
+ checkRequiredRenderedOptional(source, push);
525
+ checkBlueprintStateCoverage(source, journey, push);
526
+ checkFixtureHonesty(source, push);
122
527
  return findings;
123
528
  }
529
+ /**
530
+ * invented-timing-claim - copy that promises a duration, a settlement window
531
+ * or custody behaviour ("1-2 business days", "held until claimed",
532
+ * "estimated arrival") that no API in this stack returns. Rendering such a
533
+ * promise invents a guarantee the backend cannot honour; the journey
534
+ * contracts require a labelled omission instead. Copy rule: comment lines
535
+ * are not copy, so matches on them are skipped.
536
+ */
537
+ function checkInventedTimingClaim(source, push) {
538
+ const pattern = /\b(?:typically|usually|normally|generally)\s+(?:arrives?|takes?|clears?|settles?)\b|\b\d+\s*(?:-|–|to)\s*\d+\s+business\s+days?\b|\bwithin\s+\d+\s+(?:seconds?|minutes?|hours?|days?|business\s+days?)\b|\bheld\s+until\s+claimed\b|\bestimated\s+(?:arrival|delivery|completion)\b/gi;
539
+ for (const match of source.matchAll(pattern)) {
540
+ const at = match.index ?? 0;
541
+ if (isCommentLineAt(source, at))
542
+ continue;
543
+ push("invented-timing-claim", "error", match[0], "No API in this stack returns a duration, settlement window or custody guarantee. Render the labelled omission the contract specifies, or - if your own API does return it - name the field path on the line and add venly-allow:invented-timing-claim.", at);
544
+ }
545
+ }
546
+ // --- intl-currency-crypto -------------------------------------------------
547
+ // Intl.NumberFormat validates `currency` against ISO 4217, so a crypto asset
548
+ // code ("USDC", "DAI", ...) throws RangeError the moment the formatter is
549
+ // constructed - a screen that compiles fine crashes on first render. Each
550
+ // Intl.NumberFormat call site is judged by the 200 characters that follow it:
551
+ // a literal crypto code next to style:"currency" is a certain crash (error);
552
+ // a variable-fed `currency:` is a latent one (warn) - it only survives until
553
+ // a crypto asset reaches it. Not a copy rule, so comment lines are not
554
+ // skipped; suppression still applies via the shared venly-allow hatch.
555
+ function checkIntlCurrencyCrypto(source, push) {
556
+ const currencyStyle = /style\s*:\s*["']currency["']/;
557
+ const cryptoCode = /["'](?:USDC|EURC|USDT|USDS|DAI|PYUSD|USDG|RLUSD)["']/;
558
+ // `\s*` lives inside the lookahead: with `currency\s*:\s*(?!["'])` the
559
+ // greedy whitespace backtracks to zero and the lookahead inspects the
560
+ // space instead of the quote, flagging `currency: "USD"` as a variable.
561
+ const variableCurrency = /currency\s*:(?!\s*["'])/;
562
+ for (const match of source.matchAll(/Intl\.NumberFormat/g)) {
563
+ const at = match.index ?? 0;
564
+ const window = source.slice(at, at + 200);
565
+ const style = currencyStyle.exec(window);
566
+ if (!style)
567
+ continue; // plain decimal formatting (the kit's own formatAmount) is safe
568
+ const styleAt = style.index ?? 0;
569
+ const crypto = cryptoCode.exec(window);
570
+ if (crypto) {
571
+ const cryptoAt = crypto.index ?? 0;
572
+ const from = Math.min(styleAt, cryptoAt);
573
+ const to = Math.max(styleAt + style[0].length, cryptoAt + crypto[0].length);
574
+ push("intl-currency-crypto", "error", window.slice(from, to), "Intl.NumberFormat with style:\"currency\" throws RangeError on a non-ISO-4217 code. Render crypto amounts with the Money primitive, which places the code beside the digits instead of inside the formatter.", at);
575
+ continue; // one finding per call site; the certain crash outranks the latent one
576
+ }
577
+ const variable = variableCurrency.exec(window);
578
+ if (variable) {
579
+ const variableAt = variable.index ?? 0;
580
+ const from = Math.min(styleAt, variableAt);
581
+ const to = Math.min(window.length, Math.max(styleAt + style[0].length, variableAt) + 40);
582
+ push("intl-currency-crypto", "warn", window.slice(from, to), "This formatter takes its currency from a variable. If a crypto asset can reach it, it throws at runtime. Use the Money primitive, or narrow the variable to ISO-4217 codes.", at);
583
+ }
584
+ }
585
+ }
586
+ /**
587
+ * A required field labelled as optional. The kit deliberately ships a
588
+ * "(not required)" variant for genuinely optional rows, so the net is scoped
589
+ * tightly: only the payment reference is required-by-contract, and a payer
590
+ * who omits it produces an unmatched credit that someone has to reconcile by
591
+ * hand. The rule therefore fires only when "(not required)" appears on a real
592
+ * code line AND the surrounding code (comments removed) mentions the
593
+ * reference - a comment that merely documents this contract must not trip it.
594
+ */
595
+ function checkRequiredRenderedOptional(source, push) {
596
+ for (const match of source.matchAll(/\(not required\)/gi)) {
597
+ const idx = match.index ?? 0;
598
+ // Copy rule: only judge rendered copy, never commentary about it.
599
+ if (isCommentLineAt(source, idx))
600
+ continue;
601
+ const winStart = Math.max(0, idx - 200);
602
+ const winEnd = Math.min(source.length, idx + match[0].length + 200);
603
+ // Rebuild the window with every comment line removed. Each line is
604
+ // classified on its FULL text (a fragment cut by the window edge could
605
+ // hide its comment marker), but only the in-window portion of surviving
606
+ // code lines feeds the reference test.
607
+ let pos = source.lastIndexOf("\n", Math.max(0, winStart - 1)) + 1;
608
+ let window = "";
609
+ while (pos < winEnd) {
610
+ let lineEnd = source.indexOf("\n", pos);
611
+ if (lineEnd === -1)
612
+ lineEnd = source.length;
613
+ const line = source.slice(pos, lineEnd);
614
+ if (!COMMENT_LINE.test(line)) {
615
+ const from = Math.max(pos, winStart);
616
+ const to = Math.min(lineEnd, winEnd);
617
+ if (to > from)
618
+ window += source.slice(from, to) + "\n";
619
+ }
620
+ pos = lineEnd + 1;
621
+ }
622
+ if (!/reference/i.test(window))
623
+ continue;
624
+ push("required-rendered-optional", "error", lineAt(source, idx).trim(), 'The payment reference is required - a payer who omits it produces an unmatched credit. Render the amber Required pill; never label it "(not required)".', idx);
625
+ }
626
+ }
627
+ function checkBlueprintStateCoverage(source, journey, push) {
628
+ // Only meaningful when the caller declared which journey this screen serves.
629
+ if (journey === undefined)
630
+ return;
631
+ // Whole-source suppression: this finding has no single offending line (it
632
+ // reports blueprint states absent from the entire file), so the escape
633
+ // hatch is whole-source too - the venly-allow token anywhere drops it.
634
+ if (source.includes("venly-allow:blueprint-state-missing"))
635
+ return;
636
+ const blueprint = JOURNEYS[journey];
637
+ const startMarker = "States that must exist:";
638
+ const startIdx = blueprint.indexOf(startMarker);
639
+ if (startIdx === -1)
640
+ return;
641
+ let statesText = blueprint.slice(startIdx + startMarker.length);
642
+ const end = /^Rules that must hold/m.exec(statesText);
643
+ if (end)
644
+ statesText = statesText.slice(0, end.index);
645
+ // Blueprint prose wraps across lines mid-sentence; collapse before parsing.
646
+ statesText = statesText.replace(/\n/g, " ");
647
+ // One state per " · " or "," separator - but only at parenthesis depth 0:
648
+ // a comma inside a parenthetical is part of that state's description, not
649
+ // a state boundary. Naive splitting yields fragments like "terminal)" that
650
+ // no source can contain, making a journey structurally unable to pass.
651
+ const parts = [];
652
+ let depth = 0;
653
+ let current = "";
654
+ for (const ch of statesText) {
655
+ if (ch === "(")
656
+ depth++;
657
+ else if (ch === ")")
658
+ depth = Math.max(0, depth - 1);
659
+ if (depth === 0 && (ch === "," || ch === "·")) {
660
+ parts.push(current);
661
+ current = "";
662
+ continue;
663
+ }
664
+ current += ch;
665
+ }
666
+ parts.push(current);
667
+ // Each state's keyword is the text before the first parenthetical,
668
+ // normalised for a case-insensitive substring probe.
669
+ const keywords = [];
670
+ for (const part of parts) {
671
+ const keyword = part
672
+ .split("(")[0]
673
+ .trim()
674
+ .toLowerCase()
675
+ .replace(/\s+/g, " ")
676
+ .replace(/\.$/, "");
677
+ if (keyword)
678
+ keywords.push(keyword);
679
+ }
680
+ const lowered = source.toLowerCase();
681
+ const missing = keywords.filter((keyword) => !lowered.includes(keyword));
682
+ if (missing.length === 0)
683
+ return;
684
+ // One aggregate warn, never per-keyword findings and never an empty-list
685
+ // finding. Warn (not error) because blueprint phrases are prose - a state
686
+ // can be fully implemented under different wording.
687
+ const list = missing.join(", ");
688
+ push("blueprint-state-missing", "warn", list, `The ${journey} blueprint names ${keywords.length} states. These were not found by name in this source: ${list}. Either they are missing or they render under different wording - check each by hand.`, 0);
689
+ }
690
+ function checkFixtureHonesty(source, push) {
691
+ // Fixture honesty. A demo that seeds parity rates or round-number amounts
692
+ // teaches false patterns: parity hides the crypto/fiat unit distinction,
693
+ // and round numbers let a total look derivable when it is coincidence.
694
+ // ERROR - an explicit parity rate seeded on a rate-named field. Anchored to
695
+ // the three rate names so counters like `rateLimit: 1` never trip it.
696
+ for (const match of source.matchAll(/\b(?:exchangeRate|rate|fxRate)\s*[:=]\s*1(?:\.0+)?\b/g)) {
697
+ push("parity-fixture", "error", match[0], "A parity exchange rate makes the crypto/fiat unit distinction numerically invisible, which is the falsehood a real quoted rate exists to prevent. Seed a real non-parity rate.", match.index ?? 0);
698
+ }
699
+ // WARN - three or more round-number amounts (x.00) in one source. Comment
700
+ // lines are skipped: this sibling judges seeded copy/fixtures, and prose
701
+ // like "may display as 0.00" is documentation, not a seeded amount. One
702
+ // finding per source, anchored at the first counted match.
703
+ let roundCount = 0;
704
+ let firstRoundIdx = -1;
705
+ for (const match of source.matchAll(/\b\d+\.00\b/g)) {
706
+ const idx = match.index ?? 0;
707
+ if (isCommentLineAt(source, idx))
708
+ continue;
709
+ if (firstRoundIdx === -1)
710
+ firstRoundIdx = idx;
711
+ roundCount++;
712
+ }
713
+ if (roundCount >= 3) {
714
+ push("round-number-coincidence", "warn", `${roundCount} round-number (.00) amounts seeded in one source`, "Round-number fixtures hide arithmetic. If a total is coincidentally equal to a part, the screen teaches a false pattern - use amounts that do not divide evenly.", firstRoundIdx);
715
+ }
716
+ }
124
717
  const AGENTS_TEXT = `# Composition rules for coding agents building on the Venly UI registry
125
718
 
126
719
  Delivery: the shadcn CLI expects a working shadcn environment BEFORE any
@@ -157,8 +750,14 @@ installed venly-tokens css once at the app root.
157
750
  on "stale-version" refetch and let the operator re-decide.
158
751
  5. Theme by editing the installed venly-tokens css file and nothing else.
159
752
  6. Before declaring a screen done, run the review_screen tool on its source
160
- and fix every error-severity finding. Consult get_journey_blueprint
161
- before designing a screen the registry has no block for.
753
+ (pass the journey key so blueprint state coverage is checked too) and fix
754
+ every error-severity finding. Consult get_journey_blueprint before
755
+ designing a screen the registry has no block for.
756
+ 7. Wire the same audit into the app you generate as a CI step - it is what
757
+ turns the design contract into a gate:
758
+ \`npx @venlyfinance/settlement-mcp review "src/**/*.tsx"\`
759
+ (exit 1 on any error-severity finding). A deliberate, justified exception
760
+ carries venly-allow:<rule-id> on the offending line or the line above.
162
761
  `;
163
762
  export function registerFrontendTools(server) {
164
763
  server.registerTool("get_journey_blueprint", {
@@ -167,17 +766,70 @@ export function registerFrontendTools(server) {
167
766
  inputSchema: {
168
767
  journey: z.enum(JOURNEY_KEYS).describe("Which journey to blueprint"),
169
768
  },
170
- }, async ({ journey }) => ({
171
- content: [{ type: "text", text: JOURNEYS[journey] }],
172
- }));
769
+ outputSchema: {
770
+ runtime_contract: RUNTIME_CONTRACT_SCHEMA,
771
+ },
772
+ }, async ({ journey }) => {
773
+ const structuredContent = {
774
+ runtime_contract: runtimeContractForJourney(journey),
775
+ };
776
+ return {
777
+ content: [
778
+ { type: "text", text: JOURNEYS[journey] },
779
+ {
780
+ type: "text",
781
+ text: `\`\`\`json\n${JSON.stringify(structuredContent, null, 2)}\n\`\`\``,
782
+ },
783
+ ],
784
+ structuredContent,
785
+ };
786
+ });
787
+ server.registerTool("verify_runtime_contract", {
788
+ title: "Verify an app's Venly runtime contract",
789
+ description: "Deterministically checks supplied app source and package.json against the direct-sdk or backend-proxy runtime contract. The same rules power the verify CLI.",
790
+ inputSchema: {
791
+ files: z
792
+ .array(z.object({ path: z.string().min(1), source: z.string() }))
793
+ .min(1),
794
+ packageJson: z.string().describe("The app's package.json contents"),
795
+ profile: z.enum(["direct-sdk", "backend-proxy"]).optional(),
796
+ },
797
+ }, async ({ files, packageJson, profile }) => {
798
+ let parsedPackageJson;
799
+ try {
800
+ parsedPackageJson = JSON.parse(packageJson);
801
+ }
802
+ catch (error) {
803
+ const message = `Invalid packageJson: ${error.message}`;
804
+ return {
805
+ content: [{ type: "text", text: message }],
806
+ structuredContent: { error: message },
807
+ isError: true,
808
+ };
809
+ }
810
+ const result = verifyRuntimeContract({ files, packageJson: parsedPackageJson, profile });
811
+ const structuredContent = {
812
+ profile: result.profile,
813
+ findings: result.findings,
814
+ summary: result.summary,
815
+ };
816
+ return {
817
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
818
+ structuredContent,
819
+ };
820
+ });
173
821
  server.registerTool("review_screen", {
174
822
  title: "Design-audit a screen",
175
- description: "Deterministic audit of component/markup source against the kit's design contract: raw colours, hyphen-minus amounts, success styling on cancelled steps, masked review values, zebra striping, off-token shadows, gradients, colour-only state. Returns findings, not a score.",
823
+ description: "Deterministic audit of component/markup source against the kit's design contract: raw colours, hyphen-minus amounts, success styling on cancelled steps, masked review values, invented timing/custody copy, crypto codes inside Intl currency formatting, required fields rendered optional, parity and round-number fixtures, zebra striping, off-token shadows, gradients, colour-only state. Pass the journey key to also check the source against that journey's required blueprint states. Suppress a deliberate exception with venly-allow:<rule-id> on the offending line or the line above. Returns findings, not a score.",
176
824
  inputSchema: {
177
825
  source: z.string().min(1).describe("The component/markup/CSS source to audit"),
826
+ journey: z
827
+ .enum(JOURNEY_KEYS)
828
+ .optional()
829
+ .describe("Optional: which journey this screen implements - enables the blueprint state-coverage check"),
178
830
  },
179
- }, async ({ source }) => {
180
- const findings = reviewScreenSource(source);
831
+ }, async ({ source, journey }) => {
832
+ const findings = reviewScreenSource(source, journey);
181
833
  return {
182
834
  content: [
183
835
  {