@ingram-tech/nk-dev 0.5.0 → 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 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
- bunx nk init
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 `bunx`-resolved tools (oxlint, oxfmt, Next,
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.
@@ -56,8 +56,8 @@ if (toFormat.length === 0) process.exit(0);
56
56
 
57
57
  try {
58
58
  execFileSync(
59
- "bunx",
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
- - **Any form that emails or stores a submission MUST use `@ingram-tech/bot-protection`**
10
- (server: `verifyHuman` silently drop bots; client: honeypot + signed token).
11
- Never ship a form without it.
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>/`.
@@ -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("bunx", command, { stdio: "inherit" });
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 bunx --bun nextkit-format-staged
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. Vitest config only hint, never auto-write. Many sites test with
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
- // 5. knip config (unused deps/exports/files; run by `nk check`).
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
- // 6. Format-on-commit hook + git wiring.
135
+ // 7. Format-on-commit hook + git wiring.
113
136
  setupGitHook(cwd);
114
137
 
115
- // 7. Make sure the agent guide is imported into CLAUDE.md.
138
+ // 8. Make sure the agent guide is imported into CLAUDE.md.
116
139
  ensureGuideImport(cwd);
117
140
 
118
- // 8. A `prepare` script so the hook re-wires itself on every `bun install`.
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"));
@@ -4,8 +4,17 @@
4
4
 
5
5
  import baseUi from "./base-ui.js";
6
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";
7
10
 
8
11
  export default {
9
12
  meta: { name: "nextkit" },
10
- rules: { ...baseUi.rules, ...deferredCurrentTarget.rules },
13
+ rules: {
14
+ ...baseUi.rules,
15
+ ...deferredCurrentTarget.rules,
16
+ ...lucideIconSuffix.rules,
17
+ ...noRedirectOnlyPage.rules,
18
+ ...redundantUseStateType.rules,
19
+ },
11
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 `bunx` (resolves node_modules/.bin first) with
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("bunx", [tool, ...args], { stdio: "inherit", ...opts });
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 `bunx` — is bun installed and on PATH?");
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
@@ -7,6 +7,9 @@
7
7
  "rules": {
8
8
  "nextkit/no-radix-props-on-base-ui": "error",
9
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",
10
13
  "no-unused-vars": "warn",
11
14
  "typescript/no-non-null-assertion": "error",
12
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.5.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",
@@ -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.