fix: Add architectural lint rules with agent-friendly error messages (#232)
- landing/eslint.config.js: ban imports from web-app paths (rule 1),
direct RPC clients from viem/@wagmi/vue (rule 2), and axios (rule 4)
- web-app/eslint.config.js: ban string interpolation inside GraphQL
query/mutation property values (rule 3); fixes 4 pre-existing violations
in usePositionDashboard, usePositions, useSnatchNotifications,
useWalletDashboard by migrating to variables: {} pattern
- services/ponder/eslint.config.js: ban findMany() calls that lack a
limit parameter to prevent unbounded indexed-data growth (rule 5)
All error messages follow the [what is wrong][rule][how to fix][where to
read more] template so agents and humans fix on the first try.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e3d45bb8ef
commit
0d761744df
7 changed files with 95 additions and 13 deletions
|
|
@ -13,6 +13,56 @@ export default [
|
||||||
},
|
},
|
||||||
...pluginVue.configs['flat/essential'],
|
...pluginVue.configs['flat/essential'],
|
||||||
...vueTsEslintConfig(),
|
...vueTsEslintConfig(),
|
||||||
|
{
|
||||||
|
name: 'arch/landing-import-restrictions',
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['**/web-app/**', '@harb/web-app/*'],
|
||||||
|
message:
|
||||||
|
'Landing component imports from web-app. Landing must not depend on web-app. Move shared code to kraiken-lib or a shared/ package. See docs/ARCHITECTURE.md.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
name: 'viem',
|
||||||
|
importNames: ['createPublicClient', 'publicActions'],
|
||||||
|
message:
|
||||||
|
'Landing component imports a direct RPC client from viem. Landing must not make direct RPC calls from the browser — this will kill any node under load. Use Ponder GraphQL at /api/graphql instead. See docs/UX-DECISIONS.md.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '@wagmi/vue',
|
||||||
|
importNames: ['usePublicClient'],
|
||||||
|
message:
|
||||||
|
'Landing component imports usePublicClient from @wagmi/vue. Landing must not make direct RPC calls from the browser — this will kill any node under load. Use Ponder GraphQL at /api/graphql instead. See docs/UX-DECISIONS.md.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'axios',
|
||||||
|
message:
|
||||||
|
'Landing component imports axios. Landing must not use axios — it increases bundle size for no benefit. Use native fetch() with the /api/graphql proxy instead.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'arch/graphql-no-interpolation',
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"Property[key.name='query'] > TemplateLiteral[expressions.length>0], Property[key.name='mutation'] > TemplateLiteral[expressions.length>0]",
|
||||||
|
message:
|
||||||
|
'String interpolation used in a GraphQL query or mutation string. GraphQL queries must not use string interpolation — it bypasses type-checking and is unsafe. Use the `variables` parameter in the fetch body instead. Example: `variables: { holder: address }`. See PR #191 for the pattern.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'app/custom-rules',
|
name: 'app/custom-rules',
|
||||||
rules: {
|
rules: {
|
||||||
|
|
|
||||||
|
|
@ -72,5 +72,18 @@ export default [
|
||||||
'max-statements': 'off',
|
'max-statements': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'arch/ponder-bounded-queries',
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='findMany']:not(:has(Property[key.name='limit']))",
|
||||||
|
message:
|
||||||
|
"Ponder findMany() called without a limit parameter. Unbounded queries will grow without limit as the chain is indexed — never use findMany() without a limit. Always specify `limit` in the options object. Use the ring buffer pattern from docs/ARCHITECTURE.md.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,21 @@ export default [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'arch/graphql-no-interpolation',
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"Property[key.name='query'] > TemplateLiteral[expressions.length>0], Property[key.name='mutation'] > TemplateLiteral[expressions.length>0]",
|
||||||
|
message:
|
||||||
|
'String interpolation used in a GraphQL query or mutation string. GraphQL queries must not use string interpolation — it bypasses type-checking and is unsafe. Use the `variables` parameter in the fetch body instead. Example: `variables: { holder: address }`. See PR #191 for the pattern.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'app/tests-override',
|
name: 'app/tests-override',
|
||||||
files: ['src/**/__tests__/**/*.ts', 'src/**/__tests__/**/*.tsx'],
|
files: ['src/**/__tests__/**/*.ts', 'src/**/__tests__/**/*.tsx'],
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,8 @@ export function usePositionDashboard(positionId: Ref<string>) {
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
query: `query PositionDashboard {
|
query: `query PositionDashboard($id: String!) {
|
||||||
positions(id: "${id}") {
|
positions(id: $id) {
|
||||||
id
|
id
|
||||||
owner
|
owner
|
||||||
share
|
share
|
||||||
|
|
@ -142,6 +142,7 @@ export function usePositionDashboard(positionId: Ref<string>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
|
variables: { id },
|
||||||
},
|
},
|
||||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,8 @@ export async function loadMyClosedPositions(chainId: number, endpointOverride: s
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
targetEndpoint,
|
targetEndpoint,
|
||||||
{
|
{
|
||||||
query: `query ClosedPositions {
|
query: `query ClosedPositions($owner: String!) {
|
||||||
positionss(where: { status: "Closed", owner: "${account.address?.toLowerCase()}" }, limit: 1000) {
|
positionss(where: { status: "Closed", owner: $owner }, limit: 1000) {
|
||||||
items {
|
items {
|
||||||
id
|
id
|
||||||
lastTaxTime
|
lastTaxTime
|
||||||
|
|
@ -214,6 +214,7 @@ export async function loadMyClosedPositions(chainId: number, endpointOverride: s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
|
variables: { owner: account.address?.toLowerCase() ?? '' },
|
||||||
},
|
},
|
||||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,16 @@ function setLastSeen(address: string, timestamp: string): void {
|
||||||
|
|
||||||
async function fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise<SnatchEvent[]> {
|
async function fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise<SnatchEvent[]> {
|
||||||
const holder = address.toLowerCase();
|
const holder = address.toLowerCase();
|
||||||
const whereClause =
|
const where: { holder: string; type: string; timestamp_gt?: string } = { holder, type: 'snatch_out' };
|
||||||
since !== null
|
if (since !== null) {
|
||||||
? `{ holder: "${holder}", type: "snatch_out", timestamp_gt: "${since}" }`
|
where.timestamp_gt = since;
|
||||||
: `{ holder: "${holder}", type: "snatch_out" }`;
|
}
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
query: `query SnatchEvents {
|
query: `query SnatchEvents($where: transactionsFilter!) {
|
||||||
transactionss(
|
transactionss(
|
||||||
where: ${whereClause}
|
where: $where
|
||||||
orderBy: "timestamp"
|
orderBy: "timestamp"
|
||||||
orderDirection: "desc"
|
orderDirection: "desc"
|
||||||
limit: 20
|
limit: 20
|
||||||
|
|
@ -57,6 +57,7 @@ async function fetchSnatchEvents(endpoint: string, address: string, since: strin
|
||||||
items { id timestamp tokenAmount ethAmount txHash }
|
items { id timestamp tokenAmount ethAmount txHash }
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
|
variables: { where },
|
||||||
},
|
},
|
||||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,8 @@ export function useWalletDashboard(address: Ref<string>) {
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
query: `query WalletDashboard {
|
query: `query WalletDashboard($addr: String!) {
|
||||||
holders(address: "${addr}") {
|
holders(address: $addr) {
|
||||||
address
|
address
|
||||||
balance
|
balance
|
||||||
totalEthSpent
|
totalEthSpent
|
||||||
|
|
@ -88,7 +88,7 @@ export function useWalletDashboard(address: Ref<string>) {
|
||||||
holderCount
|
holderCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
positionss(where: { owner: "${addr}" }, limit: 1000) {
|
positionss(where: { owner: $addr }, limit: 1000) {
|
||||||
items {
|
items {
|
||||||
id
|
id
|
||||||
share
|
share
|
||||||
|
|
@ -106,6 +106,7 @@ export function useWalletDashboard(address: Ref<string>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
|
variables: { addr },
|
||||||
},
|
},
|
||||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue