@ingram-tech/nk-dev 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/bin/format-staged.mjs +2 -2
- package/bin/nk.js +7 -0
- package/guide.md +24 -4
- package/lib/ast-grep.js +53 -0
- package/lib/dev.js +1 -1
- package/lib/init.js +49 -6
- package/lib/oxlint-plugins/deferred-current-target.js +109 -0
- package/lib/oxlint-plugins/index.js +20 -0
- package/lib/oxlint-plugins/lucide-icon-suffix.js +97 -0
- package/lib/oxlint-plugins/no-redirect-only-page.js +196 -0
- package/lib/oxlint-plugins/redundant-usestate-type.js +157 -0
- package/lib/run.js +7 -3
- package/oxlintrc.json +4 -0
- package/package.json +4 -2
- package/skills/ts-codemod.md +121 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The nextkit **dev toolchain in one package**. Everything a site needs at
|
|
4
4
|
development time — and nothing that ships to production — lives here:
|
|
5
5
|
|
|
6
|
-
- the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `check` / `type-check` / `test` / `build`, plus `nk doctor`);
|
|
6
|
+
- the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `ast-grep` / `check` / `type-check` / `test` / `build`, plus `nk doctor`);
|
|
7
7
|
- the shared **oxlint + oxfmt**, **TypeScript**, and **Vitest** config;
|
|
8
8
|
- **knip** (unused dependency / export / file detection), bundled and run by `nk check`;
|
|
9
9
|
- the **oxfmt format-on-commit** git hook (`nextkit-format-staged`);
|
|
@@ -23,7 +23,7 @@ the whole stack instead of re-listing each tool per site.
|
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
bun add -d @ingram-tech/nk-dev
|
|
26
|
-
|
|
26
|
+
bun x nk init
|
|
27
27
|
bun install # the prepare script wires the git hook
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -64,7 +64,7 @@ Point your package.json scripts at it:
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
`nk` shells out to the site's own `
|
|
67
|
+
`nk` shells out to the site's own `bun x`-resolved tools (oxlint, oxfmt, Next,
|
|
68
68
|
tsc), so versions stay under each site's control — nk just orchestrates.
|
|
69
69
|
|
|
70
70
|
> **`nk` is optional.** It only orchestrates the standard commands; it never
|
|
@@ -92,6 +92,12 @@ tsc), so versions stay under each site's control — nk just orchestrates.
|
|
|
92
92
|
generated (drizzle migrations, `pg_dump` baselines, pglite fixtures).
|
|
93
93
|
- **`nk lint`** — `oxlint`.
|
|
94
94
|
- **`nk knip`** — `knip` (unused dependencies / exports / files).
|
|
95
|
+
- **`nk ast-grep [...]`** — structural search & rewrite of TS/TSX by AST pattern,
|
|
96
|
+
via the vendored [ast-grep](https://ast-grep.github.io) (args passed through to
|
|
97
|
+
it). For large mechanical refactors — import rewrites, API renames, call-shape
|
|
98
|
+
changes — instead of hand-editing or `sed`. The workflow (search → preview →
|
|
99
|
+
apply → `nk format` + `nk type-check`) and its syntactic-not-semantic limits
|
|
100
|
+
live in the codemod skill, `skills/ts-codemod.md`.
|
|
95
101
|
- **`nk check`** — `oxlint` + `oxfmt --check` + `knip` (only when the repo has a
|
|
96
102
|
knip config) + the agent-guide import gate. The CI gate; runs every checker and
|
|
97
103
|
reports them all before failing.
|
package/bin/format-staged.mjs
CHANGED
|
@@ -56,8 +56,8 @@ if (toFormat.length === 0) process.exit(0);
|
|
|
56
56
|
|
|
57
57
|
try {
|
|
58
58
|
execFileSync(
|
|
59
|
-
"
|
|
60
|
-
["oxfmt", "--write", "--no-error-on-unmatched-pattern", "--", ...toFormat],
|
|
59
|
+
"bun",
|
|
60
|
+
["x", "oxfmt", "--write", "--no-error-on-unmatched-pattern", "--", ...toFormat],
|
|
61
61
|
{ stdio: "inherit" },
|
|
62
62
|
);
|
|
63
63
|
} catch (err) {
|
package/bin/nk.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { astGrep } from "../lib/ast-grep.js";
|
|
2
3
|
import { dev } from "../lib/dev.js";
|
|
3
4
|
import { doctor } from "../lib/doctor.js";
|
|
4
5
|
import { format } from "../lib/format.js";
|
|
@@ -21,6 +22,9 @@ Commands:
|
|
|
21
22
|
format [--check] Format code with oxfmt. --check verifies without writing.
|
|
22
23
|
lint Lint with oxlint.
|
|
23
24
|
knip Find unused dependencies / exports / files with knip.
|
|
25
|
+
ast-grep [...] Structural search & rewrite of TS/TSX by AST pattern
|
|
26
|
+
(vendored ast-grep; args passed through). For large
|
|
27
|
+
mechanical refactors — see the codemod skill.
|
|
24
28
|
check The CI gate: lint + format verify + knip (when configured)
|
|
25
29
|
+ the agent-guide import gate.
|
|
26
30
|
type-check next typegen && tsc --noEmit.
|
|
@@ -51,6 +55,9 @@ switch (cmd) {
|
|
|
51
55
|
case "knip":
|
|
52
56
|
knip(rest);
|
|
53
57
|
break;
|
|
58
|
+
case "ast-grep":
|
|
59
|
+
astGrep(rest);
|
|
60
|
+
break;
|
|
54
61
|
case "check":
|
|
55
62
|
check();
|
|
56
63
|
break;
|
package/guide.md
CHANGED
|
@@ -6,9 +6,13 @@ package. Stay a thin, standard Next.js app (bun · oxlint + oxfmt · strict TS).
|
|
|
6
6
|
|
|
7
7
|
## Hard rules
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
- **Public contact/signup forms MUST use `@ingram-tech/nk-forms`** —
|
|
10
|
+
`handleFormSubmission` server-side (rate-limit → bot gate → validate →
|
|
11
|
+
escaped-email deliver → uniform 200) and `useFormSubmit` + `HoneypotInput`
|
|
12
|
+
client-side. It layers over `@ingram-tech/bot-protection`, which stays the
|
|
13
|
+
primitive for guarding *non-form* endpoints (a checkout, an authed route) —
|
|
14
|
+
call `checkBot` / `verifyHuman` directly there. Never ship a public form
|
|
15
|
+
without the bot gate.
|
|
12
16
|
- **Send email only via `@ingram-tech/nk-email`** — never add another mail client.
|
|
13
17
|
- **Never trust an external request body's shape — validate it with Zod, never
|
|
14
18
|
`as`-cast it.** Every `/api` route and webhook handler takes untrusted input;
|
|
@@ -74,6 +78,18 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
|
|
|
74
78
|
surfaces the real Postgres error and pre-flights journal drift. Generate **and
|
|
75
79
|
apply** in the same step; don't leave "run the migration" as a handoff.
|
|
76
80
|
|
|
81
|
+
## Large-scale / structural edits
|
|
82
|
+
|
|
83
|
+
For a **mechanical change repeated across many files** — rewrite an import,
|
|
84
|
+
rename an API, add a prop, reshape a call — don't hand-edit file by file or reach
|
|
85
|
+
for `sed`. Use **`nk ast-grep`** (`@ingram-tech/nk-dev`): AST-aware structural
|
|
86
|
+
search & rewrite of TS/TSX via the vendored ast-grep. **Before starting such a
|
|
87
|
+
refactor, read the skill at
|
|
88
|
+
`node_modules/@ingram-tech/nk-dev/skills/ts-codemod.md`** — it covers the
|
|
89
|
+
search → preview → apply → `nk format` + `nk type-check` workflow, pattern
|
|
90
|
+
syntax, and the syntactic-not-semantic limits (when to step up to a type-aware
|
|
91
|
+
tool instead). One-off single-file edits: just edit the file.
|
|
92
|
+
|
|
77
93
|
## What nextkit provides (reach for these)
|
|
78
94
|
|
|
79
95
|
- `@ingram-tech/nk-email` — Cloudflare email: `sendEmail`, `fromAddress`
|
|
@@ -81,8 +97,12 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
|
|
|
81
97
|
- `@ingram-tech/nk-db` — Postgres data layer: `createPool` (one TLS-aware pool) + `createQueries` (raw SQL) + `createDb` (Drizzle), the PGlite dev/test harness at `@ingram-tech/nk-db/pglite`, the prefixed-id codec at `@ingram-tech/nk-db/id`, and the drift-aware migration runner at `@ingram-tech/nk-db/migrate`
|
|
82
98
|
- `@ingram-tech/nk-api` — the standard HTTP API seam (Hono + `@hono/zod-openapi`): one `{ error, details? }` envelope, `createApiApp` / `createRouter`, auth + multi-tenant resource-scope middleware, pagination helpers, and an emitted OpenAPI/Swagger doc. Reach for it instead of hand-rolling route handlers
|
|
83
99
|
- `@ingram-tech/nk-billing` — Stripe primitives: subscriptions, a Stripe-side wallet, and an optional Postgres credit ledger behind the `/credits` subpath. Prices resolve at runtime by Stripe `lookup_key` — **never hardcode a price id**, so test and live share one code path
|
|
84
|
-
- `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID)
|
|
100
|
+
- `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID); the primitive nk-forms builds on, used directly only for non-form endpoints
|
|
101
|
+
- `@ingram-tech/nk-forms` — the public contact/signup submission pipeline over bot-protection + nk-email: `handleFormSubmission` (rate-limit → bot gate → validate → escaped-email deliver → uniform 200), `renderNotificationEmail`, `mintFormToken`, and `useFormSubmit` / `HoneypotInput` (`/react`). Reach for it instead of wiring bot-protection by hand
|
|
85
102
|
- `@ingram-tech/nk-i18n` — type-safe, English-as-key i18n: the English source text *is* the key (no `en.json`), ICU MessageFormat, colocated JSON catalogs; routing is left to the site
|
|
103
|
+
- `@ingram-tech/nk-marketing` — Postgres-backed marketing & lifecycle email: contacts + consent, newsletter broadcast audiences, and idempotent triggered campaigns, with RFC 8058 one-click unsubscribe
|
|
104
|
+
- `@ingram-tech/nk-seo` — SEO toolkit: metadata factory, JSON-LD builders, sitemap/robots routes, hreflang + canonical links, and an OG image template
|
|
105
|
+
- `@ingram-tech/nk-blog` — file-indexed blog engine: frontmatter contract, limited-MDX rendering with a component vocabulary, RSS, blog SEO, GitHub publishing
|
|
86
106
|
- `@ingram-tech/nk-dev` — the whole dev toolchain in one devDependency: the `nk` command (`nk dev` boots local PGlite via `@ingram-tech/nk-db` if installed, then Next; plus `nk format` / `lint` / `knip` / `check` / `type-check` / `test` / `build`), the shared oxlint + oxfmt / TypeScript / Vitest config, knip, the oxfmt format-on-commit hook, and this guide. `nk check` runs every fast checker (oxlint, oxfmt, knip) in one gate; `nk doctor --fix` reconciles a site back to the canonical toolchain. `nk init` scaffolds a site to use it all.
|
|
87
107
|
|
|
88
108
|
For detail on any package, read its README in `node_modules/@ingram-tech/<pkg>/`.
|
package/lib/ast-grep.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { fail } from "./run.js";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the ast-grep native binary that nk-dev pins (`@ast-grep/cli`).
|
|
9
|
+
*
|
|
10
|
+
* We can't just `bun x ast-grep`: ast-grep is a *transitive* dep here (under
|
|
11
|
+
* nk-dev), so its bin is never linked into the site's top-level node_modules/.bin
|
|
12
|
+
* — `bun x ast-grep` falls through to a global on PATH (or re-downloads). And the
|
|
13
|
+
* nested `.bin/ast-grep` that does exist points at `@ast-grep/cli`'s tiny JS
|
|
14
|
+
* launcher, not the native binary: the launcher's postinstall normally swaps
|
|
15
|
+
* itself for the binary, but Bun blocks postinstall for untrusted deps, so it
|
|
16
|
+
* stays JS and warns on every run. The launcher's own `resolveBinaryPath()`
|
|
17
|
+
* returns the exact platform binary regardless — the one entry point that's
|
|
18
|
+
* correct across both states and both OSes.
|
|
19
|
+
*/
|
|
20
|
+
function astGrepBinary() {
|
|
21
|
+
const { resolveBinaryPath } = require("@ast-grep/cli/postinstall.js");
|
|
22
|
+
const bin = resolveBinaryPath();
|
|
23
|
+
if (!bin) {
|
|
24
|
+
fail(
|
|
25
|
+
"located @ast-grep/cli but not its native binary — reinstall deps without `--no-optional`.",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return bin;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `nk ast-grep [...]` — run the ast-grep binary vendored by nk-dev (structural
|
|
33
|
+
* search & rewrite of TS/TSX by AST pattern). A thin passthrough: every arg goes
|
|
34
|
+
* to ast-grep, so its own `--help`, `run`, and `scan` subcommands work unchanged.
|
|
35
|
+
* See the codemod skill at `skills/ts-codemod.md` for the workflow.
|
|
36
|
+
*/
|
|
37
|
+
export function astGrep(extraArgs = []) {
|
|
38
|
+
let bin;
|
|
39
|
+
try {
|
|
40
|
+
bin = astGrepBinary();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
fail(
|
|
43
|
+
`could not resolve @ingram-tech/nk-dev's ast-grep (${err.message}) — reinstall your deps.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const res = spawnSync(bin, extraArgs, { stdio: "inherit" });
|
|
47
|
+
if (res.error) {
|
|
48
|
+
if (res.error.code === "ENOENT") fail(`ast-grep binary missing at ${bin}`);
|
|
49
|
+
throw res.error;
|
|
50
|
+
}
|
|
51
|
+
// A signal-killed child has status null — treat it as failure, not a pass.
|
|
52
|
+
process.exit(res.status ?? (res.signal ? 1 : 0));
|
|
53
|
+
}
|
package/lib/dev.js
CHANGED
|
@@ -39,7 +39,7 @@ export function dev(extraArgs = []) {
|
|
|
39
39
|
console.log("nk: @ingram-tech/nk-db found — booting local PGlite (no Docker)…");
|
|
40
40
|
}
|
|
41
41
|
// spawnSync inherits stdio and blocks until exit, so Ctrl-C reaches the child.
|
|
42
|
-
const res = spawnSync("
|
|
42
|
+
const res = spawnSync("bun", ["x", ...command], { stdio: "inherit" });
|
|
43
43
|
// Signal-killed (status null) is a failure, not a clean exit.
|
|
44
44
|
process.exit(res.status ?? (res.signal ? 1 : 0));
|
|
45
45
|
}
|
package/lib/init.js
CHANGED
|
@@ -50,6 +50,25 @@ const TSCONFIG = {
|
|
|
50
50
|
|
|
51
51
|
const VITEST_HINT = `import { mergeConfig } from "vitest/config";\\nimport { nextkitTestConfig } from "@ingram-tech/nk-dev/vitest";\\nexport default mergeConfig(nextkitTestConfig, {});`;
|
|
52
52
|
|
|
53
|
+
// The build-year env is the house fix for "current-year copyright" (and any
|
|
54
|
+
// once-per-deploy value): compute it once here in Node, and Next inlines the
|
|
55
|
+
// literal into both bundles — so nothing reads the clock at render. See
|
|
56
|
+
// docs/code-style.md ("Never read the clock ... at render in a Client
|
|
57
|
+
// Component"). The line to add to an existing next.config's top-level object:
|
|
58
|
+
const BUILD_YEAR_ENV =
|
|
59
|
+
"env: { NEXT_PUBLIC_BUILD_YEAR: String(new Date().getFullYear()) },";
|
|
60
|
+
const NEXT_CONFIG = `import type { NextConfig } from "next";
|
|
61
|
+
|
|
62
|
+
const nextConfig: NextConfig = {
|
|
63
|
+
// Inlined at build so once-per-deploy values (e.g. the copyright year) are a
|
|
64
|
+
// literal in both bundles — never read the clock at a Client Component's
|
|
65
|
+
// render. See docs/code-style.md.
|
|
66
|
+
${BUILD_YEAR_ENV}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default nextConfig;
|
|
70
|
+
`;
|
|
71
|
+
|
|
53
72
|
// knip has no shareable config, so each site carries its own seed. The house
|
|
54
73
|
// policy: gate on dependency/file hygiene (unused files/deps, unlisted,
|
|
55
74
|
// unresolved) — its low-false-positive checks — and turn OFF unused
|
|
@@ -77,7 +96,7 @@ const PRE_COMMIT = `#!/bin/sh
|
|
|
77
96
|
# nextkit pre-commit: format staged files with oxfmt, then re-stage them.
|
|
78
97
|
# Logic lives in @ingram-tech/nk-dev, so a version bump updates it everywhere.
|
|
79
98
|
set -eu
|
|
80
|
-
exec
|
|
99
|
+
exec bun x --bun nextkit-format-staged
|
|
81
100
|
`;
|
|
82
101
|
|
|
83
102
|
const GUIDE_IMPORT = "@./node_modules/@ingram-tech/nk-dev/guide.md";
|
|
@@ -102,20 +121,24 @@ export function init() {
|
|
|
102
121
|
// 3. TypeScript config.
|
|
103
122
|
writeIfAbsent(resolve(cwd, "tsconfig.json"), (f) => writeJson(f, TSCONFIG));
|
|
104
123
|
|
|
105
|
-
// 4.
|
|
124
|
+
// 4. next.config with the build-year env. Written only when absent; an
|
|
125
|
+
// existing config is never edited — we hint instead (see the function).
|
|
126
|
+
ensureNextConfig(cwd);
|
|
127
|
+
|
|
128
|
+
// 5. Vitest config — only hint, never auto-write. Many sites test with
|
|
106
129
|
// `bun:test` rather than Vitest, and an unused vitest.config.ts is noise.
|
|
107
130
|
hintVitestConfig(cwd);
|
|
108
131
|
|
|
109
|
-
//
|
|
132
|
+
// 6. knip config (unused deps/exports/files; run by `nk check`).
|
|
110
133
|
writeIfAbsent(resolve(cwd, "knip.json"), (f) => writeJson(f, KNIP));
|
|
111
134
|
|
|
112
|
-
//
|
|
135
|
+
// 7. Format-on-commit hook + git wiring.
|
|
113
136
|
setupGitHook(cwd);
|
|
114
137
|
|
|
115
|
-
//
|
|
138
|
+
// 8. Make sure the agent guide is imported into CLAUDE.md.
|
|
116
139
|
ensureGuideImport(cwd);
|
|
117
140
|
|
|
118
|
-
//
|
|
141
|
+
// 9. A `prepare` script so the hook re-wires itself on every `bun install`.
|
|
119
142
|
ensurePrepareScript(cwd);
|
|
120
143
|
|
|
121
144
|
log("done. Next: `bun install`, then `nk check`.");
|
|
@@ -165,6 +188,26 @@ function hintVitestConfig(cwd) {
|
|
|
165
188
|
console.log(` ${VITEST_HINT.replace(/\\n/g, "\n ")}`);
|
|
166
189
|
}
|
|
167
190
|
|
|
191
|
+
// next.config is a Next.js file init doesn't own the contents of, so we never
|
|
192
|
+
// rewrite an existing one (that's the site's config). Write a minimal typed
|
|
193
|
+
// config when none exists; otherwise hint the one env line to add.
|
|
194
|
+
function ensureNextConfig(cwd) {
|
|
195
|
+
const existing = ["next.config.ts", "next.config.mjs", "next.config.js"]
|
|
196
|
+
.map((name) => resolve(cwd, name))
|
|
197
|
+
.find((p) => existsSync(p));
|
|
198
|
+
if (!existing) {
|
|
199
|
+
writeFileSync(resolve(cwd, "next.config.ts"), NEXT_CONFIG);
|
|
200
|
+
log("wrote next.config.ts (with the NEXT_PUBLIC_BUILD_YEAR build env)");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (readFileSync(existing, "utf8").includes("NEXT_PUBLIC_BUILD_YEAR")) {
|
|
204
|
+
log("next.config already sets NEXT_PUBLIC_BUILD_YEAR.");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
log("next.config exists — add the build-year env to its config object:");
|
|
208
|
+
console.log(` ${BUILD_YEAR_ENV}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
168
211
|
function ensurePrepareScript(cwd) {
|
|
169
212
|
const pkgPath = resolve(cwd, "package.json");
|
|
170
213
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: catch `event.currentTarget` reads that
|
|
2
|
+
// outlive the event handler.
|
|
3
|
+
//
|
|
4
|
+
// The trap: React nulls a synthetic event's `currentTarget` once the handler
|
|
5
|
+
// returns — it is per-dispatch state, reassigned as the one event object
|
|
6
|
+
// traverses the propagation path (mirroring the DOM spec, where currentTarget
|
|
7
|
+
// is only defined during dispatch). Reading it inside a callback that runs
|
|
8
|
+
// after the handler — a functional setState updater, setTimeout, a promise
|
|
9
|
+
// chain, a debounced closure — crashes with "Cannot read properties of null".
|
|
10
|
+
// The failure is intermittent: React evaluates a setState updater eagerly when
|
|
11
|
+
// the queue is empty, so the first keystroke works and only the replayed or
|
|
12
|
+
// queued case crashes.
|
|
13
|
+
//
|
|
14
|
+
// tsc CANNOT catch this class: @types/react declares `currentTarget` non-null
|
|
15
|
+
// (true during dispatch), and the nulling is a temporal invariant the type
|
|
16
|
+
// system cannot express. Worse, `currentTarget` is the better-typed accessor
|
|
17
|
+
// (typed as the element the handler is attached to, unlike `target`), so
|
|
18
|
+
// TS-first code is steered toward exactly the property that expires. React 16
|
|
19
|
+
// warned at runtime on any post-handler event access (event pooling); React 17
|
|
20
|
+
// removed pooling and the warning with it, leaving `currentTarget` as the one
|
|
21
|
+
// silently expiring property. Hence a lint rule.
|
|
22
|
+
//
|
|
23
|
+
// Detection: report `x.currentTarget` where `x` is bound as a parameter by an
|
|
24
|
+
// ENCLOSING function other than the innermost one — i.e. the read crosses a
|
|
25
|
+
// closure boundary out of the handler. Locals declared in the current function
|
|
26
|
+
// (including a captured `const target = event.currentTarget` in the handler
|
|
27
|
+
// body, which is the fix) never report.
|
|
28
|
+
//
|
|
29
|
+
// Known false positive: a closure the handler invokes synchronously itself
|
|
30
|
+
// (e.g. `items.map((it) => event.currentTarget...)`). That pattern is rare and
|
|
31
|
+
// fragile anyway; prefer capturing first, or suppress with a justified
|
|
32
|
+
// oxlint-disable comment.
|
|
33
|
+
|
|
34
|
+
const collectParamBindings = (params, into) => {
|
|
35
|
+
for (const param of params) {
|
|
36
|
+
if (param.type === "Identifier") into.add(param.name);
|
|
37
|
+
else if (
|
|
38
|
+
param.type === "AssignmentPattern" &&
|
|
39
|
+
param.left.type === "Identifier"
|
|
40
|
+
) {
|
|
41
|
+
into.add(param.left.name);
|
|
42
|
+
} else if (
|
|
43
|
+
param.type === "RestElement" &&
|
|
44
|
+
param.argument.type === "Identifier"
|
|
45
|
+
) {
|
|
46
|
+
into.add(param.argument.name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const noDeferredCurrentTarget = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: "problem",
|
|
54
|
+
docs: {
|
|
55
|
+
description:
|
|
56
|
+
"Disallow reading `event.currentTarget` inside a callback nested in the event handler; React nulls `currentTarget` after dispatch.",
|
|
57
|
+
},
|
|
58
|
+
messages: {
|
|
59
|
+
deferred:
|
|
60
|
+
"`{{name}}.currentTarget` is read inside a callback nested in the event handler. React nulls `currentTarget` once the handler returns, so this can crash when the callback runs later (e.g. a replayed setState updater). Capture the value into a local in the handler body and use that instead.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
create(context) {
|
|
64
|
+
const scopes = [];
|
|
65
|
+
const enterFunction = (node) => {
|
|
66
|
+
const bindings = new Set();
|
|
67
|
+
collectParamBindings(node.params, bindings);
|
|
68
|
+
scopes.push(bindings);
|
|
69
|
+
};
|
|
70
|
+
const exitFunction = () => {
|
|
71
|
+
scopes.pop();
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
FunctionDeclaration: enterFunction,
|
|
75
|
+
"FunctionDeclaration:exit": exitFunction,
|
|
76
|
+
FunctionExpression: enterFunction,
|
|
77
|
+
"FunctionExpression:exit": exitFunction,
|
|
78
|
+
ArrowFunctionExpression: enterFunction,
|
|
79
|
+
"ArrowFunctionExpression:exit": exitFunction,
|
|
80
|
+
VariableDeclarator(node) {
|
|
81
|
+
// Track locals so a variable declared in the current function
|
|
82
|
+
// (including a rebound `event`) never reports.
|
|
83
|
+
if (scopes.length > 0 && node.id.type === "Identifier") {
|
|
84
|
+
scopes[scopes.length - 1].add(node.id.name);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
MemberExpression(node) {
|
|
88
|
+
if (node.computed) return;
|
|
89
|
+
if (
|
|
90
|
+
node.property.type !== "Identifier" ||
|
|
91
|
+
node.property.name !== "currentTarget"
|
|
92
|
+
) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (node.object.type !== "Identifier") return;
|
|
96
|
+
if (scopes.length < 2) return;
|
|
97
|
+
const name = node.object.name;
|
|
98
|
+
if (scopes[scopes.length - 1].has(name)) return;
|
|
99
|
+
if (!scopes.slice(0, -1).some((frame) => frame.has(name))) return;
|
|
100
|
+
context.report({ node, messageId: "deferred", data: { name } });
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export default {
|
|
107
|
+
meta: { name: "nextkit" },
|
|
108
|
+
rules: { "no-deferred-current-target": noDeferredCurrentTarget },
|
|
109
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The `nextkit` oxlint JS plugin: one rule per file, merged here. This is the
|
|
2
|
+
// module `@ingram-tech/nk-dev/oxlint-plugin` resolves to and the shared
|
|
3
|
+
// oxlintrc.json loads via `jsPlugins`.
|
|
4
|
+
|
|
5
|
+
import baseUi from "./base-ui.js";
|
|
6
|
+
import deferredCurrentTarget from "./deferred-current-target.js";
|
|
7
|
+
import lucideIconSuffix from "./lucide-icon-suffix.js";
|
|
8
|
+
import noRedirectOnlyPage from "./no-redirect-only-page.js";
|
|
9
|
+
import redundantUseStateType from "./redundant-usestate-type.js";
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: { name: "nextkit" },
|
|
13
|
+
rules: {
|
|
14
|
+
...baseUi.rules,
|
|
15
|
+
...deferredCurrentTarget.rules,
|
|
16
|
+
...lucideIconSuffix.rules,
|
|
17
|
+
...noRedirectOnlyPage.rules,
|
|
18
|
+
...redundantUseStateType.rules,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: enforce the `Icon` suffix on lucide-react
|
|
2
|
+
// icon imports.
|
|
3
|
+
//
|
|
4
|
+
// lucide-react now ships every icon under an `Icon`-suffixed name (`HomeIcon`,
|
|
5
|
+
// `ArrowRightIcon`, ...) and has deprecated the bare aliases (`Home`,
|
|
6
|
+
// `ArrowRight`). Standardizing on the suffixed names keeps imports off the
|
|
7
|
+
// deprecated path and makes an icon obvious at the use site: `<TrashIcon />`
|
|
8
|
+
// reads as an icon, `<Trash />` reads like a domain component.
|
|
9
|
+
//
|
|
10
|
+
// The rule only fires on imports from "lucide-react", so it is inert on sites
|
|
11
|
+
// that do not use lucide — no package.json gate needed (unlike the base-ui
|
|
12
|
+
// rule, which keys off a component name that also exists in Radix). The
|
|
13
|
+
// package's non-icon named exports (`LucideProps`, `IconNode`, `icons`,
|
|
14
|
+
// `dynamicIconImports`) are skipped so the autofix never renames them to
|
|
15
|
+
// nonsense like `LucidePropsIcon`.
|
|
16
|
+
//
|
|
17
|
+
// Autofix renames the import specifier and, when the local name is not aliased,
|
|
18
|
+
// every reference to it in the file.
|
|
19
|
+
|
|
20
|
+
const NON_ICON_EXPORTS = new Set([
|
|
21
|
+
"LucideProps",
|
|
22
|
+
"IconNode",
|
|
23
|
+
"icons",
|
|
24
|
+
"dynamicIconImports",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const lucideIconSuffix = {
|
|
28
|
+
meta: {
|
|
29
|
+
type: "suggestion",
|
|
30
|
+
docs: {
|
|
31
|
+
description: "Enforce the Icon suffix on lucide-react imports",
|
|
32
|
+
},
|
|
33
|
+
fixable: "code",
|
|
34
|
+
messages: {
|
|
35
|
+
missingIconSuffix:
|
|
36
|
+
"Import `{{imported}}` from lucide-react must use the Icon suffix: `{{suggested}}`.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
const sourceCode = context.sourceCode;
|
|
41
|
+
return {
|
|
42
|
+
ImportDeclaration(node) {
|
|
43
|
+
if (node.source.value !== "lucide-react") return;
|
|
44
|
+
for (const specifier of node.specifiers) {
|
|
45
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
46
|
+
if (specifier.imported.type !== "Identifier") continue;
|
|
47
|
+
|
|
48
|
+
const importedName = specifier.imported.name;
|
|
49
|
+
if (
|
|
50
|
+
importedName.endsWith("Icon") ||
|
|
51
|
+
NON_ICON_EXPORTS.has(importedName)
|
|
52
|
+
) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const suggested = `${importedName}Icon`;
|
|
57
|
+
const notAliased = specifier.imported.name === specifier.local.name;
|
|
58
|
+
context.report({
|
|
59
|
+
node: specifier,
|
|
60
|
+
messageId: "missingIconSuffix",
|
|
61
|
+
data: { imported: importedName, suggested },
|
|
62
|
+
fix(fixer) {
|
|
63
|
+
const fixes = [
|
|
64
|
+
fixer.replaceText(specifier.imported, suggested),
|
|
65
|
+
];
|
|
66
|
+
if (notAliased) {
|
|
67
|
+
for (const variable of sourceCode.getDeclaredVariables(
|
|
68
|
+
node,
|
|
69
|
+
)) {
|
|
70
|
+
if (variable.name !== specifier.local.name) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const ref of variable.references) {
|
|
74
|
+
if (ref.identifier !== specifier.local) {
|
|
75
|
+
fixes.push(
|
|
76
|
+
fixer.replaceText(
|
|
77
|
+
ref.identifier,
|
|
78
|
+
suggested,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return fixes;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default {
|
|
95
|
+
meta: { name: "nextkit" },
|
|
96
|
+
rules: { "lucide-icon-suffix": lucideIconSuffix },
|
|
97
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: flag App Router `page.tsx` files whose only
|
|
2
|
+
// job is to call `redirect(...)`.
|
|
3
|
+
//
|
|
4
|
+
// A page that renders nothing and immediately redirects still costs a route
|
|
5
|
+
// match, a server component render, and a `redirect()` throw on every request.
|
|
6
|
+
// The same hop belongs in `next.config`'s `redirects()` array, where it is
|
|
7
|
+
// handled at the routing layer before any rendering — cheaper and cacheable.
|
|
8
|
+
//
|
|
9
|
+
// This is a heuristic suggestion, not a correctness rule: it fires only when the
|
|
10
|
+
// page body is a bare `redirect(...)` (optionally with a leading variable or
|
|
11
|
+
// return) AND the destination is a statically extractable string / simple
|
|
12
|
+
// template, so the equivalent config entry can be shown. Anything with real
|
|
13
|
+
// logic is left alone. Scope is limited to `**/page.tsx`.
|
|
14
|
+
//
|
|
15
|
+
// Ported from the upstream Ingram ESLint rule, de-noised: that version emitted
|
|
16
|
+
// two diagnostics per hit (the finding and a separate example); this emits one
|
|
17
|
+
// with the config snippet inlined.
|
|
18
|
+
|
|
19
|
+
const noRedirectOnlyPage = {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "suggestion",
|
|
22
|
+
docs: {
|
|
23
|
+
description:
|
|
24
|
+
"Prefer next.config redirects over pages that only call redirect()",
|
|
25
|
+
},
|
|
26
|
+
messages: {
|
|
27
|
+
useConfigRedirect:
|
|
28
|
+
'This page only performs a redirect. Prefer a next.config redirect: add `{ source: "{{source}}", destination: "{{destination}}", permanent: false }` to `redirects()` — it bounces at the routing layer instead of rendering a page to do it.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
const filename = context.physicalFilename || context.filename || "";
|
|
33
|
+
if (!filename.endsWith("/page.tsx")) return {};
|
|
34
|
+
|
|
35
|
+
let hasRedirectCall = false;
|
|
36
|
+
let redirectDestination = null;
|
|
37
|
+
let isSimpleRedirect = true;
|
|
38
|
+
let redirectNode = null;
|
|
39
|
+
|
|
40
|
+
// Turn the file path into the route it serves: strip to the segment
|
|
41
|
+
// after `src/app`, drop `(group)` folders, map `[param]` to `:param`.
|
|
42
|
+
const getSourcePath = () => {
|
|
43
|
+
const match = filename.match(/src\/app(.*)\/page\.tsx$/);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
let path = match[1].replace(/\/\([^)]+\)/g, "");
|
|
46
|
+
if (!path) return "/";
|
|
47
|
+
return path.replace(/\[([^\]]+)\]/g, ":$1");
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// True when a function body is nothing but a `redirect(...)` call,
|
|
51
|
+
// optionally preceded by a variable declaration or an early return.
|
|
52
|
+
const isOnlyRedirect = (body) => {
|
|
53
|
+
if (!body) return false;
|
|
54
|
+
|
|
55
|
+
if (body.type === "BlockStatement") {
|
|
56
|
+
const statements = body.body.filter(
|
|
57
|
+
(stmt) => stmt.type !== "EmptyStatement",
|
|
58
|
+
);
|
|
59
|
+
if (statements.length === 0 || statements.length > 2) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
for (const stmt of statements) {
|
|
63
|
+
if (stmt.type === "ExpressionStatement") {
|
|
64
|
+
const expr = stmt.expression;
|
|
65
|
+
if (
|
|
66
|
+
expr.type === "CallExpression" &&
|
|
67
|
+
expr.callee.type === "Identifier" &&
|
|
68
|
+
expr.callee.name === "redirect"
|
|
69
|
+
) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
stmt.type === "VariableDeclaration" ||
|
|
76
|
+
stmt.type === "ReturnStatement"
|
|
77
|
+
) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Arrow function with a direct `redirect(...)` expression body.
|
|
86
|
+
return (
|
|
87
|
+
body.type === "CallExpression" &&
|
|
88
|
+
body.callee.type === "Identifier" &&
|
|
89
|
+
body.callee.name === "redirect"
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Pull a static destination out of the first `redirect()` argument, or
|
|
94
|
+
// null when it is dynamic in a way we cannot render as a config entry.
|
|
95
|
+
const extractDestination = (node) => {
|
|
96
|
+
if (!node.arguments || node.arguments.length === 0) return null;
|
|
97
|
+
const firstArg = node.arguments[0];
|
|
98
|
+
|
|
99
|
+
if (firstArg.type === "Literal" && typeof firstArg.value === "string") {
|
|
100
|
+
return firstArg.value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (firstArg.type === "TemplateLiteral") {
|
|
104
|
+
let result = "";
|
|
105
|
+
for (let i = 0; i < firstArg.quasis.length; i++) {
|
|
106
|
+
result += firstArg.quasis[i].value.raw;
|
|
107
|
+
if (i < firstArg.expressions.length) {
|
|
108
|
+
const expr = firstArg.expressions[i];
|
|
109
|
+
if (expr.type === "Identifier") {
|
|
110
|
+
result += `:${expr.name}`;
|
|
111
|
+
} else if (
|
|
112
|
+
expr.type === "AwaitExpression" &&
|
|
113
|
+
expr.argument &&
|
|
114
|
+
expr.argument.type === "CallExpression"
|
|
115
|
+
) {
|
|
116
|
+
result += ":id";
|
|
117
|
+
} else {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Any component whose body is more than a bare redirect disqualifies the
|
|
129
|
+
// page. Covers all page shapes — `const XxxPage = () => {}`,
|
|
130
|
+
// `export default function Page() {}`, `export default () => {}` — so a
|
|
131
|
+
// function-declaration page with real logic is not falsely flagged.
|
|
132
|
+
const disqualifyIfComplex = (fn) => {
|
|
133
|
+
if (
|
|
134
|
+
fn &&
|
|
135
|
+
(fn.type === "ArrowFunctionExpression" ||
|
|
136
|
+
fn.type === "FunctionExpression" ||
|
|
137
|
+
fn.type === "FunctionDeclaration") &&
|
|
138
|
+
!isOnlyRedirect(fn.body)
|
|
139
|
+
) {
|
|
140
|
+
isSimpleRedirect = false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
VariableDeclarator(node) {
|
|
146
|
+
if (node.id.type === "Identifier" && node.id.name.endsWith("Page")) {
|
|
147
|
+
disqualifyIfComplex(node.init);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
FunctionDeclaration(node) {
|
|
152
|
+
if (node.id && node.id.name.endsWith("Page")) {
|
|
153
|
+
disqualifyIfComplex(node);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
ExportDefaultDeclaration(node) {
|
|
158
|
+
disqualifyIfComplex(node.declaration);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
CallExpression(node) {
|
|
162
|
+
if (
|
|
163
|
+
node.callee.type === "Identifier" &&
|
|
164
|
+
node.callee.name === "redirect"
|
|
165
|
+
) {
|
|
166
|
+
hasRedirectCall = true;
|
|
167
|
+
redirectNode = node;
|
|
168
|
+
redirectDestination = extractDestination(node);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
"Program:exit"() {
|
|
173
|
+
if (
|
|
174
|
+
!hasRedirectCall ||
|
|
175
|
+
!isSimpleRedirect ||
|
|
176
|
+
!redirectDestination ||
|
|
177
|
+
!redirectNode
|
|
178
|
+
) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const source = getSourcePath();
|
|
182
|
+
if (!source) return;
|
|
183
|
+
context.report({
|
|
184
|
+
node: redirectNode,
|
|
185
|
+
messageId: "useConfigRedirect",
|
|
186
|
+
data: { source, destination: redirectDestination },
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export default {
|
|
194
|
+
meta: { name: "nextkit" },
|
|
195
|
+
rules: { "no-redirect-only-page": noRedirectOnlyPage },
|
|
196
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: strip redundant `useState<T>` type arguments
|
|
2
|
+
// that TypeScript already infers from the initial value.
|
|
3
|
+
//
|
|
4
|
+
// `useState<boolean>(false)`, `useState<string>("")`, `useState<number>(0)`
|
|
5
|
+
// each annotate exactly the type React infers from the literal — pure noise.
|
|
6
|
+
// `useState<number | undefined>(undefined)` is the same story spelled with a
|
|
7
|
+
// union: `useState<number>()` (no argument) yields the identical
|
|
8
|
+
// `[number | undefined, ...]` tuple with the identical `undefined` initial
|
|
9
|
+
// value, so the union + explicit `undefined` argument are redundant too.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately NARROWER than the upstream Ingram ESLint rule this is ported
|
|
12
|
+
// from, on the two cases where that rule changed behavior instead of removing
|
|
13
|
+
// redundancy:
|
|
14
|
+
// - No `null` handling. `useState<string | null>(null)` -> `useState<string>()`
|
|
15
|
+
// is a runtime change (the initial value becomes `undefined`); an autofix
|
|
16
|
+
// must never do that. Whether to prefer `undefined` over `null` is a style
|
|
17
|
+
// call this rule does not make.
|
|
18
|
+
// - No array handling. `useState<string[]>([])` is NOT redundant: `useState([])`
|
|
19
|
+
// infers `never[]`, not `string[]`, so the annotation is load-bearing. The
|
|
20
|
+
// upstream rule stripped it and silently broadened the state to `never[]`.
|
|
21
|
+
//
|
|
22
|
+
// tsc does not flag redundant annotations, so this is a lint-only cleanup; every
|
|
23
|
+
// reported case is autofixable and behavior-preserving.
|
|
24
|
+
|
|
25
|
+
const noRedundantUseStateType = {
|
|
26
|
+
meta: {
|
|
27
|
+
type: "suggestion",
|
|
28
|
+
docs: {
|
|
29
|
+
description:
|
|
30
|
+
"Disallow redundant useState type arguments that are inferable from the initial value",
|
|
31
|
+
},
|
|
32
|
+
fixable: "code",
|
|
33
|
+
messages: {
|
|
34
|
+
redundantSimpleType:
|
|
35
|
+
"Redundant `useState` type argument: `{{type}}` is already inferred from the initial value.",
|
|
36
|
+
redundantUndefinedUnion:
|
|
37
|
+
"Redundant `| undefined` in `useState` type: use `useState<{{baseType}}>()` instead of `useState<{{baseType}} | undefined>(undefined)`.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
const sourceCode = context.sourceCode;
|
|
42
|
+
|
|
43
|
+
// Remove the whole `<...>` type-argument list, brackets included, by
|
|
44
|
+
// deleting the span between the callee token and the opening `(`.
|
|
45
|
+
const removeTypeArguments = (fixer, typeArguments) => {
|
|
46
|
+
const before = sourceCode.getTokenBefore(typeArguments);
|
|
47
|
+
const after = sourceCode.getTokenAfter(typeArguments);
|
|
48
|
+
return fixer.removeRange([before.range[1], after.range[0]]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
CallExpression(node) {
|
|
53
|
+
if (
|
|
54
|
+
node.callee.type !== "Identifier" ||
|
|
55
|
+
node.callee.name !== "useState"
|
|
56
|
+
) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const typeArguments = node.typeArguments;
|
|
61
|
+
if (!typeArguments || typeArguments.params.length === 0) return;
|
|
62
|
+
|
|
63
|
+
const typeParam = typeArguments.params[0];
|
|
64
|
+
const argument = node.arguments[0];
|
|
65
|
+
|
|
66
|
+
// Case 1: a scalar keyword type whose literal initial value
|
|
67
|
+
// infers exactly that type. Arrays are excluded on purpose (see
|
|
68
|
+
// the header): `useState<string[]>([])` is not redundant.
|
|
69
|
+
if (argument) {
|
|
70
|
+
const typeText = sourceCode.getText(typeParam);
|
|
71
|
+
const redundant =
|
|
72
|
+
(typeText === "boolean" &&
|
|
73
|
+
argument.type === "Literal" &&
|
|
74
|
+
typeof argument.value === "boolean") ||
|
|
75
|
+
(typeText === "string" &&
|
|
76
|
+
argument.type === "Literal" &&
|
|
77
|
+
typeof argument.value === "string") ||
|
|
78
|
+
(typeText === "number" &&
|
|
79
|
+
argument.type === "Literal" &&
|
|
80
|
+
typeof argument.value === "number") ||
|
|
81
|
+
(typeText === "undefined" &&
|
|
82
|
+
argument.type === "Identifier" &&
|
|
83
|
+
argument.name === "undefined");
|
|
84
|
+
|
|
85
|
+
if (redundant) {
|
|
86
|
+
context.report({
|
|
87
|
+
node: typeArguments,
|
|
88
|
+
messageId: "redundantSimpleType",
|
|
89
|
+
data: { type: typeText },
|
|
90
|
+
fix: (fixer) => removeTypeArguments(fixer, typeArguments),
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Case 2: `T | undefined` with an explicit `undefined` initial
|
|
97
|
+
// value collapses to `useState<T>()`.
|
|
98
|
+
if (
|
|
99
|
+
typeParam.type === "TSUnionType" &&
|
|
100
|
+
argument &&
|
|
101
|
+
argument.type === "Identifier" &&
|
|
102
|
+
argument.name === "undefined"
|
|
103
|
+
) {
|
|
104
|
+
const rest = typeParam.types.filter(
|
|
105
|
+
(t) => t.type !== "TSUndefinedKeyword",
|
|
106
|
+
);
|
|
107
|
+
const undefined_ = typeParam.types.filter(
|
|
108
|
+
(t) => t.type === "TSUndefinedKeyword",
|
|
109
|
+
);
|
|
110
|
+
if (rest.length === 1 && undefined_.length === 1) {
|
|
111
|
+
const baseText = sourceCode.getText(rest[0]);
|
|
112
|
+
context.report({
|
|
113
|
+
node,
|
|
114
|
+
messageId: "redundantUndefinedUnion",
|
|
115
|
+
data: { baseType: baseText },
|
|
116
|
+
fix(fixer) {
|
|
117
|
+
const fixes = [fixer.replaceText(typeParam, baseText)];
|
|
118
|
+
if (node.arguments.length === 1) {
|
|
119
|
+
const openParen =
|
|
120
|
+
sourceCode.getTokenAfter(typeArguments);
|
|
121
|
+
const closeParen =
|
|
122
|
+
sourceCode.getTokenAfter(argument);
|
|
123
|
+
fixes.push(
|
|
124
|
+
fixer.replaceTextRange(
|
|
125
|
+
[openParen.range[0], closeParen.range[1]],
|
|
126
|
+
"()",
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return fixes;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Case 3: bare `useState<undefined>()` with no initial value.
|
|
138
|
+
if (
|
|
139
|
+
typeParam.type === "TSUndefinedKeyword" &&
|
|
140
|
+
node.arguments.length === 0
|
|
141
|
+
) {
|
|
142
|
+
context.report({
|
|
143
|
+
node: typeArguments,
|
|
144
|
+
messageId: "redundantSimpleType",
|
|
145
|
+
data: { type: "undefined" },
|
|
146
|
+
fix: (fixer) => removeTypeArguments(fixer, typeArguments),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default {
|
|
155
|
+
meta: { name: "nextkit" },
|
|
156
|
+
rules: { "no-redundant-usestate-type": noRedundantUseStateType },
|
|
157
|
+
};
|
package/lib/run.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Run a site-local tool through `
|
|
4
|
+
* Run a site-local tool through `bun x` (resolves node_modules/.bin first) with
|
|
5
5
|
* inherited stdio. Returns the exit code; never throws on a non-zero exit.
|
|
6
|
+
*
|
|
7
|
+
* We spawn `bun x` rather than the `bunx` shim: on some installs (notably
|
|
8
|
+
* Windows and Git's bundled sh) only `bun` lands on PATH, and `bunx` is an
|
|
9
|
+
* alias for `bun x`, so this form works in a strict superset of environments.
|
|
6
10
|
*/
|
|
7
11
|
export function run(tool, args = [], opts = {}) {
|
|
8
|
-
const res = spawnSync("
|
|
12
|
+
const res = spawnSync("bun", ["x", tool, ...args], { stdio: "inherit", ...opts });
|
|
9
13
|
if (res.error) {
|
|
10
14
|
if (res.error.code === "ENOENT") {
|
|
11
|
-
fail("could not run `
|
|
15
|
+
fail("could not run `bun` — is bun installed and on PATH?");
|
|
12
16
|
}
|
|
13
17
|
throw res.error;
|
|
14
18
|
}
|
package/oxlintrc.json
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
},
|
|
7
7
|
"rules": {
|
|
8
8
|
"nextkit/no-radix-props-on-base-ui": "error",
|
|
9
|
+
"nextkit/no-deferred-current-target": "error",
|
|
10
|
+
"nextkit/no-redundant-usestate-type": "warn",
|
|
11
|
+
"nextkit/lucide-icon-suffix": "warn",
|
|
12
|
+
"nextkit/no-redirect-only-page": "warn",
|
|
9
13
|
"no-unused-vars": "warn",
|
|
10
14
|
"typescript/no-non-null-assertion": "error",
|
|
11
15
|
"typescript/no-explicit-any": "error",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ingram-tech/nk-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The nextkit dev toolchain in one package: the `nk` CLI plus shared oxlint/oxfmt, TypeScript, and Vitest config, the format-on-commit hook, and the AI agent guide. `nk init` scaffolds a site to use it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"bin",
|
|
17
17
|
"lib",
|
|
18
|
+
"skills",
|
|
18
19
|
"tsconfig",
|
|
19
20
|
"vitest",
|
|
20
21
|
"oxlintrc.json",
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"./oxlintrc.json": "./oxlintrc.json",
|
|
31
32
|
"./oxfmtrc.json": "./oxfmtrc.json",
|
|
32
33
|
"./tier-b.json": "./tier-b.json",
|
|
33
|
-
"./oxlint-plugin": "./lib/oxlint-plugins/
|
|
34
|
+
"./oxlint-plugin": "./lib/oxlint-plugins/index.js",
|
|
34
35
|
"./tsconfig": "./tsconfig/nextjs.json",
|
|
35
36
|
"./tsconfig/base.json": "./tsconfig/base.json",
|
|
36
37
|
"./tsconfig/nextjs.json": "./tsconfig/nextjs.json",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"test": "vitest run"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
48
|
+
"@ast-grep/cli": "^0.44.1",
|
|
47
49
|
"@testing-library/jest-dom": "^6.9.1",
|
|
48
50
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
49
51
|
"jsdom": "^29.1.1",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Skill: large-scale TS/TSX codemods with `nk ast-grep`
|
|
2
|
+
|
|
3
|
+
You're an AI agent working in a **nextkit** site. When a change is **mechanical
|
|
4
|
+
and repeats across many files** — rename an API, rewrite an import, add a prop,
|
|
5
|
+
swap a call shape — don't hand-edit file by file and don't sed. Reach for
|
|
6
|
+
`nk ast-grep`: the [ast-grep](https://ast-grep.github.io) binary vendored by
|
|
7
|
+
`@ingram-tech/nk-dev`, which matches and rewrites by **syntax tree**, so it
|
|
8
|
+
respects TS/TSX structure instead of guessing with regex.
|
|
9
|
+
|
|
10
|
+
`nk ast-grep` is a thin passthrough — every argument goes straight to `ast-grep`,
|
|
11
|
+
so its own `--help` / `run` / `scan` subcommands and docs all apply.
|
|
12
|
+
|
|
13
|
+
## When to use it (and when not to)
|
|
14
|
+
|
|
15
|
+
Use it for **syntactic, pattern-shaped** edits repeated at scale:
|
|
16
|
+
|
|
17
|
+
- rewrite every `import { x } from "old"` → `"@ingram-tech/new"`
|
|
18
|
+
- rename a function/method across the codebase (`foo(...)` → `bar(...)`)
|
|
19
|
+
- add/rename/remove a prop on a component or an option on a call
|
|
20
|
+
- change a call's argument shape (positional → options object, etc.)
|
|
21
|
+
|
|
22
|
+
Do **not** use it when the change needs **type information or semantics** —
|
|
23
|
+
"rename this symbol only where it refers to *this* declaration", "update every
|
|
24
|
+
caller whose argument is a `User`". ast-grep sees syntax, not types, so it can't
|
|
25
|
+
tell two identically-named things apart. For type-driven refactors use the TS
|
|
26
|
+
language service (editor rename), `tsc`, or a type-aware tool (`ts-morph`). For a
|
|
27
|
+
one-off edit in one file, just edit the file.
|
|
28
|
+
|
|
29
|
+
## The workflow — always search, preview, then apply
|
|
30
|
+
|
|
31
|
+
1. **Search first — never rewrite blind.** See what the pattern matches:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
nk ast-grep run -p 'useOldHook($$$ARGS)' -l tsx
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Read the hits. If it matches too much or too little, tighten the pattern
|
|
38
|
+
before going further. A syntactic pattern over-matches easily.
|
|
39
|
+
|
|
40
|
+
2. **Preview the rewrite** (prints a diff, writes nothing without `-U`):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
nk ast-grep run -p 'useOldHook($$$ARGS)' -r 'useNewHook($$$ARGS)' -l tsx
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3. **Apply** once the diff is exactly right (`-U` / `--update-all`):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
nk ast-grep run -p 'useOldHook($$$ARGS)' -r 'useNewHook($$$ARGS)' -l tsx -U
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
4. **Normalise, then verify.** ast-grep's output isn't house-formatted and the
|
|
53
|
+
edit is unchecked. Always follow with:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
nk format # oxfmt — reflow the rewritten code
|
|
57
|
+
nk type-check # tsc — did the rewrite actually type-check?
|
|
58
|
+
nk check # oxlint + format-verify + knip
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then read the full diff yourself. Treat a codemod as unverified until the
|
|
62
|
+
type-checker and your own eyes have passed over it.
|
|
63
|
+
|
|
64
|
+
## Pattern syntax you need
|
|
65
|
+
|
|
66
|
+
- **`$A`, `$FOO`** — a metavariable: matches one named node. Same name used twice
|
|
67
|
+
must match the same code (`$A === $A`). Uppercase/underscore names.
|
|
68
|
+
- **`$$$ARGS`** — matches **zero or more** nodes (argument lists, statements,
|
|
69
|
+
JSX children). This is what makes rewrites arity-agnostic.
|
|
70
|
+
- **`-l ts` / `-l tsx`** — the language. Use `tsx` for anything with JSX (most of
|
|
71
|
+
a Next.js `app/`), `ts` for plain `.ts`. Getting this wrong makes patterns
|
|
72
|
+
silently fail to parse.
|
|
73
|
+
- **`-p` pattern**, **`-r` rewrite**, **`-U`** apply, **`-i`** interactive
|
|
74
|
+
(approve each edit). Scope by passing paths: `nk ast-grep run ... src/app`.
|
|
75
|
+
|
|
76
|
+
Example — positional arg → options object:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
nk ast-grep run -l ts \
|
|
80
|
+
-p 'createClient($URL, $KEY)' \
|
|
81
|
+
-r 'createClient({ url: $URL, key: $KEY })'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## When a single pattern isn't enough — rule files
|
|
85
|
+
|
|
86
|
+
For matches that need context ("only inside a `useEffect`", "only calls that have
|
|
87
|
+
a `.then`"), one `-p` pattern won't express it. Write an ast-grep **YAML rule**
|
|
88
|
+
using relational clauses (`inside`, `has`, `follows`) and `constraints`, then:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
nk ast-grep scan --rule ./rule.yml # report
|
|
92
|
+
nk ast-grep scan --rule ./rule.yml -U # apply
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Keep that rule file **out of the committed tree** — write it to a scratch/temp
|
|
96
|
+
path and delete it after. nextkit sites don't carry codemod config as repo noise;
|
|
97
|
+
the rule is a throwaway for one migration, not a fixture. (An `sgconfig.yml` at
|
|
98
|
+
the repo root would make `nk ast-grep` pick up committed rules — deliberately not
|
|
99
|
+
part of the nextkit convention.)
|
|
100
|
+
|
|
101
|
+
Minimal rule shape:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
id: rename-hook-in-effect
|
|
105
|
+
language: tsx
|
|
106
|
+
rule:
|
|
107
|
+
pattern: useOldHook($$$ARGS)
|
|
108
|
+
inside: { pattern: useEffect($$$) }
|
|
109
|
+
fix: useNewHook($$$ARGS)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Guardrails
|
|
113
|
+
|
|
114
|
+
- Syntactic, not semantic — **it can and will over-match.** Search before you
|
|
115
|
+
rewrite, every time.
|
|
116
|
+
- Never apply (`-U`) straight to a dirty tree you can't diff. Commit or stash
|
|
117
|
+
first so the codemod's change is the only thing in the diff.
|
|
118
|
+
- Always `nk format` + `nk type-check` after applying. A green type-check is the
|
|
119
|
+
real proof the rewrite held; the diff being pretty is not.
|
|
120
|
+
- If ast-grep can't cleanly express the change in one or two rules, it's probably
|
|
121
|
+
a semantic refactor — step up to a type-aware tool instead of forcing it.
|