From 21e507ac1c974e64fe799b7d93ed4a107e43e3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 9 Aug 2026 16:51:28 +0000 Subject: [PATCH 1/2] task: start deterministic runtime-boundary quality program --- ...-deterministic-runtime-boundary-quality.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md diff --git a/tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md b/tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md new file mode 100644 index 0000000000..1c09083f2d --- /dev/null +++ b/tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md @@ -0,0 +1,222 @@ +# Deterministic runtime-boundary and lint quality program + +## Problem + +SAM has strong TypeScript settings, runtime-validation helpers, CI, coverage, and targeted quality scripts, but the repository still has deterministic enforcement gaps: + +- five workspace packages do not participate in root lint; +- two workspace packages have no explicit type/template validation path; +- the legacy ESLint configuration has a parser/plugin major mismatch and no durable flat-config custom-rule host; +- known runtime-boundary debt regrew after a 2026-06-25 cleanup because no repository-wide ratchet prevented new occurrences; +- formatting, secret scanning, direct-dependency evidence, and diff-local Go vulnerability checks are not consistently wired into CI; +- Oxlint has not been measured against SAM's actual rule semantics, coverage, or fix behavior. + +The implementation must improve editor feedback and CI determinism without changing application runtime behavior, suppressing existing findings, or making existing debt fail unrelated pull requests. + +## Source and scope + +- SAM idea: `01KZK7TFEX05MVMDKZWKABBNS7` +- Coordinator task: `01KZKN713FBXPX8YNF5JGKQ1XT` +- Audited base: `origin/main` at `8c689a6a7923f76a33d96d6272797090598d6c2d` +- Integration branch: `sam/coordinate-implement-deterministic-runtime-gkq1xt` +- No feature spec is edited: this is repository quality infrastructure outside an active `specs/` context. + +## Preflight + +### Classification + +- `cross-component-change`: root commands, every TypeScript workspace, quality scripts, and CI must share one deterministic contract. +- `public-surface-change`: contributor-facing commands and diagnostics change. +- `docs-sync-change`: contributor and repository rules must describe the new authoritative/advisory roles. +- `security-sensitive-change`: secret and vulnerability scanning must fail closed without publishing findings. +- `infra-change`: GitHub Actions and supply-chain tooling change, but application deployment/runtime infrastructure must remain behaviorally unchanged. + +### Verified assumptions before editing + +- Fresh fetch found the task workspace one commit behind; the integration branch was fast-forwarded and pushed to exact current main before dispatch. +- Current `pnpm lint` passes with zero errors and 2,372 warnings across 2,229 files in the seven workspaces that currently define lint. Rule totals are 1,658 `no-non-null-assertion`, 644 `no-explicit-any`, and 70 React/hooks/a11y warnings. This is the before-parity reference, not debt to clean up here. +- Current `pnpm typecheck` passes, but Turbo has no typecheck task to execute for `apps/www` or `tools/og-image`. +- Current toolchain is TypeScript 5.9.3, ESLint 8.57.1, `@typescript-eslint/eslint-plugin` 7.18.0, and `@typescript-eslint/parser` 8.65.0. Oxlint is absent. +- `prepare: husky`, `lint-staged`, and lint-staged configuration exist, but `.husky/` does not; CI is the actual authoritative path. +- Current shared runtime helpers are `apps/api/src/lib/runtime-validation.ts` and `apps/api/src/schemas/_validator.ts`; established Valibot use remains preferred. Existing bounded Zod subsystems are not migration targets. +- Official documentation checked before tool selection: + - ESLint flat configuration and migration: and + - typescript-eslint supported dependency ranges: + - Oxlint plugins, CLI, config, migration, and alpha JS plugin host: , , and + - Astro template diagnostics: + - Gitleaks scan modes: + - Go vulnerability analysis: + - OSV source scanning and expiring ignores: and + +### Current-main coverage inventory + +Tracked files were counted with `git ls-files` for `.ts`, `.tsx`, `.mts`, `.cts`, and `.astro`, so generated/untracked output is excluded. + +| Workspace | Lint today | Type/template check today | TS | TSX | MTS | CTS | Astro | Total | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `apps/api` | yes | yes | 1151 | 0 | 0 | 0 | 0 | 1151 | +| `apps/tail-worker` | no | yes | 4 | 0 | 0 | 0 | 0 | 4 | +| `apps/web` | yes | yes | 335 | 501 | 0 | 0 | 0 | 836 | +| `apps/www` | no | no | 12 | 0 | 0 | 0 | 32 | 44 | +| `infra` | no | yes | 20 | 0 | 0 | 0 | 0 | 20 | +| `packages/acp-client` | yes | yes | 34 | 41 | 0 | 0 | 0 | 75 | +| `packages/cloud-init` | no | yes | 5 | 0 | 0 | 0 | 0 | 5 | +| `packages/providers` | yes | yes | 60 | 0 | 0 | 0 | 0 | 60 | +| `packages/shared` | yes | yes | 104 | 0 | 0 | 0 | 0 | 104 | +| `packages/terminal` | yes | yes | 12 | 9 | 0 | 0 | 0 | 21 | +| `packages/ui` | yes | yes | 8 | 30 | 0 | 0 | 0 | 38 | +| `tools/og-image` | no | no | 3 | 0 | 0 | 0 | 0 | 3 | + +The five lint gaps now contain **76** tracked TS/Astro files, not the historical 44. The repository has 2,401 tracked TS-family files; the audited non-test/config source scope contains 1,315 files. + +### Current-main boundary inventory + +A ts-morph syntax pass over the 1,315-file non-test/config source scope found: + +| Pattern | Current count | Rollout role | +| --- | ---: | --- | +| `as any` assertions | 1 | remove the runtime occurrence, then block at zero | +| Hono-style `*.req.json()` | 24 | advisory ESLint diagnostic + blocking net-count ratchet | +| typed `JSON.parse(...) as T` excluding `as unknown` | 23 | advisory ESLint diagnostic + blocking net-count ratchet | +| local `isRecord`/`isObject` definitions | 9 | advisory ESLint diagnostic + blocking net-count ratchet | +| `as Record` | 90 | report-only population; never a broad ban | +| nested `as unknown as` | 132 | report-only population; never a blanket ban | +| files importing Valibot | 70 | context only | +| files importing Zod | 5 | bounded existing subsystems; no incidental migration | + +The one real non-test `as any` is `apps/web/src/pages/ToolsCli.tsx:58`. `JSON.parse(...) as unknown` is explicitly safe and excluded from the unsafe-assertion rule and ratchet. + +### Impact/data-flow trace + +1. A developer runs `pnpm check:fast` from `package.json`. +2. Root scripts invoke formatting, the current authoritative lint layer, the ESLint custom/import-sort tail, and `scripts/quality/check-type-boundaries.ts` as explicit leaf commands. +3. Workspace lint/typecheck scripts cover the package files listed above; `apps/www` uses `astro check` so `.astro` templates receive diagnostics rather than being falsely described as `tsc` coverage. +4. `.github/workflows/ci.yml` invokes the same leaf commands and scanner helpers; it does not reimplement their matching logic. +5. ESLint plugin findings point developers to `apps/api/src/lib/runtime-validation.ts`, `apps/api/src/schemas/_validator.ts`, `jsonValidator`, `parseWithSchema`, `readResponseJson`, and row-mapper patterns. +6. Existing boundary debt remains passable through checked-in counts; only a repository-wide net increase exits nonzero with deterministic `file:line` guidance. +7. Gitleaks examines the current tree/PR range without public comments/artifacts containing findings. Direct dependency evidence and diff-local govulncheck apply only when their manifests change. Scheduled OSV output routes to private owned follow-up, not a public issue or unrelated PR failure. +8. Oxlint runs report-only until parity, scoping, suppression, fix-diff, coverage, and cold-performance evidence satisfy every promotion criterion. Otherwise ESLint stays authoritative and Oxlint stays shadow. + +## Orchestration and ownership + +The durable dependency graph lives in `.workflow-state.md` (gitignored). Coding lanes start only from the pushed integration branch and must not deploy staging or merge to main. + +- Coordinator exclusively owns `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, root ESLint/Oxlint configuration, `turbo.json`, `.github/workflows/ci.yml`, scheduled workflow integration, parity/benchmark evidence, integration commits, and the final PR. +- Child lane 1 exclusively owns the new unpublished ESLint plugin workspace implementation, RuleTester fixtures, and rule manifest. +- Child lane 2 exclusively owns named type-boundary ratchet and targeted semantic-check files/tests/baselines under `scripts/quality/`. +- Child lane 3 exclusively owns named supply-chain checker/helper files and their tests; coordinator owns workflow wiring. +- Child lane 4 exclusively owns assigned leaf workspace manifests/configs required for lint/type/template coverage; coordinator owns root catalog and lockfile integration. +- Independent review happens only after integration. Reviewer concerns return to implementation before final validation. + +## Implementation checklist + +### Foundation and coverage + +- [ ] Add lint scripts for `apps/www`, `apps/tail-worker`, `packages/cloud-init`, `infra`, and `tools/og-image`, covering all 76 current TS/Astro files without broad repository churn. +- [ ] Add explicit type/template validation for `apps/www` through `astro check` and for `tools/og-image` through a scoped TypeScript configuration. +- [ ] Add a deterministic inventory/contract test proving every pnpm workspace has intended lint and type/template coverage. +- [ ] Enforce `format:check` in CI through the same leaf command used by `check:fast`. +- [ ] Align supported ESLint/typescript-eslint versions and migrate to ESLint 9 flat config with unchanged rule semantics. +- [ ] Capture machine-readable ESLint before/after finding parity for the prior authoritative scope. +- [ ] Remove dead Husky/lint-staged dependencies/configuration unless concrete active hook ownership is established; CI remains authoritative. +- [ ] Add caching only where local/CI measurements justify it and invalidation is obvious. + +### Local SAM ESLint plugin and lifecycle + +- [ ] Create an unpublished workspace plugin tested with ESLint 9 `RuleTester`. +- [ ] Implement `sam/no-unvalidated-request-json` for precise Hono-style `*.req.json()` calls, with a non-automatic suggestion. +- [ ] Implement `sam/no-unsafe-json-parse-assertion` for `TSAsExpression` over `JSON.parse`, excluding only an `unknown` target and narrowly justified fixtures. +- [ ] Implement `sam/no-local-record-guard` for known local `isRecord`/`isObject` definition shapes, suggestion only and no semantic-changing fix. +- [ ] Include every current true-positive shape plus comments, strings, multiline calls, aliases/near misses, and at least two negative edge cases per rule. +- [ ] Keep DO/D1 row narrowing and blind external-payload narrowing out of the syntax plugin. +- [ ] Add `rules.manifest.json` with evidence, owner, matcher version, stage, gate owner, baseline/backlog link, dates, false-positive samples, and expiring exemptions; standard `meta.docs.url` points to the manifest/docs. +- [ ] Configure boundary rules as advisory while debt exists; do not add inline suppressions. + +### Dedicated type-boundary ratchet + +- [ ] Add a deterministic repository-wide checker and Vitest suite for `as any`, `*.req.json()`, local record-guard definitions, and typed JSON.parse assertions excluding `as unknown`. +- [ ] Check in current counts with owner/backlog/review metadata; existing debt passes and net increases fail with precise `file:line` guidance. +- [ ] Prove N→N+1 fails, a file move/split passes, decreases pass without unrelated cleanup, and repeated clean runs are identical. +- [ ] Keep `Record` and `as unknown as` populations report-only until discriminating matchers exist. + +### Remaining quality controls + +- [ ] Add a portable `.claude/rules/` runtime-boundary rule citing current Valibot helpers and sanctioned env/DO-stub/RPC/guard-then-cast patterns. +- [ ] Replace the one runtime `navigator as any` with a bounded local interface, if it still exists at integration time. +- [ ] Add Gitleaks for current-tree and PR-range scanning; keep full-history audit output private operational evidence. +- [ ] Add deterministic direct-dependency evidence enforcement for npm and Go manifest diffs, with authoritative registry/homepage link and one-line necessity. +- [ ] Add diff-local blocking `govulncheck` when Go module files change. +- [ ] Add scheduled OSV source scanning with private owned SAM/backlog routing; every ignore requires a reason and expiry. +- [ ] Extend a bounded ts-morph checker with only unvalidated DO/D1 row narrowing and blind external-payload narrowing, initially scoped to `apps/api/src`, with positive/negative fixtures and low-noise evidence. +- [ ] Do not add generic mock-density, PR-size, Semgrep, Knip, whole-repo type-aware lint, or other unproven gates. + +### Oxlint measured adoption + +- [ ] Install/configure Oxlint in report-only shadow mode without type-aware mode. +- [ ] Compare standard/recommended TypeScript, React/hooks/a11y, API `no-console`/logger exclusion, and `typescript/consistent-type-imports` inline-import behavior against ESLint. +- [ ] Capture machine-readable finding parity, safe-fix diff parity, correct ignores/scopes, TS/Astro coverage, suppression count, and clean cold timing. +- [ ] Shadow-run the SAM fixture corpus through Oxlint's alpha JS-plugin host and record conformance; it remains non-authoritative unless 100% parity survives controlled upgrades. +- [ ] Promote Oxlint only if all intended findings remain, no unreviewed semantic fix drift exists, no new inline suppression exists, directory/template coverage is correct, and clean cold CI time is faster. +- [ ] Keep Oxlint shadow-only if sampled false positives exceed 5%, more than three suppressions would be needed, scoping/template coverage fails, findings regress, fix semantics drift, or no speed improvement is measured. +- [ ] If promotion gates pass, run Oxlint first and use `eslint-plugin-oxlint` to disable overlap; keep only SAM rules and simple-import-sort in the ESLint tail. + +### Root developer/CI contract + +- [ ] Add one obvious `pnpm check:fast` entry point running format check, Oxlint/current lint layer, ESLint custom tail, and the boundary ratchet deterministically. +- [ ] Make CI call the same leaf commands, including workspace lint/type/template coverage, quality checker tests, secret/dependency/vulnerability gates, and source-contract/wiring tests. +- [ ] Keep pre-existing debt advisory/baselined and reject only net-new debt. +- [ ] Keep old authoritative systems enabled until proven parity and document every rollback switch. + +### Integration, review, staging, and delivery + +- [ ] Integrate child commits/PRs in progressively ordered commits and re-audit current main before accepting baselines. +- [ ] Run and archive concise evidence for frozen-lockfile clean install, format, lint/plugin fixtures, all workspace type/template checks, affected JS/TS tests and coverage, quality checker tests, Go tests/race/govulncheck as applicable, build, ESLint parity, Oxlint benchmark, CI wiring, and artifact/suppression cleanliness. +- [ ] Run independent picky architecture/code-quality, security, test, constitution, doc-sync, and task-completion reviews; fix every actionable correctness/security concern. +- [ ] Re-run the local contract after review fixes and ensure CI is green. +- [ ] Immediately before staging, call `list_project_agents`, coordinate a quiet window, re-check active users, and pin one final SHA. +- [ ] Dispatch one Staging Validator using profile `01KQH75F9JGKG0X27GJZ5767B6` with the pinned SHA and consolidated checklist. +- [ ] Complete one consolidated staging sweep, validate zero deployment drift, query authorized Cloudflare state/logs as needed, and leave zero staging VMs/workspaces at rest. +- [ ] Open one cohesive final PR to `main` with evidence, phases, command, ownership, baseline, Oxlint measurements, pinned staging SHA, review outcomes, and rollback notes. +- [ ] Run PR-body preflight and specialist-evidence checkers locally against the live PR body before the final evidence push. +- [ ] Merge only with every hard gate green; otherwise leave an honest draft with old systems authoritative and exact next steps. +- [ ] After merge, match the merged head SHA to the successful production deployment workflow and verify deployed behavior. + +## Acceptance criteria + +- [ ] Runtime behavior and build output remain unchanged apart from the bounded `navigator.userAgentData` typing cleanup. +- [ ] Every pnpm workspace has explicit lint and type/template validation coverage, including Astro templates. +- [ ] ESLint 9 flat config preserves the captured current finding set before any deliberate role split. +- [ ] The three SAM rules have fixture-backed high-precision advisory diagnostics and lifecycle ownership metadata. +- [ ] Existing boundary debt passes; a net-new occurrence fails deterministically with actionable `file:line` guidance; moves/splits/decreases pass. +- [ ] `JSON.parse(...) as unknown` remains allowed; structural assertions are never presented as runtime validation. +- [ ] Gitleaks, direct-dependency evidence, diff-local govulncheck, and scheduled OSV behavior satisfy the privacy/expiry/ownership constraints. +- [ ] Only the two approved semantic checks are added, initially bounded to `apps/api/src` and proven low-noise. +- [ ] `pnpm check:fast` is the obvious local contract and CI invokes its leaf commands rather than duplicating matcher logic. +- [ ] Oxlint is either promoted by complete evidence or remains explicitly safe in shadow mode; TypeScript 5.x and non-type-aware Oxlint are retained. +- [ ] No tracked generated artifacts, unexplained suppressions, exposed secret findings, or broad import-sort churn are introduced. +- [ ] All independent reviewers are PASS/ADDRESSED and consolidated staging passes on the exact final SHA without drift. +- [ ] The PR is merged only if all hard gates pass; otherwise it remains a safe draft with actionable evidence. + +## Rollback plan + +- **Coverage/ESLint foundation:** revert workspace scripts/flat config and restore the captured legacy ESLint config; the legacy path stays present until parity is proven. +- **SAM plugin:** disable the advisory `sam/*` rules or remove the plugin workspace reference; the separate ratchet remains independently reversible. +- **Boundary ratchet:** remove its CI leaf invocation while retaining report output/baseline for diagnosis; no runtime code depends on it. +- **Supply-chain checks:** disable the affected job/leaf command independently; do not publish or baseline secret findings during rollback. +- **Oxlint:** keep or return `lint:oxlint` to report-only and make ESLint authoritative; no TypeScript/toolchain downgrade is needed. +- **CI developer contract:** each leaf command is independently callable and can be removed from `check:fast`/CI without changing application runtime. + +## References + +- `package.json` +- `pnpm-workspace.yaml` +- `.eslintrc.cjs` +- `.github/workflows/ci.yml` +- `scripts/quality/ast-checks.ts` +- `scripts/quality/dependency-governance.test.ts` +- `apps/api/src/lib/runtime-validation.ts` +- `apps/api/src/schemas/_validator.ts` +- `tasks/archive/2026-06-25-replace-isrecord-runtime-validation.md` +- `tasks/archive/2026-03-31-adopt-valibot-api-validation.md` +- `.claude/rules/50-list-read-row-fault-isolation.md` +- `tasks/backlog/2026-07-16-project-data-row-fault-isolation-audit.md` From 7fec2b76b5ff719a7b85b136afc28a891c0697f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 9 Aug 2026 17:16:16 +0000 Subject: [PATCH 2/2] feat: add SAM eslint plugin boundary rules --- packages/eslint-plugin-sam/.eslintrc.cjs | 14 ++ .../docs/rules/no-local-record-guard.md | 7 + .../rules/no-unsafe-json-parse-assertion.md | 13 ++ .../docs/rules/no-unvalidated-request-json.md | 7 + packages/eslint-plugin-sam/package.json | 24 +++ .../eslint-plugin-sam/rules.manifest.json | 75 ++++++++ packages/eslint-plugin-sam/src/index.js | 19 ++ packages/eslint-plugin-sam/src/rules/ast.js | 31 +++ .../src/rules/no-local-record-guard.js | 182 ++++++++++++++++++ .../rules/no-unsafe-json-parse-assertion.js | 79 ++++++++ .../src/rules/no-unvalidated-request-json.js | 63 ++++++ .../tests/fixtures/no-local-record-guard.ts | 25 +++ .../no-unsafe-json-parse-assertion.ts | 37 ++++ .../fixtures/no-unvalidated-request-json.ts | 33 ++++ .../eslint-plugin-sam/tests/rules.test.js | 108 +++++++++++ packages/eslint-plugin-sam/tsconfig.json | 13 ++ 16 files changed, 730 insertions(+) create mode 100644 packages/eslint-plugin-sam/.eslintrc.cjs create mode 100644 packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md create mode 100644 packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md create mode 100644 packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md create mode 100644 packages/eslint-plugin-sam/package.json create mode 100644 packages/eslint-plugin-sam/rules.manifest.json create mode 100644 packages/eslint-plugin-sam/src/index.js create mode 100644 packages/eslint-plugin-sam/src/rules/ast.js create mode 100644 packages/eslint-plugin-sam/src/rules/no-local-record-guard.js create mode 100644 packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js create mode 100644 packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js create mode 100644 packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts create mode 100644 packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts create mode 100644 packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts create mode 100644 packages/eslint-plugin-sam/tests/rules.test.js create mode 100644 packages/eslint-plugin-sam/tsconfig.json diff --git a/packages/eslint-plugin-sam/.eslintrc.cjs b/packages/eslint-plugin-sam/.eslintrc.cjs new file mode 100644 index 0000000000..d735a6187d --- /dev/null +++ b/packages/eslint-plugin-sam/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + root: true, + env: { + es2022: true, + node: true, + }, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md b/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md new file mode 100644 index 0000000000..5f86ed6d14 --- /dev/null +++ b/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md @@ -0,0 +1,7 @@ +# sam/no-local-record-guard + +Flags known local `isRecord` / `isObject` guard definitions that duplicate SAM runtime-validation helpers. + +The matcher is intentionally narrow: it targets local definitions whose body is the familiar `typeof value === 'object' && value !== null` shape, with an optional `!Array.isArray(value)` clause and a TypeScript type-predicate return. + +This rule is advisory and suggestion-only. Replacement requires call-site review because local guard semantics may intentionally differ. diff --git a/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md b/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md new file mode 100644 index 0000000000..c3a8a77fa5 --- /dev/null +++ b/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md @@ -0,0 +1,13 @@ +# sam/no-unsafe-json-parse-assertion + +Flags TypeScript assertions that narrow the result of `JSON.parse(...)` directly to application shapes. + +Allowed: + +```ts +const parsed = JSON.parse(raw) as unknown; +``` + +Disallowed examples include `Record`, `Partial`, concrete object shapes, and nested typed assertions such as `JSON.parse(raw) as unknown as Payload`. + +This rule is advisory and provides suggestions only. Runtime parsing/validation must be chosen by the owning code path. diff --git a/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md b/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md new file mode 100644 index 0000000000..4660a09d27 --- /dev/null +++ b/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md @@ -0,0 +1,7 @@ +# sam/no-unvalidated-request-json + +Flags typed Hono-style `*.req.json()` calls. The type argument is compile-time-only and does not validate request bodies at runtime. + +Use route-level `jsonValidator(schema)` or an established parsing helper before consuming the request body. + +This rule is advisory and provides suggestions only. It intentionally does not auto-fix because inserting validation changes route semantics and error behavior. diff --git a/packages/eslint-plugin-sam/package.json b/packages/eslint-plugin-sam/package.json new file mode 100644 index 0000000000..d27f7a3bd8 --- /dev/null +++ b/packages/eslint-plugin-sam/package.json @@ -0,0 +1,24 @@ +{ + "name": "@simple-agent-manager/eslint-plugin-sam", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Unpublished SAM-specific ESLint rules for deterministic quality gates.", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run", + "lint": "eslint 'src/**/*.js' 'tests/**/*.test.js'", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "eslint": "^9.0.0" + }, + "devDependencies": { + "@typescript-eslint/parser": "catalog:", + "eslint": "^9.39.1", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/eslint-plugin-sam/rules.manifest.json b/packages/eslint-plugin-sam/rules.manifest.json new file mode 100644 index 0000000000..62b2e82cef --- /dev/null +++ b/packages/eslint-plugin-sam/rules.manifest.json @@ -0,0 +1,75 @@ +{ + "schemaVersion": 1, + "plugin": "@simple-agent-manager/eslint-plugin-sam", + "stage": "advisory", + "gateOwner": "deterministic-runtime-boundary-quality", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "rules": [ + { + "name": "sam/no-unvalidated-request-json", + "owner": "runtime-boundary-quality", + "matcherVersion": "2026-08-09.1", + "advisoryStage": "shadow", + "gateOwner": "quality-program", + "evidenceIncident": "Typed Hono request JSON masks unvalidated request bodies at runtime.", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "addedDate": "2026-08-09", + "reviewDate": "2026-09-09", + "falsePositiveSamples": [ + "Untyped c.req.json() calls are intentionally excluded for this syntax rule.", + "Route handlers already using jsonValidator(schema) are handled by integration wiring, not this isolated syntax matcher." + ], + "expiringExemptions": [ + { + "scope": "existing debt", + "expiresOn": "2026-10-09", + "reason": "Advisory rollout while the deterministic ratchet establishes current baseline ownership." + } + ] + }, + { + "name": "sam/no-unsafe-json-parse-assertion", + "owner": "runtime-boundary-quality", + "matcherVersion": "2026-08-09.1", + "advisoryStage": "shadow", + "gateOwner": "quality-program", + "evidenceIncident": "Type assertions over JSON.parse were repeatedly mistaken for runtime validation.", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "addedDate": "2026-08-09", + "reviewDate": "2026-09-09", + "falsePositiveSamples": [ + "JSON.parse(raw) as unknown is allowed as the neutral parse boundary.", + "Non-JSON.parse assertions are excluded even when their target type is structural." + ], + "expiringExemptions": [ + { + "scope": "Record population", + "expiresOn": "2026-10-09", + "reason": "Kept advisory until discriminating validation-oriented matchers and baselines are integrated." + } + ] + }, + { + "name": "sam/no-local-record-guard", + "owner": "runtime-boundary-quality", + "matcherVersion": "2026-08-09.1", + "advisoryStage": "shadow", + "gateOwner": "quality-program", + "evidenceIncident": "Local record/object guards drift from established runtime-validation helpers.", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "addedDate": "2026-08-09", + "reviewDate": "2026-09-09", + "falsePositiveSamples": [ + "Guards with different names are excluded.", + "Guards with extra semantic checks are excluded because automatic replacement is unsafe." + ], + "expiringExemptions": [ + { + "scope": "existing local guard definitions", + "expiresOn": "2026-10-09", + "reason": "Existing call sites need semantics review before shared-helper migration." + } + ] + } + ] +} diff --git a/packages/eslint-plugin-sam/src/index.js b/packages/eslint-plugin-sam/src/index.js new file mode 100644 index 0000000000..01077c7f5c --- /dev/null +++ b/packages/eslint-plugin-sam/src/index.js @@ -0,0 +1,19 @@ +import noLocalRecordGuard from './rules/no-local-record-guard.js'; +import noUnsafeJsonParseAssertion from './rules/no-unsafe-json-parse-assertion.js'; +import noUnvalidatedRequestJson from './rules/no-unvalidated-request-json.js'; + +const rules = { + 'no-local-record-guard': noLocalRecordGuard, + 'no-unsafe-json-parse-assertion': noUnsafeJsonParseAssertion, + 'no-unvalidated-request-json': noUnvalidatedRequestJson, +}; + +export default { + meta: { + name: '@simple-agent-manager/eslint-plugin-sam', + version: '0.0.0', + }, + rules, +}; + +export { rules }; diff --git a/packages/eslint-plugin-sam/src/rules/ast.js b/packages/eslint-plugin-sam/src/rules/ast.js new file mode 100644 index 0000000000..9f488b8021 --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/ast.js @@ -0,0 +1,31 @@ +export function unwrapChainExpression(node) { + return node?.type === 'ChainExpression' ? node.expression : node; +} + +export function getPropertyName(node) { + if (!node) { + return undefined; + } + + if (node.type === 'Identifier') { + return node.name; + } + + if (node.type === 'PrivateIdentifier') { + return node.name; + } + + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + + return undefined; +} + +export function getCallTypeArguments(node) { + return node.typeArguments ?? node.typeParameters; +} + +export function isIdentifierNamed(node, name) { + return node?.type === 'Identifier' && node.name === name; +} diff --git a/packages/eslint-plugin-sam/src/rules/no-local-record-guard.js b/packages/eslint-plugin-sam/src/rules/no-local-record-guard.js new file mode 100644 index 0000000000..fa3c8300e0 --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/no-local-record-guard.js @@ -0,0 +1,182 @@ +import { isIdentifierNamed } from './ast.js'; + +const docsUrl = + 'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md'; + +function isRecordGuardName(name) { + return name === 'isRecord' || name === 'isObject'; +} + +function getFunctionName(node) { + if (node.type === 'FunctionDeclaration') { + return node.id?.name; + } + + if ( + node.type === 'VariableDeclarator' && + node.id.type === 'Identifier' && + (node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression') + ) { + return node.id.name; + } + + return undefined; +} + +function getFunctionNode(node) { + if (node.type === 'FunctionDeclaration') { + return node; + } + + if (node.type === 'VariableDeclarator') { + return node.init; + } + + return undefined; +} + +function getReturnExpression(functionNode) { + if (!functionNode || functionNode.body.type !== 'BlockStatement') { + return functionNode?.body; + } + + if (functionNode.body.body.length !== 1) { + return undefined; + } + + const statement = functionNode.body.body[0]; + return statement?.type === 'ReturnStatement' ? statement.argument : undefined; +} + +function hasTypePredicateReturn(functionNode, parameterName) { + const returnType = functionNode.returnType?.typeAnnotation; + if (returnType?.type !== 'TSTypePredicate') { + return false; + } + + return isIdentifierNamed(returnType.parameterName, parameterName); +} + +function flattenLogicalAnd(node) { + if (node?.type === 'LogicalExpression' && node.operator === '&&') { + return [...flattenLogicalAnd(node.left), ...flattenLogicalAnd(node.right)]; + } + + return node ? [node] : []; +} + +function isTypeofObjectCheck(node, parameterName) { + return ( + node.type === 'BinaryExpression' && + (node.operator === '===' || node.operator === '==') && + node.left.type === 'UnaryExpression' && + node.left.operator === 'typeof' && + isIdentifierNamed(node.left.argument, parameterName) && + node.right.type === 'Literal' && + node.right.value === 'object' + ); +} + +function isNotNullCheck(node, parameterName) { + return ( + node.type === 'BinaryExpression' && + (node.operator === '!==' || node.operator === '!=') && + isIdentifierNamed(node.left, parameterName) && + node.right.type === 'Literal' && + node.right.value === null + ); +} + +function isNotArrayCheck(node, parameterName) { + return ( + node.type === 'UnaryExpression' && + node.operator === '!' && + node.argument.type === 'CallExpression' && + node.argument.callee.type === 'MemberExpression' && + node.argument.callee.object.type === 'Identifier' && + node.argument.callee.object.name === 'Array' && + node.argument.callee.property.type === 'Identifier' && + node.argument.callee.property.name === 'isArray' && + node.argument.arguments.length === 1 && + isIdentifierNamed(node.argument.arguments[0], parameterName) + ); +} + +function isKnownLocalRecordGuard(functionNode) { + const parameter = functionNode?.params[0]; + if (!parameter || parameter.type !== 'Identifier') { + return false; + } + + if (!hasTypePredicateReturn(functionNode, parameter.name)) { + return false; + } + + const clauses = flattenLogicalAnd(getReturnExpression(functionNode)); + const hasObject = clauses.some((clause) => isTypeofObjectCheck(clause, parameter.name)); + const hasNotNull = clauses.some((clause) => isNotNullCheck(clause, parameter.name)); + const onlyKnownClauses = clauses.every( + (clause) => + isTypeofObjectCheck(clause, parameter.name) || + isNotNullCheck(clause, parameter.name) || + isNotArrayCheck(clause, parameter.name), + ); + + return clauses.length >= 2 && hasObject && hasNotNull && onlyKnownClauses; +} + +function checkNode(context, node) { + const name = getFunctionName(node); + if (!name || !isRecordGuardName(name)) { + return; + } + + const functionNode = getFunctionNode(node); + if (!isKnownLocalRecordGuard(functionNode)) { + return; + } + + context.report({ + node, + messageId: 'localRecordGuard', + data: { name }, + suggest: [ + { + messageId: 'useSharedValidation', + fix: (fixer) => { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + return fixer.replaceText(node, sourceCode.getText(node)); + }, + }, + ], + }); +} + +export default { + meta: { + type: 'suggestion', + docs: { + description: 'Discourage local isRecord/isObject guard definitions that duplicate shared runtime validation.', + recommended: false, + url: docsUrl, + }, + hasSuggestions: true, + messages: { + localRecordGuard: + 'Local {{name}} guard definitions drift from shared runtime-validation helpers. Use the established helper instead.', + useSharedValidation: + 'Replace this local guard with the shared runtime-validation helper after checking call-site semantics.', + }, + schema: [], + }, + create(context) { + return { + FunctionDeclaration(node) { + checkNode(context, node); + }, + VariableDeclarator(node) { + checkNode(context, node); + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js b/packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js new file mode 100644 index 0000000000..ac2af10e6e --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js @@ -0,0 +1,79 @@ +import { getPropertyName, unwrapChainExpression } from './ast.js'; + +const docsUrl = + 'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md'; + +function isJsonParseCall(node) { + const expression = unwrapChainExpression(node); + if (expression?.type !== 'CallExpression') { + return false; + } + + const callee = unwrapChainExpression(expression.callee); + if (callee?.type !== 'MemberExpression' || callee.computed) { + return false; + } + + return ( + callee.object.type === 'Identifier' && + callee.object.name === 'JSON' && + getPropertyName(callee.property) === 'parse' + ); +} + +function unwrapAssertionExpression(node) { + let current = unwrapChainExpression(node); + while (current?.type === 'TSAsExpression' || current?.type === 'TSSatisfiesExpression') { + current = unwrapChainExpression(current.expression); + } + return current; +} + +function isUnknownAssertion(node) { + return node.typeAnnotation?.type === 'TSUnknownKeyword'; +} + +export default { + meta: { + type: 'problem', + docs: { + description: 'Disallow typed assertions over JSON.parse except the neutral unknown boundary.', + recommended: false, + url: docsUrl, + }, + hasSuggestions: true, + messages: { + unsafeAssertion: + 'A TypeScript assertion over JSON.parse is not runtime validation. Only `as unknown` is allowed at the parse boundary.', + parseUnknownThenValidate: + 'Parse as unknown, then validate with a schema or established parsing helper before narrowing.', + }, + schema: [], + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + TSAsExpression(node) { + if (isUnknownAssertion(node)) { + return; + } + + if (!isJsonParseCall(unwrapAssertionExpression(node.expression))) { + return; + } + + context.report({ + node, + messageId: 'unsafeAssertion', + suggest: [ + { + messageId: 'parseUnknownThenValidate', + fix: (fixer) => fixer.replaceText(node, sourceCode.getText(node)), + }, + ], + }); + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js b/packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js new file mode 100644 index 0000000000..6d95fd172a --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js @@ -0,0 +1,63 @@ +import { getCallTypeArguments, getPropertyName, unwrapChainExpression } from './ast.js'; + +const docsUrl = + 'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md'; + +function isHonoRequestJsonCall(node) { + const callee = unwrapChainExpression(node.callee); + if (callee?.type !== 'MemberExpression') { + return false; + } + + if (callee.computed || getPropertyName(callee.property) !== 'json') { + return false; + } + + const reqMember = unwrapChainExpression(callee.object); + if (reqMember?.type !== 'MemberExpression') { + return false; + } + + return !reqMember.computed && getPropertyName(reqMember.property) === 'req'; +} + +export default { + meta: { + type: 'problem', + docs: { + description: 'Disallow typed Hono request JSON reads that bypass runtime validation.', + recommended: false, + url: docsUrl, + }, + hasSuggestions: true, + messages: { + unvalidatedRequestJson: + 'Typed Hono request JSON is not validation. Validate with jsonValidator(schema) or an established parsing helper before using the body.', + useRuntimeValidator: + 'Use jsonValidator(schema) on the route or parse the unknown body with an established runtime-validation helper.', + }, + schema: [], + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + CallExpression(node) { + if (!getCallTypeArguments(node) || !isHonoRequestJsonCall(node)) { + return; + } + + context.report({ + node, + messageId: 'unvalidatedRequestJson', + suggest: [ + { + messageId: 'useRuntimeValidator', + fix: (fixer) => fixer.replaceText(node, sourceCode.getText(node)), + }, + ], + }); + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts b/packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts new file mode 100644 index 0000000000..99942b4811 --- /dev/null +++ b/packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts @@ -0,0 +1,25 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export const isObject = (value: unknown): value is object => + typeof value === 'object' && value !== null; + +export function isRuntimeRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isRecordWithSemantics(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0; +} + +export function isRecordBoolean(value: unknown): boolean { + return typeof value === 'object' && value !== null; +} + +export function isRecordAlias(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +const stringNearMiss = 'function isRecord(value: unknown): value is Record {}'; +// function isObject(value: unknown): value is object {} diff --git a/packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts b/packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts new file mode 100644 index 0000000000..ab39853e65 --- /dev/null +++ b/packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts @@ -0,0 +1,37 @@ +type Payload = { message: string }; + +export function currentRecordShape(raw: string) { + return JSON.parse(raw) as Record; +} + +export function currentPartialShape(raw: string) { + return JSON.parse(raw) as Partial; +} + +export function currentConcreteShape(raw: string) { + return JSON.parse(raw) as { error?: string; cause?: string }; +} + +export function nestedTypedAssertion(raw: string) { + return JSON.parse(raw) as unknown as Payload; +} + +export function safeUnknown(raw: string) { + return JSON.parse(raw) as unknown; +} + +export function safeValidated(raw: string, parsePayload: (value: unknown) => Payload) { + const parsed = JSON.parse(raw) as unknown; + return parsePayload(parsed); +} + +export function aliasNearMiss(raw: string, parse: (value: string) => unknown) { + return parse(raw) as Payload; +} + +export function computedNearMiss(raw: string) { + return JSON['parse'](raw) as Payload; +} + +const stringNearMiss = 'JSON.parse(raw) as Payload'; +// JSON.parse(raw) as Payload diff --git a/packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts b/packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts new file mode 100644 index 0000000000..9728d93c4e --- /dev/null +++ b/packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts @@ -0,0 +1,33 @@ +import { jsonValidator } from '../../src/schemas/_validator'; + +type CreatePolicyRequest = { name: string }; + +export async function currentTruePositive(c: { req: { json(): Promise } }) { + const body = await c.req.json(); + return body.name; +} + +export async function multilineTruePositive(context: { req: { json(): Promise } }) { + const body = await context.req + .json<{ + defaultModel: string; + }>(); + return body.defaultModel; +} + +export async function safeUntyped(c: { req: { json(): Promise } }) { + return c.req.json(); +} + +export async function safeValidatorRoute(app: { post(path: string, validator: unknown, handler: unknown): void }) { + app.post('/ok', jsonValidator('json', {}), async (c: { req: { valid(kind: 'json'): CreatePolicyRequest } }) => { + return c.req.valid('json'); + }); +} + +export async function aliasNearMiss(c: { request: { json(): Promise } }) { + return c.request.json(); +} + +const stringNearMiss = 'await c.req.json()'; +// await c.req.json(); diff --git a/packages/eslint-plugin-sam/tests/rules.test.js b/packages/eslint-plugin-sam/tests/rules.test.js new file mode 100644 index 0000000000..fc50642c7f --- /dev/null +++ b/packages/eslint-plugin-sam/tests/rules.test.js @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RuleTester } from 'eslint'; +import parser from '@typescript-eslint/parser'; +import { describe, it } from 'vitest'; + +import noLocalRecordGuard from '../src/rules/no-local-record-guard.js'; +import noUnsafeJsonParseAssertion from '../src/rules/no-unsafe-json-parse-assertion.js'; +import noUnvalidatedRequestJson from '../src/rules/no-unvalidated-request-json.js'; + +const require = createRequire(import.meta.url); +const eslintVersion = require('eslint/package.json').version; +const eslintMajor = Number(eslintVersion.split('.')[0]); +const dirname = fileURLToPath(new URL('.', import.meta.url)); + +function fixture(name) { + return readFileSync(join(dirname, 'fixtures', name), 'utf8'); +} + +const unvalidatedRequestJsonFixture = fixture('no-unvalidated-request-json.ts'); +const unsafeJsonParseAssertionFixture = fixture('no-unsafe-json-parse-assertion.ts'); +const localRecordGuardFixture = fixture('no-local-record-guard.ts'); + +function expectedError(messageId, suggestionMessageId) { + return eslintMajor >= 9 + ? { messageId, suggestions: 1 } + : { messageId, suggestions: [{ messageId: suggestionMessageId }] }; +} + +const languageOptions = { + ecmaVersion: 2022, + sourceType: 'module', + parser, +}; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = + eslintMajor >= 9 + ? new RuleTester({ languageOptions }) + : new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }); + +ruleTester.run('no-unvalidated-request-json', noUnvalidatedRequestJson, { + valid: [ + "const body = await c.req.json();", + "const body = await c.request.json();", + "const body = await c.req['json']();", + "app.post('/ok', jsonValidator('json', schema), (c) => c.req.valid('json'));", + "const text = 'await c.req.json()'; // await c.req.json()", + ], + invalid: [ + { + code: unvalidatedRequestJsonFixture, + errors: [ + expectedError('unvalidatedRequestJson', 'useRuntimeValidator'), + expectedError('unvalidatedRequestJson', 'useRuntimeValidator'), + ], + }, + ], +}); + +ruleTester.run('no-unsafe-json-parse-assertion', noUnsafeJsonParseAssertion, { + valid: [ + 'const parsed = JSON.parse(raw) as unknown;', + 'const parsed = parse(raw) as Payload;', + "const parsed = JSON['parse'](raw) as Payload;", + "const text = 'JSON.parse(raw) as Payload'; // JSON.parse(raw) as Payload", + ], + invalid: [ + { + code: unsafeJsonParseAssertionFixture, + errors: [ + expectedError('unsafeAssertion', 'parseUnknownThenValidate'), + expectedError('unsafeAssertion', 'parseUnknownThenValidate'), + expectedError('unsafeAssertion', 'parseUnknownThenValidate'), + expectedError('unsafeAssertion', 'parseUnknownThenValidate'), + ], + }, + ], +}); + +ruleTester.run('no-local-record-guard', noLocalRecordGuard, { + valid: [ + 'function isRuntimeRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; }', + 'function isRecord(value: unknown): boolean { return typeof value === "object" && value !== null; }', + 'function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && Object.keys(value).length > 0; }', + "const text = 'function isRecord(value: unknown): value is Record {}';", + ], + invalid: [ + { + code: localRecordGuardFixture, + errors: [ + expectedError('localRecordGuard', 'useSharedValidation'), + expectedError('localRecordGuard', 'useSharedValidation'), + ], + }, + ], +}); diff --git a/packages/eslint-plugin-sam/tsconfig.json b/packages/eslint-plugin-sam/tsconfig.json new file mode 100644 index 0000000000..4949a620c4 --- /dev/null +++ b/packages/eslint-plugin-sam/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitAny": false, + "noEmit": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.js", "tests/**/*.test.js"] +}