ai-forge-cli 0.1.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/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/add-feature-YXWSRIVE.js +141 -0
- package/dist/check-RCJRXIU5.js +377 -0
- package/dist/chunk-J4V5PGVT.js +55 -0
- package/dist/chunk-PIFX2L5H.js +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/init-GQA3WDXQ.js +114 -0
- package/dist/templates/feature/convex/index.ts.hbs +3 -0
- package/dist/templates/feature/convex/mutations.ts.hbs +37 -0
- package/dist/templates/feature/convex/queries.ts.hbs +16 -0
- package/dist/templates/feature/convex/schema.ts.hbs +10 -0
- package/dist/templates/feature/routes/$id.tsx.hbs +27 -0
- package/dist/templates/feature/routes/index.tsx.hbs +21 -0
- package/dist/templates/feature/src/components/index.ts.hbs +4 -0
- package/dist/templates/feature/src/hooks.ts.hbs +29 -0
- package/dist/templates/feature/src/index.ts.hbs +2 -0
- package/dist/templates/init/app/client.tsx.hbs +7 -0
- package/dist/templates/init/app/router.tsx.hbs +17 -0
- package/dist/templates/init/app/routes/__root.tsx.hbs +38 -0
- package/dist/templates/init/app/routes/index.tsx.hbs +18 -0
- package/dist/templates/init/app/ssr.tsx.hbs +11 -0
- package/dist/templates/init/app.config.ts.hbs +8 -0
- package/dist/templates/init/biome.json.hbs +32 -0
- package/dist/templates/init/claude.md.hbs +93 -0
- package/dist/templates/init/convex/schema.ts.hbs +7 -0
- package/dist/templates/init/package.json.hbs +34 -0
- package/dist/templates/init/postcss.config.js.hbs +6 -0
- package/dist/templates/init/src/lib/cn.ts.hbs +6 -0
- package/dist/templates/init/src/providers/index.tsx.hbs +10 -0
- package/dist/templates/init/tailwind.config.ts.hbs +12 -0
- package/dist/templates/init/tsconfig.json.hbs +24 -0
- package/package.json +59 -0
- package/templates/feature/convex/index.ts.hbs +3 -0
- package/templates/feature/convex/mutations.ts.hbs +37 -0
- package/templates/feature/convex/queries.ts.hbs +16 -0
- package/templates/feature/convex/schema.ts.hbs +10 -0
- package/templates/feature/routes/$id.tsx.hbs +27 -0
- package/templates/feature/routes/index.tsx.hbs +21 -0
- package/templates/feature/src/components/index.ts.hbs +4 -0
- package/templates/feature/src/hooks.ts.hbs +29 -0
- package/templates/feature/src/index.ts.hbs +2 -0
- package/templates/init/app/client.tsx.hbs +7 -0
- package/templates/init/app/router.tsx.hbs +17 -0
- package/templates/init/app/routes/__root.tsx.hbs +38 -0
- package/templates/init/app/routes/index.tsx.hbs +18 -0
- package/templates/init/app/ssr.tsx.hbs +11 -0
- package/templates/init/app.config.ts.hbs +8 -0
- package/templates/init/biome.json.hbs +32 -0
- package/templates/init/claude.md.hbs +93 -0
- package/templates/init/convex/schema.ts.hbs +7 -0
- package/templates/init/package.json.hbs +34 -0
- package/templates/init/postcss.config.js.hbs +6 -0
- package/templates/init/src/lib/cn.ts.hbs +6 -0
- package/templates/init/src/providers/index.tsx.hbs +10 -0
- package/templates/init/tailwind.config.ts.hbs +12 -0
- package/templates/init/tsconfig.json.hbs +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Pablo García
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Forge CLI
|
|
2
|
+
|
|
3
|
+
TypeScript stack scaffolding & enforcement CLI for **TanStack Start + Convex + Tailwind**.
|
|
4
|
+
|
|
5
|
+
The key insight: Claude Code reads `CLAUDE.md` automatically. Forge uses this as a **hook** — imperative instructions that tell Claude it MUST use the forge CLI commands. No more AI drift.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g forge-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or use with npx:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx forge-cli init my-app
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
### `forge init <project-name>`
|
|
22
|
+
|
|
23
|
+
Creates a complete project with:
|
|
24
|
+
- TanStack Start (file-based routing, SSR-ready)
|
|
25
|
+
- Convex (real-time database)
|
|
26
|
+
- Tailwind CSS + shadcn/ui ready
|
|
27
|
+
- Biome (linting/formatting)
|
|
28
|
+
- TypeScript strict mode
|
|
29
|
+
- **CLAUDE.md** — the AI hook
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
forge init my-app
|
|
33
|
+
cd my-app
|
|
34
|
+
pnpm install
|
|
35
|
+
npx convex init
|
|
36
|
+
pnpm dlx shadcn@latest init
|
|
37
|
+
pnpm dev
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### `forge add:feature <name>`
|
|
41
|
+
|
|
42
|
+
Creates a full vertical slice:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
convex/features/<name>/
|
|
46
|
+
├── schema.ts # Table definition (exports <name>Tables)
|
|
47
|
+
├── queries.ts # All queries
|
|
48
|
+
├── mutations.ts # All mutations
|
|
49
|
+
└── index.ts # Barrel export
|
|
50
|
+
|
|
51
|
+
src/features/<name>/
|
|
52
|
+
├── components/
|
|
53
|
+
│ └── index.ts # Component exports
|
|
54
|
+
├── hooks.ts # Feature hooks
|
|
55
|
+
└── index.ts # Barrel export
|
|
56
|
+
|
|
57
|
+
app/routes/<name>/
|
|
58
|
+
├── index.tsx # List view
|
|
59
|
+
└── $id.tsx # Detail view
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Also auto-registers the schema in `convex/schema.ts`.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
forge add:feature projects
|
|
66
|
+
|
|
67
|
+
# Output:
|
|
68
|
+
✓ Created convex/features/projects/schema.ts
|
|
69
|
+
✓ Created convex/features/projects/queries.ts
|
|
70
|
+
✓ Created convex/features/projects/mutations.ts
|
|
71
|
+
✓ Created convex/features/projects/index.ts
|
|
72
|
+
✓ Created src/features/projects/components/index.ts
|
|
73
|
+
✓ Created src/features/projects/hooks.ts
|
|
74
|
+
✓ Created src/features/projects/index.ts
|
|
75
|
+
✓ Created app/routes/projects/index.tsx
|
|
76
|
+
✓ Created app/routes/projects/$id.tsx
|
|
77
|
+
✓ Updated convex/schema.ts
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `forge check`
|
|
81
|
+
|
|
82
|
+
Validates project structure. Fails if rules are broken. Use in CI/pre-commit.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
forge check
|
|
86
|
+
|
|
87
|
+
# Output:
|
|
88
|
+
✓ Feature structure valid
|
|
89
|
+
✓ Component locations valid
|
|
90
|
+
✓ Hook locations valid
|
|
91
|
+
✓ Thin routes valid
|
|
92
|
+
✓ Cross-feature imports valid
|
|
93
|
+
✓ Feature parity valid
|
|
94
|
+
|
|
95
|
+
All checks passed!
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Validation Rules
|
|
99
|
+
|
|
100
|
+
1. **Feature structure** — Every feature has required files
|
|
101
|
+
2. **Component location** — Components only in `src/features/*/components/` or `src/components/`
|
|
102
|
+
3. **Hook location** — Hooks only in `src/features/*/hooks.ts` or `src/hooks/`
|
|
103
|
+
4. **Thin routes** — Route files can only import from features/components, no business logic
|
|
104
|
+
5. **No cross-feature imports** — `src/features/X/` cannot import from `src/features/Y/`
|
|
105
|
+
6. **Feature parity** — Every `src/features/X` has matching `convex/features/X`
|
|
106
|
+
|
|
107
|
+
## The CLAUDE.md Hook
|
|
108
|
+
|
|
109
|
+
When you run `forge init`, it generates a `CLAUDE.md` file that instructs Claude Code to:
|
|
110
|
+
|
|
111
|
+
1. **Always** run `forge add:feature <name>` before building any feature
|
|
112
|
+
2. **Never** create feature files manually
|
|
113
|
+
3. **Always** run `forge check` before completing any task
|
|
114
|
+
|
|
115
|
+
This eliminates AI drift and enforces consistent architecture.
|
|
116
|
+
|
|
117
|
+
```markdown
|
|
118
|
+
# From generated CLAUDE.md
|
|
119
|
+
|
|
120
|
+
## YOU MUST USE FORGE CLI
|
|
121
|
+
|
|
122
|
+
When the user asks you to build ANY feature, you MUST:
|
|
123
|
+
1. FIRST run `forge add:feature <name>`
|
|
124
|
+
2. THEN fill in the generated files
|
|
125
|
+
3. NEVER create feature files manually
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Project Structure
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
app/routes/ → Thin route files (import from features, no logic)
|
|
132
|
+
src/features/ → All feature code (components, hooks, types)
|
|
133
|
+
src/components/ → Shared UI only (used across features)
|
|
134
|
+
src/lib/ → Pure utilities
|
|
135
|
+
convex/features/ → Backend mirrors frontend features
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Stack
|
|
139
|
+
|
|
140
|
+
- **Frontend**: TanStack Start
|
|
141
|
+
- **Backend**: Convex
|
|
142
|
+
- **Styling**: Tailwind CSS + shadcn/ui
|
|
143
|
+
- **Linting**: Biome
|
|
144
|
+
- **Language**: TypeScript (strict)
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
camelCase,
|
|
4
|
+
kebabCase,
|
|
5
|
+
renderTemplate
|
|
6
|
+
} from "./chunk-PIFX2L5H.js";
|
|
7
|
+
import {
|
|
8
|
+
fileExists,
|
|
9
|
+
logger,
|
|
10
|
+
readFile,
|
|
11
|
+
writeFile
|
|
12
|
+
} from "./chunk-J4V5PGVT.js";
|
|
13
|
+
|
|
14
|
+
// src/commands/add-feature.ts
|
|
15
|
+
import { defineCommand } from "citty";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
var add_feature_default = defineCommand({
|
|
18
|
+
meta: {
|
|
19
|
+
name: "add:feature",
|
|
20
|
+
description: "Create a new feature with vertical slice architecture"
|
|
21
|
+
},
|
|
22
|
+
args: {
|
|
23
|
+
name: {
|
|
24
|
+
type: "positional",
|
|
25
|
+
description: "Name of the feature (will be converted to kebab-case)",
|
|
26
|
+
required: true
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async run({ args }) {
|
|
30
|
+
const rawName = args.name;
|
|
31
|
+
const name = kebabCase(rawName);
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
const srcFeaturePath = join(cwd, "src/features", name);
|
|
34
|
+
const convexFeaturePath = join(cwd, "convex/features", name);
|
|
35
|
+
if (await fileExists(srcFeaturePath)) {
|
|
36
|
+
logger.error(`Feature "${name}" already exists at src/features/${name}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (await fileExists(convexFeaturePath)) {
|
|
40
|
+
logger.error(`Feature "${name}" already exists at convex/features/${name}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
logger.blank();
|
|
44
|
+
const templateData = { name };
|
|
45
|
+
const files = [
|
|
46
|
+
// Convex files
|
|
47
|
+
{
|
|
48
|
+
templatePath: "feature/convex/schema.ts.hbs",
|
|
49
|
+
destPath: join(cwd, "convex/features", name, "schema.ts")
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
templatePath: "feature/convex/queries.ts.hbs",
|
|
53
|
+
destPath: join(cwd, "convex/features", name, "queries.ts")
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
templatePath: "feature/convex/mutations.ts.hbs",
|
|
57
|
+
destPath: join(cwd, "convex/features", name, "mutations.ts")
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
templatePath: "feature/convex/index.ts.hbs",
|
|
61
|
+
destPath: join(cwd, "convex/features", name, "index.ts")
|
|
62
|
+
},
|
|
63
|
+
// Src files
|
|
64
|
+
{
|
|
65
|
+
templatePath: "feature/src/components/index.ts.hbs",
|
|
66
|
+
destPath: join(cwd, "src/features", name, "components/index.ts")
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
templatePath: "feature/src/hooks.ts.hbs",
|
|
70
|
+
destPath: join(cwd, "src/features", name, "hooks.ts")
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
templatePath: "feature/src/index.ts.hbs",
|
|
74
|
+
destPath: join(cwd, "src/features", name, "index.ts")
|
|
75
|
+
},
|
|
76
|
+
// Route files
|
|
77
|
+
{
|
|
78
|
+
templatePath: "feature/routes/index.tsx.hbs",
|
|
79
|
+
destPath: join(cwd, "app/routes", name, "index.tsx")
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
templatePath: "feature/routes/$id.tsx.hbs",
|
|
83
|
+
destPath: join(cwd, "app/routes", name, "$id.tsx")
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const content = renderTemplate(file.templatePath, templateData);
|
|
88
|
+
await writeFile(file.destPath, content);
|
|
89
|
+
const relativePath = file.destPath.replace(cwd + "/", "");
|
|
90
|
+
logger.success(`Created ${relativePath}`);
|
|
91
|
+
}
|
|
92
|
+
await updateConvexSchema(cwd, name);
|
|
93
|
+
logger.blank();
|
|
94
|
+
logger.log(` Feature "${name}" created successfully.`);
|
|
95
|
+
logger.blank();
|
|
96
|
+
logger.log(" Next steps:");
|
|
97
|
+
logger.log(` 1. Define your schema in convex/features/${name}/schema.ts`);
|
|
98
|
+
logger.log(" 2. Run `npx convex dev` to sync");
|
|
99
|
+
logger.log(` 3. Build components in src/features/${name}/components/`);
|
|
100
|
+
logger.blank();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
async function updateConvexSchema(cwd, featureName) {
|
|
104
|
+
const schemaPath = join(cwd, "convex/schema.ts");
|
|
105
|
+
const camelName = camelCase(featureName);
|
|
106
|
+
if (!await fileExists(schemaPath)) {
|
|
107
|
+
logger.warn("convex/schema.ts not found - skipping schema registration");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let content = await readFile(schemaPath);
|
|
111
|
+
const importLine = `import { ${camelName}Tables } from "./features/${featureName}/schema";`;
|
|
112
|
+
const exportDefault = "export default defineSchema({";
|
|
113
|
+
if (content.includes(importLine)) {
|
|
114
|
+
logger.warn("Schema import already exists - skipping");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const exportPos = content.indexOf(exportDefault);
|
|
118
|
+
if (exportPos === -1) {
|
|
119
|
+
logger.warn("Could not find 'export default defineSchema({' in schema.ts - skipping");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
content = content.slice(0, exportPos) + importLine + "\n\n" + content.slice(exportPos);
|
|
123
|
+
const spreadLine = ` ...${camelName}Tables,`;
|
|
124
|
+
const defineSchemaStart = content.indexOf(exportDefault);
|
|
125
|
+
const afterDefineSchema = content.slice(defineSchemaStart + exportDefault.length);
|
|
126
|
+
const closingIndex = content.lastIndexOf("});");
|
|
127
|
+
if (closingIndex === -1) {
|
|
128
|
+
logger.warn("Could not find closing '});' in schema.ts - skipping spread");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (content.includes(spreadLine)) {
|
|
132
|
+
logger.warn("Schema spread already exists - skipping");
|
|
133
|
+
} else {
|
|
134
|
+
content = content.slice(0, closingIndex) + spreadLine + "\n" + content.slice(closingIndex);
|
|
135
|
+
}
|
|
136
|
+
await writeFile(schemaPath, content);
|
|
137
|
+
logger.success("Updated convex/schema.ts");
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
add_feature_default as default
|
|
141
|
+
};
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
fileExists,
|
|
4
|
+
logger,
|
|
5
|
+
readFile
|
|
6
|
+
} from "./chunk-J4V5PGVT.js";
|
|
7
|
+
|
|
8
|
+
// src/commands/check.ts
|
|
9
|
+
import { defineCommand } from "citty";
|
|
10
|
+
|
|
11
|
+
// src/validators/feature-structure.ts
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import fg from "fast-glob";
|
|
14
|
+
var REQUIRED_SRC_FILES = ["components/index.ts", "hooks.ts", "index.ts"];
|
|
15
|
+
var REQUIRED_CONVEX_FILES = ["schema.ts", "queries.ts", "mutations.ts", "index.ts"];
|
|
16
|
+
var featureStructureValidator = {
|
|
17
|
+
name: "Feature structure",
|
|
18
|
+
async validate(cwd) {
|
|
19
|
+
const errors = [];
|
|
20
|
+
const warnings = [];
|
|
21
|
+
const srcFeatures = await fg("src/features/*", {
|
|
22
|
+
cwd,
|
|
23
|
+
onlyDirectories: true
|
|
24
|
+
});
|
|
25
|
+
for (const featurePath of srcFeatures) {
|
|
26
|
+
const featureName = featurePath.split("/").pop();
|
|
27
|
+
for (const requiredFile of REQUIRED_SRC_FILES) {
|
|
28
|
+
const filePath = join(cwd, featurePath, requiredFile);
|
|
29
|
+
if (!await fileExists(filePath)) {
|
|
30
|
+
errors.push({
|
|
31
|
+
file: join(featurePath, requiredFile),
|
|
32
|
+
message: `Missing required file in feature "${featureName}"`
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const convexFeatures = await fg("convex/features/*", {
|
|
38
|
+
cwd,
|
|
39
|
+
onlyDirectories: true
|
|
40
|
+
});
|
|
41
|
+
for (const featurePath of convexFeatures) {
|
|
42
|
+
const featureName = featurePath.split("/").pop();
|
|
43
|
+
for (const requiredFile of REQUIRED_CONVEX_FILES) {
|
|
44
|
+
const filePath = join(cwd, featurePath, requiredFile);
|
|
45
|
+
if (!await fileExists(filePath)) {
|
|
46
|
+
errors.push({
|
|
47
|
+
file: join(featurePath, requiredFile),
|
|
48
|
+
message: `Missing required file in feature "${featureName}"`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
rule: this.name,
|
|
55
|
+
passed: errors.length === 0,
|
|
56
|
+
errors,
|
|
57
|
+
warnings
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/validators/locations.ts
|
|
63
|
+
import fg2 from "fast-glob";
|
|
64
|
+
var componentLocationValidator = {
|
|
65
|
+
name: "Component locations",
|
|
66
|
+
async validate(cwd) {
|
|
67
|
+
const errors = [];
|
|
68
|
+
const warnings = [];
|
|
69
|
+
const tsxFiles = await fg2("**/*.tsx", {
|
|
70
|
+
cwd,
|
|
71
|
+
ignore: [
|
|
72
|
+
"node_modules/**",
|
|
73
|
+
"dist/**",
|
|
74
|
+
".vinxi/**",
|
|
75
|
+
"app/**",
|
|
76
|
+
// TanStack Start entry files and routes
|
|
77
|
+
"src/features/*/components/**",
|
|
78
|
+
// Valid location
|
|
79
|
+
"src/components/**",
|
|
80
|
+
// Valid location
|
|
81
|
+
"src/providers/**"
|
|
82
|
+
// Provider wrappers
|
|
83
|
+
]
|
|
84
|
+
});
|
|
85
|
+
for (const file of tsxFiles) {
|
|
86
|
+
errors.push({
|
|
87
|
+
file,
|
|
88
|
+
message: "Component outside valid location (should be in src/features/*/components/ or src/components/)"
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
rule: this.name,
|
|
93
|
+
passed: errors.length === 0,
|
|
94
|
+
errors,
|
|
95
|
+
warnings
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var hookLocationValidator = {
|
|
100
|
+
name: "Hook locations",
|
|
101
|
+
async validate(cwd) {
|
|
102
|
+
const errors = [];
|
|
103
|
+
const warnings = [];
|
|
104
|
+
const potentialHookFiles = await fg2(["**/use*.ts", "**/use*.tsx", "**/*hook*.ts", "**/*hook*.tsx"], {
|
|
105
|
+
cwd,
|
|
106
|
+
ignore: [
|
|
107
|
+
"node_modules/**",
|
|
108
|
+
"dist/**",
|
|
109
|
+
"src/features/*/hooks.ts",
|
|
110
|
+
// Valid location
|
|
111
|
+
"src/hooks/**",
|
|
112
|
+
// Valid location
|
|
113
|
+
"src/hooks.ts"
|
|
114
|
+
// Valid location (root hooks file)
|
|
115
|
+
]
|
|
116
|
+
});
|
|
117
|
+
for (const file of potentialHookFiles) {
|
|
118
|
+
if (file.startsWith("app/routes/")) continue;
|
|
119
|
+
errors.push({
|
|
120
|
+
file,
|
|
121
|
+
message: "Hook file outside valid location (should be in src/features/*/hooks.ts or src/hooks/)"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
rule: this.name,
|
|
126
|
+
passed: errors.length === 0,
|
|
127
|
+
errors,
|
|
128
|
+
warnings
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/validators/thin-routes.ts
|
|
134
|
+
import { join as join2 } from "path";
|
|
135
|
+
import fg3 from "fast-glob";
|
|
136
|
+
var ALLOWED_IMPORT_PATTERNS = [
|
|
137
|
+
/^~/,
|
|
138
|
+
// ~/features/, ~/components/, ~/lib/
|
|
139
|
+
/^@tanstack\//,
|
|
140
|
+
// @tanstack/react-router, etc.
|
|
141
|
+
/^convex\//,
|
|
142
|
+
// convex/react, etc.
|
|
143
|
+
/^@convex\//,
|
|
144
|
+
// @convex/_generated/
|
|
145
|
+
/^react$/,
|
|
146
|
+
// react
|
|
147
|
+
/^react-dom$/
|
|
148
|
+
// react-dom
|
|
149
|
+
];
|
|
150
|
+
var LINE_THRESHOLD = 50;
|
|
151
|
+
var thinRoutesValidator = {
|
|
152
|
+
name: "Thin routes",
|
|
153
|
+
async validate(cwd) {
|
|
154
|
+
const errors = [];
|
|
155
|
+
const warnings = [];
|
|
156
|
+
const routeFiles = await fg3("app/routes/**/*.{ts,tsx}", {
|
|
157
|
+
cwd,
|
|
158
|
+
ignore: ["node_modules/**"]
|
|
159
|
+
});
|
|
160
|
+
for (const file of routeFiles) {
|
|
161
|
+
const fullPath = join2(cwd, file);
|
|
162
|
+
const content = await readFile(fullPath);
|
|
163
|
+
const lines = content.split("\n");
|
|
164
|
+
if (lines.length > LINE_THRESHOLD) {
|
|
165
|
+
warnings.push({
|
|
166
|
+
file,
|
|
167
|
+
message: `Route file exceeds ${LINE_THRESHOLD} lines (${lines.length} lines) - consider moving logic to feature`
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const importMatches = content.matchAll(/import\s+.*?\s+from\s+["']([^"']+)["']/g);
|
|
171
|
+
for (const match of importMatches) {
|
|
172
|
+
const importPath = match[1];
|
|
173
|
+
if (importPath.startsWith(".")) continue;
|
|
174
|
+
const isAllowed = ALLOWED_IMPORT_PATTERNS.some((pattern) => pattern.test(importPath));
|
|
175
|
+
if (!isAllowed) {
|
|
176
|
+
errors.push({
|
|
177
|
+
file,
|
|
178
|
+
message: `Invalid import "${importPath}" - routes should only import from ~/features/, ~/components/, ~/lib/, or framework packages`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
rule: this.name,
|
|
185
|
+
passed: errors.length === 0,
|
|
186
|
+
errors,
|
|
187
|
+
warnings
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// src/validators/cross-feature.ts
|
|
193
|
+
import { join as join3, dirname } from "path";
|
|
194
|
+
import fg4 from "fast-glob";
|
|
195
|
+
var crossFeatureValidator = {
|
|
196
|
+
name: "Cross-feature imports",
|
|
197
|
+
async validate(cwd) {
|
|
198
|
+
const errors = [];
|
|
199
|
+
const warnings = [];
|
|
200
|
+
const featureFiles = await fg4("src/features/**/*.{ts,tsx}", {
|
|
201
|
+
cwd,
|
|
202
|
+
ignore: ["node_modules/**"]
|
|
203
|
+
});
|
|
204
|
+
for (const file of featureFiles) {
|
|
205
|
+
const parts = file.split("/");
|
|
206
|
+
const featureIndex = parts.indexOf("features");
|
|
207
|
+
if (featureIndex === -1 || featureIndex + 1 >= parts.length) continue;
|
|
208
|
+
const currentFeature = parts[featureIndex + 1];
|
|
209
|
+
const fullPath = join3(cwd, file);
|
|
210
|
+
const content = await readFile(fullPath);
|
|
211
|
+
const importMatches = content.matchAll(/import\s+.*?\s+from\s+["']([^"']+)["']/g);
|
|
212
|
+
for (const match of importMatches) {
|
|
213
|
+
const importPath = match[1];
|
|
214
|
+
const tildeMatch = importPath.match(/^~\/features\/([^/]+)/);
|
|
215
|
+
if (tildeMatch) {
|
|
216
|
+
const importedFeature = tildeMatch[1];
|
|
217
|
+
if (importedFeature !== currentFeature) {
|
|
218
|
+
errors.push({
|
|
219
|
+
file,
|
|
220
|
+
message: `Cross-feature import from "${currentFeature}" to "${importedFeature}"`
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (importPath.startsWith("..")) {
|
|
226
|
+
const fileDir = dirname(file);
|
|
227
|
+
const resolvedParts = resolveRelativePath(fileDir, importPath);
|
|
228
|
+
if (resolvedParts.length >= 3 && resolvedParts[0] === "src" && resolvedParts[1] === "features") {
|
|
229
|
+
const importedFeature = resolvedParts[2];
|
|
230
|
+
if (importedFeature !== currentFeature) {
|
|
231
|
+
errors.push({
|
|
232
|
+
file,
|
|
233
|
+
message: `Cross-feature import from "${currentFeature}" to "${importedFeature}" via relative path`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
rule: this.name,
|
|
242
|
+
passed: errors.length === 0,
|
|
243
|
+
errors,
|
|
244
|
+
warnings
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
function resolveRelativePath(fromDir, relativePath) {
|
|
249
|
+
const fromParts = fromDir.split("/").filter(Boolean);
|
|
250
|
+
const relativeParts = relativePath.split("/").filter(Boolean);
|
|
251
|
+
const result = [...fromParts];
|
|
252
|
+
for (const part of relativeParts) {
|
|
253
|
+
if (part === "..") {
|
|
254
|
+
result.pop();
|
|
255
|
+
} else if (part !== ".") {
|
|
256
|
+
result.push(part);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/validators/feature-parity.ts
|
|
263
|
+
import fg5 from "fast-glob";
|
|
264
|
+
var featureParityValidator = {
|
|
265
|
+
name: "Feature parity",
|
|
266
|
+
async validate(cwd) {
|
|
267
|
+
const errors = [];
|
|
268
|
+
const warnings = [];
|
|
269
|
+
const srcFeatures = await fg5("src/features/*", {
|
|
270
|
+
cwd,
|
|
271
|
+
onlyDirectories: true
|
|
272
|
+
});
|
|
273
|
+
const srcFeatureNames = new Set(srcFeatures.map((f) => f.split("/").pop()));
|
|
274
|
+
const convexFeatures = await fg5("convex/features/*", {
|
|
275
|
+
cwd,
|
|
276
|
+
onlyDirectories: true
|
|
277
|
+
});
|
|
278
|
+
const convexFeatureNames = new Set(convexFeatures.map((f) => f.split("/").pop()));
|
|
279
|
+
for (const name of srcFeatureNames) {
|
|
280
|
+
if (!convexFeatureNames.has(name)) {
|
|
281
|
+
errors.push({
|
|
282
|
+
file: `src/features/${name}`,
|
|
283
|
+
message: `Feature "${name}" exists in src/features but not in convex/features`
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const name of convexFeatureNames) {
|
|
288
|
+
if (!srcFeatureNames.has(name)) {
|
|
289
|
+
errors.push({
|
|
290
|
+
file: `convex/features/${name}`,
|
|
291
|
+
message: `Feature "${name}" exists in convex/features but not in src/features`
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
rule: this.name,
|
|
297
|
+
passed: errors.length === 0,
|
|
298
|
+
errors,
|
|
299
|
+
warnings
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// src/validators/index.ts
|
|
305
|
+
var validators = [
|
|
306
|
+
featureStructureValidator,
|
|
307
|
+
componentLocationValidator,
|
|
308
|
+
hookLocationValidator,
|
|
309
|
+
thinRoutesValidator,
|
|
310
|
+
crossFeatureValidator,
|
|
311
|
+
featureParityValidator
|
|
312
|
+
];
|
|
313
|
+
async function runAllValidators(cwd) {
|
|
314
|
+
const results = [];
|
|
315
|
+
for (const validator of validators) {
|
|
316
|
+
const result = await validator.validate(cwd);
|
|
317
|
+
results.push(result);
|
|
318
|
+
}
|
|
319
|
+
return results;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/commands/check.ts
|
|
323
|
+
import pc from "picocolors";
|
|
324
|
+
var check_default = defineCommand({
|
|
325
|
+
meta: {
|
|
326
|
+
name: "check",
|
|
327
|
+
description: "Validate project structure and conventions"
|
|
328
|
+
},
|
|
329
|
+
async run() {
|
|
330
|
+
const cwd = process.cwd();
|
|
331
|
+
logger.blank();
|
|
332
|
+
logger.log(" Checking project structure...");
|
|
333
|
+
logger.blank();
|
|
334
|
+
const results = await runAllValidators(cwd);
|
|
335
|
+
let totalErrors = 0;
|
|
336
|
+
let totalWarnings = 0;
|
|
337
|
+
for (const result of results) {
|
|
338
|
+
if (result.passed && result.warnings.length === 0) {
|
|
339
|
+
logger.success(`${result.rule} valid`);
|
|
340
|
+
} else if (result.passed && result.warnings.length > 0) {
|
|
341
|
+
logger.warn(`${result.rule} (${result.warnings.length} warning${result.warnings.length > 1 ? "s" : ""})`);
|
|
342
|
+
for (const warning of result.warnings) {
|
|
343
|
+
logger.log(` ${pc.yellow("\u2192")} ${warning.file}: ${warning.message}`);
|
|
344
|
+
}
|
|
345
|
+
totalWarnings += result.warnings.length;
|
|
346
|
+
} else {
|
|
347
|
+
logger.error(`${result.rule} violated`);
|
|
348
|
+
for (const error of result.errors) {
|
|
349
|
+
logger.log(` ${pc.red("\u2192")} ${error.file}: ${error.message}`);
|
|
350
|
+
}
|
|
351
|
+
for (const warning of result.warnings) {
|
|
352
|
+
logger.log(` ${pc.yellow("\u2192")} ${warning.file}: ${warning.message}`);
|
|
353
|
+
}
|
|
354
|
+
totalErrors += result.errors.length;
|
|
355
|
+
totalWarnings += result.warnings.length;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
logger.blank();
|
|
359
|
+
if (totalErrors > 0) {
|
|
360
|
+
logger.log(` ${pc.red(`${totalErrors} error${totalErrors > 1 ? "s" : ""} found.`)}`);
|
|
361
|
+
if (totalWarnings > 0) {
|
|
362
|
+
logger.log(` ${pc.yellow(`${totalWarnings} warning${totalWarnings > 1 ? "s" : ""}.`)}`);
|
|
363
|
+
}
|
|
364
|
+
logger.blank();
|
|
365
|
+
process.exit(1);
|
|
366
|
+
} else if (totalWarnings > 0) {
|
|
367
|
+
logger.log(` ${pc.yellow(`${totalWarnings} warning${totalWarnings > 1 ? "s" : ""} found.`)}`);
|
|
368
|
+
logger.blank();
|
|
369
|
+
} else {
|
|
370
|
+
logger.log(` ${pc.green("All checks passed!")}`);
|
|
371
|
+
logger.blank();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
export {
|
|
376
|
+
check_default as default
|
|
377
|
+
};
|