eslint-plugin-kerfjs 0.8.2 → 0.9.1

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
@@ -11,9 +11,9 @@ This plugin sits alongside two other defense layers shipped by [`kerfjs`](https:
11
11
  |---|---|---|
12
12
  | `tsc --noEmit` with strict typings | Hard Rules 8 (partial-set), most type errors | Build time |
13
13
  | Opt-in dev-warns (`KERF_DEV_WARN_*`) | Hard Rules 4 (rebuilt listeners), 7 (untracked signals), 8 (narrow set) | Runtime |
14
- | **This plugin** | Hard Rules 2, 5, 9, 11 — AST-shaped antipatterns | **Edit time** |
14
+ | **This plugin** | Hard Rules 2, 5, 9, 11 — AST-shaped antipatterns; plus a project-hygiene nudge for the bundled AI-assistant configs | **Edit time** |
15
15
 
16
- The rules are AST-only — no `@typescript-eslint/parser` *service* dependency is required by the plugin (consumers configure their own parser).
16
+ The four Hard-Rule rules are AST-only — no `@typescript-eslint/parser` *service* dependency is required by the plugin (consumers configure their own parser). The fifth rule, `ai-assistant-configs`, reads the filesystem instead of the AST and runs once per lint pass.
17
17
 
18
18
  ## Install
19
19
 
@@ -58,8 +58,9 @@ export default [
58
58
  | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
59
59
  | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 5 — one `mount()` per root | `error` |
60
60
  | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 11 — augment `kerfjs/jsx-runtime`, not global | `error` |
61
+ | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
61
62
 
62
- The "Hard Rule" column refers to the numbered rules in [`docs/ai/usage-guide.md`](../docs/ai/usage-guide.md) on the main kerf repo.
63
+ The "Hard Rule" column refers to the numbered rules in [`docs/ai/usage-guide.md`](../docs/ai/usage-guide.md) on the main kerf repo. `ai-assistant-configs` doesn't map to a Hard Rule — it checks that the bundled kerf-app Claude Code skill / Cursor rules drop-ins are installed and current in projects that use those tools. See [`docs/12-ai-assistant-configs.md`](../docs/12-ai-assistant-configs.md) on the main kerf repo for the design.
63
64
 
64
65
  ## Why these four (and not more)?
65
66
 
@@ -74,7 +75,7 @@ npm install
74
75
  npm test
75
76
  ```
76
77
 
77
- The test suite uses ESLint's `RuleTester` with `@typescript-eslint/parser`.
78
+ The AST rules' test suites use ESLint's `RuleTester` with `@typescript-eslint/parser`. The `ai-assistant-configs` tests are filesystem-driven — they build temp project roots with fixture `node_modules/kerfjs/ai/` bundles and drive the rule's classifier directly, since `RuleTester` doesn't simulate the filesystem.
78
79
 
79
80
  ## License
80
81
 
@@ -0,0 +1,80 @@
1
+ # `kerfjs/ai-assistant-configs`
2
+
3
+ Check that the [kerf-app Claude Code skill](https://github.com/brianwestphal/kerf/blob/main/kerf.claude-skill.md) and [Cursor rules](https://github.com/brianwestphal/kerf/blob/main/kerf.cursorrules) drop-ins are installed in the project and up-to-date with the canonical files bundled inside the consumer's installed `kerfjs` package.
4
+
5
+ Unlike the four AST-only rules in this plugin, this rule reads the **filesystem**, not source code. It runs once per lint pass and reports project-level hygiene issues rather than per-file code defects. Severity in `kerfjs.configs.recommended` is `warn`, not `error` — a missing skill file shouldn't fail CI.
6
+
7
+ Maps to design doc [`docs/12-ai-assistant-configs.md`](https://github.com/brianwestphal/kerf/blob/main/docs/12-ai-assistant-configs.md) §12.4.
8
+
9
+ ## What it checks
10
+
11
+ The rule resolves `kerfjs/ai/manifest.json` from the consumer's installed `kerfjs` (silently no-ops if `kerfjs` isn't a dep). For each bundled file, it picks a trigger heuristic — "is this project using the tool this file is for?" — and only nudges the consumer when the trigger says yes:
12
+
13
+ | Bundled file | Triggers when | Installed to |
14
+ |---|---|---|
15
+ | `ai/skill.md` (Claude Code) | `.claude/` directory exists at the project root | `.claude/skills/kerf-app/SKILL.md` |
16
+ | `ai/cursorrules` (Cursor) | `.cursorrules` file or `.cursor/` directory exists | `.cursorrules` |
17
+
18
+ A triggered file is then classified into one of three reported states (a fourth, "up-to-date," is silent):
19
+
20
+ - **Missing** — the consumer's `dest` doesn't exist.
21
+ - **Stale** — exists, parses cleanly, but its `kerf-skill-version` line is older than the bundle's.
22
+ - **Forked** — the consumer's file no longer matches the canonical layout: marker is missing, marker appears more than once, `kerf-skill-version` line is missing, or the content above the marker has been edited.
23
+
24
+ ## ❌ Reported
25
+
26
+ ```
27
+ $ eslint .
28
+
29
+ src/index.ts
30
+ 1:1 warning Claude Code kerf-app skill drop-in at `.claude/skills/kerf-app/SKILL.md`
31
+ is stale (have 1.0.0, latest is 1.1.0). Run `eslint --fix` to update
32
+ the canonical section above the `KERF-APP-CANONICAL-END` marker; your
33
+ customizations below the marker are preserved.
34
+ kerfjs/ai-assistant-configs
35
+ ```
36
+
37
+ The warning attaches to whichever source file ESLint happens to lint first — it's a project-level report, not a code-level one. The message text makes that clear.
38
+
39
+ ## ✅ Auto-fix
40
+
41
+ ```bash
42
+ $ eslint --fix .
43
+ ```
44
+
45
+ For **missing** files, `--fix` copies the bundled canonical (`node_modules/kerfjs/ai/skill.md` / `node_modules/kerfjs/ai/cursorrules`) to the consumer's `dest` path, creating any missing parent directories.
46
+
47
+ For **stale** files, `--fix` replaces only the content **above and including the `KERF-APP-CANONICAL-END` marker**. Everything below the marker — the consumer's append zone — is preserved byte-for-byte. This is the "versioned-section preservation" strategy from the design doc.
48
+
49
+ The `fix()` callback writes to a file OTHER than the linted source — unusual for an ESLint rule. ESLint only invokes `fix()` under `--fix`, so the side effect is opt-in by definition; plain `eslint` will report the warning without touching disk.
50
+
51
+ ## ❗ Forked — no auto-fix
52
+
53
+ If the consumer has edited the file in a way that breaks the canonical/append-zone contract, the rule refuses to auto-fix. It reports the specific shape mismatch instead:
54
+
55
+ - **No marker present.** The file pre-dates the marker convention, the consumer deleted the marker, or the file is a hand-written variant. Auto-fixing would either clobber legitimate customizations or leave the file in an ambiguous state.
56
+ - **Multiple markers.** A well-formed file has exactly one. Auto-fix would have to guess which boundary is real.
57
+ - **No `kerf-skill-version` line.** The staleness signal is missing; we can't tell what's there.
58
+ - **Content above the marker has been edited.** The contract is "above the marker is kerf's; below is yours." Above-the-marker edits are a deliberate fork.
59
+
60
+ Resolution: either restore the canonical layout (move customizations below a freshly-inserted marker, delete extras, re-add the version line) or disable the rule for this project — `'kerfjs/ai-assistant-configs': 'off'` in `eslint.config.js`.
61
+
62
+ ## Options
63
+
64
+ ```js
65
+ // eslint.config.js — disable both checks
66
+ { rules: { 'kerfjs/ai-assistant-configs': 'off' } }
67
+
68
+ // Disable only one tool's check
69
+ { rules: { 'kerfjs/ai-assistant-configs': ['warn', { claude: false }] } }
70
+ { rules: { 'kerfjs/ai-assistant-configs': ['warn', { cursor: false }] } }
71
+ ```
72
+
73
+ Both `claude` and `cursor` default to `true`. Setting either to `false` makes that specific drop-in silent regardless of trigger state.
74
+
75
+ ## What this rule does NOT do
76
+
77
+ - It does not install the configs at `npm install` time — there are no postinstall scripts. The first lint pass after `npm install kerfjs` surfaces the recommendation.
78
+ - It does not write to disk under plain `eslint` (no `--fix`). The warning is emitted; nothing changes on disk.
79
+ - It does not push Claude Code or Cursor on consumers who haven't signalled they use them — no `.claude/` or `.cursor*` ⇒ silent.
80
+ - It does not validate the *content* of the canonical files in the bundle. That's the kerf maintainer's responsibility (and the `check:ai-bundle-in-sync` gate in the kerf repo).
package/index.js CHANGED
@@ -1,37 +1,43 @@
1
+ import aiAssistantConfigs from './lib/rules/ai-assistant-configs.js';
1
2
  import noInlineJsxEventHandlers from './lib/rules/no-inline-jsx-event-handlers.js';
2
3
  import noNestedMount from './lib/rules/no-nested-mount.js';
3
4
  import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
4
5
  import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
5
6
 
6
7
  const plugin = {
7
- meta: { name: 'eslint-plugin-kerfjs', version: '0.1.0' },
8
+ meta: { name: 'eslint-plugin-kerfjs', version: '0.9.0' },
8
9
  rules: {
9
10
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
10
11
  'require-data-key-in-each': requireDataKeyInEach,
11
12
  'no-nested-mount': noNestedMount,
12
13
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
14
+ 'ai-assistant-configs': aiAssistantConfigs,
13
15
  },
14
16
  configs: {},
15
17
  };
16
18
 
17
- const errorRules = {
19
+ // Most rules ship as `error` in recommended (AST-shaped antipatterns are
20
+ // bugs). `ai-assistant-configs` is `warn` — it's a project-hygiene nudge,
21
+ // not a code defect, and a missing skill file shouldn't fail CI.
22
+ const recommendedRules = {
18
23
  'kerfjs/no-inline-jsx-event-handlers': 'error',
19
24
  'kerfjs/require-data-key-in-each': 'error',
20
25
  'kerfjs/no-nested-mount': 'error',
21
26
  'kerfjs/prefer-module-jsx-augmentation': 'error',
27
+ 'kerfjs/ai-assistant-configs': 'warn',
22
28
  };
23
29
 
24
30
  // Flat config (ESLint v9+) — consumers add this object to their config array.
25
31
  plugin.configs.recommended = {
26
32
  plugins: { kerfjs: plugin },
27
- rules: errorRules,
33
+ rules: recommendedRules,
28
34
  };
29
35
  plugin.configs.all = plugin.configs.recommended;
30
36
 
31
37
  // Legacy `.eslintrc` config — consumers extend `'plugin:kerfjs/legacy-recommended'`.
32
38
  plugin.configs['legacy-recommended'] = {
33
39
  plugins: ['kerfjs'],
34
- rules: errorRules,
40
+ rules: recommendedRules,
35
41
  };
36
42
 
37
43
  export default plugin;
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Check that the kerf-app Claude Code skill / Cursor rules are installed and
3
+ * up-to-date against the canonical files bundled inside the consumer's
4
+ * installed `kerfjs` package (`node_modules/kerfjs/ai/manifest.json`).
5
+ *
6
+ * Implements §12.4 of kerf's `docs/12-ai-assistant-configs.md`. On
7
+ * `eslint --fix`, replaces only the canonical section above the
8
+ * `KERF-APP-CANONICAL-END` marker; the consumer's append zone below the
9
+ * marker is preserved byte-for-byte (the "option 2" strategy from KF-217).
10
+ *
11
+ * Unusual for an ESLint rule: the `fix()` callback writes to a file
12
+ * OTHER than the linted source. ESLint only invokes `fix()` when
13
+ * `--fix` is enabled, so the side effect is opt-in by definition.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ const MARKER = '<!-- KERF-APP-CANONICAL-END · your customizations below -->';
21
+ const VERSION_RE = /kerf-skill-version:\s*(\d+\.\d+\.\d+(?:-[\w.]+)?)/;
22
+ const VERSION_SCAN_BYTES = 512;
23
+
24
+ // Module-level once-per-lint-run guard. ESLint instantiates the rule
25
+ // per-file; we do the project-level filesystem check the first time the
26
+ // `Program` visitor runs and cache the result for the remaining files in
27
+ // the same lint pass.
28
+ let CHECKED = false;
29
+ let CACHED_RESULT = null;
30
+
31
+ export function _resetForTests() {
32
+ CHECKED = false;
33
+ CACHED_RESULT = null;
34
+ }
35
+
36
+ function resolveManifestPath(cwd) {
37
+ try {
38
+ // `createRequire` from a path inside cwd resolves npm deps from there.
39
+ const req = createRequire(join(cwd, 'noop.js'));
40
+ return req.resolve('kerfjs/ai/manifest.json');
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function compareSemver(a, b) {
47
+ const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
48
+ const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
49
+ for (let i = 0; i < 3; i++) {
50
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
51
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ function findMarkerIndex(text) {
57
+ const first = text.indexOf(MARKER);
58
+ if (first === -1) return -1;
59
+ const second = text.indexOf(MARKER, first + MARKER.length);
60
+ if (second !== -1) return -2; // sentinel for "multiple markers"
61
+ return first;
62
+ }
63
+
64
+ function sha256(text) {
65
+ return createHash('sha256').update(text, 'utf8').digest('hex');
66
+ }
67
+
68
+ function extractVersion(text) {
69
+ const m = text.slice(0, VERSION_SCAN_BYTES).match(VERSION_RE);
70
+ return m ? m[1] : null;
71
+ }
72
+
73
+ function canonicalEndOffset(text) {
74
+ const idx = findMarkerIndex(text);
75
+ if (idx < 0) return idx;
76
+ // Include the marker line + the trailing newline. If the file ends
77
+ // exactly at the marker (no newline), use end-of-file.
78
+ const lineEnd = idx + MARKER.length;
79
+ if (text.length > lineEnd && text[lineEnd] === '\n') return lineEnd + 1;
80
+ return lineEnd;
81
+ }
82
+
83
+ /**
84
+ * Classify a single bundled file against the consumer's local copy.
85
+ * Returns one of: { state: 'missing' }, { state: 'ok' },
86
+ * { state: 'stale', consumerVersion, bundledVersion, appendZone },
87
+ * { state: 'forked', reason }.
88
+ *
89
+ * Exported for tests.
90
+ */
91
+ export function classifyFile(file, bundleDir, cwd) {
92
+ const destAbs = join(cwd, file.dest);
93
+ let consumer;
94
+ try {
95
+ consumer = readFileSync(destAbs, 'utf8');
96
+ } catch {
97
+ return { state: 'missing' };
98
+ }
99
+
100
+ const markerIdx = findMarkerIndex(consumer);
101
+ if (markerIdx === -1) return { state: 'forked', reason: 'no canonical-end marker' };
102
+ if (markerIdx === -2) return { state: 'forked', reason: 'multiple canonical-end markers' };
103
+
104
+ const canonicalEnd = canonicalEndOffset(consumer);
105
+ const consumerCanonical = consumer.slice(0, canonicalEnd);
106
+ const appendZone = consumer.slice(canonicalEnd);
107
+
108
+ const consumerVersion = extractVersion(consumer);
109
+ if (!consumerVersion) {
110
+ return { state: 'forked', reason: 'no `kerf-skill-version` line' };
111
+ }
112
+
113
+ const cmp = compareSemver(consumerVersion, file.version);
114
+ if (cmp === 0) {
115
+ // Same version. Above-marker section should match the bundled sha256.
116
+ if (sha256(consumerCanonical) === file.sha256) return { state: 'ok' };
117
+ return { state: 'forked', reason: 'content above marker has been edited' };
118
+ }
119
+ if (cmp < 0) {
120
+ return {
121
+ state: 'stale',
122
+ consumerVersion,
123
+ bundledVersion: file.version,
124
+ appendZone,
125
+ };
126
+ }
127
+ // consumer is newer than bundled — treat as ok (consumer is ahead of installed kerfjs).
128
+ return { state: 'ok' };
129
+ }
130
+
131
+ function loadBundle(cwd) {
132
+ const manifestPath = resolveManifestPath(cwd);
133
+ if (!manifestPath) return null;
134
+ let manifest;
135
+ try {
136
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
137
+ } catch {
138
+ return null;
139
+ }
140
+ if (!manifest || !Array.isArray(manifest.files)) return null;
141
+ const bundleDir = dirname(manifestPath);
142
+ return { manifest, bundleDir };
143
+ }
144
+
145
+ function isTriggered(name, cwd) {
146
+ if (name === 'skill') return existsSync(join(cwd, '.claude'));
147
+ if (name === 'cursorrules') {
148
+ return existsSync(join(cwd, '.cursorrules')) || existsSync(join(cwd, '.cursor'));
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Run the project-level check. Exported for tests; the rule wraps this
155
+ * with a once-per-lint-run guard.
156
+ */
157
+ export function runCheck(cwd) {
158
+ const bundle = loadBundle(cwd);
159
+ if (!bundle) return null;
160
+ const results = [];
161
+ for (const file of bundle.manifest.files) {
162
+ if (!isTriggered(file.name, cwd)) continue;
163
+ results.push({ file, result: classifyFile(file, bundle.bundleDir, cwd) });
164
+ }
165
+ return { results, bundleDir: bundle.bundleDir };
166
+ }
167
+
168
+ /**
169
+ * Apply the `--fix` for one file: write the bundled canonical to the
170
+ * consumer's `dest`, appending the consumer's existing append-zone (if
171
+ * any). Exported for tests.
172
+ */
173
+ export function applyFix(file, bundleDir, cwd, appendZone) {
174
+ const bundlePath = join(bundleDir, file.bundle.replace(/^ai\//, ''));
175
+ const canonical = readFileSync(bundlePath, 'utf8');
176
+ const dest = join(cwd, file.dest);
177
+ mkdirSync(dirname(dest), { recursive: true });
178
+ writeFileSync(dest, canonical + (appendZone || ''));
179
+ }
180
+
181
+ const meta = {
182
+ type: 'suggestion',
183
+ docs: {
184
+ description:
185
+ 'Check that the kerf-app Claude Code skill / Cursor rules are installed and up-to-date against the canonical files bundled in `kerfjs/ai/`.',
186
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ai-assistant-configs.md',
187
+ },
188
+ schema: [
189
+ {
190
+ type: 'object',
191
+ additionalProperties: false,
192
+ properties: {
193
+ claude: { type: 'boolean' },
194
+ cursor: { type: 'boolean' },
195
+ },
196
+ },
197
+ ],
198
+ messages: {
199
+ missing:
200
+ '{{tool}} drop-in is missing at `{{dest}}`. Run `eslint --fix` to install the bundled `kerfjs/{{bundle}}`. See docs/12-ai-assistant-configs.md.',
201
+ stale:
202
+ '{{tool}} drop-in at `{{dest}}` is stale (have {{consumerVersion}}, latest is {{bundledVersion}}). Run `eslint --fix` to update the canonical section above the `KERF-APP-CANONICAL-END` marker; your customizations below the marker are preserved.',
203
+ forked:
204
+ '{{tool}} drop-in at `{{dest}}` is forked: {{reason}}. Restore the canonical layout (one `KERF-APP-CANONICAL-END` marker, no edits above it) or disable this rule with `\'kerfjs/ai-assistant-configs\': \'off\'`.',
205
+ },
206
+ // Mark as fixable so ESLint runs the `fix()` callback under `--fix`.
207
+ // The callback writes to a separate file and returns null, so ESLint
208
+ // applies no edit to the linted source itself — see file header.
209
+ fixable: 'code',
210
+ };
211
+
212
+ function create(context) {
213
+ return {
214
+ Program(node) {
215
+ if (CHECKED) return;
216
+ CHECKED = true;
217
+ const options = context.options[0] || {};
218
+ const claudeEnabled = options.claude !== false;
219
+ const cursorEnabled = options.cursor !== false;
220
+ // ESLint v9: `context.cwd` is a string. ESLint v8: `context.getCwd()`.
221
+ const cwd =
222
+ (typeof context.cwd === 'string' && context.cwd)
223
+ || (typeof context.getCwd === 'function' && context.getCwd())
224
+ || process.cwd();
225
+ const checked = runCheck(cwd);
226
+ CACHED_RESULT = checked;
227
+ if (!checked) return;
228
+
229
+ for (const { file, result } of checked.results) {
230
+ if (file.name === 'skill' && !claudeEnabled) continue;
231
+ if (file.name === 'cursorrules' && !cursorEnabled) continue;
232
+ if (result.state === 'ok') continue;
233
+
234
+ const tool = file.name === 'skill' ? 'Claude Code kerf-app skill' : 'Cursor kerf rules';
235
+ const bundle = file.bundle; // e.g. 'ai/skill.md'
236
+ const data = { tool, dest: file.dest, bundle };
237
+
238
+ if (result.state === 'missing') {
239
+ context.report({
240
+ node,
241
+ messageId: 'missing',
242
+ data,
243
+ fix() {
244
+ applyFix(file, checked.bundleDir, cwd, '');
245
+ return null;
246
+ },
247
+ });
248
+ } else if (result.state === 'stale') {
249
+ context.report({
250
+ node,
251
+ messageId: 'stale',
252
+ data: {
253
+ ...data,
254
+ consumerVersion: result.consumerVersion,
255
+ bundledVersion: result.bundledVersion,
256
+ },
257
+ fix() {
258
+ applyFix(file, checked.bundleDir, cwd, result.appendZone);
259
+ return null;
260
+ },
261
+ });
262
+ } else if (result.state === 'forked') {
263
+ context.report({
264
+ node,
265
+ messageId: 'forked',
266
+ data: { ...data, reason: result.reason },
267
+ });
268
+ }
269
+ }
270
+ },
271
+ };
272
+ }
273
+
274
+ export default { meta, create };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "description": "ESLint rules that enforce kerf's hard rules — catches AI-shaped bugs at edit time.",
5
5
  "type": "module",
6
6
  "license": "MIT",