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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/code-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The rules live in `CLAUDE.md › Code structure`; `npm run lint:structure` (`scr

1. Find where the code belongs by layer and concern: main process (`src/main/<area>/`), shared contract (`src/shared/`), renderer slice (`app → pages → widgets → features → entities → shared`, see `docs/DESIGN_SYSTEM.md`).
2. Open the target folder. If the file you'd edit is near 150 lines, or your change adds a second concern to it, split first (below), then add your code in its own file.
3. Name the new file after the one thing it holds: `sinceInput.ts`, `useDragCursor.ts`, `ResourceTracker.ts`, `EntryRow.tsx`.
3. Name the new file after the one thing it holds: `sinceInput.ts`, `useDragCursor.ts`, `ResourceTracker.ts`, `EntryRow.tsx`. Never a name a sibling has in another case (`actionFields.ts` beside `ActionFields.tsx`): macOS and Windows ignore case, so an import can load the wrong one there. Say what sets it apart instead (`ruleActionFields.ts`).

## How to split

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Before pushing:
```bash
npm run typecheck
npm run lint:fsd
npm run lint:structure # the Code structure rules below: thin files, one function each, no switch
npm run lint:structure # the Code structure rules below: thin files, one function each, no switch, no case clashes
npm run lint # oxlint, type-aware: React's rules (hooks, refs, purity) and misused promises
npm run lint:unused # knip: no unused file, dependency or export (knip.jsonc says what counts)
npm run lint:duplicates # jscpd: no copy of code that isn't in .jscpd-baseline.json
Expand Down Expand Up @@ -56,6 +56,7 @@ Agents: when the environment assigns a generated branch (`claude/…`) and says
- **No magic values.** Name every literal that drives logic: ids, keys and prefixes, the app's own channel and event names (IPC channels, `AppEvent` types), durations (a step of `DURATION` in `shared/lib/motion.ts`), sizes used in more than one place, and any value that has to match another system (Monaco command ids, `execCommand` names, `KeyboardEvent.key`, env vars). The platform's own event names (`'keydown'`, `'did-navigate'`), which its typed listeners check, stay inline. A constant goes at the top of the file that uses it, or in the folder's `constants.ts` when several files do; app-wide ones go in `shared/config` (renderer) or `src/shared/constants.ts` (both processes). Display copy, Tailwind classes and identity values (`0`, `1`, `''`, `true`) stay inline.
- **No copies.** `npm run lint:duplicates` refuses code copied from elsewhere in `src` or `scripts` (jscpd, 50 tokens or more): move it to a function both use. `.jscpd-baseline.json` lists the copies there were when the check came in; it only shrinks. After removing one, run `npm run lint:duplicates -- --update-baseline` and commit the smaller file; never add a copy to it to get a commit through.
- **No `switch`.** Dispatch through a typed table (`Record<Union, Handler>`, or a mapped type when each handler takes its own member), so a new union member fails typecheck until it's handled (see `app/model/bridge/`). An if/else chain or nested ternary over one value counts as a switch.
- **No names that differ only in case** in one folder of `src`, `scripts` or `test` (`ActionFields.tsx` beside `actionFields.ts`, or a folder `foo/` beside `Foo.ts`). macOS and Windows ignore case, so `import './ActionFields'` can load the other file there, which no Linux build shows. Name the helper after what it holds (`ruleActionFields.ts`). Files imported with their extension (`Markdown.tsx` beside `markdown.css`) are fine.

## Commits and pull requests

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ npm run demo-site # in another terminal: pages to try it on (port 5174)
```bash
npm run typecheck
npm run lint:fsd
npm run lint:structure # files of at most 150 lines, one function each, no switch (CLAUDE.md › Code structure)
npm run lint:structure # files of at most 150 lines, one function each, no switch, no names differing only in case (CLAUDE.md › Code structure)
npm run lint # oxlint with type information: React's rules and misused promises (.oxlintrc.json)
npm run lint:unused # no unused files, dependencies or exports (knip; types in a slice's index.ts are its public API)
npm run lint:duplicates # no new copies of code (jscpd; .jscpd-baseline.json lists the older ones, and only shrinks)
Expand Down
2 changes: 1 addition & 1 deletion docs/SPEC.md

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions scripts/check-structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@
* Checks the source (src/, scripts/; tests are exempt) against CLAUDE.md ›
* Code structure: files of at most 150 lines, one function, component, class
* or store per file named after it, data-only constants/types/index files, and
* no `switch` (nor if/else chains or nested ternaries over one value).
* no `switch` (nor if/else chains or nested ternaries over one value). Also
* checks src/, scripts/ and test/ for names that differ only in case, which
* macOS and Windows take for one while Linux (and CI) tell them apart.
* Exits with 1 and lists what to fix when a file breaks a rule.
*
* npm run lint:structure
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { checkFile } from './structure/checkFile.ts';
import { findCaseClashes } from './structure/findCaseClashes.ts';
import { sourceFiles } from './structure/sourceFiles.ts';

const root = join(import.meta.dirname, '..');
const files = sourceFiles(root);
const violations = files.flatMap((file) => checkFile(file, readFileSync(join(root, file), 'utf8')));
const violations = [...files.flatMap((file) => checkFile(file, readFileSync(join(root, file), 'utf8'))), ...findCaseClashes(root)];

for (const v of violations) console.error(`${v.file}${v.line ? `:${v.line}` : ''} ${v.rule}: ${v.detail}`);
if (violations.length) {
Expand Down
27 changes: 27 additions & 0 deletions scripts/structure/caseClashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { lookupNames } from './lookupNames.ts';
import type { FolderEntry } from './types.ts';

/**
* The entries of one folder that a file system ignoring case (macOS, Windows) can't tell apart, in groups of names:
* two whose names differ only in case (`Foo.css`, `foo.css`), or two modules an import without an extension reaches
* by names that do (`./ActionFields` finds `actionFields.ts` there before `ActionFields.tsx`). `Markdown.tsx` beside
* `markdown.css` is fine: a stylesheet is imported by its whole name.
*/
export function caseClashes(entries: FolderEntry[]): string[][] {
// Each name a lookup can use, lower-cased → the entries it reaches, each with the spelling that reaches it.
const reached = new Map<string, Map<string, string>>();
for (const entry of entries) {
for (const spelling of lookupNames(entry)) {
const key = spelling.toLowerCase();
reached.set(key, (reached.get(key) ?? new Map<string, string>()).set(entry.name, spelling));
}
}
// A lookup that reaches entries spelled alike is no clash (`Foo.ts` beside `Foo.tsx`); one pair can clash twice.
const groups = new Map<string, string[]>();
for (const spellings of reached.values()) {
if (new Set(spellings.values()).size < 2) continue;
const names = [...spellings.keys()].sort();
groups.set(JSON.stringify(names), names);
}
return [...groups.values()];
}
9 changes: 9 additions & 0 deletions scripts/structure/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ export const EQUALITY_OPERATORS = ['===', '=='];

/** A value compared with literals this many times in one if/else chain or nested ternary makes it a switch. */
export const SWITCH_LIKE_COMPARISONS = 2;

/**
* Folders checked for names that macOS and Windows, whose file systems ignore case, can't tell apart. Tests too:
* they are built and run there.
*/
export const CASE_CHECKED_ROOTS = ['src', 'scripts', 'test'];

/** What an import without an extension may find (Vite's `resolve.extensions`): `./Foo` reaches `foo.ts` there. */
export const MODULE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'];
20 changes: 20 additions & 0 deletions scripts/structure/findCaseClashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { caseClashes } from './caseClashes.ts';
import { CASE_CHECKED_ROOTS } from './constants.ts';
import type { Violation } from './types.ts';

/** Every group of names under the checked folders that macOS and Windows can't tell apart, one violation each. */
export function findCaseClashes(root: string): Violation[] {
const violations: Violation[] = [];
const visit = (dir: string) => {
const entries = readdirSync(dir, { withFileTypes: true });
for (const names of caseClashes(entries.map((entry) => ({ name: entry.name, directory: entry.isDirectory() })))) {
const detail = `${names.join(', ')}: macOS and Windows ignore case, so they take one for the other (an import may load the wrong one): rename one`;
violations.push({ file: relative(root, dir), rule: 'no case clashes', detail });
}
for (const entry of entries) if (entry.isDirectory()) visit(join(dir, entry.name));
};
for (const dir of CASE_CHECKED_ROOTS) visit(join(root, dir));
return violations;
}
13 changes: 13 additions & 0 deletions scripts/structure/lookupNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { extname } from 'node:path';
import { MODULE_EXTENSIONS } from './constants.ts';
import type { FolderEntry } from './types.ts';

/**
* The names a lookup can reach `entry` by: its own, and for a module file the one an import without the extension
* uses (`Foo.tsx` is also `Foo`). A folder is a module by its own name, through its index.
*/
export function lookupNames({ name, directory }: FolderEntry): string[] {
const extension = extname(name);
if (directory || !MODULE_EXTENSIONS.includes(extension)) return [name];
return [name, name.slice(0, -extension.length)];
}
6 changes: 6 additions & 0 deletions scripts/structure/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ export interface TopLevelFunction {
name: string;
line: number;
}

/** An entry of a folder, as the case check reads it. */
export interface FolderEntry {
name: string;
directory: boolean;
}
54 changes: 54 additions & 0 deletions test/unit/checkStructure.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { caseClashes } from '../../scripts/structure/caseClashes';
import { checkFile } from '../../scripts/structure/checkFile';
import { findCaseClashes } from '../../scripts/structure/findCaseClashes';

const rules = (file: string, text: string) => checkFile(file, text).map((v) => v.rule);

Expand Down Expand Up @@ -37,3 +42,52 @@ describe('code-structure check', () => {
expect(rules('src/a/f.ts', "export const f = (x: string, y: string) => (x === 'a' ? 1 : y === 'b' ? 2 : 3);\n")).toEqual([]);
});
});

describe('case clash check', () => {
const files = (...names: string[]) => names.map((name) => ({ name, directory: false }));

it('flags modules an import without an extension reaches by names that differ only in case', () => {
// The pairs that broke the 0.4.0 build on macOS and Windows.
expect(caseClashes(files('ActionFields.tsx', 'actionFields.ts', 'RuleNotes.tsx', 'ruleNotes.ts', 'types.ts'))).toEqual([
['ActionFields.tsx', 'actionFields.ts'],
['RuleNotes.tsx', 'ruleNotes.ts'],
]);
// A folder is a module by its name, through its index.
expect(caseClashes([...files('Foo.ts'), { name: 'foo', directory: true }])).toEqual([['Foo.ts', 'foo']]);
expect(caseClashes(files('data.json', 'Data.js'))).toEqual([['Data.js', 'data.json']]);
});

it('flags any two names that differ only in case, module or not', () => {
expect(caseClashes(files('logo.svg', 'Logo.svg'))).toEqual([['Logo.svg', 'logo.svg']]);
// One group, though the pair clashes both by its names and as modules.
expect(caseClashes(files('Foo.ts', 'foo.ts'))).toEqual([['Foo.ts', 'foo.ts']]);
});

it('passes names a lookup can tell apart', () => {
// A stylesheet is imported by its whole name.
expect(caseClashes(files('Markdown.tsx', 'markdown.css', 'index.ts'))).toEqual([]);
// Spelled alike: the same on every file system.
expect(caseClashes([...files('Foo.ts', 'Foo.tsx'), { name: 'Foo', directory: true }])).toEqual([]);
expect(caseClashes(files('ruleActionFields.ts', 'ActionFields.tsx', 'ruleNotesFor.ts', 'RuleNotes.tsx'))).toEqual([]);
});

it('walks src, scripts and test, and names the folder of each clash', () => {
const root = mkdtempSync(join(tmpdir(), 'case-clashes-'));
const write = (path: string) => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), '');
};
try {
['src/a/b/Row.tsx', 'src/a/b/row.ts', 'src/ui/Markdown.tsx', 'src/ui/markdown.css', 'scripts/check.ts', 'test/unit/Tree.test.ts', 'test/unit/tree.test.ts', 'docs/X.ts', 'docs/x.ts'].forEach(write);
expect(findCaseClashes(root).map((v) => [v.file, v.rule, v.detail.split(':')[0]])).toEqual([
[join('src', 'a', 'b'), 'no case clashes', 'Row.tsx, row.ts'],
[join('test', 'unit'), 'no case clashes', 'Tree.test.ts, tree.test.ts'],
]);
rmSync(join(root, 'src/a/b/row.ts'));
rmSync(join(root, 'test/unit/tree.test.ts'));
expect(findCaseClashes(root)).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading