@venlyfinance/settlement-mcp 0.5.0 → 0.6.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 +32 -2
- package/README.md +39 -4
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/frontend.d.ts +17 -1
- package/dist/frontend.js +301 -22
- package/dist/index.js +21 -4
- package/dist/review-cli.d.ts +4 -0
- package/dist/review-cli.js +131 -0
- package/package.json +5 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Composition rules for coding agents
|
|
2
|
+
|
|
3
|
+
You are building a financial product UI on `@venlyfinance/react`. These rules exist because the failure modes of money UIs are specific; follow them over generic dashboard instincts.
|
|
4
|
+
|
|
5
|
+
## Non-negotiable
|
|
6
|
+
|
|
7
|
+
1. **Never hand-roll API calls, auth, retries, or transfer state.** Every read is a hook (`useAccounts`, `useTransfers`, `useRampRequests`, …); every regulated lifecycle is a flow machine (`useStagedTransfer`, `useFourEyesApproval`, `useRampLifecycle`). If you are writing `fetch` or a `useEffect` polling loop, stop – the hook exists.
|
|
8
|
+
2. **Wrap the tree once** in `<VenlyProvider environment="mock">`. Mock mode needs zero credentials and zero network; it is the correct default for any demo, test, or first build. Going live is a constructor change, not a rewrite.
|
|
9
|
+
3. **Never place `clientSecret` in browser code.** The provider throws if you try. For browser apps use `proxyClientOptions()` against your own backend route.
|
|
10
|
+
4. **Money movement is stage-then-confirm.** Render a review step showing `state.staged` (the exact request) before calling `confirm()`. Never wire a form submit directly to execution.
|
|
11
|
+
5. **Approval UIs render the rule, not the error.** Use `capability` from `useFourEyesApproval`: when `reason` is `"actor-is-creator"`, say that a second person must approve – do not show buttons that will be refused. On failure `"stale-version"`, refetch and let the operator re-decide; never auto-retry an approval.
|
|
12
|
+
6. **Gate every finished screen with the design audit, and wire it into CI.** `npx @venlyfinance/settlement-mcp review "src/**/*.tsx"` fails (exit 1) on any error-severity finding – raw colours, invented timing or custody copy, crypto codes inside `Intl.NumberFormat` currency formatting, a required field labelled optional, parity fixtures, and more. Add it as a CI step in the app you build. A deliberate, justified exception carries `venly-allow:<rule-id>` on the offending line or the line above.
|
|
13
|
+
|
|
14
|
+
## Rendering money states
|
|
15
|
+
|
|
16
|
+
- Use `descriptor` from `useRampLifecycle` for status pills and timelines: `intent` gives the semantic colour, but always pair colour with a glyph or label – state is never carried by colour alone.
|
|
17
|
+
- A waiting state must answer: must I act, who is it waiting on, what still works. `descriptor.waitingOn` and `descriptor.explanation` carry this; render them instead of a bare "Pending".
|
|
18
|
+
- Amounts: tabular figures, currency code after the amount, and never render a debit in red as the only signal. Empty numeric cells are an em dash, not 0.
|
|
19
|
+
- Terminal failure states show the reason from the record; the status field is the explanation field.
|
|
20
|
+
|
|
21
|
+
## Bring your own auth
|
|
22
|
+
|
|
23
|
+
The Venly APIs authenticate machines (OAuth2 client credentials), not people: there is no end-user login, sign-up, session, password, or MFA endpoint in either API, by design. **The Venly APIs never see end-user credentials.** End-user auth is your identity layer's job; the sanctioned browser shape is a backend proxy that inherits YOUR app's session (`proxyClientOptions()`), never Venly's.
|
|
24
|
+
|
|
25
|
+
The UI kit's auth and team blocks therefore render against two adapter interfaces, `AuthAdapter` and `TeamAdapter`, instead of an SDK client:
|
|
26
|
+
|
|
27
|
+
- **Real implementations** wrap what you already run: OAuth/OIDC, Better Auth, Auth0, Clerk, Keycloak, or a plain session cookie. Implement `signIn`/`verifyTotp`/`signUp`/`session`/`signOut` (and the team CRUD) over your provider's SDK.
|
|
28
|
+
- **Session-expiry contract:** `session()` returns `null` once the session has expired for any reason. The shell treats null as signed-out and redirects to sign-in; no other expiry signal exists in the interface.
|
|
29
|
+
- **Mock adapters ship with the blocks** (`createMockAuthAdapter`, `createMockTeamAdapter`): zero credentials, deterministic 2FA code `000000`, an `expireSession()` driver, and invites that mint a display-only link – the mock never claims an email was sent.
|
|
30
|
+
- Password reset, SSO buttons, and passkeys are provider features: point users at your provider's flow rather than rebuilding it behind the adapter.
|
|
31
|
+
|
|
32
|
+
## Demo choreography (mock mode)
|
|
33
|
+
|
|
34
|
+
`useVenlyMock()` exposes the store controls. A credible end-to-end demo:
|
|
35
|
+
create party → `advanceVerification(id)` → create account → virtual bank account (note its `referenceCode`) → stage + confirm a transfer → `advanceTransfer(id)` → show the ledger. Inject failures with `failNext("CONFLICT")` to show the stale-version approval path – error states are part of the product.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 0.3.0 – 2026-08-04
|
|
4
4
|
|
|
5
|
-
Wording-is-the-safety-surface release. An outside integrator audit (
|
|
6
|
-
2026-08-04) found the server's words disagreeing with its behavior in three
|
|
5
|
+
Wording-is-the-safety-surface release. An outside integrator audit (2026-08-04) found the server's words disagreeing with its behavior in three
|
|
7
6
|
places; all fixed, plus the SDK under the mock now teaches the documented
|
|
8
7
|
lifecycle (see @venlyfinance/sdk 0.2.0).
|
|
9
8
|
|
|
@@ -104,3 +103,34 @@ Frontend toolset: the judgment layer for interface assembly.
|
|
|
104
103
|
## 0.4.1 – 2026-08-07
|
|
105
104
|
|
|
106
105
|
- `venly://frontend/agents` now carries the full cold-start recipe, learned from a fresh-agent build run: Tailwind + path-alias prerequisites before `shadcn init`, the non-interactive `-y -b radix -p nova` flags, that blocks land under `components/venly/` at the project root (relative imports, not the `@/` alias), that `shadcn add` auto-installs the npm dependencies, and the `.js`-extension bundler note.
|
|
106
|
+
|
|
107
|
+
## 0.5.0 – 2026-08-14 (backfill; published 2026-08-15)
|
|
108
|
+
|
|
109
|
+
Entry added retroactively in 0.6.0 – the publish predates it.
|
|
110
|
+
|
|
111
|
+
- Nine payout tools over the re-vendored finance contract (payouts, payout routes +
|
|
112
|
+
ownership proof, party payout bank accounts), fail-closed writes.
|
|
113
|
+
- Corrected capabilities text; react/ui alignment with the 0.4.0 SDK line.
|
|
114
|
+
|
|
115
|
+
## 0.6.0 – 2026-08-18
|
|
116
|
+
|
|
117
|
+
`review_screen` grows teeth: five new rule classes, a suppression hatch, and a CI-runnable command.
|
|
118
|
+
|
|
119
|
+
- New error rules: `invented-timing-claim` (copy promising durations, settlement windows
|
|
120
|
+
or custody behaviour no API in this stack returns), `intl-currency-crypto`
|
|
121
|
+
(`Intl.NumberFormat` + `style:"currency"` + a crypto code throws `RangeError` at render;
|
|
122
|
+
a variable-fed `currency:` in the same call is a warn), `required-rendered-optional`
|
|
123
|
+
(the payment reference labelled "(not required)"), `parity-fixture` (seeded 1:1
|
|
124
|
+
exchange rates).
|
|
125
|
+
- New warn rules: `blueprint-state-missing` (pass the new optional `journey` argument to
|
|
126
|
+
`review_screen` and the audit lists blueprint-named states not found in the source) and
|
|
127
|
+
`round-number-coincidence` (three or more `x.00` amounts seeded in one source).
|
|
128
|
+
- Suppression hatch on every rule, old and new: `venly-allow:<rule-id>` on the offending
|
|
129
|
+
line or the line above drops the finding silently.
|
|
130
|
+
- Copy-judging rules skip comment lines – a comment documenting a rule must not trip it.
|
|
131
|
+
- Findings now carry a 1-based `line`.
|
|
132
|
+
- New `review` CLI over the same audit: `npx @venlyfinance/settlement-mcp review "src/**/*.tsx"`
|
|
133
|
+
exits 1 on any error-severity finding (0 otherwise, 2 on usage errors/no matches), with
|
|
134
|
+
self-expanded globs and zero dependencies. Added a `settlement-mcp` bin alias so the
|
|
135
|
+
npx form resolves under npm's unscoped-name rule. This repo's CI now runs the command
|
|
136
|
+
over its own registry and example sources.
|
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ the normalized write requests. Staging and production writes fail closed.
|
|
|
15
15
|
|
|
16
16
|
The Venly Finance builder surface documented here is the v0.2.0 release line.
|
|
17
17
|
|
|
18
|
+
Building with a coding agent? Start from [AGENTS.md](AGENTS.md) - the MCP also serves it as the \venly://frontend/agents resource.
|
|
19
|
+
|
|
18
20
|
## What it is
|
|
19
21
|
|
|
20
22
|
- Built on the official MCP TypeScript SDK (`@modelcontextprotocol/sdk`), Node
|
|
@@ -98,6 +100,15 @@ Operator writes: `approve_ramp_request`, `reject_ramp_request`. The legacy
|
|
|
98
100
|
Each is dry-run by default and returns the exact request it would send. See the
|
|
99
101
|
safety model below.
|
|
100
102
|
|
|
103
|
+
### Payout tools (since 0.5.0)
|
|
104
|
+
|
|
105
|
+
The payout surface of the finance contract, same tiering as above. Reads:
|
|
106
|
+
`list_payouts`, `get_payout`, `list_payout_routes`, `list_payout_bank_accounts`.
|
|
107
|
+
Writes (dry-run by default, fail-closed like every write):
|
|
108
|
+
`register_payout_bank_account`, `create_payout_route`,
|
|
109
|
+
`prepare_payout_ownership_proof`, `complete_payout_ownership_proof`,
|
|
110
|
+
`request_payout`.
|
|
111
|
+
|
|
101
112
|
### 3. x402 tool (position + stub)
|
|
102
113
|
|
|
103
114
|
`quote_x402_payment` returns an HTTP-402-shaped quote (price, asset, payTo,
|
|
@@ -116,13 +127,36 @@ what a registry cannot:
|
|
|
116
127
|
- `get_journey_blueprint` – screen inventory, required states, registry items and binding
|
|
117
128
|
hooks for eight money-product journeys.
|
|
118
129
|
- `review_screen` – deterministic design audit of a screen's source (raw colours,
|
|
119
|
-
hyphen-minus amounts, success styling on cancelled steps, masked review values,
|
|
120
|
-
|
|
130
|
+
hyphen-minus amounts, success styling on cancelled steps, masked review values,
|
|
131
|
+
invented timing/custody copy, crypto codes inside `Intl.NumberFormat` currency
|
|
132
|
+
formatting, required fields rendered optional, parity and round-number fixtures,
|
|
133
|
+
zebra striping, off-token shadows, colour-only state). Pass the optional `journey`
|
|
134
|
+
key to also check that every state the journey blueprint names appears in the
|
|
135
|
+
source. Findings, not a score.
|
|
121
136
|
- `venly://frontend/agents` – the composition rules an agent should read before building.
|
|
122
137
|
|
|
123
138
|
The `build_international_account` prompt assembles the interface from the registry and
|
|
124
139
|
gates every finished screen on `review_screen`. See [`ui/`](../ui/README.md) for the kit itself.
|
|
125
140
|
|
|
141
|
+
### Design-audit CLI
|
|
142
|
+
|
|
143
|
+
The same audit runs as a command, so a generated app can gate itself in CI:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npx @venlyfinance/settlement-mcp review "src/**/*.tsx"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Exit `1` on any error-severity finding, `0` otherwise (warnings print either way),
|
|
150
|
+
`2` on usage errors or a pattern that matches nothing. Suppress a deliberate,
|
|
151
|
+
justified exception with `venly-allow:<rule-id>` on the offending line or the line
|
|
152
|
+
above – the finding is dropped silently. This repo runs the same command over its
|
|
153
|
+
own registry sources in CI.
|
|
154
|
+
|
|
155
|
+
Scope the glob to component source, never to token or theme files: the
|
|
156
|
+
`raw-colour` rule fires on any hex/rgba literal by design, and a tokens/theme
|
|
157
|
+
css file is the one legitimate home of raw colour values (theming stays a
|
|
158
|
+
one-file edit). `"src/**/*.tsx"` is the right default.
|
|
159
|
+
|
|
126
160
|
## Safety model (fail closed)
|
|
127
161
|
|
|
128
162
|
Outside explicit mock mode, read-only/dry-run is the default posture. A staging
|
|
@@ -228,8 +262,9 @@ reads, and fail-closed write gate without mutating staging:
|
|
|
228
262
|
VENLY_CLIENT_ID=... VENLY_CLIENT_SECRET=... npm run smoke:staging
|
|
229
263
|
```
|
|
230
264
|
|
|
231
|
-
The command starts the MCP with `VENLY_ENV=staging`,
|
|
232
|
-
|
|
265
|
+
The command starts the MCP with `VENLY_ENV=staging`, verifies the discovery
|
|
266
|
+
surface against the exact tool/resource/prompt inventory pinned in the smoke
|
|
267
|
+
script itself, then reads parties, accounts, and reference data.
|
|
233
268
|
It deliberately removes `VENLY_MCP_LIVE` and `VENLY_MCP_PRODUCTION` from the child
|
|
234
269
|
process before submitting one confirmed `create_party` request. Passing requires that
|
|
235
270
|
request to return `mode: dry-run`, `environment: staging`, and an unarmed gate. Output
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared constants. The default environment is MOCK so an unconfigured run
|
|
2
2
|
* never touches real infrastructure; staging/production are explicit. */
|
|
3
3
|
export declare const SERVER_NAME = "venly-finance-mcp-server";
|
|
4
|
-
export declare const SERVER_VERSION = "0.
|
|
4
|
+
export declare const SERVER_VERSION = "0.6.0";
|
|
5
5
|
export declare const ENVIRONMENT_FLAG = "VENLY_ENV";
|
|
6
6
|
export type VenlyEnvironment = "mock" | "qa" | "staging" | "production";
|
|
7
7
|
export declare function resolveVenlyEnvironment(env: Record<string, string | undefined>): VenlyEnvironment;
|
package/dist/constants.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared constants. The default environment is MOCK so an unconfigured run
|
|
2
2
|
* never touches real infrastructure; staging/production are explicit. */
|
|
3
3
|
export const SERVER_NAME = "venly-finance-mcp-server";
|
|
4
|
-
export const SERVER_VERSION = "0.
|
|
4
|
+
export const SERVER_VERSION = "0.6.0";
|
|
5
5
|
export const ENVIRONMENT_FLAG = "VENLY_ENV";
|
|
6
6
|
export function resolveVenlyEnvironment(env) {
|
|
7
7
|
// Default is MOCK (since 0.3.0): the mock-first product must not point at
|
package/dist/frontend.d.ts
CHANGED
|
@@ -9,13 +9,29 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
export declare const REGISTRY_URL_TEMPLATE = "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json";
|
|
12
|
+
declare const JOURNEYS: {
|
|
13
|
+
readonly auth: "# Auth (sign-in, 2FA, sign-up)\nShell: outside the app shell - a centred card column.\nRegistry items: venly-tokens; block: auth (SignInForm, TwoFactorForm, SignUpForm).\nBinding: an AuthAdapter YOU implement - the Venly APIs authenticate machines\n(client credentials), never people, so end-user auth is your identity layer\n(OAuth/OIDC, Better Auth, Auth0, Clerk, Keycloak). createMockAuthAdapter ships\nfor demos: deterministic 2FA code 000000, expireSession() driver.\nStates that must exist: signed out, bad credentials (ONE combined message -\nno user enumeration), 2FA challenge with wrong-code path, session expired\n(session() returns null - redirect, no other signal), duplicate sign-up email.\nRules that must hold: credential errors never confirm which half was wrong;\nthe code field is six slots with paste distribution and full keyboard support;\nthe mock never claims an email was sent.";
|
|
14
|
+
readonly team: "# Team\nShell: in-shell content column.\nRegistry items: venly-tokens, data-table, status-pill; block: team (TeamTable, InviteDialog).\nBinding: a TeamAdapter over your auth provider (createMockTeamAdapter for demos).\nStates that must exist: ACTIVE/INVITED/DISABLED members on first paint, invite\ncreated (display-only link in mock - never a fake sent-email claim), role\nchange persisting, self-actions blocked with the reason.\nRules that must hold: member status is word + glyph; role controls live in the\nrow; you cannot change your own role or disable yourself - the control is\ndisabled AND explains why.";
|
|
15
|
+
readonly "home-balances": "# Home / balances\nShell: left nav rail + thin top bar; full-width content.\nRegistry items: venly-tokens, balance-card, data-table, status-pill; block: balances (BalancesBlock, BalanceMiniature).\nHooks: useAccounts, useWallets; balances rendered per asset across the account's wallets.\nStates that must exist: loading, zero balances (first-run guidance), reserved buckets, entirely reserved (available 0 rendered honestly - the acct-escrow seed exercises it), balance load error degrading locally with a retry.\nRules that must hold: available is the emphasised figure and the only one above the rule; reserved is demoted by position and scale, never colour, and carries the still-yours qualifier; unspendable buckets carry the padlock; masking covers every figure including the chrome miniature; arithmetic mismatches are surfaced, never corrected; never assume stablecoin parity - render the quoted rate.";
|
|
16
|
+
readonly receive: "# Receive\nShell: content column - a warning callout, the field card, an advisory below.\nRegistry items: venly-tokens, field-list; block: receive.\nHooks: useVirtualBankAccounts (first active EUR account).\nStates that must exist: no virtual bank account yet (offer creation), details present, reference not yet assigned (\"Not assigned yet\" + Required pill - never \"(not required)\").\nRules that must hold: the payment reference is enforced as mandatory (amber Required pill, warning above the fields); per-field copy names the field it copied and only confirms on a successful write; rows never vanish - render the \"(not required)\" variant.";
|
|
17
|
+
readonly send: "# Send\nShell: full page; form clamped ~600px; review step replaces the form.\nRegistry items: venly-tokens, arithmetic-ladder, timeline; block: send.\nHooks: useStagedTransfer (the machine IS the flow), useFeeQuote when fees apply.\nStates that must exist: draft (validation issues listed), staged review, submitting, pending (polling), completed, failed (reason shown, terminal).\nRules that must hold: money movement is stage-then-confirm - the review renders the exact staged request as an arithmetic ladder (working before the answer, uncertainty attached to the number); the commit button restates the amount and never carries a countdown; values are never masked on review; execution is single-shot on an idempotency key pinned at staging.";
|
|
18
|
+
readonly activity: "# Activity\nShell: full-width table + side panel.\nRegistry items: venly-tokens, data-table, status-pill, side-panel, timeline; block: activity.\nHooks: useTransfers (and useRampRequests where ramps are in scope).\nStates that must exist: loading, empty ledger, rows with pending/failed pills, open detail panel that stays in sync with refetches.\nRules that must hold: a row click opens the panel, never navigates; no scrim - the source row stays tinted; settled rows stay quiet (colour is a budget; pills only where action or failure lives); the panel's hero is the amount; the failure reason rides the terminal timeline node.";
|
|
19
|
+
readonly "onboarding-status": "# Onboarding / verification status\nShell: full page, form clamped ~600px; a status home once submitted.\nRegistry items: venly-tokens, timeline, status-pill, field-list; block: onboarding (CompanyForm, VerificationStatusHome, RestrictedBanner).\nHooks: useCreateParty, useCreateAccount, useParty, useAccount; verification status from the party/account records verbatim.\nStates that must exist: collecting (review before submit), submitted/waiting (say who acts next, on which channel, what still works meanwhile), approved, declined (humane copy, review-request as the primary action), re-verification on a live account (banner naming what pauses and what keeps working).\nRules that must hold: never render a fake progress percentage - use real status; a waiting state answers how long / who acts / what still works, and where no review window is published the copy says so instead of inventing one; a decline explains and offers a next step, not a dead end; creating a party is NOT completed verification - show the honest state.";
|
|
20
|
+
readonly "withdraw-bank-accounts": "# Withdraw + bank accounts (off-ramp)\nShell: settings page for the whitelist; full page for the flow, form clamped ~600px.\nRegistry items: venly-tokens, data-table, status-pill, timeline, field-list, arithmetic-ladder; blocks: bank-accounts (BankAccountsBlock, AddBankAccountForm), withdraw (WithdrawFlow, WithdrawalsTable, ConnectedWithdrawDetail).\nHooks: useCompanyBankAccounts, useBankAccountConfig, useCreateCompanyBankAccount, useRampRequests, useRampRequest, useCreateRampRequest, useFeeQuote, useRampPairs, useReferenceData, useFourEyesApproval, useInitiateRamp, describeRampStatus.\nStates that must exist: empty whitelist (one CTA), account in review / verified / declined, no-verified-destination block, amount over balance (two-place signal), fee quote with its unit, awaiting approval (creator sees why they can't approve), stale decision (409 - refetch and re-decide, never auto-retry), awaiting funds (deposit instructions + mandatory reference + tx-hash report), processing, paid out, failed, rejected, cancelled, on hold.\nRules that must hold: destinations are the company's OWN verified accounts - unverified rows are disabled with the reason, never hidden; the pre-create review renders only known figures (no invented rate, no bank-receives placeholder - the created record carries the fiat arithmetic and the detail opens on it); a refusal never reads as a wait; the event timeline renders actor, role and absolute timestamps.";
|
|
21
|
+
readonly reconciliation: "# Reconciliation\nShell: split pane (roughly one-third list, two-thirds evidence) - not a drawer.\nRegistry items: venly-tokens, data-table, side-panel, status-pill, field-list.\nHooks: reconcile_by_reference_code (MCP composite) or useVirtualBankAccounts + useTransfers joined on referenceCode.\nStates that must exist: matched, unmatched with candidate expectations, partial/many-to-one with a live shortfall figure, resolved.\nRules that must hold: show per-signal match rationale (which fields agree), never a bare score; keep zero-counts visible - an empty exception queue is information; keyboard row-stepping for review throughput.";
|
|
22
|
+
readonly "proof-of-segregation": "# Proof of segregation\nShell: content column, single card.\nRegistry items: venly-tokens, field-list, balance-card.\nHooks: useWallets, useAccount; on-chain balance beside the ledger figure.\nStates that must exist: reconciled (figures agree, timestamped), reconciling, source unavailable (say so - never render a stale figure as current).\nRules that must hold: the wallet address renders monospace with copy; the on-chain figure and ledger figure sit side by side with their as-of times; discrepancies are stated, not smoothed.";
|
|
23
|
+
readonly approvals: "# Approvals\nShell: full-width queue + side panel tailored to the approver.\nRegistry items: venly-tokens, data-table, status-pill, side-panel, timeline.\nHooks: useRampRequests, useFourEyesApproval (capability decides what renders), useRampLifecycle.\nStates 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).\nRules 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.";
|
|
24
|
+
};
|
|
25
|
+
type JourneyKey = keyof typeof JOURNEYS;
|
|
12
26
|
interface Finding {
|
|
13
27
|
rule: string;
|
|
14
28
|
severity: "error" | "warn";
|
|
15
29
|
evidence: string;
|
|
16
30
|
fix: string;
|
|
31
|
+
/** 1-based line the finding fired on - lets a CLI print path:line. */
|
|
32
|
+
line?: number;
|
|
17
33
|
}
|
|
18
34
|
/** Deterministic design audit. Text in, findings out - no model, no taste. */
|
|
19
|
-
export declare function reviewScreenSource(source: string): Finding[];
|
|
35
|
+
export declare function reviewScreenSource(source: string, journey?: JourneyKey): Finding[];
|
|
20
36
|
export declare function registerFrontendTools(server: McpServer): void;
|
|
21
37
|
export {};
|
package/dist/frontend.js
CHANGED
|
@@ -80,15 +80,75 @@ States that must exist: queue with awaiting-approval items, detail with the deci
|
|
|
80
80
|
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.`,
|
|
81
81
|
};
|
|
82
82
|
const JOURNEY_KEYS = Object.keys(JOURNEYS);
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Shared mechanics. Three cross-cutting behaviours every rule participates in:
|
|
85
|
+
//
|
|
86
|
+
// 1. Suppression: `venly-allow:<rule-id>` on the offending line or the line
|
|
87
|
+
// immediately above drops the finding silently - no counter, no second
|
|
88
|
+
// severity tier. A consumer's own API may legitimately return what ours
|
|
89
|
+
// does not; without this hatch the audit is uninstallable for them.
|
|
90
|
+
// 2. Comment lines are not copy: rules that judge words skip lines whose
|
|
91
|
+
// trimmed form starts with `*`, `//`, `/*` or `{/*` - otherwise the rule
|
|
92
|
+
// fires on the comment that documents the rule itself.
|
|
93
|
+
// 3. Findings carry the character index they fired at, so suppression can be
|
|
94
|
+
// resolved against the exact offending line.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
const COMMENT_LINE = /^\s*(?:\*|\/\/|\/\*|\{\/\*)/;
|
|
97
|
+
function lineBoundsAt(source, idx) {
|
|
98
|
+
const at = Math.min(Math.max(idx, 0), source.length);
|
|
99
|
+
const start = source.lastIndexOf("\n", Math.max(0, at - 1)) + 1;
|
|
100
|
+
const nl = source.indexOf("\n", at);
|
|
101
|
+
return { start, end: nl === -1 ? source.length : nl };
|
|
102
|
+
}
|
|
103
|
+
function lineAt(source, idx) {
|
|
104
|
+
const { start, end } = lineBoundsAt(source, idx);
|
|
105
|
+
return source.slice(start, end);
|
|
106
|
+
}
|
|
107
|
+
function lineAboveAt(source, idx) {
|
|
108
|
+
const { start } = lineBoundsAt(source, idx);
|
|
109
|
+
if (start === 0)
|
|
110
|
+
return "";
|
|
111
|
+
const prevEnd = start - 1; // the \n terminating the previous line
|
|
112
|
+
const prevStart = source.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
113
|
+
return source.slice(prevStart, prevEnd);
|
|
114
|
+
}
|
|
115
|
+
function isCommentLineAt(source, idx) {
|
|
116
|
+
return COMMENT_LINE.test(lineAt(source, idx));
|
|
117
|
+
}
|
|
118
|
+
function isSuppressedAt(source, idx, ruleId) {
|
|
119
|
+
const token = `venly-allow:${ruleId}`;
|
|
120
|
+
return lineAt(source, idx).includes(token) || lineAboveAt(source, idx).includes(token);
|
|
121
|
+
}
|
|
122
|
+
function lineNumberAt(source, idx) {
|
|
123
|
+
let line = 1;
|
|
124
|
+
for (let i = 0; i < idx && i < source.length; i++)
|
|
125
|
+
if (source[i] === "\n")
|
|
126
|
+
line++;
|
|
127
|
+
return line;
|
|
128
|
+
}
|
|
83
129
|
/** Deterministic design audit. Text in, findings out - no model, no taste. */
|
|
84
|
-
export function reviewScreenSource(source) {
|
|
130
|
+
export function reviewScreenSource(source, journey) {
|
|
85
131
|
const findings = [];
|
|
86
|
-
|
|
132
|
+
// Returns whether the finding was recorded, so rules that stop after the
|
|
133
|
+
// first hit can keep scanning past a suppressed occurrence instead of
|
|
134
|
+
// letting one venly-allow blind them to a later real violation.
|
|
135
|
+
const push = (rule, severity, evidence, fix, atIndex) => {
|
|
136
|
+
if (isSuppressedAt(source, atIndex, rule))
|
|
137
|
+
return false;
|
|
138
|
+
findings.push({
|
|
139
|
+
rule,
|
|
140
|
+
severity,
|
|
141
|
+
evidence: evidence.slice(0, 120),
|
|
142
|
+
fix,
|
|
143
|
+
line: lineNumberAt(source, atIndex),
|
|
144
|
+
});
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
87
147
|
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.");
|
|
148
|
+
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
149
|
}
|
|
90
150
|
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).");
|
|
151
|
+
push("hyphen-minus-amount", "error", match[0], "Use the true minus sign − before negative amounts (the Money primitive does this).", match.index ?? 0);
|
|
92
152
|
}
|
|
93
153
|
// Only a RENDERED cancelled state counts (a quoted/JSX label or a state
|
|
94
154
|
// value), never the verb "cancel" in prose or a token file's comment; and
|
|
@@ -98,29 +158,238 @@ export function reviewScreenSource(source) {
|
|
|
98
158
|
const idx = match.index ?? 0;
|
|
99
159
|
const around = source.slice(Math.max(0, idx - 150), idx + 150);
|
|
100
160
|
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
|
-
|
|
161
|
+
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))
|
|
162
|
+
break;
|
|
103
163
|
}
|
|
104
164
|
}
|
|
105
|
-
|
|
106
|
-
|
|
165
|
+
// The once-per-source rules below scan every occurrence and stop at the
|
|
166
|
+
// first RECORDED finding, so a venly-allow on one occurrence never hides a
|
|
167
|
+
// later unsuppressed one.
|
|
168
|
+
if (/review|confirm/i.test(source)) {
|
|
169
|
+
for (const masked of source.matchAll(/[•*]{3,}/g)) {
|
|
170
|
+
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))
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
107
173
|
}
|
|
108
|
-
|
|
109
|
-
push("zebra-striping", "warn", "nth-child(even/odd) background", "No finance reference uses zebra striping - separate rows with hairlines and spacing.")
|
|
174
|
+
for (const zebra of source.matchAll(/nth-child\(\s*(?:even|odd|2n)/g)) {
|
|
175
|
+
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))
|
|
176
|
+
break;
|
|
110
177
|
}
|
|
111
|
-
if (
|
|
112
|
-
|
|
178
|
+
if (!/var\(--shadow-overlay\)/.test(source)) {
|
|
179
|
+
for (const shadow of source.matchAll(/box-shadow[^;"}]*/g)) {
|
|
180
|
+
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))
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
113
183
|
}
|
|
114
|
-
|
|
115
|
-
push("gradient-surface", "warn",
|
|
184
|
+
for (const gradient of source.matchAll(/(?:linear|radial)-gradient(?:\([^)]*\))?/g)) {
|
|
185
|
+
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))
|
|
186
|
+
break;
|
|
116
187
|
}
|
|
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.")
|
|
188
|
+
if (/(?:status|state)/i.test(source) && !/[✓✕↺⚠●○]|aria-hidden/.test(source)) {
|
|
189
|
+
for (const stateVar of source.matchAll(/var\(--state-/g)) {
|
|
190
|
+
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))
|
|
191
|
+
break;
|
|
120
192
|
}
|
|
121
193
|
}
|
|
194
|
+
// --- New rule classes (invented timing copy, crypto currency formatting,
|
|
195
|
+
// required-rendered-optional, blueprint state coverage, fixture honesty)
|
|
196
|
+
// are registered below. Each judges text only, honours the suppression
|
|
197
|
+
// hatch, and skips comment lines wherever it judges copy.
|
|
198
|
+
checkInventedTimingClaim(source, push);
|
|
199
|
+
checkIntlCurrencyCrypto(source, push);
|
|
200
|
+
checkRequiredRenderedOptional(source, push);
|
|
201
|
+
checkBlueprintStateCoverage(source, journey, push);
|
|
202
|
+
checkFixtureHonesty(source, push);
|
|
122
203
|
return findings;
|
|
123
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* invented-timing-claim - copy that promises a duration, a settlement window
|
|
207
|
+
* or custody behaviour ("1-2 business days", "held until claimed",
|
|
208
|
+
* "estimated arrival") that no API in this stack returns. Rendering such a
|
|
209
|
+
* promise invents a guarantee the backend cannot honour; the journey
|
|
210
|
+
* contracts require a labelled omission instead. Copy rule: comment lines
|
|
211
|
+
* are not copy, so matches on them are skipped.
|
|
212
|
+
*/
|
|
213
|
+
function checkInventedTimingClaim(source, push) {
|
|
214
|
+
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;
|
|
215
|
+
for (const match of source.matchAll(pattern)) {
|
|
216
|
+
const at = match.index ?? 0;
|
|
217
|
+
if (isCommentLineAt(source, at))
|
|
218
|
+
continue;
|
|
219
|
+
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);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// --- intl-currency-crypto -------------------------------------------------
|
|
223
|
+
// Intl.NumberFormat validates `currency` against ISO 4217, so a crypto asset
|
|
224
|
+
// code ("USDC", "DAI", ...) throws RangeError the moment the formatter is
|
|
225
|
+
// constructed - a screen that compiles fine crashes on first render. Each
|
|
226
|
+
// Intl.NumberFormat call site is judged by the 200 characters that follow it:
|
|
227
|
+
// a literal crypto code next to style:"currency" is a certain crash (error);
|
|
228
|
+
// a variable-fed `currency:` is a latent one (warn) - it only survives until
|
|
229
|
+
// a crypto asset reaches it. Not a copy rule, so comment lines are not
|
|
230
|
+
// skipped; suppression still applies via the shared venly-allow hatch.
|
|
231
|
+
function checkIntlCurrencyCrypto(source, push) {
|
|
232
|
+
const currencyStyle = /style\s*:\s*["']currency["']/;
|
|
233
|
+
const cryptoCode = /["'](?:USDC|EURC|USDT|USDS|DAI|PYUSD|USDG|RLUSD)["']/;
|
|
234
|
+
// `\s*` lives inside the lookahead: with `currency\s*:\s*(?!["'])` the
|
|
235
|
+
// greedy whitespace backtracks to zero and the lookahead inspects the
|
|
236
|
+
// space instead of the quote, flagging `currency: "USD"` as a variable.
|
|
237
|
+
const variableCurrency = /currency\s*:(?!\s*["'])/;
|
|
238
|
+
for (const match of source.matchAll(/Intl\.NumberFormat/g)) {
|
|
239
|
+
const at = match.index ?? 0;
|
|
240
|
+
const window = source.slice(at, at + 200);
|
|
241
|
+
const style = currencyStyle.exec(window);
|
|
242
|
+
if (!style)
|
|
243
|
+
continue; // plain decimal formatting (the kit's own formatAmount) is safe
|
|
244
|
+
const styleAt = style.index ?? 0;
|
|
245
|
+
const crypto = cryptoCode.exec(window);
|
|
246
|
+
if (crypto) {
|
|
247
|
+
const cryptoAt = crypto.index ?? 0;
|
|
248
|
+
const from = Math.min(styleAt, cryptoAt);
|
|
249
|
+
const to = Math.max(styleAt + style[0].length, cryptoAt + crypto[0].length);
|
|
250
|
+
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);
|
|
251
|
+
continue; // one finding per call site; the certain crash outranks the latent one
|
|
252
|
+
}
|
|
253
|
+
const variable = variableCurrency.exec(window);
|
|
254
|
+
if (variable) {
|
|
255
|
+
const variableAt = variable.index ?? 0;
|
|
256
|
+
const from = Math.min(styleAt, variableAt);
|
|
257
|
+
const to = Math.min(window.length, Math.max(styleAt + style[0].length, variableAt) + 40);
|
|
258
|
+
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);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A required field labelled as optional. The kit deliberately ships a
|
|
264
|
+
* "(not required)" variant for genuinely optional rows, so the net is scoped
|
|
265
|
+
* tightly: only the payment reference is required-by-contract, and a payer
|
|
266
|
+
* who omits it produces an unmatched credit that someone has to reconcile by
|
|
267
|
+
* hand. The rule therefore fires only when "(not required)" appears on a real
|
|
268
|
+
* code line AND the surrounding code (comments removed) mentions the
|
|
269
|
+
* reference - a comment that merely documents this contract must not trip it.
|
|
270
|
+
*/
|
|
271
|
+
function checkRequiredRenderedOptional(source, push) {
|
|
272
|
+
for (const match of source.matchAll(/\(not required\)/gi)) {
|
|
273
|
+
const idx = match.index ?? 0;
|
|
274
|
+
// Copy rule: only judge rendered copy, never commentary about it.
|
|
275
|
+
if (isCommentLineAt(source, idx))
|
|
276
|
+
continue;
|
|
277
|
+
const winStart = Math.max(0, idx - 200);
|
|
278
|
+
const winEnd = Math.min(source.length, idx + match[0].length + 200);
|
|
279
|
+
// Rebuild the window with every comment line removed. Each line is
|
|
280
|
+
// classified on its FULL text (a fragment cut by the window edge could
|
|
281
|
+
// hide its comment marker), but only the in-window portion of surviving
|
|
282
|
+
// code lines feeds the reference test.
|
|
283
|
+
let pos = source.lastIndexOf("\n", Math.max(0, winStart - 1)) + 1;
|
|
284
|
+
let window = "";
|
|
285
|
+
while (pos < winEnd) {
|
|
286
|
+
let lineEnd = source.indexOf("\n", pos);
|
|
287
|
+
if (lineEnd === -1)
|
|
288
|
+
lineEnd = source.length;
|
|
289
|
+
const line = source.slice(pos, lineEnd);
|
|
290
|
+
if (!COMMENT_LINE.test(line)) {
|
|
291
|
+
const from = Math.max(pos, winStart);
|
|
292
|
+
const to = Math.min(lineEnd, winEnd);
|
|
293
|
+
if (to > from)
|
|
294
|
+
window += source.slice(from, to) + "\n";
|
|
295
|
+
}
|
|
296
|
+
pos = lineEnd + 1;
|
|
297
|
+
}
|
|
298
|
+
if (!/reference/i.test(window))
|
|
299
|
+
continue;
|
|
300
|
+
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);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function checkBlueprintStateCoverage(source, journey, push) {
|
|
304
|
+
// Only meaningful when the caller declared which journey this screen serves.
|
|
305
|
+
if (journey === undefined)
|
|
306
|
+
return;
|
|
307
|
+
// Whole-source suppression: this finding has no single offending line (it
|
|
308
|
+
// reports blueprint states absent from the entire file), so the escape
|
|
309
|
+
// hatch is whole-source too - the venly-allow token anywhere drops it.
|
|
310
|
+
if (source.includes("venly-allow:blueprint-state-missing"))
|
|
311
|
+
return;
|
|
312
|
+
const blueprint = JOURNEYS[journey];
|
|
313
|
+
const startMarker = "States that must exist:";
|
|
314
|
+
const startIdx = blueprint.indexOf(startMarker);
|
|
315
|
+
if (startIdx === -1)
|
|
316
|
+
return;
|
|
317
|
+
let statesText = blueprint.slice(startIdx + startMarker.length);
|
|
318
|
+
const end = /^Rules that must hold/m.exec(statesText);
|
|
319
|
+
if (end)
|
|
320
|
+
statesText = statesText.slice(0, end.index);
|
|
321
|
+
// Blueprint prose wraps across lines mid-sentence; collapse before parsing.
|
|
322
|
+
statesText = statesText.replace(/\n/g, " ");
|
|
323
|
+
// One state per " · " or "," separator - but only at parenthesis depth 0:
|
|
324
|
+
// a comma inside a parenthetical is part of that state's description, not
|
|
325
|
+
// a state boundary. Naive splitting yields fragments like "terminal)" that
|
|
326
|
+
// no source can contain, making a journey structurally unable to pass.
|
|
327
|
+
const parts = [];
|
|
328
|
+
let depth = 0;
|
|
329
|
+
let current = "";
|
|
330
|
+
for (const ch of statesText) {
|
|
331
|
+
if (ch === "(")
|
|
332
|
+
depth++;
|
|
333
|
+
else if (ch === ")")
|
|
334
|
+
depth = Math.max(0, depth - 1);
|
|
335
|
+
if (depth === 0 && (ch === "," || ch === "·")) {
|
|
336
|
+
parts.push(current);
|
|
337
|
+
current = "";
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
current += ch;
|
|
341
|
+
}
|
|
342
|
+
parts.push(current);
|
|
343
|
+
// Each state's keyword is the text before the first parenthetical,
|
|
344
|
+
// normalised for a case-insensitive substring probe.
|
|
345
|
+
const keywords = [];
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
const keyword = part
|
|
348
|
+
.split("(")[0]
|
|
349
|
+
.trim()
|
|
350
|
+
.toLowerCase()
|
|
351
|
+
.replace(/\s+/g, " ")
|
|
352
|
+
.replace(/\.$/, "");
|
|
353
|
+
if (keyword)
|
|
354
|
+
keywords.push(keyword);
|
|
355
|
+
}
|
|
356
|
+
const lowered = source.toLowerCase();
|
|
357
|
+
const missing = keywords.filter((keyword) => !lowered.includes(keyword));
|
|
358
|
+
if (missing.length === 0)
|
|
359
|
+
return;
|
|
360
|
+
// One aggregate warn, never per-keyword findings and never an empty-list
|
|
361
|
+
// finding. Warn (not error) because blueprint phrases are prose - a state
|
|
362
|
+
// can be fully implemented under different wording.
|
|
363
|
+
const list = missing.join(", ");
|
|
364
|
+
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);
|
|
365
|
+
}
|
|
366
|
+
function checkFixtureHonesty(source, push) {
|
|
367
|
+
// Fixture honesty. A demo that seeds parity rates or round-number amounts
|
|
368
|
+
// teaches false patterns: parity hides the crypto/fiat unit distinction,
|
|
369
|
+
// and round numbers let a total look derivable when it is coincidence.
|
|
370
|
+
// ERROR - an explicit parity rate seeded on a rate-named field. Anchored to
|
|
371
|
+
// the three rate names so counters like `rateLimit: 1` never trip it.
|
|
372
|
+
for (const match of source.matchAll(/\b(?:exchangeRate|rate|fxRate)\s*[:=]\s*1(?:\.0+)?\b/g)) {
|
|
373
|
+
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);
|
|
374
|
+
}
|
|
375
|
+
// WARN - three or more round-number amounts (x.00) in one source. Comment
|
|
376
|
+
// lines are skipped: this sibling judges seeded copy/fixtures, and prose
|
|
377
|
+
// like "may display as 0.00" is documentation, not a seeded amount. One
|
|
378
|
+
// finding per source, anchored at the first counted match.
|
|
379
|
+
let roundCount = 0;
|
|
380
|
+
let firstRoundIdx = -1;
|
|
381
|
+
for (const match of source.matchAll(/\b\d+\.00\b/g)) {
|
|
382
|
+
const idx = match.index ?? 0;
|
|
383
|
+
if (isCommentLineAt(source, idx))
|
|
384
|
+
continue;
|
|
385
|
+
if (firstRoundIdx === -1)
|
|
386
|
+
firstRoundIdx = idx;
|
|
387
|
+
roundCount++;
|
|
388
|
+
}
|
|
389
|
+
if (roundCount >= 3) {
|
|
390
|
+
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);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
124
393
|
const AGENTS_TEXT = `# Composition rules for coding agents building on the Venly UI registry
|
|
125
394
|
|
|
126
395
|
Delivery: the shadcn CLI expects a working shadcn environment BEFORE any
|
|
@@ -157,8 +426,14 @@ installed venly-tokens css once at the app root.
|
|
|
157
426
|
on "stale-version" refetch and let the operator re-decide.
|
|
158
427
|
5. Theme by editing the installed venly-tokens css file and nothing else.
|
|
159
428
|
6. Before declaring a screen done, run the review_screen tool on its source
|
|
160
|
-
|
|
161
|
-
|
|
429
|
+
(pass the journey key so blueprint state coverage is checked too) and fix
|
|
430
|
+
every error-severity finding. Consult get_journey_blueprint before
|
|
431
|
+
designing a screen the registry has no block for.
|
|
432
|
+
7. Wire the same audit into the app you generate as a CI step - it is what
|
|
433
|
+
turns the design contract into a gate:
|
|
434
|
+
\`npx @venlyfinance/settlement-mcp review "src/**/*.tsx"\`
|
|
435
|
+
(exit 1 on any error-severity finding). A deliberate, justified exception
|
|
436
|
+
carries venly-allow:<rule-id> on the offending line or the line above.
|
|
162
437
|
`;
|
|
163
438
|
export function registerFrontendTools(server) {
|
|
164
439
|
server.registerTool("get_journey_blueprint", {
|
|
@@ -172,12 +447,16 @@ export function registerFrontendTools(server) {
|
|
|
172
447
|
}));
|
|
173
448
|
server.registerTool("review_screen", {
|
|
174
449
|
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.",
|
|
450
|
+
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
451
|
inputSchema: {
|
|
177
452
|
source: z.string().min(1).describe("The component/markup/CSS source to audit"),
|
|
453
|
+
journey: z
|
|
454
|
+
.enum(JOURNEY_KEYS)
|
|
455
|
+
.optional()
|
|
456
|
+
.describe("Optional: which journey this screen implements - enables the blueprint state-coverage check"),
|
|
178
457
|
},
|
|
179
|
-
}, async ({ source }) => {
|
|
180
|
-
const findings = reviewScreenSource(source);
|
|
458
|
+
}, async ({ source, journey }) => {
|
|
459
|
+
const findings = reviewScreenSource(source, journey);
|
|
181
460
|
return {
|
|
182
461
|
content: [
|
|
183
462
|
{
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,24 @@ async function main() {
|
|
|
27
27
|
: "writes DISARMED: mutations return dry-run previews (arming needs confirm:true + VENLY_MCP_LIVE=1 + credentials)";
|
|
28
28
|
process.stderr.write(`venly-finance-mcp started in ${client.environment}. ${writeState}.\n`);
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
if (argv[0] === "review") {
|
|
32
|
+
// Design-audit CLI mode: `... review "src/**/*.tsx"`. Dynamic import so the
|
|
33
|
+
// MCP/SDK path is never touched; MCP hosts launch with zero args, so plain
|
|
34
|
+
// startup is unchanged.
|
|
35
|
+
import("./review-cli.js")
|
|
36
|
+
.then(({ runReviewCli }) => runReviewCli(argv.slice(1)))
|
|
37
|
+
.then((code) => {
|
|
38
|
+
process.exitCode = code;
|
|
39
|
+
})
|
|
40
|
+
.catch((err) => {
|
|
41
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
main().catch((err) => {
|
|
47
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Expand one glob pattern (posix-style separators) relative to cwd. */
|
|
2
|
+
export declare function expandPattern(pattern: string, cwd: string): string[];
|
|
3
|
+
export declare function expandPatterns(patterns: string[], cwd: string): string[];
|
|
4
|
+
export declare function runReviewCli(args: string[], out?: NodeJS.WritableStream, err?: NodeJS.WritableStream): Promise<0 | 1 | 2>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `review` subcommand: the review_screen design audit as a CI gate.
|
|
2
|
+
//
|
|
3
|
+
// npx @venlyfinance/settlement-mcp review "src/**/*.tsx"
|
|
4
|
+
//
|
|
5
|
+
// Exit codes: 0 clean (warnings allowed, printed either way) · 1 at least one
|
|
6
|
+
// error-severity finding · 2 usage error or a pattern that matched nothing
|
|
7
|
+
// (a typo'd path must never pass CI silently).
|
|
8
|
+
//
|
|
9
|
+
// Patterns are self-expanded (**, *, {a,b}) so the quoted form works on any
|
|
10
|
+
// shell; unquoted shell-expanded literal paths work too. No dependencies.
|
|
11
|
+
// (Line comments on purpose: a glob's **/ would terminate a block comment.)
|
|
12
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
13
|
+
import { join, relative } from "node:path";
|
|
14
|
+
import { reviewScreenSource } from "./frontend.js";
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);
|
|
16
|
+
function braceExpand(pattern) {
|
|
17
|
+
const m = /\{([^{}]*)\}/.exec(pattern);
|
|
18
|
+
if (!m)
|
|
19
|
+
return [pattern];
|
|
20
|
+
const before = pattern.slice(0, m.index);
|
|
21
|
+
const after = pattern.slice(m.index + m[0].length);
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const option of m[1].split(",")) {
|
|
24
|
+
out.push(...braceExpand(before + option + after));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function patternToRegExp(pattern) {
|
|
29
|
+
// Escape everything regex-special except the glob characters we translate.
|
|
30
|
+
const escaped = pattern.replace(/[.+^$()|[\]\\?]/g, "\\$&");
|
|
31
|
+
const translated = escaped
|
|
32
|
+
.replace(/\*\*\//g, "\u0000") // **/ may match zero segments
|
|
33
|
+
.replace(/\*\*/g, "\u0001") // a bare ** matches anything
|
|
34
|
+
.replace(/\*/g, "[^/]*")
|
|
35
|
+
.replace(/\u0000/g, "(?:.*/)?")
|
|
36
|
+
.replace(/\u0001/g, ".*");
|
|
37
|
+
return new RegExp(`^${translated}$`);
|
|
38
|
+
}
|
|
39
|
+
function walk(dir, into) {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
50
|
+
walk(join(dir, entry.name), into);
|
|
51
|
+
}
|
|
52
|
+
else if (entry.isFile()) {
|
|
53
|
+
into.push(join(dir, entry.name));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Expand one glob pattern (posix-style separators) relative to cwd. */
|
|
58
|
+
export function expandPattern(pattern, cwd) {
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const variant of braceExpand(pattern)) {
|
|
61
|
+
const segments = variant.split("/");
|
|
62
|
+
const firstWild = segments.findIndex((s) => s.includes("*"));
|
|
63
|
+
if (firstWild === -1) {
|
|
64
|
+
if (existsSync(join(cwd, variant)) && statSync(join(cwd, variant)).isFile()) {
|
|
65
|
+
results.push(variant);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const staticPrefix = segments.slice(0, firstWild).join("/");
|
|
70
|
+
const root = staticPrefix ? join(cwd, staticPrefix) : cwd;
|
|
71
|
+
const files = [];
|
|
72
|
+
walk(root, files);
|
|
73
|
+
const matcher = patternToRegExp(variant);
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
// relative(), not string slicing: a pattern like "../ui/**/*.tsx"
|
|
76
|
+
// walks outside cwd, where prefix slicing produces garbage.
|
|
77
|
+
const rel = relative(cwd, file).split("\\").join("/");
|
|
78
|
+
if (matcher.test(rel))
|
|
79
|
+
results.push(rel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(results)].sort();
|
|
83
|
+
}
|
|
84
|
+
export function expandPatterns(patterns, cwd) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const pattern of patterns) {
|
|
87
|
+
if (/[*{]/.test(pattern)) {
|
|
88
|
+
out.push(...expandPattern(pattern.split("\\").join("/"), cwd));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
out.push(pattern); // shell-expanded or literal; existence checked by caller
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...new Set(out)];
|
|
95
|
+
}
|
|
96
|
+
export async function runReviewCli(args, out = process.stdout, err = process.stderr) {
|
|
97
|
+
const patterns = args.filter((a) => !a.startsWith("-"));
|
|
98
|
+
if (patterns.length === 0) {
|
|
99
|
+
err.write('Usage: review "<glob>" [more globs or files]\n' +
|
|
100
|
+
' e.g. review "src/**/*.tsx"\n' +
|
|
101
|
+
"Exits 1 on any error-severity finding, 2 when nothing matched.\n");
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
const cwd = process.cwd();
|
|
105
|
+
const files = expandPatterns(patterns, cwd);
|
|
106
|
+
const missing = files.filter((f) => !existsSync(f));
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
err.write(`No such file: ${missing.join(", ")}\n`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
if (files.length === 0) {
|
|
112
|
+
err.write(`Nothing matched: ${patterns.join(" ")}\n`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
let errors = 0;
|
|
116
|
+
let warnings = 0;
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
const findings = reviewScreenSource(readFileSync(file, "utf8"));
|
|
119
|
+
for (const finding of findings) {
|
|
120
|
+
if (finding.severity === "error")
|
|
121
|
+
errors++;
|
|
122
|
+
else
|
|
123
|
+
warnings++;
|
|
124
|
+
const line = finding.line === undefined ? "" : `:${finding.line}`;
|
|
125
|
+
out.write(`${file}${line} ${finding.severity} ${finding.rule} ${finding.evidence}\n`);
|
|
126
|
+
out.write(` fix: ${finding.fix}\n`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
out.write(`${errors} error(s), ${warnings} warning(s) across ${files.length} file(s)\n`);
|
|
130
|
+
return errors > 0 ? 1 : 0;
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venlyfinance/settlement-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Venly Finance MCP: SDK-backed tools, resources and prompts for building international money products safely.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"venly-finance-mcp": "dist/index.js",
|
|
8
|
-
"venly-settlement-mcp": "dist/index.js"
|
|
8
|
+
"venly-settlement-mcp": "dist/index.js",
|
|
9
|
+
"settlement-mcp": "dist/index.js"
|
|
9
10
|
},
|
|
10
11
|
"main": "dist/index.js",
|
|
11
12
|
"files": [
|
|
12
13
|
"dist",
|
|
13
14
|
"scripts",
|
|
14
15
|
"skills",
|
|
16
|
+
"AGENTS.md",
|
|
15
17
|
"README.md",
|
|
16
18
|
"CHANGELOG.md"
|
|
17
19
|
],
|
|
@@ -60,4 +62,4 @@
|
|
|
60
62
|
"eur",
|
|
61
63
|
"viban"
|
|
62
64
|
]
|
|
63
|
-
}
|
|
65
|
+
}
|