Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/eslint-plugin-read-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@paramour-js/eslint-plugin": minor
---

Two new rules complete the bypass audit in both directions, and the existing rule's messages now name the real API.

- **`no-raw-param-reads`** — the read-side twin of `no-raw-hrefs`. Flags `useSearchParams()` / `useParams()` imported from `next/navigation` (nudging `useSearch(route)` / `useRouteParams(route)` from `@paramour-js/next/app`) and `router.query` on a `next/router` `useRouter()` router, including the direct-call and destructured forms (nudging the `@paramour-js/next/pages` hooks). An `allow` option (`"routerQuery" | "useParams" | "useSearchParams"`) switches a surface off wholesale.
- **`no-href-arithmetic`** — flags string content appended after an `href()` result (`href(route) + "?tab=1"`, `` `${href(route)}/reviews` ``); prefix-only concatenation (`origin + href(route)`) stays legal. The pure hash case is the plugin's first autofix: `href(route) + "#top"` rewrites to `href(route, { hash: "top" })` when the options provably carry no `hash`.
- Both rules ship in `configs.recommended` at `warn`.
- `no-raw-hrefs` messages now say `href(route, …)` — there is no `route.href()` method to point at.
179 changes: 168 additions & 11 deletions docs/content/docs/reference/eslint-plugin.mdx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
---
title: "@paramour-js/eslint-plugin"
description: The ESLint plugin — find every raw string href paramour never sees.
description: The ESLint plugin — find every place paramour never sees, in both directions.
---

`@paramour-js/eslint-plugin` exists because paramour's value proposition —
validated params, typed path building, explicit serialization — evaporates
silently the moment someone writes `<Link href="/users/123">` or
`router.push("/shop?page=2")`. The code compiles, the navigation works, and
the route's codecs simply never run. In a codebase mid-migration this is
the default failure mode, not an edge case: every pre-existing link is a
raw string, and every new one written from habit is too. The plugin's one
rule finds them all.
`useSearchParams()`. The code compiles, the page renders, and the route's
codecs simply never run. In a codebase mid-migration this is the default
failure mode, not an edge case. The plugin audits the bypass in both
directions — raw writes (`no-raw-hrefs`), raw reads
(`no-raw-param-reads`) — and guards the integrity of what `href()` builds
(`no-href-arithmetic`).

## Install

Expand All @@ -34,7 +35,7 @@ export default [
];
```

Or wire the rule manually:
Or wire the rules manually:

```js title="eslint.config.js"
import paramour from "@paramour-js/eslint-plugin";
Expand All @@ -43,15 +44,18 @@ export default [
{
plugins: { paramour },
rules: {
"paramour/no-href-arithmetic": "warn",
"paramour/no-raw-hrefs": "warn",
"paramour/no-raw-param-reads": "warn",
},
},
];
```

The preset registers the rule at `warn` on purpose: a raw string href is
*working* code, and this is a nudge toward migration, not a correctness
gate. Once your routes are migrated, promote it in one line:
The preset registers every rule at `warn` on purpose: a raw string href or
a raw param read is *working* code, and this is a nudge toward migration,
not a correctness gate. Once your routes are migrated, promote any rule
individually:

```js
rules: { "paramour/no-raw-hrefs": "error" }
Expand Down Expand Up @@ -79,14 +83,15 @@ that start with `/`, in three Next.js App Router surfaces:
```tsx
import Link from "next/link";
import { redirect, useRouter } from "next/navigation";
import { href } from "paramour";

<Link href="/users/123" />; // ✗ flagged
redirect("/login"); // ✗ flagged

const router = useRouter();
router.push("/shop?page=2"); // ✗ flagged

<Link href={usersRoute.href({ id: 123 })} />; // ✓ what the rule nudges toward
<Link href={href(usersRoute, { params: { id: 123 } })} />; // ✓ what the rule nudges toward
```

There is no autofix and no suggestion: a correct fix requires knowing
Expand Down Expand Up @@ -144,3 +149,155 @@ Recorded so the omissions read as decisions, not oversights:
rule is purely syntactic by design: no type information, no
`parserOptions.project` requirement, works in any parser setup that
produces JSX nodes.

## no-raw-param-reads

The read-side twin of `no-raw-hrefs`: reports the Next.js read APIs whose
results paramour's codecs never validate, in two surfaces:

1. **`useSearchParams()` / `useParams()`** — calls of either hook imported
from `next/navigation`, under any local name, including the namespace
form (`nav.useParams()`). Imports are tracked through scope resolution,
so the same-named hooks from `react-router-dom` (or anywhere else)
never fire. The message nudges the like-for-like replacement:
`useSearch(route)` for `useSearchParams()`, `useRouteParams(route)` for
`useParams()`, both from `@paramour-js/next/app`.
2. **`router.query`** — `.query` access on a router obtained from
`next/router`'s `useRouter()` (the extensionful `next/router.js`
spelling counts too), in the variable form
(`const router = useRouter()`), the direct form
(`useRouter().query`), and the destructured form
(`const { query } = useRouter()`, including renames and nested
patterns). The nudge is `useRouteParams(route)` and `useSearch(route)`
from `@paramour-js/next/pages`.

```tsx
import { useParams, useSearchParams } from "next/navigation";
import { useRouteParams, useSearch } from "@paramour-js/next/app";

const params = useParams(); // ✗ flagged
const search = useSearchParams(); // ✗ flagged

const result = useRouteParams(productRoute); // ✓ what the rule nudges toward
const query = useSearch(productRoute); // ✓
```

There is no autofix and no suggestion: a correct fix requires knowing
which route object the component belongs to — not mechanically derivable
from the call site.

### What is exempt

Only reads through the tracked imports fire. `useParams` from
`react-router-dom`, a locally defined `useParams`, and type-only imports
never fire. `useRouter().query` where `useRouter` comes from
`next/navigation` never fires either — the App Router's router has no
`query`. Computed access (`router["query"]`) and a router crossing a
function or prop boundary escape detection — the rule is purely
syntactic, same as `no-raw-hrefs`.

Other `next/navigation` reads (`usePathname`, `useSelectedLayoutSegment`)
are not paramour's territory and are never flagged.

### Options

| Option | Type | Default | Description |
| ------- | ----------------------------------------------------- | ------- | ---------------------------------- |
| `allow` | `("routerQuery" \| "useParams" \| "useSearchParams")[]` | `[]` | Surfaces to switch off wholesale. |

`allow` is the escape hatch for a codebase that deliberately keeps one
surface untyped — for example an app that reads `useParams()` only in a
handful of not-yet-migrated components:

```js
rules: {
"paramour/no-raw-param-reads": ["warn", { allow: ["useParams"] }],
}
```

For a *single* legitimate raw read — say, forwarding ad-hoc `utm_*`
params the app deliberately leaves out of its route definitions — prefer
a targeted disable comment over switching off the whole surface:

```ts
// eslint-disable-next-line paramour/no-raw-param-reads -- untyped utm_* forwarding
const search = useSearchParams();
```

### Deliberately out of scope (v1)

Recorded so the omissions read as decisions, not oversights:

- **Direct `props.searchParams` / `props.params` access** in `page.tsx` /
`layout.tsx` — the noisiest surface and the only one needing filename
awareness; it follows once the hook surfaces prove the false-positive
policy, mirroring how `no-raw-hrefs` sequenced dynamic strings out of
its v1.
- **Routers crossing boundaries** — same accepted cost as `no-raw-hrefs`.
- **Computed access** — `router["query"]` is invisible to a syntactic
rule.

## no-href-arithmetic

Reports string arithmetic that appends content *after* an `href()` result
— the exact thing explicit serialization exists to prevent — in two
surfaces:

1. **`+` concatenation** — `href(route) + "?tab=1"`, including chained
forms (`href(route) + "#a" + x`). The `href` import from `paramour` is
tracked through scope resolution, aliased and namespace forms
included.
2. **Template literals** — content after `${href(route)}`:
`` `${href(route)}?page=2` ``, `` `${href(route)}/${child}` ``.

Prepending is fine: `origin + href(route)` and
`` `${origin}${href(route)}` `` are the legitimate way to build absolute
URLs for metadata, emails, and redirects, and never fire.

```ts
import { href } from "paramour";

const a = href(route) + "?tab=1"; // ✗ flagged — declare tab in the route's search codecs
const b = `${href(route)}/reviews`; // ✗ flagged
const c = href(route) + "#reviews"; // ✗ flagged, autofixed ↓

const fixed = href(route, { hash: "reviews" }); // ✓ what the autofix produces
const url = "https://example.com" + href(route); // ✓ prefixing is legitimate
```

This is the plugin's first fixable rule, and the fixer is deliberately
narrow: it rewrites only the pure hash case — a single appended
`"#fragment"` literal on a call whose options provably have no `hash`
(absent, or an object literal without one). The rewrite is exactly
semantics-preserving: `href()` renders `hash` as a trailing `#fragment`,
byte-identical to the concatenation it replaces. `?` suffixes are never
autofixed — the appended query needs a codec key in the route's `search`
config, which no fixer can invent — so the message points at the `search`
option instead.

### What is exempt

Prefix-only concatenation and interpolation, as above. `href` from any
other module, shadowed locals, and type-only imports never fire. Tagged
templates (`` sql`${href(route)}#x` ``) are skipped — the tag's semantics
are unknown.

### Options

No options in v1. One-off exceptions use a standard
`// eslint-disable-next-line paramour/no-href-arithmetic` comment.

### Deliberately out of scope (v1)

Recorded so the omissions read as decisions, not oversights:

- **Method-style flows** — `href(route).concat("#a")`,
`[href(route), "#a"].join("")`, and `+=` accumulation.
- **Values crossing bindings** — `const base = href(route); base + "#a"`
escapes detection; same purely-syntactic boundary as the other rules.
- **The prefix-template autofix** — `` `${origin}${href(route)}#top` ``
gets the hash message but no fix; the fixer only rewrites expressions
that *are* the call plus a suffix.
- **A `route.href()` method form** — not an omission but a fact: paramour
has no such method. `href()` is a standalone import, which is what makes
the scope-resolved detection precise.
38 changes: 20 additions & 18 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @paramour-js/eslint-plugin

ESLint plugin for [paramour](https://paramour.dev). A raw string href — `<Link href="/users/123">`, `router.push("/shop?page=2")`, `redirect("/login")` — compiles, navigates, and silently bypasses everything paramour does: the route's codecs never run, params are never validated, and typos ship. In a codebase mid-migration this is the default failure mode. This plugin finds every unmigrated link.
ESLint plugin for [paramour](https://paramour.dev). A raw string href — `<Link href="/users/123">` — or a raw param read — `useSearchParams()`, `router.query` — compiles, renders, and silently bypasses everything paramour does: the route's codecs never run, params are never validated, and typos ship. In a codebase mid-migration this is the default failure mode. This plugin finds every place paramour never sees, in both directions, and guards the integrity of what `href()` builds.

Docs: <https://paramour.dev/docs/reference/eslint-plugin>

Expand All @@ -25,7 +25,7 @@ export default [
];
```

Or wire the rule manually:
Or wire the rules manually:

```js
import paramour from "@paramour-js/eslint-plugin";
Expand All @@ -34,44 +34,46 @@ export default [
{
plugins: { paramour },
rules: {
"paramour/no-href-arithmetic": "warn",
"paramour/no-raw-hrefs": "warn",
"paramour/no-raw-param-reads": "warn",
},
},
];
```

The preset registers the rule at `warn` — it is a migration nudge, not a correctness gate. Once your routes are migrated, promote it in one line:
The preset registers every rule at `warn` — it is a migration nudge, not a correctness gate. Once your routes are migrated, promote any rule individually:

```js
rules: { "paramour/no-raw-hrefs": "error" }
```

## What it flags
## Rules

`paramour/no-raw-hrefs` reports string literals (and expression-free template literals) starting with `/` in three Next.js App Router surfaces:
- [`no-raw-hrefs`](https://paramour.dev/docs/reference/eslint-plugin#no-raw-hrefs) — raw string paths flowing into `<Link href>`, `router.push`/`replace`/`prefetch`, and `redirect`/`permanentRedirect`.
- [`no-raw-param-reads`](https://paramour.dev/docs/reference/eslint-plugin#no-raw-param-reads) — raw reads through `useSearchParams()`/`useParams()` from `next/navigation` and `router.query` from `next/router`.
- [`no-href-arithmetic`](https://paramour.dev/docs/reference/eslint-plugin#no-href-arithmetic) — string content appended after an `href()` result; the pure-hash case is autofixed to `href()`'s `hash` option.

1. the `href` attribute of `Link` imported from `next/link` (any local name — imports are tracked, not names matched);
2. the first argument of `push`, `replace`, and `prefetch` on a router obtained from `next/navigation`'s `useRouter()` — including the destructured form `const { push } = useRouter()`;
3. arguments to `redirect` and `permanentRedirect` imported from `next/navigation`.
### no-raw-hrefs

External URLs (`https://…`, protocol-relative `//…`), fragments (`#…`), `mailto:`/`tel:`, relative paths, and empty strings never start with `/` (or are explicitly exempt) and are ignored.
Reports string literals (and expression-free template literals) starting with `/` in three Next.js App Router surfaces: the `href` attribute of `Link` imported from `next/link` (any local name — imports are tracked, not names matched); the first argument of `push`, `replace`, and `prefetch` on a router obtained from `next/navigation`'s `useRouter()` — including the destructured form `const { push } = useRouter()`; and arguments to `redirect` and `permanentRedirect` imported from `next/navigation`.

Deliberately out of scope in v1: dynamic strings (`"/users/" + id`, template literals with expressions), the Pages router (`next/router`), the `UrlObject` href form (`href={{ pathname: "/foo" }}`), `Link` re-exported through a wrapper component, and a router instance passed across function or prop boundaries.

## Options

| Option | Type | Default | Description |
| ------------- | ---------- | ------- | ------------------------------------------- |
| `ignorePaths` | `string[]` | `[]` | Path prefixes to exempt during a migration. |

`ignorePaths` matches path-segment prefixes, not raw substrings: `"/legacy"` exempts `/legacy`, `/legacy/old`, `/legacy?tab=1`, and `/legacy#top`, but **not** `/legacybar`. A trailing slash is ignored (`"/legacy/"` behaves like `"/legacy"`); `"/"` exempts everything.
External URLs (`https://…`, protocol-relative `//…`), fragments (`#…`), `mailto:`/`tel:`, relative paths, and empty strings are ignored. `ignorePaths` (path-segment prefixes, not substrings or globs) exempts sections a migration has not reached yet:

```js
rules: {
"paramour/no-raw-hrefs": ["warn", { ignorePaths: ["/legacy", "/admin"] }],
}
```

### no-raw-param-reads

The read-side twin: reports `useSearchParams()` / `useParams()` imported from `next/navigation` (nudging `useSearch(route)` / `useRouteParams(route)` from `@paramour-js/next/app`) and `router.query` on a `next/router` `useRouter()` router, including `useRouter().query` and destructured forms (nudging the `@paramour-js/next/pages` hooks). The `allow` option (`"routerQuery" | "useParams" | "useSearchParams"`) switches a surface off wholesale; one-off legitimate reads (e.g. untyped `utm_*` forwarding) use a targeted disable comment.

### no-href-arithmetic

Reports content appended after an `href()` result — `href(route) + "?tab=1"`, `` `${href(route)}/reviews` `` — which reintroduces unvalidated URL content through the back door. Prefixing is fine (`origin + href(route)` is the legitimate absolute-URL pattern). The pure hash case (`href(route) + "#top"`) is autofixed to `href(route, { hash: "top" })`; `?` suffixes are message-only because the appended query needs a codec key in the route's `search` config. No options.

## License

MIT
2 changes: 1 addition & 1 deletion packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"eslint": "^10.6.0",
"typescript": "^6.0.3"
},
"description": "ESLint plugin for paramour — flags raw string hrefs in Next.js navigation APIs that bypass paramour's typed href() building",
"description": "ESLint plugin for paramour — flags raw hrefs, raw param reads, and href() arithmetic that bypass paramour's typed routing",
"author": "Jason Paff <jasonpaff@gmail.com>",
"keywords": [
"eslint",
Expand Down
6 changes: 6 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { TSESLint } from "@typescript-eslint/utils";

import { noHrefArithmetic } from "./rules/no-href-arithmetic.js";
import { noRawHrefs } from "./rules/no-raw-hrefs.js";
import { noRawParamReads } from "./rules/no-raw-param-reads.js";

// meta.version is intentionally hardcoded (importing package.json would break
// the build's rootDir) — it is debug/cache-key metadata only and may lag the
Expand All @@ -12,7 +14,9 @@ const plugin = {
version: "0.1.0",
},
rules: {
"no-href-arithmetic": noHrefArithmetic,
"no-raw-hrefs": noRawHrefs,
"no-raw-param-reads": noRawParamReads,
},
};

Expand All @@ -22,7 +26,9 @@ const recommended: TSESLint.FlatConfig.Config = {
paramour: plugin,
},
rules: {
"paramour/no-href-arithmetic": "warn",
"paramour/no-raw-hrefs": "warn",
"paramour/no-raw-param-reads": "warn",
},
};

Expand Down
Loading
Loading