Non-deterministic architectural check that complements the deterministic gate (bun run check — typecheck, lint, format, unit tests, skill lint). Determinism catches typos; this catches drift.
When to invoke
- After finishing any non-trivial change, before claiming "done."
- On user
/review.
Inputs
- Diff scope:
git diff(uncommitted) ANDgit diff main...HEADif on a feature branch.- If outside a git repo, review uncommitted changes only.
CLAUDE.md.
Read both before forming any judgment. All other rules required for this review are embedded in this skill below — do not depend on any external doc.
Project layering (memorize)
A bun workspace with two packages. packages/client (@metabase/client) is the private Metabase API client, built by tsdown to a dist/ its exports map names; packages/cli (@metabase/cli) is the publishable CLI built on it, and inlines the client at build time. The e2e tier lives at the repo root under tests/e2e/ and drives the built binary; unit tests sit beside their source under packages/<p>/src/**/*.test.ts.
packages/client/src/
index.ts public barrel: client factory, error taxonomy, Credential, parseJson
client.ts createClient — composes the resource namespaces onto one transport
resources/ one file per Metabase resource; every /api/ path and transport call
http/ the HTTP boundary — transport.ts, errors.ts, network-error.ts,
response-shape.ts, sanitize.ts, retry.ts, oauth.ts
testing/ the shipped test doubles — fake-client.ts, fetch-capture.ts
auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts
version/ tag.ts, probe.ts, known-range.ts (KNOWN_RANGE), profile.ts (ServerProfile), features.ts (FEATURE_RULES —
the only file comparing a Metabase major), requirements.ts (METHOD_REQUIREMENTS),
requirement-check.ts (checkFeatures), capability-error.ts,
refusal.ts (explainer → explain / explainWalk / refuse / refuseAfterReading, explainRefusal)
domain/ Metabase API resource Zod schemas + inferred types
errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage,
isNonInterruptFailure
list.ts ListResult<T> = { data, total } — what a one-response list method returns
url.ts normalizeUrl, displayUrl, assertEndpointOrigin — the only home for new URL()
json.ts parseJson — the only home for JSON.parse
poll.ts pollUntil — the wait-loop home (http/retry.ts drives its own backoff wait)
paginate.ts, signal.ts, predicates.ts, json-pointer.ts
packages/cli/src/
cli.ts entrypoint; only file allowed to call process.exit
main.ts root citty command, lazy subCommands
commands/ CLI shell — defineMetabaseCommand, flags, prompts, orchestration
core/ pure logic, no CLI deps
core/auth/ credential storage + verification
core/config.ts flag → env → stored resolver
core/env.ts readEnv — every MB_* env name, no raw process.env reads elsewhere
core/user-agent.ts USER_AGENT, the CLI's wire identity
core/npm-registry.ts the upgrade check's dist-tags lookup — the CLI's only fetch()
core/system-ca.ts trustSystemCa — the only node:tls in the repo, a CLI prerogative
output/ presentation; takes typed values; renders text or JSON
runtime/ platform glue: stdin, csv, yaml, citty helpers
Allowed import direction — the package boundary first, then within each package:
- Nothing in the client may import from the CLI — not by an
@metabase/clispecifier, not by a relative path that escapes the client's own src tree. The reverse is expected: the CLI consumes the client as@metabase/client/<path>. - The client's dependency budget is
zod+node:builtins. Root devDependencies hoist, so the resolver will not reject a third dependency — a production client file importing anything else is FAIL even though it type-checks. domain/*imports onlyzod, siblingdomain/*files and — for a reader that selects a wire schema by generation (<resource><Endpoint>Schema(features)) —import type { Features } from "../version/features". A value import fromversion/, or anything else from elsewhere in either package, is FAIL.resources/*is imported byclient.tsand by siblingresources/*files, and by nothing else. Adomain/,http/,auth/orversion/file importing a resource is FAIL — the direction runs one way, and the permitted importers are an allowlist, so a directory added later is not exempt for having arrived after the rule.commands/*may importcore/*,output/*,runtime/*, and any@metabase/client/*path.core/*may importruntime/*and@metabase/client/*. Nevercommands/, neveroutput/.output/*may importruntime/*,core/*exceptcore/auth/, and@metabase/client/*except@metabase/client/http/— presentation consumes the error taxonomy from@metabase/client/errors, not the HTTP boundary. Nevercommands/.runtime/*may import@metabase/client/*(typed errors are the project's pattern:runtime/body.tsusesparseJsonandConfigError,runtime/yaml.tsusesValidationError). It must not import fromcommands/,core/, oroutput/.
Domain-file contract
Every Metabase API resource in packages/client/src/domain/<resource-singular>.ts exports exactly two things per resource — the client's agent-facing contract:
<Resource>—z.object({ ... }).loose()for the full API shape, with the type inferred viaz.infer..loose()is the default so Metabase API additions don't break the parse — tighten over time, don't preempt. (Zod 4 —.passthrough()is deprecated; use.loose().)<Resource>Compact—<Resource>.pick({ ... }).strip()projection with its inferred type. The trailing.strip()is mandatory:.pick()on a.loose()parent inherits the loose catchall and silently passes every API field through at parse time, which means the "compact" projection isn't actually compact. A diff that lands a Compact missing.strip()is FAIL — quote<Resource>.pick({...})without trailing.strip().
Presentation is CLI-owned, never the client's: the <resource>View (ResourceView<T> with compactPick: <Resource>Compact and tableColumns: ColumnDef<T>[]) lives in packages/cli/src/output/views/<r>.ts, with any format: cell helper colocated there. A ResourceView, a ColumnDef, a tableColumns list, or a terminal-presentation format: helper appearing in packages/client/src/domain/ is FAIL. Inlining a column list inside a command is FAIL.
Naming: <Resource> is PascalCase (Card); the CLI-side <resource>View is camelCase (cardView). <Resource> is the resource name, not the file name — a file may host multiple resources (e.g. domain/user.ts → CurrentUser + CurrentUserCompact); the pair contract holds per-resource.
Adding a resource is additive: a new packages/client/src/domain/<r>.ts for the pair, packages/client/src/resources/<r>.ts for the methods, and packages/cli/src/output/views/<r>.ts for the view — consumed by commands as client.<r>.<method>(…), @metabase/client/domain/<r> and ../../output/views/<r>. A schema declared in packages/cli instead — or a command importing one by a relative path into the client's src tree — is FAIL. Editing existing commands to wire the schema is FAIL — commands import what they need. Do not require a fixture + parse-test pair — that is a tautology against Zod itself. The schema is contract-tested by the e2e tier when a command consuming it runs against the live API.
A new or modified API resource without both exports, or that omits .loose() on the full schema, or that omits .strip() after .pick() on the Compact, or that hand-rolls Array<Record<string, unknown>> / inline as { ... } casts in commands instead of consuming z.infer<typeof Schema>, is a structural failure.
Record<string, unknown> is forbidden in command files. The single exemption is packages/cli/src/output/projection.ts — the canonical structurally-untyped envelope for dot-path --fields projection over already-validated payloads.
Resource-method contract
packages/client/src/resources/<r>.ts exports <r>Resource(transport) and holds every /api/ path the client sends. packages/client/src/client.ts composes each namespace onto the client, so a resource file with no line in client.ts is unreachable and FAIL. Eight conventions bind every method:
client.<resource>.<method>(...), namespace named after the API resource (mb.card, singular). The resource is what Metabase calls the thing, not what it calls the route: the file and the namespace aresnippeteven though the endpoint is/api/native-query-snippet, because the domain schema, the CLI noun and the Metabase concept all readsnippet.- Path parameters positional, then params, then options:
update(id, params, options?). - Params use Metabase's own field names verbatim —
f,model_id,include_inactive. No mapping layer, no renaming. - Transport concerns (
signal,timeoutMs,retries) live in the trailingoptions, never in params. - Wire envelope schemas are module-private to
resources/; they never appear indomain/. - Methods return domain values, never wire envelopes. Non-paginated lists return
ListResult<T>={ data, total }. - A string path parameter always goes through
encodeURIComponent. - Every method carries the endpoint's description as a doc comment.
A method violating any of the eight is FAIL — quote the signature or the path template. Convention 7 is the one with a silent failure mode: an un-escaped string interpolated into a path template lets a / or ? in a setting key or collection ref rewrite the request.
A new or changed method comes with its wire test in packages/client/src/resources/<r>.test.ts — one it per method, asserting URL, verb, headers and body in a single toEqual over capture.calls from @metabase/client/testing/fetch-capture. That test is what pins the path and query vocabulary; a method landed without one is FAIL.
Version contract
packages/client/src/version/features.ts is the only production file that compares a Metabase major (major-comparison-guard.test.ts scans both packages); profile.ts places a version and tag.ts parses one. A .major comparison or a literal major used as a decision anywhere else is FAIL. Feature names describe a behaviour (transformTargetTableId), never a version (transformV61Shape is FAIL), and every version-gated rule must flip somewhere inside KNOWN_RANGE — features.test.ts enforces it, so a diff that raises KNOWN_RANGE.min without deleting the rules that went constant is FAIL.
Every method in resources/*.ts has a matching METHOD_REQUIREMENTS entry under its own namespace, strictest feature first (requirements.test.ts). A gated method not exported through exactly one of the resource's explain, explainWalk, refuse or refuseAfterReading (explainer(transport, "<ns>")) is FAIL (explain-guard.test.ts). A method refusing before the wire is FAIL unless the refusal guards a parameter an unsupporting server would drop or rewrite without a word, asked through transport.requireFeatures or a value check on transport.preflightServer() whose refusal is thrown through transport.refuseBeforeSending (a refusal thrown directly, or a value check reading verifiedServer, is FAIL: the caller could not skip it), or the method is one whose every call such a server would answer wrongly without a word, exported through refuse and ending in optional RequestOptions, or through refuseAfterReading with a reader when it answers from a setting the session properties carry (a refuse-wrapped method that reads the session properties itself pays a second request); either way with a comment saying what the server would do; a parameter the server rejects belongs in explain's parameter callback, with the request fields it travels in. A method that chooses what to send from transport.server()'s features is FAIL — that profile may be a stale cache; send what every server accepts, or decide against verifiedServer. A method without an entry, or a command under packages/cli/src/commands/ declaring requires that differs from the client.<ns>.<method>( calls in its body (requires-guard.test.ts), is FAIL. A defineMetabaseCommand declaring a version floor or a token feature is FAIL — the features are derived from requires, never declared.
Version-driven drift is a <Resource>WireV<N> schema plus a converter, selected by a feature and handed to requestParsed through an exported <resource><Endpoint>Schema(features): z.ZodType<Resource> reader. The canonical schema is the newest server's shape; a field an older server cannot report is .nullable(). FAIL: .optional() meaning "an older server omits it"; a converter mapping two distinguishable wire states onto one canonical state; a z.union of generation shapes parsed by trial (runtime shape detection); a converter that leaves a wire-only field on the canonical value; a discriminated union of generations as a method's public return type; version prose in packages/cli/skill-data/ where a requires: frontmatter key or a <!-- requires: … --> … <!-- /requires --> fence belongs (markers on their own lines, never inside a table).
Forbidden patterns and their permitted homes
CLAUDE.md declares these as a separate policy per package, applied to production source only (*.test.ts files are exempt). A pattern's permitted home in one package says nothing about the other: JSON.parse has a home in the client and none in the CLI, fetch has a home in each but at different paths. The e2e-side restrictions below are part of this review too.
The client — every file under packages/client/src/
| Pattern | Permitted only in |
|---|---|
bare fetch(, globalThis.fetch, third-party HTTP libs (got/axios/node-fetch/undici) |
http/** |
new URL( |
http/**, url.ts |
JSON.parse( |
json.ts |
setTimeout inside an await wait loop |
poll.ts, http/retry.ts |
process.stdout.write, process.stderr.write |
nowhere — the client owns no output |
process.exit( |
nowhere — the client never ends the run |
@clack/prompts, @clack/core |
nowhere — prompting is the CLI's job |
fs, fs/promises, node:fs, node:fs/promises |
nowhere — the client touches no files |
child_process, node:child_process |
nowhere — the client spawns nothing |
a .ts extension in an import specifier |
nowhere |
any dependency beyond zod and node: builtins |
nowhere |
an import resolving into packages/cli |
nowhere |
The CLI — every file under packages/cli/src/
| Pattern | Permitted only in |
|---|---|
process.exit( |
cli.ts |
process.stderr.write |
cli.ts, output/** |
process.stdout.write |
output/** |
JSON.parse( |
nowhere — use parseJson from @metabase/client/json |
new URL( |
nowhere — use the helpers in @metabase/client/url |
bare fetch(, globalThis.fetch, third-party HTTP libs |
core/npm-registry.ts (not a Metabase endpoint) |
setTimeout inside an await wait loop |
nowhere — use pollUntil from @metabase/client/poll |
child_process (spawn, exec, etc.) |
runtime/process.ts |
@clack/prompts, @clack/core |
output/prompt.ts |
Record<string, unknown> |
nowhere under commands/ — the walker scopes this rule to command files; output/projection.ts is the canonical untyped envelope and sits outside that scope |
an import of @metabase/client/http/** from output/** |
nowhere — presentation uses @metabase/client/errors |
a .ts extension in an import specifier |
nowhere |
process.env["MB_*"], process.env["METABASE_*"] |
core/env.ts — CLI env vars go through readEnv. A CLAUDE.md rule, not walker-enforced; judge it by reading, not by trusting the gate |
an /api/… path literal ("/api/ or `/api/) |
nowhere under commands/ — endpoints are the client's resources/ layer |
.requestParsed(, .requestRaw(, .requestStream(, paginatePages( |
nowhere under commands/ — a command calls client.<resource>.<method>(…) |
The last two are absolute: neither has a sanctioned home anywhere in the CLI. A diff that lands a command naming a path or driving the transport is FAIL.
What those two patterns bound is a path literal and a transport call, not every client function that can open a socket. commands/auth/login.ts calls tryDiscoverMetadata from @metabase/client/http/oauth, which fetches the OAuth discovery document before any credential exists and therefore before a client can be constructed; that one is sanctioned. A second such call from commands/ is FAIL — the pattern rules cannot see it, so review is the only thing that can.
Two rules bind both packages but come from elsewhere, so judge them repo-wide rather than per-package:
console.{log,warn,error,info,debug}— forbidden everywhere, includingoutput/**andtests/..oxlintrc.jsonsetsno-console: errorwith no overrides; write throughoutput/render.tsinstead.- Platform env vars (
XDG_CONFIG_HOME,APPDATA,npm_config_prefix) are read where they are used —core/paths.ts,core/install-method.ts,core/auth/temp-config-home.ts.readEnvowns theMB_/METABASE_family only; do not flag the others.
The test tree — every file under the repo-root tests/, outside the walker but still reviewed here
| Pattern | Permitted only in |
|---|---|
process.exit(, process.std*.write |
tests/e2e/setup/** |
/api/testing/snapshot, /api/testing/restore |
tests/e2e/setup/** — a beforeEach there restores before every test |
fetch( against Metabase |
tests/e2e/setup/** (bootstrap, oauth harness) |
child_process / execa |
tests/e2e/run-cli.ts, tests/e2e/setup/**, scripts/** |
Hand-written ENOENT shape check (error.code === "ENOENT") |
nowhere — use isFileNotFoundError from @metabase/client/errors |
Inline JSON.parse(...) ; Schema.parse(...) |
nowhere — use parseJson(raw, Schema, { source }) from @metabase/client/json |
Type strictness rules (always)
- No
ascast in any form:as X,as unknown as,as never,as any. Use type guards or Zod.parse. - No
anyin declared types. - No
Record<string, unknown>used as a typed value (it is allowed only inside the structurally-untyped output envelope). - No
!non-null assertions. - No
// @ts-ignore,// @ts-nocheck,// @ts-expect-error(paired comment with// reason:and// FIXME(date)only). - Every cross-network value passes through Zod
.parsebefore downstream code touches it. - Imports are extensionless. Type-only imports use
import type.
Test-parity rules
- New
packages/cli/src/commands/<noun>/<verb>.ts⇒ atests/e2e/<noun>.e2e.test.tsexists and exercises that subcommand end-to-end viarunCli. Adding a new command without an e2e test is FAIL. - New
packages/cli/src/commands/<noun>/<verb>.ts(any leaf) ⇒packages/cli/src/runtime/command-help.test.ts's literalALL_COMMANDSlist includes the new path. Renaming or deleting a command without updating the list is FAIL. - New or changed method in
packages/client/src/resources/<r>.ts⇒packages/client/src/resources/<r>.test.tscovers it with a wire assertion overcapture.calls(URL, verb, headers, body) built oncaptureFetchfrom@metabase/client/testing/fetch-capture. A method with no wire test is FAIL — nothing else pins the path or the query vocabulary at the unit tier. - New
packages/client/src/domain/<r>.ts⇒ no fixture-and-parse-test pair required. A unit test of the formSchema.parse(fixture).toEqual(fixture)is a tautology against Zod and is FAIL if introduced. The schema's contract is tested by the e2e tier hitting the live API. - New pure helper anywhere in
packages/client/src/or in the CLI'score/,output/,runtime/⇒ a colocated unit test (<name>.test.tsbeside<name>.ts, inside the same package) imports from it. - New helper with infinite input space (URL parsing, JSON parsing, projection, byte cap, polling) ⇒ a property test (
fast-check).
E2E test contract
- E2E tests drive the built
packages/cli/dist/cli.mjsthroughrunClifromtests/e2e/run-cli.ts. Directexeca,spawn, orchild_processcalls insidetests/e2e/**/*.e2e.test.tsare FAIL. - E2E tests read admin credentials via
readBootstrap()fromtests/e2e/bootstrap-data.tsand seeded entity ids viaSEEDEDfromtests/e2e/seed/seeded.ts. Inlinefetchto Metabase, hard-coded API keys, hard-coded entity ids, or calls to/api/setupfrom a test are FAIL. - The
BootstrapZod schema andBOOTSTRAP_FILE_PATHconstant live only intests/e2e/bootstrap-data.ts. Re-declaring either intests/e2e/setup/bootstrap.ts(or anywhere else) is FAIL — the writer imports from the reader to prevent shape drift. --jsonoutput assertions parse through the schema imported from the package that owns it:@metabase/client/domain/<r>for a resource,../../packages/cli/src/commands/<noun>/<verb>for a command-shaped output (e.g.LoginResult,AuthStatus,<Resource>ListEnvelope). Re-declaring az.object({...})in a test that mirrors either export is FAIL.- Each test or test-group gets its own
XDG_CONFIG_HOMEviamkTempConfigHome(). Sharing config home across tests that mutate credentials is FAIL. - A suite whose command's methods need a feature gates itself with
requireServer("<lane>", ["<feature>"])fromtests/e2e/server-gate.ts, passing a lane label naming the describe or test the gate guards so an unmet gate is reported in the closing coverage block. A suite that fails instead of skipping on a server that cannot satisfy the command is FAIL. A suite that compares a version (.major, a literal major) instead of askingserverHas("<feature>")is FAIL — the feature rule is the one place a major is read.
Axes (output one row per axis)
For each axis below, output PASS, FAIL, or UNKNOWN. For every FAIL and UNKNOWN, quote the offending snippet with path:line and a one-sentence explanation.
- Layering — direction rules above, judged against the package the file is in.
- Type strictness — rules above.
- Output discipline —
console.*,process.exit,process.std*.writeoutside permitted homes. A file underpackages/client/src/writing to a stream or ending the process is FAIL regardless of path — the client has no permitted home for either. - Boundary discipline —
JSON.parse, rawfetch/globalThis.fetch/third-party HTTP libraries (got/axios/node-fetch/undici)/new URL,child_process, pollingsetTimeoutoutside permitted homes. Read the per-package table — the CLI has no home forJSON.parse,new URL, or a wait loop, and delegates all three to the client. 4a. Package boundary — an import inpackages/client/**that resolves intopackages/cli(by@metabase/clispecifier or by a relative path escaping the client's src tree) is FAIL. A production client file importing anything beyondzodandnode:builtins is FAIL. A CLI file reaching into the client by relative path instead of the@metabase/client/<path>specifier is FAIL.packages/cli/src/output/**importing@metabase/client/http/**is FAIL. Anything outsideclient.tsandresources/importingresources/is FAIL. 4b. Request placement — a file underpackages/cli/src/commands/that names an/api/…path literal, or callsrequestParsed/requestRaw/requestStream/paginatePages, is FAIL. Quote the path template and name the resource method it belongs on. - Resource contract — every new or changed resource in
packages/client/src/domain/exports<Resource>(with.loose()) and<Resource>Compact(with.pick({...}).strip()— the trailing.strip()is mandatory), and its<resource>Viewlives CLI-side inpackages/cli/src/output/views/<r>.ts(aResourceView/ColumnDef/tableColumnsin the domain file is FAIL); naming follows the convention; commands consumez.infer<typeof Schema>rather thanArray<Record<string, unknown>>or inlineas { ... }casts. Every new or changed method inpackages/client/src/resources/satisfies all eight conventions above, is composed ontoclient.ts, and returns a domain value rather than the server's envelope. - List windowing — a list command builds its envelope with one of the three helpers in
packages/cli/src/output/window.ts, picked by who applied the window:windowList(data, ctx.range, total)when the method returned the whole result set as aListResult<T>and the slice is client-side (the common case);windowServerPage(data, total, ctx.range)when the endpoint appliedlimit/offsetitself and reports a count;collectForOutput(source, view, ctx)for a genuinely paged endpoint, wheresourceis aPageSource<T>forwarding the helper'sPageRequest(max,pageSize) into the resource's<thing>Pagesmethod. A source that dropsmaxorpageSizeis FAIL — the byte budget cannot bound the walk. A hand-rolled envelope literal is FAIL — it produces a plausible shape whosehas_more/next_offsetno test catches. A list verb missing...listFlags, or one that never readsctx.range, is FAIL: the--limit/--offsetan agent needs to page silently do nothing. - Test parity — rules above.
- Comment hygiene — no WHAT-comments; no task/PR/issue/external-path references; no comments-as-history.
- Imports / extensions — no
from "...\.(ts|js)";. Type-only imports useimport type. - TS escape hatches — no
// @ts-ignore,// @ts-nocheck,// @ts-expect-errorwithout pairedreason:+FIXME(date). - Reuse / supersession — for each new helper, grep both packages for existing helpers covering the same intent; a CLI helper duplicating something the client already exports (
parseJson,pollUntil,paginatePages,errorMessage,normalizeUrl) is the common case and is FAIL. A new helper that subsumes an older narrower one without deleting the old one is FAIL. - Type drift — a hand-written
interface/typewhose keys mirror an in-repo value's shape (could betypeof X, mapped overkeyof typeof X, or a library generic likeParsedArgs<typeof cmd.args>) is FAIL. Hand-maintained parallel definitions drift silently. - Catch-all directories — new files under
_shared/,_helpers/,_utils/,common/,lib/,misc/, or any other bucket-name directory is FAIL. Files must be named by purpose. - Test quality — assertions over a structure must be full (
toEqual(<full object>)/toEqual(<full array>)) — single-property pokes when the whole object is observable is FAIL (e.g. a sequence ofexpect(parsed.id).toBe(...)/expect(parsed.name).toBe(...)afterparseJsoncollapses to onetoEqual({...})). Redundant assertions (e.g. asserting.lengthbeforetoEqualof the array) is FAIL. Error assertions that check only the type or only the message — never both — is FAIL. Fixture fields that no test reads (and are not structurally required to satisfy a type) is FAIL.- Exit codes:
expect(<r>.exitCode).not.toBe(0)is FAIL. Assert the exact integer —toBe(2)forConfigError,toBe(130)forAbortError,toBe(2)forCapabilityError,toBe(1)for everything else (HttpError,ValidationError,NetworkError,TimeoutError,UnknownError). The taxonomy lives inpackages/client/src/errors.ts(CapabilityErrorinpackages/client/src/version/capability-error.ts); if you can't tell which code is right, the test isn't asserting the contract you think it is. - Error strings:
expect(<stream>).toMatch(/.../i)for stderr / error messages is FAIL. UsetoContain("<exact substring>")ortoBe("<exact full string>"). The narrow exception is asserting absence of a pattern (expect(out).not.toMatch(/pattern/)). Where the message contains a dynamic value (a byte count, a generated id, a path), build the expected string from the same data the production code consumed and assert withtoBe. Replacing the dynamic part with\d+or.*in a regex is FAIL. - e2e stderr: the harness is never a TTY, so the CLI writes a JSON error envelope to stderr.
expect(<r>.stderr).toBe("<message>")can never match and is FAIL — stderr carries the envelope plus any leadingwarn()lines. Assert throughcliErrorMessage(<r>.stderr)fromtests/e2e/cli-error.ts. A rawexpect(<r>.stderr).toContain(...)is acceptable for a plain substring, but is FAIL when the expected message contains a quote, backslash, or newline — the envelope JSON-escapes those, so the raw substring is absent and onlycliErrorMessagesees the real text.
- Exit codes:
- README parity — the root
README.mdis the user-facing command reference and must document every command inpackages/cli/src/commands/(each group exported as a top-level subcommand). Adding a new command, renaming one, removing one, or changing a flag's name/description without a matching rootREADME.mdupdate is FAIL. Adding/removing a flag inGLOBAL_FLAG_ARGS(packages/cli/src/commands/global-flags.ts, composed fromoutputFlags/profileFlag/connectionFlagsinpackages/cli/src/commands/flags.ts) without updating the common-flags section is FAIL. A change to a root script (bun run check, thee2e:*family) without updating the "Working in the repo" section is FAIL.packages/client/README.mdis the client's own reference, governed by the barrel exports inpackages/client/src/index.tsand thedomain/pair contract — adding, removing, or renaming a barrel export, or changing a documented<Resource>/<Resource>Compactsample so it diverges from the schema, without updating it is FAIL.packages/cli/README.mdis a pointer to the root one for the npm page — a diff that grows it into a second command reference is FAIL. - Fail-fast boundaries — boundary code (JSON parsing, file I/O, HTTP response handling, stdin reads) must throw or return a typed error on malformed input. Diff that introduces silent coercion to
{}/[]/"",?? ""/?? 0/?? []/?? {}to satisfy a type when the real meaning is "absent" or "error", emptycatch {}blocks, ortry/catchthat swallows the error and returns a default — is FAIL. Quote the offending site. - Magic literals — diff introduces a numeric or string literal that already appears elsewhere in the repo without a shared constant (timeouts, byte caps, exit codes, profile names, account identifiers, file modes). Grep both packages for the literal before deciding; one occurrence is fine, two without a constant is FAIL.
- Boolean traps — diff adds a function with 2+ boolean parameters, or a call site with 2+ positional boolean arguments where intent is non-obvious from the call. Use a named-options object or split functions. FAIL.
- Type-guard validity — a function declared
(value): value is Tmust validate the property that distinguishesT.instanceof Errornarrowing to a more specific error subtype, or any guard whose body cannot rule out a non-Tinput, is a hidden cast and FAIL. - Eloquence — diff introduces ceremony that has no real-world counterpart. Stacked
override readonlychains where a plain assignment would do, intermediate abstract classes that exist solely to deduplicate four lines, generic parameters that don't earn their complexity, option-bag wrappers around single-arg calls — FAIL. The test: would two engineers writing this from scratch both reach for this pattern? If no, it's overengineered. Quote the offending construct and a simpler alternative. - Expression complexity — diff introduces a big inline boolean or ternary chain in an
if,return, or assignment. Multi-clause&&/||mixes,?.ladders combined with??fallbacks, or nested ternaries (a ? x : b ? y : z) inside a conditional are FAIL. Each meaningful clause should be a named local (const hasBudget = …; const isFresh = …). The condition in theifshould read as one phrase, not a puzzle. Quote the offending site. - Mocking discipline —
vi.mock(...)is a last resort. Diff introduces a module mock to test a one-line wrapper / one-line delegation, or to substitute logic owned by the system under test, is FAIL — that test asserts only what the mock was configured to return (a tautology). Acceptable: hermetic isolation of side-effecting external deps in integration tests where the real path pollutes the host (e.g.@napi-rs/keyring, real network, real subprocess), or fixture injection into a fully-exercised pipeline. Not acceptable: mocking an internal module so a unit test can fire a single branch — instead prefer a real value flowing through real imports (realZodError, realHttpError, real Zod schema), or push the coverage to the smoke/integration tier. Prefer integration over isolation when in doubt. Quote anyvi.mockcall and the one-line wrapper it shadows. - E2E CLI invocation discipline — under
tests/e2e/**/*.e2e.test.ts, any direct call toexeca,spawn,exec, orchild_processis FAIL — e2e tests drive the binary throughrunClifromtests/e2e/run-cli.ts. Inlinefetch(<metabase-url>...)from a test file is FAIL — credential setup belongs intests/e2e/setup/bootstrap.tsand tests read state viareadBootstrap(). - Bootstrap-data unity — the
BootstrapZod schema (the.bootstrap.jsonshape) and theBOOTSTRAP_FILE_PATHconstant must live only intests/e2e/bootstrap-data.ts. Any redeclaration of the same shape elsewhere (typically intests/e2e/setup/bootstrap.ts) is FAIL — writer and reader share one source. - Test schema reuse — when a production module exports a Zod schema describing CLI output, tests must import it from wherever it lives. Schemas that fall under this rule include: per-command output schemas (e.g.
LoginResult,AuthStatus) and every<Resource>ListEnvelopeexported frompackages/cli/src/commands/<noun>/list.ts(built vialistEnvelopeSchema(<Resource>Compact)frompackages/cli/src/output/types.ts), plus every<Resource>/<Resource>Compactinpackages/client/src/domain/. A test file that declares az.object({...})mirroring the same fields as a known export — including hand-rolling a list-envelope shape ({ data, returned, offset, limit, total, has_more, next_offset, truncated }) instead of importing the command's<Resource>ListEnvelope— is FAIL. Quote both the test declaration and the canonical export. - Helper reuse in tests — test code reimplementing a helper the client already exports (e.g. inline
JSON.parse+Schema.parseinstead ofparseJsonfrom@metabase/client/json;error.code === "ENOENT"instead ofisFileNotFoundErrorfrom@metabase/client/errors; deadline+sleep loops instead ofpollUntilfrom@metabase/client/poll) is FAIL. Test files are allowed to import these across the layering and package boundaries because the layering rules apply to production source only. - License-token discipline — diff that introduces (a) a hardcoded real EE token (any string matching
^mb_(dev_[0-9a-f]{57}|[0-9a-f]{64})$outside a clearly-fake placeholder likemb_dev_0123…), (b)console.log/process.std*.write/Bash(echo $...)/Read(.env*)ofMB_PREMIUM_EMBEDDING_TOKENorMETASTORE_DEV_SERVER_URLor any binding holding their value, (c) writing the token to a file outside the test process's stdin pipe, (d) an assertiontoContain/toEqual/toBethat compares against the token string is FAIL. Acceptable:process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === ""for skip-gating;runCli({ stdin: token })to thread the token opaquely. Quote the offending site.
Output format
| # | Axis | Status | Detail |
|---|------|--------|--------|
| 1 | Layering | PASS | |
| 2 | Type strictness | FAIL | packages/cli/src/commands/card/get.ts:12 — `as unknown as Card` |
| 3 | Output discipline | PASS | |
...
Then exactly one of:
RESULT: PASS — task may be marked done.RESULT: FAIL (<n> failures, <m> unknowns) — fix issues above before marking done.
If FAIL, follow the table with a bulleted list of the next concrete actions to address each failure (one bullet per FAIL, with path:line).
Rules for the review itself
- Read-only. Do not modify any files.
- Do not run
tsc,oxlint,oxfmt, or the test suites here. Those are separate; this skill is the architectural / non-deterministic layer. - Do not green-light by default. If you cannot determine an axis from the diff, mark
UNKNOWNwith the reason — never PASS. - Be ruthless. The point of this skill is to be strict where the type-checker can't be.