@chemx/starter-kit 26.9.25-789 → 26.9.26-253
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 +2 -2
- package/blueprints/molecule-capsule/m-sample-card.spec.ts +10 -4
- package/cli/audit/prompts.js +1 -1
- package/cli/commands/cmd-router.js +20 -2
- package/cli/commands-schema.js +65 -6
- package/cli/generator-help.js +26 -6
- package/cli/generator-jig-cli.js +67 -0
- package/cli/generator-jig.js +479 -0
- package/cli/generator-jig.spec.js +148 -0
- package/cli/generator.js +15 -2
- package/cli/index.js +4 -1
- package/cli/installer-templates.js +8 -3
- package/cli/mcp/manifests.js +22 -4
- package/cli/mcp/tools-generate.js +22 -2
- package/cli/mcp/tools-read.js +50 -8
- package/cli/mcp/tools-verify.js +2 -0
- package/cli/mcp/tools.js +52 -3
- package/cli/project-detector.js +105 -2
- package/cli/reader-cli.js +146 -0
- package/cli/reader-logic.js +392 -0
- package/cli/reader-logic.spec.js +160 -0
- package/cli/reader.js +78 -171
- package/cli/search-commands-graph.js +125 -1
- package/cli/search-commands.js +7 -1
- package/cli/search-db.js +2 -0
- package/cli/search-queries-graph.js +294 -0
- package/cli/search-queries.js +6 -1
- package/cli/search.js +28 -30
- package/cli/team/team-adversarial-locks.spec.js +71 -0
- package/cli/team/team-commands-lock.js +64 -0
- package/cli/team/team-commands.js +16 -23
- package/cli/team/team-db-locks.js +1 -1
- package/cli/team/team-flags.js +4 -1
- package/cli/team/team-format.js +88 -0
- package/cli/team/team-memory.js +99 -13
- package/cli/team/team-memory.spec.js +49 -0
- package/cli/team/team-multiprocess-concurrency.spec.js +1 -1
- package/cli/tesseract.spec.js +2 -2
- package/cli/theme.js +1 -0
- package/cli/trace.spec.js +92 -0
- package/cli/verify-helpers.js +80 -18
- package/cli/verify-lint.js +224 -0
- package/cli/verify-lint.spec.js +44 -0
- package/cli/verify.js +8 -1
- package/cli/verify.spec.js +52 -0
- package/package.json +4 -4
- package/scripts/pre-commit.sh +8 -3
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { toPascalCase, toCamelCase } from './generator-templates/naming.js';
|
|
4
|
+
import { detectTestRunner, detectJigBaseDir } from './project-detector.js';
|
|
5
|
+
import { indexGeneratedFiles } from './generator-indexer.js';
|
|
6
|
+
|
|
7
|
+
export const JIG_KINDS = new Set([
|
|
8
|
+
'service',
|
|
9
|
+
'route',
|
|
10
|
+
'endpoint',
|
|
11
|
+
'store',
|
|
12
|
+
'repo',
|
|
13
|
+
'repository',
|
|
14
|
+
'util',
|
|
15
|
+
'utility',
|
|
16
|
+
'spec'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const normalizeJigKind = (rawKind = '') => {
|
|
20
|
+
const norm = String(rawKind).trim().toLowerCase();
|
|
21
|
+
if (norm === 'endpoint') return 'route';
|
|
22
|
+
if (norm === 'repository') return 'repo';
|
|
23
|
+
if (norm === 'utility') return 'util';
|
|
24
|
+
if (JIG_KINDS.has(norm)) return norm;
|
|
25
|
+
return 'service';
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const normalizeMethods = (rawMethods) => {
|
|
29
|
+
if (!rawMethods) return [];
|
|
30
|
+
if (Array.isArray(rawMethods)) {
|
|
31
|
+
return rawMethods.map((m) => {
|
|
32
|
+
if (typeof m === 'string') {
|
|
33
|
+
const match = m.match(/^([a-zA-Z0-9_]+)(?:\(([^)]*)\))?(?::\s*([a-zA-Z0-9_<>[\]]+))?$/);
|
|
34
|
+
if (match) {
|
|
35
|
+
return { name: match[1], params: match[2] || '', returnType: match[3] || 'Promise<ResultTuple<any>>', isAsync: true };
|
|
36
|
+
}
|
|
37
|
+
return { name: m.trim(), params: '', returnType: 'Promise<ResultTuple<any>>', isAsync: true };
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name: m.name || 'execute',
|
|
41
|
+
params: m.params || '',
|
|
42
|
+
returnType: m.returnType || (m.isAsync !== false ? 'Promise<ResultTuple<any>>' : 'ResultTuple<any>'),
|
|
43
|
+
isAsync: m.isAsync !== false
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (typeof rawMethods === 'string') {
|
|
48
|
+
return rawMethods.split(',').map((s) => s.trim()).filter(Boolean).map((name) => ({
|
|
49
|
+
name,
|
|
50
|
+
params: '',
|
|
51
|
+
returnType: 'Promise<ResultTuple<any>>',
|
|
52
|
+
isAsync: true
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const normalizeState = (rawState) => {
|
|
59
|
+
if (!rawState) return [];
|
|
60
|
+
const list = Array.isArray(rawState) ? rawState : (typeof rawState === 'string' ? rawState.split(',') : []);
|
|
61
|
+
return list.map((item) => {
|
|
62
|
+
if (typeof item === 'object' && item !== null && item.name) {
|
|
63
|
+
return { name: item.name, type: item.type || 'string', default: item.default ?? 'null' };
|
|
64
|
+
}
|
|
65
|
+
const parts = String(item).split(':').map((p) => p.trim());
|
|
66
|
+
return { name: parts[0], type: parts[1] || 'string', default: parts[2] || 'null' };
|
|
67
|
+
}).filter((s) => Boolean(s.name));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const normalizeRoutes = (rawRoutes) => {
|
|
71
|
+
if (!rawRoutes) return [];
|
|
72
|
+
const list = Array.isArray(rawRoutes) ? rawRoutes : (typeof rawRoutes === 'string' ? rawRoutes.split(',') : []);
|
|
73
|
+
return list.map((r) => {
|
|
74
|
+
if (typeof r === 'object' && r !== null && r.method) {
|
|
75
|
+
return { method: r.method.toUpperCase(), path: r.path || '/' };
|
|
76
|
+
}
|
|
77
|
+
const parts = String(r).trim().split(/\s+/).map((p) => p.trim());
|
|
78
|
+
if (parts.length >= 2) return { method: parts[0].toUpperCase(), path: parts[1] };
|
|
79
|
+
return { method: 'GET', path: parts[0] || '/' };
|
|
80
|
+
}).filter((r) => Boolean(r.method && r.path));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Builders for Code, Types, and Specs (<100 LOC per file guarantee)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const buildServiceFiles = ({ name, pascalName, camelName, methods, desc, runner }) => {
|
|
88
|
+
const methodList = methods.length > 0 ? methods : [
|
|
89
|
+
{ name: `get${pascalName}`, params: 'id: string', returnType: `Promise<ResultTuple<${pascalName}Record | null>>`, isAsync: true },
|
|
90
|
+
{ name: `save${pascalName}`, params: `record: ${pascalName}Input`, returnType: `Promise<ResultTuple<${pascalName}Record>>`, isAsync: true }
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const codeLines = [
|
|
94
|
+
`/**`,
|
|
95
|
+
` * ${pascalName} Service`,
|
|
96
|
+
` * ${desc || 'Domain service with Result tuple error handling'}`,
|
|
97
|
+
` */`,
|
|
98
|
+
`import type { ResultTuple, ${pascalName}Record, ${pascalName}Input } from './${name}.types';`,
|
|
99
|
+
``,
|
|
100
|
+
`export class ${pascalName}Service {`,
|
|
101
|
+
` private readonly baseUrl: string;`,
|
|
102
|
+
``,
|
|
103
|
+
` constructor(baseUrl: string = '') {`,
|
|
104
|
+
` this.baseUrl = baseUrl;`,
|
|
105
|
+
` }`,
|
|
106
|
+
``
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
for (const m of methodList) {
|
|
110
|
+
const asyncKw = m.isAsync ? 'async ' : '';
|
|
111
|
+
codeLines.push(` ${asyncKw}${m.name}(${m.params}): ${m.returnType} {`);
|
|
112
|
+
codeLines.push(` const isReady = Boolean(this.baseUrl !== undefined);`);
|
|
113
|
+
codeLines.push(` if (!isReady) return [null, new Error('${pascalName}Service not configured')];`);
|
|
114
|
+
codeLines.push(` return [null, null];`);
|
|
115
|
+
codeLines.push(` }`);
|
|
116
|
+
codeLines.push(``);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
codeLines.push(`}`);
|
|
120
|
+
codeLines.push(``);
|
|
121
|
+
codeLines.push(`export const create${pascalName}Service = (baseUrl?: string): ${pascalName}Service => {`);
|
|
122
|
+
codeLines.push(` return new ${pascalName}Service(baseUrl);`);
|
|
123
|
+
codeLines.push(`};`);
|
|
124
|
+
|
|
125
|
+
const typesLines = [
|
|
126
|
+
`export type ResultTuple<T, E = Error> = [T, null] | [null, E];`,
|
|
127
|
+
``,
|
|
128
|
+
`export interface ${pascalName}Record {`,
|
|
129
|
+
` readonly id: string;`,
|
|
130
|
+
` readonly createdAt: number;`,
|
|
131
|
+
`}`,
|
|
132
|
+
``,
|
|
133
|
+
`export interface ${pascalName}Input {`,
|
|
134
|
+
` readonly label: string;`,
|
|
135
|
+
`}`
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const isVitest = runner === 'vitest';
|
|
139
|
+
const testImport = isVitest ? `import { describe, it, expect } from 'vitest';` : `import { describe, it } from 'node:test';\nimport assert from 'node:assert';`;
|
|
140
|
+
|
|
141
|
+
const specLines = [
|
|
142
|
+
testImport,
|
|
143
|
+
`import { create${pascalName}Service } from './${name}';`,
|
|
144
|
+
``,
|
|
145
|
+
`describe('${pascalName}Service', () => {`,
|
|
146
|
+
` it('instantiates service cleanly', () => {`,
|
|
147
|
+
` const service = create${pascalName}Service('https://api.local');`,
|
|
148
|
+
isVitest ? ` expect(service).toBeDefined();` : ` assert.ok(service);`,
|
|
149
|
+
` });`,
|
|
150
|
+
``,
|
|
151
|
+
` it('executes primary method with Result tuple guard', async () => {`,
|
|
152
|
+
` const service = create${pascalName}Service();`,
|
|
153
|
+
` const [data, err] = await service.${methodList[0].name}('test-id');`,
|
|
154
|
+
isVitest ? ` expect(err).toBeNull();` : ` assert.strictEqual(err, null);`,
|
|
155
|
+
` });`,
|
|
156
|
+
`});`
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
code: codeLines.join('\n') + '\n',
|
|
161
|
+
types: typesLines.join('\n') + '\n',
|
|
162
|
+
spec: specLines.join('\n') + '\n'
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const buildRouteFiles = ({ name, pascalName, routes, desc, runner }) => {
|
|
167
|
+
const routeList = routes.length > 0 ? routes : [
|
|
168
|
+
{ method: 'GET', path: `/${name}` },
|
|
169
|
+
{ method: 'POST', path: `/${name}` }
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const codeLines = [
|
|
173
|
+
`/**`,
|
|
174
|
+
` * ${pascalName} API Route Handlers`,
|
|
175
|
+
` * ${desc || 'HTTP Route handler with early guard returns and typed envelopes'}`,
|
|
176
|
+
` */`,
|
|
177
|
+
`export type RouteResult<T> = { status: number; body: { ok: boolean; data?: T; error?: string } };`,
|
|
178
|
+
``
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
for (const r of routeList) {
|
|
182
|
+
const fnName = `handle${r.method}${pascalName}`;
|
|
183
|
+
codeLines.push(`export const ${fnName} = async (req: { params?: Record<string, string>; body?: unknown }): Promise<RouteResult<unknown>> => {`);
|
|
184
|
+
codeLines.push(` const hasPayload = Boolean(req);`);
|
|
185
|
+
codeLines.push(` if (!hasPayload) {`);
|
|
186
|
+
codeLines.push(` return { status: 400, body: { ok: false, error: 'Invalid request' } };`);
|
|
187
|
+
codeLines.push(` }`);
|
|
188
|
+
codeLines.push(` return { status: 200, body: { ok: true, data: null } };`);
|
|
189
|
+
codeLines.push(`};`);
|
|
190
|
+
codeLines.push(``);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const isVitest = runner === 'vitest';
|
|
194
|
+
const testImport = isVitest ? `import { describe, it, expect } from 'vitest';` : `import { describe, it } from 'node:test';\nimport assert from 'node:assert';`;
|
|
195
|
+
|
|
196
|
+
const specLines = [
|
|
197
|
+
testImport,
|
|
198
|
+
`import { handle${routeList[0].method}${pascalName} } from './${name}';`,
|
|
199
|
+
``,
|
|
200
|
+
`describe('${pascalName} Routes', () => {`,
|
|
201
|
+
` it('handles ${routeList[0].method} successfully', async () => {`,
|
|
202
|
+
` const res = await handle${routeList[0].method}${pascalName}({ body: {} });`,
|
|
203
|
+
isVitest ? ` expect(res.status).toBe(200);` : ` assert.strictEqual(res.status, 200);`,
|
|
204
|
+
isVitest ? ` expect(res.body.ok).toBe(true);` : ` assert.strictEqual(res.body.ok, true);`,
|
|
205
|
+
` });`,
|
|
206
|
+
`});`
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
code: codeLines.join('\n') + '\n',
|
|
211
|
+
types: null,
|
|
212
|
+
spec: specLines.join('\n') + '\n'
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const buildStoreFiles = ({ name, pascalName, state, desc, runner }) => {
|
|
217
|
+
const stateProps = state.length > 0 ? state : [
|
|
218
|
+
{ name: 'records', type: 'string[]', default: '[]' }
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
const codeLines = [
|
|
222
|
+
`/**`,
|
|
223
|
+
` * ${pascalName} Store Slice`,
|
|
224
|
+
` * ${desc || 'Reactive store with discriminated union states and immutable actions'}`,
|
|
225
|
+
` */`,
|
|
226
|
+
`export type ${pascalName}State =`,
|
|
227
|
+
` | { readonly status: 'idle'; readonly data: null }`,
|
|
228
|
+
` | { readonly status: 'loading'; readonly data: null }`,
|
|
229
|
+
` | { readonly status: 'active'; readonly data: { ${stateProps.map(s => `${s.name}: ${s.type}`).join('; ')} } }`,
|
|
230
|
+
` | { readonly status: 'fault'; readonly error: string };`,
|
|
231
|
+
``,
|
|
232
|
+
`export const createInitial${pascalName}State = (): ${pascalName}State => ({`,
|
|
233
|
+
` status: 'idle',`,
|
|
234
|
+
` data: null`,
|
|
235
|
+
`});`,
|
|
236
|
+
``,
|
|
237
|
+
`export const transitionToActive = (payload: { ${stateProps.map(s => `${s.name}: ${s.type}`).join('; ')} }): ${pascalName}State => {`,
|
|
238
|
+
` const isValid = Boolean(payload);`,
|
|
239
|
+
` if (!isValid) return { status: 'fault', error: 'Invalid state payload' };`,
|
|
240
|
+
` return { status: 'active', data: payload };`,
|
|
241
|
+
`};`
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const isVitest = runner === 'vitest';
|
|
245
|
+
const testImport = isVitest ? `import { describe, it, expect } from 'vitest';` : `import { describe, it } from 'node:test';\nimport assert from 'node:assert';`;
|
|
246
|
+
|
|
247
|
+
const specLines = [
|
|
248
|
+
testImport,
|
|
249
|
+
`import { createInitial${pascalName}State, transitionToActive } from './${name}';`,
|
|
250
|
+
``,
|
|
251
|
+
`describe('${pascalName} Store', () => {`,
|
|
252
|
+
` it('creates initial idle state', () => {`,
|
|
253
|
+
` const initial = createInitial${pascalName}State();`,
|
|
254
|
+
isVitest ? ` expect(initial.status).toBe('idle');` : ` assert.strictEqual(initial.status, 'idle');`,
|
|
255
|
+
` });`,
|
|
256
|
+
``,
|
|
257
|
+
` it('transitions to active with payload', () => {`,
|
|
258
|
+
` const next = transitionToActive({ ${stateProps.map(s => `${s.name}: ${s.default}`).join(', ')} });`,
|
|
259
|
+
isVitest ? ` expect(next.status).toBe('active');` : ` assert.strictEqual(next.status, 'active');`,
|
|
260
|
+
` });`,
|
|
261
|
+
`});`
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
code: codeLines.join('\n') + '\n',
|
|
266
|
+
types: null,
|
|
267
|
+
spec: specLines.join('\n') + '\n'
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const buildRepoFiles = ({ name, pascalName, desc, runner }) => {
|
|
272
|
+
const codeLines = [
|
|
273
|
+
`/**`,
|
|
274
|
+
` * ${pascalName} Repository`,
|
|
275
|
+
` * ${desc || 'Data access repository with Result tuple error handling'}`,
|
|
276
|
+
` */`,
|
|
277
|
+
`export type ResultTuple<T, E = Error> = [T, null] | [null, E];`,
|
|
278
|
+
``,
|
|
279
|
+
`export class ${pascalName}Repository {`,
|
|
280
|
+
` private readonly db: any;`,
|
|
281
|
+
``,
|
|
282
|
+
` constructor(db: any = null) {`,
|
|
283
|
+
` this.db = db;`,
|
|
284
|
+
` }`,
|
|
285
|
+
``,
|
|
286
|
+
` async findById(id: string): Promise<ResultTuple<any | null>> {`,
|
|
287
|
+
` const hasId = Boolean(id && id.trim());`,
|
|
288
|
+
` if (!hasId) return [null, new Error('Valid id required')];`,
|
|
289
|
+
` return [null, null];`,
|
|
290
|
+
` }`,
|
|
291
|
+
``,
|
|
292
|
+
` async insert(entity: Record<string, unknown>): Promise<ResultTuple<boolean>> {`,
|
|
293
|
+
` const hasEntity = Boolean(entity && typeof entity === 'object');`,
|
|
294
|
+
` if (!hasEntity) return [null, new Error('Valid entity required')];`,
|
|
295
|
+
` return [true, null];`,
|
|
296
|
+
` }`,
|
|
297
|
+
`}`
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
const isVitest = runner === 'vitest';
|
|
301
|
+
const testImport = isVitest ? `import { describe, it, expect } from 'vitest';` : `import { describe, it } from 'node:test';\nimport assert from 'node:assert';`;
|
|
302
|
+
|
|
303
|
+
const specLines = [
|
|
304
|
+
testImport,
|
|
305
|
+
`import { ${pascalName}Repository } from './${name}';`,
|
|
306
|
+
``,
|
|
307
|
+
`describe('${pascalName}Repository', () => {`,
|
|
308
|
+
` it('instantiates repository cleanly', () => {`,
|
|
309
|
+
` const repo = new ${pascalName}Repository();`,
|
|
310
|
+
isVitest ? ` expect(repo).toBeDefined();` : ` assert.ok(repo);`,
|
|
311
|
+
` });`,
|
|
312
|
+
`});`
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
code: codeLines.join('\n') + '\n',
|
|
317
|
+
types: null,
|
|
318
|
+
spec: specLines.join('\n') + '\n'
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const buildUtilFiles = ({ name, pascalName, methods, desc, runner }) => {
|
|
323
|
+
const methodList = methods.length > 0 ? methods : [
|
|
324
|
+
{ name: `format${pascalName}`, params: 'value: unknown', returnType: 'string' }
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
const codeLines = [
|
|
328
|
+
`/**`,
|
|
329
|
+
` * ${pascalName} Utilities`,
|
|
330
|
+
` * ${desc || 'Pure stateless functions'}`,
|
|
331
|
+
` */`,
|
|
332
|
+
``
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
for (const m of methodList) {
|
|
336
|
+
codeLines.push(`export const ${m.name} = (${m.params}): ${m.returnType} => {`);
|
|
337
|
+
codeLines.push(` const hasInput = arguments.length > 0;`);
|
|
338
|
+
codeLines.push(` if (!hasInput) return '';`);
|
|
339
|
+
codeLines.push(` return String(arguments[0] ?? '');`);
|
|
340
|
+
codeLines.push(`};`);
|
|
341
|
+
codeLines.push(``);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const isVitest = runner === 'vitest';
|
|
345
|
+
const testImport = isVitest ? `import { describe, it, expect } from 'vitest';` : `import { describe, it } from 'node:test';\nimport assert from 'node:assert';`;
|
|
346
|
+
|
|
347
|
+
const specLines = [
|
|
348
|
+
testImport,
|
|
349
|
+
`import { ${methodList[0].name} } from './${name}';`,
|
|
350
|
+
``,
|
|
351
|
+
`describe('${pascalName} Utilities', () => {`,
|
|
352
|
+
` it('executes ${methodList[0].name} reliably', () => {`,
|
|
353
|
+
` const result = ${methodList[0].name}('sample');`,
|
|
354
|
+
isVitest ? ` expect(typeof result).toBe('string');` : ` assert.strictEqual(typeof result, 'string');`,
|
|
355
|
+
` });`,
|
|
356
|
+
`});`
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
code: codeLines.join('\n') + '\n',
|
|
361
|
+
types: null,
|
|
362
|
+
spec: specLines.join('\n') + '\n'
|
|
363
|
+
};
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const buildSpecOnlyFile = ({ name, pascalName, runner }) => {
|
|
367
|
+
const isVitest = runner === 'vitest';
|
|
368
|
+
const testImport = isVitest ? `import { describe, it, expect } from 'vitest';` : `import { describe, it } from 'node:test';\nimport assert from 'node:assert';`;
|
|
369
|
+
|
|
370
|
+
const specLines = [
|
|
371
|
+
testImport,
|
|
372
|
+
``,
|
|
373
|
+
`describe('${pascalName}', () => {`,
|
|
374
|
+
` it('satisfies basic invariants', () => {`,
|
|
375
|
+
isVitest ? ` expect(true).toBe(true);` : ` assert.strictEqual(true, true);`,
|
|
376
|
+
` });`,
|
|
377
|
+
`});`
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
code: null,
|
|
382
|
+
types: null,
|
|
383
|
+
spec: specLines.join('\n') + '\n'
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Main createJigFiles Orchestrator
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
export const createJigFiles = ({
|
|
392
|
+
kind = 'service',
|
|
393
|
+
name = 'sample-service',
|
|
394
|
+
targetParent = null,
|
|
395
|
+
dir = null,
|
|
396
|
+
preset = null,
|
|
397
|
+
methods = [],
|
|
398
|
+
state = [],
|
|
399
|
+
routes = [],
|
|
400
|
+
schema = null,
|
|
401
|
+
desc = '',
|
|
402
|
+
description = '',
|
|
403
|
+
dryRun = false,
|
|
404
|
+
cwd = process.cwd()
|
|
405
|
+
} = {}) => {
|
|
406
|
+
const normKind = normalizeJigKind(kind);
|
|
407
|
+
let cleanName = String(name || `sample-${normKind}`).trim().toLowerCase();
|
|
408
|
+
const kindPrefix = new RegExp(`^${normKind}-`);
|
|
409
|
+
cleanName = cleanName.replace(kindPrefix, '');
|
|
410
|
+
const slug = `${normKind}-${cleanName}`;
|
|
411
|
+
const pascalName = toPascalCase(cleanName);
|
|
412
|
+
const camelName = toCamelCase(cleanName);
|
|
413
|
+
const parsedMethods = normalizeMethods(methods);
|
|
414
|
+
const parsedState = normalizeState(state);
|
|
415
|
+
const parsedRoutes = normalizeRoutes(routes);
|
|
416
|
+
const finalDesc = desc || description || '';
|
|
417
|
+
|
|
418
|
+
const detectedDir = detectJigBaseDir(normKind, { cwd, preset });
|
|
419
|
+
const chosenDir = dir || targetParent || detectedDir;
|
|
420
|
+
const resolvedTargetDir = path.resolve(cwd, chosenDir);
|
|
421
|
+
const runner = detectTestRunner(cwd);
|
|
422
|
+
|
|
423
|
+
let builtFiles = null;
|
|
424
|
+
if (normKind === 'service') {
|
|
425
|
+
builtFiles = buildServiceFiles({ name: slug, pascalName, camelName, methods: parsedMethods, desc: finalDesc, runner });
|
|
426
|
+
} else if (normKind === 'route') {
|
|
427
|
+
builtFiles = buildRouteFiles({ name: slug, pascalName, routes: parsedRoutes, desc: finalDesc, runner });
|
|
428
|
+
} else if (normKind === 'store') {
|
|
429
|
+
builtFiles = buildStoreFiles({ name: slug, pascalName, state: parsedState, desc: finalDesc, runner });
|
|
430
|
+
} else if (normKind === 'repo') {
|
|
431
|
+
builtFiles = buildRepoFiles({ name: slug, pascalName, desc: finalDesc, runner });
|
|
432
|
+
} else if (normKind === 'util') {
|
|
433
|
+
builtFiles = buildUtilFiles({ name: slug, pascalName, methods: parsedMethods, desc: finalDesc, runner });
|
|
434
|
+
} else {
|
|
435
|
+
builtFiles = buildSpecOnlyFile({ name: slug, pascalName, runner });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const filesToCreate = [];
|
|
439
|
+
if (builtFiles.code) {
|
|
440
|
+
filesToCreate.push({ relName: `${slug}.ts`, content: builtFiles.code });
|
|
441
|
+
}
|
|
442
|
+
if (builtFiles.types) {
|
|
443
|
+
filesToCreate.push({ relName: `${slug}.types.d.ts`, content: builtFiles.types });
|
|
444
|
+
}
|
|
445
|
+
if (builtFiles.spec) {
|
|
446
|
+
filesToCreate.push({ relName: `${slug}.spec.ts`, content: builtFiles.spec });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const previews = filesToCreate.map((f) => ({
|
|
450
|
+
file: f.relName,
|
|
451
|
+
lines: f.content.split('\n').length
|
|
452
|
+
}));
|
|
453
|
+
|
|
454
|
+
const filesCreated = filesToCreate.map((f) => f.relName);
|
|
455
|
+
|
|
456
|
+
if (!dryRun) {
|
|
457
|
+
if (!fs.existsSync(resolvedTargetDir)) {
|
|
458
|
+
fs.mkdirSync(resolvedTargetDir, { recursive: true });
|
|
459
|
+
}
|
|
460
|
+
for (const f of filesToCreate) {
|
|
461
|
+
const absPath = path.join(resolvedTargetDir, f.relName);
|
|
462
|
+
fs.writeFileSync(absPath, f.content, 'utf-8');
|
|
463
|
+
}
|
|
464
|
+
indexGeneratedFiles(cwd, resolvedTargetDir, filesCreated);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
success: true,
|
|
469
|
+
jig: true,
|
|
470
|
+
kind: normKind,
|
|
471
|
+
name: slug,
|
|
472
|
+
pascalName,
|
|
473
|
+
dryRun: Boolean(dryRun),
|
|
474
|
+
targetDir: resolvedTargetDir,
|
|
475
|
+
relativeDir: path.relative(cwd, resolvedTargetDir),
|
|
476
|
+
filesCreated,
|
|
477
|
+
previews
|
|
478
|
+
};
|
|
479
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { createJigFiles, normalizeJigKind, JIG_KINDS } from './generator-jig.js';
|
|
7
|
+
import { detectJigBaseDir, PRESET_DIRECTORY_MAPS } from './project-detector.js';
|
|
8
|
+
|
|
9
|
+
test('Jig: normalizeJigKind normalizes aliases and unknown kinds', () => {
|
|
10
|
+
assert.strictEqual(normalizeJigKind('service'), 'service');
|
|
11
|
+
assert.strictEqual(normalizeJigKind('endpoint'), 'route');
|
|
12
|
+
assert.strictEqual(normalizeJigKind('route'), 'route');
|
|
13
|
+
assert.strictEqual(normalizeJigKind('repository'), 'repo');
|
|
14
|
+
assert.strictEqual(normalizeJigKind('repo'), 'repo');
|
|
15
|
+
assert.strictEqual(normalizeJigKind('utility'), 'util');
|
|
16
|
+
assert.strictEqual(normalizeJigKind('util'), 'util');
|
|
17
|
+
assert.strictEqual(normalizeJigKind('spec'), 'spec');
|
|
18
|
+
assert.strictEqual(normalizeJigKind('unknown-kind'), 'service');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('Jig: createJigFiles dry run previews files without disk writes', () => {
|
|
22
|
+
const tmpDir = path.join(os.tmpdir(), `chemx-jig-test-${Date.now()}`);
|
|
23
|
+
const res = createJigFiles({
|
|
24
|
+
kind: 'service',
|
|
25
|
+
name: 'payment-gateway',
|
|
26
|
+
dir: 'services',
|
|
27
|
+
methods: ['charge(amount: number): Promise<ResultTuple<Receipt>>', 'refund(id: string)'],
|
|
28
|
+
desc: 'Stripe payments handler',
|
|
29
|
+
dryRun: true,
|
|
30
|
+
cwd: tmpDir
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.strictEqual(res.success, true);
|
|
34
|
+
assert.strictEqual(res.jig, true);
|
|
35
|
+
assert.strictEqual(res.dryRun, true);
|
|
36
|
+
assert.strictEqual(res.kind, 'service');
|
|
37
|
+
assert.strictEqual(res.filesCreated.length, 3);
|
|
38
|
+
assert.ok(res.filesCreated.includes('service-payment-gateway.ts'));
|
|
39
|
+
assert.ok(res.filesCreated.includes('service-payment-gateway.types.d.ts'));
|
|
40
|
+
assert.ok(res.filesCreated.includes('service-payment-gateway.spec.ts'));
|
|
41
|
+
assert.strictEqual(fs.existsSync(tmpDir), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('Jig: createJigFiles generates service with Result tuples and tests on disk', () => {
|
|
45
|
+
const tmpDir = path.join(os.tmpdir(), `chemx-jig-disk-${Date.now()}`);
|
|
46
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const res = createJigFiles({
|
|
49
|
+
kind: 'service',
|
|
50
|
+
name: 'billing-service',
|
|
51
|
+
dir: 'src/services',
|
|
52
|
+
methods: ['charge', 'refund'],
|
|
53
|
+
desc: 'Billing domain service',
|
|
54
|
+
dryRun: false,
|
|
55
|
+
cwd: tmpDir
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.strictEqual(res.success, true);
|
|
59
|
+
const serviceFile = path.join(tmpDir, 'src/services/service-billing-service.ts');
|
|
60
|
+
const typesFile = path.join(tmpDir, 'src/services/service-billing-service.types.d.ts');
|
|
61
|
+
const specFile = path.join(tmpDir, 'src/services/service-billing-service.spec.ts');
|
|
62
|
+
|
|
63
|
+
assert.ok(fs.existsSync(serviceFile));
|
|
64
|
+
assert.ok(fs.existsSync(typesFile));
|
|
65
|
+
assert.ok(fs.existsSync(specFile));
|
|
66
|
+
|
|
67
|
+
const serviceContent = fs.readFileSync(serviceFile, 'utf-8');
|
|
68
|
+
assert.ok(serviceContent.includes('export class BillingServiceService'));
|
|
69
|
+
assert.ok(serviceContent.includes('ResultTuple'));
|
|
70
|
+
assert.ok(serviceContent.includes('async charge'));
|
|
71
|
+
assert.ok(serviceContent.includes('async refund'));
|
|
72
|
+
assert.ok(serviceContent.split('\n').length < 100);
|
|
73
|
+
|
|
74
|
+
const specContent = fs.readFileSync(specFile, 'utf-8');
|
|
75
|
+
assert.ok(specContent.includes('BillingServiceService'));
|
|
76
|
+
|
|
77
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('Jig: createJigFiles generates route with validation and status tests', () => {
|
|
81
|
+
const tmpDir = path.join(os.tmpdir(), `chemx-jig-route-${Date.now()}`);
|
|
82
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
const res = createJigFiles({
|
|
85
|
+
kind: 'route',
|
|
86
|
+
name: 'api-orders',
|
|
87
|
+
routes: ['GET /orders', 'POST /orders'],
|
|
88
|
+
dryRun: false,
|
|
89
|
+
cwd: tmpDir
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.strictEqual(res.success, true);
|
|
93
|
+
const routeFile = path.join(res.targetDir, 'route-api-orders.ts');
|
|
94
|
+
const specFile = path.join(res.targetDir, 'route-api-orders.spec.ts');
|
|
95
|
+
|
|
96
|
+
assert.ok(fs.existsSync(routeFile));
|
|
97
|
+
assert.ok(fs.existsSync(specFile));
|
|
98
|
+
|
|
99
|
+
const content = fs.readFileSync(routeFile, 'utf-8');
|
|
100
|
+
assert.ok(content.includes('handleGETApiOrders'));
|
|
101
|
+
assert.ok(content.includes('handlePOSTApiOrders'));
|
|
102
|
+
assert.ok(content.includes('status: 200'));
|
|
103
|
+
assert.ok(content.includes('status: 400'));
|
|
104
|
+
assert.ok(content.split('\n').length < 100);
|
|
105
|
+
|
|
106
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('Jig: createJigFiles generates store with discriminated union state', () => {
|
|
110
|
+
const tmpDir = path.join(os.tmpdir(), `chemx-jig-store-${Date.now()}`);
|
|
111
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
112
|
+
|
|
113
|
+
const res = createJigFiles({
|
|
114
|
+
kind: 'store',
|
|
115
|
+
name: 'auth-session',
|
|
116
|
+
state: ['user: string', 'token: string'],
|
|
117
|
+
dryRun: false,
|
|
118
|
+
cwd: tmpDir
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
assert.strictEqual(res.success, true);
|
|
122
|
+
const storeFile = path.join(res.targetDir, 'store-auth-session.ts');
|
|
123
|
+
assert.ok(fs.existsSync(storeFile));
|
|
124
|
+
|
|
125
|
+
const content = fs.readFileSync(storeFile, 'utf-8');
|
|
126
|
+
assert.ok(content.includes("status: 'idle'"));
|
|
127
|
+
assert.ok(content.includes("status: 'active'"));
|
|
128
|
+
assert.ok(content.includes("status: 'fault'"));
|
|
129
|
+
assert.ok(content.includes('transitionToActive'));
|
|
130
|
+
assert.ok(content.split('\n').length < 100);
|
|
131
|
+
|
|
132
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('Jig: detectJigBaseDir resolves presets correctly', () => {
|
|
136
|
+
const tmpDir = path.join(os.tmpdir(), `chemx-jig-preset-${Date.now()}`);
|
|
137
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(detectJigBaseDir('service', { cwd: tmpDir, preset: 'src' }), 'src/services');
|
|
140
|
+
assert.strictEqual(detectJigBaseDir('service', { cwd: tmpDir, preset: 'app' }), 'app/services');
|
|
141
|
+
assert.strictEqual(detectJigBaseDir('service', { cwd: tmpDir, preset: 'root' }), 'services');
|
|
142
|
+
assert.strictEqual(detectJigBaseDir('service', { cwd: tmpDir, preset: 'lib' }), 'src/lib/services');
|
|
143
|
+
|
|
144
|
+
assert.strictEqual(detectJigBaseDir('route', { cwd: tmpDir, preset: 'app' }), 'server/api');
|
|
145
|
+
assert.strictEqual(detectJigBaseDir('route', { cwd: tmpDir, preset: 'src' }), 'src/routes');
|
|
146
|
+
|
|
147
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
148
|
+
});
|
package/cli/generator.js
CHANGED
|
@@ -32,7 +32,11 @@ import {
|
|
|
32
32
|
import { detectFramework, resolveFramework, detectTierBaseDir, detectInstalledFamily, detectTestRunner, detectStylingStack } from './project-detector.js';
|
|
33
33
|
import { indexGeneratedFiles } from './generator-indexer.js';
|
|
34
34
|
import { printGenerateHelp } from './generator-help.js';
|
|
35
|
+
import { createJigFiles, JIG_KINDS } from './generator-jig.js';
|
|
36
|
+
import { handleJigCli } from './generator-jig-cli.js';
|
|
35
37
|
export { printGenerateHelp } from './generator-help.js';
|
|
38
|
+
export { createJigFiles, JIG_KINDS } from './generator-jig.js';
|
|
39
|
+
export { handleJigCli } from './generator-jig-cli.js';
|
|
36
40
|
|
|
37
41
|
const TIERS = [
|
|
38
42
|
{ prefix: 'm-', tier: 'molecule', label: '1. m- Molecule (Self-contained feature block < 100 lines - Recommended)' },
|
|
@@ -50,7 +54,7 @@ const FRAMEWORKS = [
|
|
|
50
54
|
];
|
|
51
55
|
|
|
52
56
|
const KNOWN_TIER_NAMES = new Set(['molecule', 'atom', 'organism', 'template', 'hook', 'composable', 'view', 'page']);
|
|
53
|
-
const IGNORED_NAME_TOKENS = new Set(['generate', 'capsule', 'add', 'g', 'gen']);
|
|
57
|
+
const IGNORED_NAME_TOKENS = new Set(['generate', 'capsule', 'add', 'g', 'gen', 'jig']);
|
|
54
58
|
export const isCapsuleNameArg = (arg) => !arg.startsWith('-') && !IGNORED_NAME_TOKENS.has(arg);
|
|
55
59
|
|
|
56
60
|
const resolveSelectedTier = (explicitTier, cleanName) => {
|
|
@@ -293,7 +297,7 @@ export const runGenerateWizard = async (rawArgs = []) => {
|
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
const isJson = rawArgs.includes('--json');
|
|
296
|
-
const isYes = rawArgs.includes('-y') || rawArgs.includes('--yes') || !process.stdin.isTTY;
|
|
300
|
+
const isYes = rawArgs.includes('-y') || rawArgs.includes('--yes') || isJson || !process.stdin.isTTY;
|
|
297
301
|
if (!isJson) renderBanner('Chemical X: Molecular Capsule Wizard');
|
|
298
302
|
await checkOrPromptEvaluation('generate capsule', { isYes });
|
|
299
303
|
|
|
@@ -343,6 +347,15 @@ export const runGenerateWizard = async (rawArgs = []) => {
|
|
|
343
347
|
const hasFlatFlag = rawArgs.includes('--flat');
|
|
344
348
|
const isCompact = hasCompactFlag || hasFlatFlag;
|
|
345
349
|
|
|
350
|
+
const jigFlag = (rawArgs.find((a) => a.startsWith('--jig=')) || '').split('=')[1];
|
|
351
|
+
const kindFlag = (rawArgs.find((a) => a.startsWith('--kind=')) || '').split('=')[1];
|
|
352
|
+
const isJigWord = positional[0] && (JIG_KINDS.has(positional[0].toLowerCase()) || positional[0].toLowerCase() === 'jig');
|
|
353
|
+
const isJig = Boolean(jigFlag || kindFlag || rawArgs.includes('--jig') || isJigWord);
|
|
354
|
+
|
|
355
|
+
if (isJig) {
|
|
356
|
+
return handleJigCli({ rawArgs, positional, rawName, dirArg, descArg, isDryRun, isJson });
|
|
357
|
+
}
|
|
358
|
+
|
|
346
359
|
if (!rawName && !isYes) {
|
|
347
360
|
rawName = useGum
|
|
348
361
|
? gumInput('Capsule feature name (e.g. user-avatar, spark-kpi, auth-status):', 'user-avatar')
|