@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/AGENTS.md +35 -0
- package/CHANGELOG.md +53 -2
- package/README.md +63 -7
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +2 -1
- package/dist/frontend.d.ts +21 -1
- package/dist/frontend.js +677 -25
- package/dist/index.js +32 -4
- package/dist/review-cli.d.ts +4 -0
- package/dist/review-cli.js +131 -0
- package/dist/server.js +3 -1
- package/dist/staging-smoke.d.ts +1 -1
- package/dist/staging-smoke.js +1 -0
- package/dist/verify-cli.d.ts +24 -0
- package/dist/verify-cli.js +371 -0
- package/package.json +6 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 (
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
push("gradient-surface", "warn",
|
|
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) &&
|
|
118
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
{
|