@chemx/create-chemx 26.9.16-41 → 26.9.16-668
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/cli/audit/prompts.js +10 -12
- package/cli/audit/roadmap.js +5 -20
- package/cli/audit/rules.js +4 -0
- package/cli/audit-preflight-git.js +54 -0
- package/cli/audit-preflight-scope.js +87 -0
- package/cli/audit-preflight.d.ts +16 -0
- package/cli/audit-preflight.js +85 -0
- package/cli/audit-preflight.spec.js +104 -0
- package/cli/audit.js +47 -22
- package/cli/help.js +9 -0
- package/cli/index.js +41 -7
- package/cli/installer-templates.js +39 -6
- package/package.json +1 -1
package/cli/audit/prompts.js
CHANGED
|
@@ -293,13 +293,12 @@ export const buildHotspotsPrompt = (report, options = {}) => {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
lines.push('### STRICT EXECUTION RULES:');
|
|
296
|
-
lines.push('1.
|
|
297
|
-
lines.push('2.
|
|
298
|
-
lines.push('3.
|
|
299
|
-
lines.push('4.
|
|
300
|
-
lines.push('5.
|
|
301
|
-
lines.push('6.
|
|
302
|
-
lines.push('7. Zero breaking changes to external component APIs, route exports, or existing props.');
|
|
296
|
+
lines.push('1. Pre-Split Pattern Discovery: Audit recurring UI layouts, state machines, and predicates across monoliths first; extract canonical shared capsules before slicing to prevent bespoke pattern duplication.');
|
|
297
|
+
lines.push('2. Molecular Limits: Molecule capsules must strictly remain under 100 lines per file.');
|
|
298
|
+
lines.push('3. Table-of-Contents Views: Top-level page views must be 10 to 20 line declarative Table of Contents assembling molecules via named slots.');
|
|
299
|
+
lines.push('4. Composable Return Contracts: Custom hooks/composables must strictly limit returns to 3 to 5 properties (State + Status + Actions).');
|
|
300
|
+
lines.push('5. Type Co-location: Co-locate granular types/*.d.ts files inside each feature capsule (< 100 lines per type file) instead of creating type monoliths.');
|
|
301
|
+
lines.push('6. Zero breaking changes to external component APIs, route exports, or existing props.');
|
|
303
302
|
if (!isSubSection) {
|
|
304
303
|
lines.push('');
|
|
305
304
|
lines.push(buildAgentCommandsSection());
|
|
@@ -340,11 +339,10 @@ export const buildPillarPrompt = (report, pillarName, options = {}) => {
|
|
|
340
339
|
}
|
|
341
340
|
|
|
342
341
|
lines.push('### STRICT EXECUTION RULES:');
|
|
343
|
-
lines.push('1.
|
|
344
|
-
lines.push('2.
|
|
345
|
-
lines.push('3.
|
|
346
|
-
lines.push('4.
|
|
347
|
-
lines.push('5. Zero breaking changes to external component APIs, route exports, or existing props.');
|
|
342
|
+
lines.push('1. Pre-Split Pattern Discovery: Survey cross-file patterns before slicing; extract canonical shared capsules first.');
|
|
343
|
+
lines.push('2. Molecular Capsule Limit: Maximum 100 lines per molecule capsule file.');
|
|
344
|
+
lines.push('3. Table-of-Contents Views: Top-level page views must be 10 to 20 line declarative templates assembling components via named slots.');
|
|
345
|
+
lines.push('4. Zero breaking changes to external component APIs, route exports, or existing props.');
|
|
348
346
|
if (!isSubSection) {
|
|
349
347
|
lines.push('');
|
|
350
348
|
lines.push(buildAgentCommandsSection());
|
package/cli/audit/roadmap.js
CHANGED
|
@@ -128,12 +128,6 @@ export const buildRemediationRoadmap = (report) => {
|
|
|
128
128
|
target: '.git/hooks/pre-commit & .github/workflows/',
|
|
129
129
|
action: 'Run npx chemx install to lock in line budgets and prevent future monolith regressions.',
|
|
130
130
|
locations: []
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
title: 'Branch, Commit & Pull Request Workflow',
|
|
134
|
-
target: 'git checkout -b chem-x/NAMEOFIMPROVEMENT',
|
|
135
|
-
action: 'Change to a branch prefixed with chem-x/NAMEOFIMPROVEMENT to make changes, commit the branch, and create a PR to main.',
|
|
136
|
-
locations: []
|
|
137
131
|
}
|
|
138
132
|
]
|
|
139
133
|
});
|
|
@@ -202,13 +196,6 @@ export const buildSelfHealingRoadmapPrompt = (report) => {
|
|
|
202
196
|
|
|
203
197
|
lines.push('Act as a Principal Systems Architect. Execute a self-healing architectural remediation of our codebase by following this strict multi-phase sequence in order:\n');
|
|
204
198
|
|
|
205
|
-
lines.push('### MANDATORY GIT BRANCHING & PR WORKFLOW:');
|
|
206
|
-
lines.push('1. Before modifying any files, change to a dedicated feature branch prefixed with `chem-x/NAMEOFIMPROVEMENT`:');
|
|
207
|
-
lines.push(' `git checkout -b chem-x/NAMEOFIMPROVEMENT`');
|
|
208
|
-
lines.push('2. Execute each remediation phase sequentially on this branch.');
|
|
209
|
-
lines.push('3. Commit the branch with atomic, intention-revealing commit messages.');
|
|
210
|
-
lines.push('4. Create a Pull Request (PR) to `main` upon completing the remediation phases.\n');
|
|
211
|
-
|
|
212
199
|
lines.push('### AI AGENT DISCOVERY & REFACTORING COMMANDS:');
|
|
213
200
|
lines.push('- Discovery & Inspect: Run `pnpm q "<target>" --inspect` (or `npx chemx search "<target>" --inspect`) to inspect component props and hooks before editing.');
|
|
214
201
|
lines.push('- Tier Filter: Run `pnpm q "<query>" --tier=molecule` (or `atom`, `organism`, `hook`) to find related capsules.');
|
|
@@ -234,13 +221,11 @@ export const buildSelfHealingRoadmapPrompt = (report) => {
|
|
|
234
221
|
});
|
|
235
222
|
|
|
236
223
|
lines.push('### EXECUTION DISCIPLINE:');
|
|
237
|
-
lines.push('1.
|
|
238
|
-
lines.push('2.
|
|
239
|
-
lines.push('3.
|
|
240
|
-
lines.push('4.
|
|
241
|
-
lines.push('5.
|
|
242
|
-
lines.push('6. Zero synthetic or mock data; zero em dashes (use hyphens or colons).');
|
|
243
|
-
lines.push('7. PR to Main: Commit the branch and create a Pull Request to main.');
|
|
224
|
+
lines.push('1. Complete Step 1 (Pattern Harvesting) completely before touching any monolithic file in Step 3.');
|
|
225
|
+
lines.push('2. Spliced views in Step 3 must directly import and bind to the canonical capsules created in Step 1.');
|
|
226
|
+
lines.push('3. Every new molecule component must stay under 100 lines.');
|
|
227
|
+
lines.push('4. Top-level page views must be 10-20 line declarative Table-of-Contents templates assembling molecules via named slots.');
|
|
228
|
+
lines.push('5. Zero synthetic or mock data; zero em dashes (use hyphens or colons).');
|
|
244
229
|
|
|
245
230
|
return lines.join('\n');
|
|
246
231
|
};
|
package/cli/audit/rules.js
CHANGED
|
@@ -102,6 +102,10 @@ export const auditCode = (content, filePath, relativePath, options = {}) => {
|
|
|
102
102
|
// Pillars 8-11: Extended Fast Checks (A11y, Security secrets, Fake tests, Co-located specs)
|
|
103
103
|
checkExtendedTextPatterns(content, lines, relativePath, filePath, violations);
|
|
104
104
|
|
|
105
|
+
if (options.fast) {
|
|
106
|
+
return violations;
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
// Vue template structural pattern discovery
|
|
106
110
|
if (ext === '.vue' && options.patternRegistry) {
|
|
107
111
|
const templateMatch = content.match(/<template\b[^>]*>([\s\S]*?)<\/template>/i);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
const SOURCE_FILE_PATTERN = /\.(tsx|ts|jsx|js|vue|svelte)$/;
|
|
5
|
+
const EXCLUDED_PATTERNS = ['.test.', '.spec.', '.min.', '.d.ts'];
|
|
6
|
+
|
|
7
|
+
export const isSourceFilePath = (filePath) => {
|
|
8
|
+
const isExtensionValid = SOURCE_FILE_PATTERN.test(filePath);
|
|
9
|
+
if (!isExtensionValid) return false;
|
|
10
|
+
const isExcluded = EXCLUDED_PATTERNS.some((pat) => filePath.includes(pat));
|
|
11
|
+
return !isExcluded;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const parseGitStatusOutput = (output) => {
|
|
15
|
+
const lines = output.split('\n');
|
|
16
|
+
const files = [];
|
|
17
|
+
|
|
18
|
+
for (const rawLine of lines) {
|
|
19
|
+
const trimmed = rawLine.trim();
|
|
20
|
+
if (!trimmed) continue;
|
|
21
|
+
const cleanPath = trimmed.replace(/^[MADRCU?! ]+\s+/, '').trim().replace(/^"|"$/g, '');
|
|
22
|
+
if (cleanPath) {
|
|
23
|
+
files.push(cleanPath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return files;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const getGitChangedFiles = (cwd = process.cwd()) => {
|
|
31
|
+
try {
|
|
32
|
+
const result = spawnSync('git', ['status', '--porcelain', '-u'], { cwd, encoding: 'utf-8' });
|
|
33
|
+
const isSuccess = result.status === 0;
|
|
34
|
+
if (!isSuccess || !result.stdout) return [];
|
|
35
|
+
return parseGitStatusOutput(result.stdout);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const resolveGitAuditScope = (cwd = process.cwd()) => {
|
|
42
|
+
const rawFiles = getGitChangedFiles(cwd);
|
|
43
|
+
const existingSourceFiles = rawFiles.filter((relPath) => {
|
|
44
|
+
const isSource = isSourceFilePath(relPath);
|
|
45
|
+
const exists = fs.existsSync(relPath);
|
|
46
|
+
return isSource && exists;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const hasChangedFiles = existingSourceFiles.length > 0;
|
|
50
|
+
return {
|
|
51
|
+
ok: hasChangedFiles,
|
|
52
|
+
files: existingSourceFiles
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hasGum, gumChoose, gumInput, promptQuestion } from './terminal.js';
|
|
4
|
+
import { resolveGitAuditScope } from './audit-preflight-git.js';
|
|
5
|
+
|
|
6
|
+
const CANDIDATE_NAMES = ['src', 'app', 'cli', 'components', 'lib', 'packages', 'modules'];
|
|
7
|
+
|
|
8
|
+
export const detectCandidateDirectories = (cwd = process.cwd()) => {
|
|
9
|
+
return CANDIDATE_NAMES.filter((name) => {
|
|
10
|
+
const fullPath = path.resolve(cwd, name);
|
|
11
|
+
return fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory();
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const promptSpecificDirectory = async (cwd) => {
|
|
16
|
+
const candidates = detectCandidateDirectories(cwd);
|
|
17
|
+
const options = [...candidates.map((dir) => `📂 ${dir}`), '✏️ Custom directory path...'];
|
|
18
|
+
|
|
19
|
+
let choice = '';
|
|
20
|
+
if (hasGum()) {
|
|
21
|
+
choice = gumChoose(options, 'Select Target Directory:');
|
|
22
|
+
} else {
|
|
23
|
+
process.stdout.write('\n' + options.map((opt, i) => ` ${i + 1}. ${opt}`).join('\n') + '\n\n');
|
|
24
|
+
const ans = await promptQuestion(`Select directory [1-${options.length}]: `);
|
|
25
|
+
const index = parseInt(ans, 10) - 1;
|
|
26
|
+
choice = options[index] || '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isCustomPath = !choice || choice.includes('Custom');
|
|
30
|
+
if (isCustomPath) {
|
|
31
|
+
if (hasGum()) {
|
|
32
|
+
return gumInput('Target directory path:', 'src') || 'src';
|
|
33
|
+
}
|
|
34
|
+
const entered = await promptQuestion('Target directory path [src]: ');
|
|
35
|
+
return entered || 'src';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const matched = candidates.find((dir) => choice.includes(dir));
|
|
39
|
+
return matched || 'src';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const promptAuditScope = async (defaultDir, rawArgs, cwd = process.cwd()) => {
|
|
43
|
+
const hasGitFlag = rawArgs.includes('--git') || rawArgs.includes('--changed');
|
|
44
|
+
if (hasGitFlag) {
|
|
45
|
+
const gitScope = resolveGitAuditScope(cwd);
|
|
46
|
+
return { targetDir: defaultDir, fileList: gitScope.ok ? gitScope.files : null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const scopeOptions = [
|
|
50
|
+
'📁 Whole Project (Scan entire workspace)',
|
|
51
|
+
'📂 Specific Directory (Select target folder)',
|
|
52
|
+
'🌿 Git Changed Files (Scan modified or staged files only)'
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
let scopeChoice = '';
|
|
56
|
+
if (hasGum()) {
|
|
57
|
+
scopeChoice = gumChoose(scopeOptions, 'Select Audit Scope:');
|
|
58
|
+
} else {
|
|
59
|
+
process.stdout.write('\n' + scopeOptions.map((opt, i) => ` ${i + 1}. ${opt}`).join('\n') + '\n\n');
|
|
60
|
+
const ans = await promptQuestion(`Select audit scope [1-${scopeOptions.length}]: `);
|
|
61
|
+
const index = parseInt(ans, 10) - 1;
|
|
62
|
+
scopeChoice = scopeOptions[index] || '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isCancelled = !scopeChoice;
|
|
66
|
+
if (isCancelled) return null;
|
|
67
|
+
|
|
68
|
+
const isGit = scopeChoice.includes('Git');
|
|
69
|
+
if (isGit) {
|
|
70
|
+
const gitScope = resolveGitAuditScope(cwd);
|
|
71
|
+
const hasFiles = gitScope.ok;
|
|
72
|
+
if (!hasFiles) {
|
|
73
|
+
process.stdout.write('\n\x1b[33m⚠ No git modified or staged source files found. Auditing whole project.\x1b[0m\n');
|
|
74
|
+
return { targetDir: defaultDir, fileList: null };
|
|
75
|
+
}
|
|
76
|
+
process.stdout.write(`\n\x1b[32m✔ Focused on ${gitScope.files.length} git modified/staged file(s).\x1b[0m\n`);
|
|
77
|
+
return { targetDir: defaultDir, fileList: gitScope.files };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isSpecific = scopeChoice.includes('Specific');
|
|
81
|
+
if (isSpecific) {
|
|
82
|
+
const chosenDir = await promptSpecificDirectory(cwd);
|
|
83
|
+
return { targetDir: chosenDir, fileList: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { targetDir: defaultDir, fileList: null };
|
|
87
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface PreflightResult {
|
|
2
|
+
readonly targetDir: string;
|
|
3
|
+
readonly fast: boolean;
|
|
4
|
+
readonly fileList: readonly string[] | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PreflightOptions {
|
|
8
|
+
readonly customDir?: string | null;
|
|
9
|
+
readonly defaultDir?: string;
|
|
10
|
+
readonly cwd?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export declare function detectCandidateDirectories(cwd?: string): string[];
|
|
14
|
+
export declare function resolveGitAuditScope(cwd?: string): { ok: boolean; files: string[] };
|
|
15
|
+
export declare function getGitChangedFiles(cwd?: string): string[];
|
|
16
|
+
export declare function runAuditPreflight(rawArgs: readonly string[], options?: PreflightOptions): Promise<PreflightResult>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { hasGum, gumChoose, promptQuestion } from './terminal.js';
|
|
2
|
+
import { promptAuditScope } from './audit-preflight-scope.js';
|
|
3
|
+
import { isNonInteractiveSession } from './audit/rules-predicates.js';
|
|
4
|
+
import { resolveGitAuditScope } from './audit-preflight-git.js';
|
|
5
|
+
|
|
6
|
+
export { detectCandidateDirectories } from './audit-preflight-scope.js';
|
|
7
|
+
export { resolveGitAuditScope, getGitChangedFiles } from './audit-preflight-git.js';
|
|
8
|
+
|
|
9
|
+
const DEPTH_OPTIONS = [
|
|
10
|
+
'🔬 Full AST Audit (Deep molecular analysis, AST visitors, slop, patterns)',
|
|
11
|
+
'⚡ Fast Scan (Instant line-budget, token burn, and file metrics)'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const promptAuditDepth = async (rawArgs) => {
|
|
15
|
+
const hasFastFlag = rawArgs.includes('--fast') || rawArgs.includes('--quick');
|
|
16
|
+
const hasFullFlag = rawArgs.includes('--full') || rawArgs.includes('--deep');
|
|
17
|
+
|
|
18
|
+
if (hasFastFlag) return true;
|
|
19
|
+
if (hasFullFlag) return false;
|
|
20
|
+
|
|
21
|
+
let depthChoice = '';
|
|
22
|
+
if (hasGum()) {
|
|
23
|
+
depthChoice = gumChoose(DEPTH_OPTIONS, 'Select Audit Depth:');
|
|
24
|
+
} else {
|
|
25
|
+
process.stdout.write('\n' + DEPTH_OPTIONS.map((opt, i) => ` ${i + 1}. ${opt}`).join('\n') + '\n\n');
|
|
26
|
+
const ans = await promptQuestion(`Select audit depth [1-${DEPTH_OPTIONS.length}]: `);
|
|
27
|
+
const index = parseInt(ans, 10) - 1;
|
|
28
|
+
depthChoice = DEPTH_OPTIONS[index] || '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isCancelled = !depthChoice;
|
|
32
|
+
if (isCancelled) return null;
|
|
33
|
+
|
|
34
|
+
return depthChoice.includes('Fast');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const runAuditPreflight = async (rawArgs, options = {}) => {
|
|
38
|
+
const { customDir = null, defaultDir = 'src', cwd = process.cwd() } = options;
|
|
39
|
+
|
|
40
|
+
const isNonInteractive = isNonInteractiveSession(rawArgs);
|
|
41
|
+
const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
42
|
+
const hasYesFlag = rawArgs.includes('-y') || rawArgs.includes('--yes');
|
|
43
|
+
const isInteractive = !isNonInteractive && hasTty && !hasYesFlag;
|
|
44
|
+
|
|
45
|
+
const hasExplicitDir = Boolean(customDir) || rawArgs.some((arg) => arg.startsWith('--dir='));
|
|
46
|
+
const hasExplicitGit = rawArgs.includes('--git') || rawArgs.includes('--changed');
|
|
47
|
+
const hasExplicitFast = rawArgs.includes('--fast') || rawArgs.includes('--quick');
|
|
48
|
+
const hasExplicitFull = rawArgs.includes('--full') || rawArgs.includes('--deep');
|
|
49
|
+
const isFullySpecified = (hasExplicitDir || hasExplicitGit) && (hasExplicitFast || hasExplicitFull);
|
|
50
|
+
|
|
51
|
+
if (!isInteractive || isFullySpecified) {
|
|
52
|
+
const fast = hasExplicitFast;
|
|
53
|
+
const fileList = hasExplicitGit ? resolveGitAuditScope(cwd).files : null;
|
|
54
|
+
return { targetDir: customDir || defaultDir, fast, fileList };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.stdout.write('\n\x1b[1m\x1b[38;5;45m⚡ Chemical X Protocol\x1b[0m \x1b[38;5;220mInteractive Pre-Flight\x1b[0m\n\n');
|
|
58
|
+
|
|
59
|
+
let targetDir = customDir || defaultDir;
|
|
60
|
+
let fileList = null;
|
|
61
|
+
|
|
62
|
+
const needsScopePrompt = !hasExplicitDir && !hasExplicitGit;
|
|
63
|
+
if (needsScopePrompt) {
|
|
64
|
+
const scopeResult = await promptAuditScope(defaultDir, rawArgs, cwd);
|
|
65
|
+
if (!scopeResult) {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
targetDir = scopeResult.targetDir;
|
|
69
|
+
fileList = scopeResult.fileList;
|
|
70
|
+
} else if (hasExplicitGit) {
|
|
71
|
+
fileList = resolveGitAuditScope(cwd).files;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const depthResult = await promptAuditDepth(rawArgs);
|
|
75
|
+
if (depthResult === null) {
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
const fast = depthResult;
|
|
79
|
+
|
|
80
|
+
const modeLabel = fast ? 'Fast line-budget scan' : 'Full molecular AST audit';
|
|
81
|
+
const targetLabel = fileList ? `${fileList.length} git modified file(s)` : targetDir;
|
|
82
|
+
process.stdout.write(`\n\x1b[36m⚡ Starting ${modeLabel} for ${targetLabel}...\x1b[0m\n\n`);
|
|
83
|
+
|
|
84
|
+
return { targetDir, fast, fileList };
|
|
85
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { isSourceFilePath, parseGitStatusOutput } from './audit-preflight-git.js';
|
|
4
|
+
import { detectCandidateDirectories } from './audit-preflight-scope.js';
|
|
5
|
+
import { runAuditPreflight } from './audit-preflight.js';
|
|
6
|
+
import { scanTree, runAudit } from './audit.js';
|
|
7
|
+
import { auditCode } from './audit/rules.js';
|
|
8
|
+
|
|
9
|
+
test('isSourceFilePath: correctly validates source files and excludes tests', () => {
|
|
10
|
+
assert.strictEqual(isSourceFilePath('src/app.tsx'), true);
|
|
11
|
+
assert.strictEqual(isSourceFilePath('cli/index.js'), true);
|
|
12
|
+
assert.strictEqual(isSourceFilePath('components/card.vue'), true);
|
|
13
|
+
assert.strictEqual(isSourceFilePath('ui/button.svelte'), true);
|
|
14
|
+
assert.strictEqual(isSourceFilePath('src/app.test.tsx'), false);
|
|
15
|
+
assert.strictEqual(isSourceFilePath('src/app.spec.js'), false);
|
|
16
|
+
assert.strictEqual(isSourceFilePath('dist/bundle.min.js'), false);
|
|
17
|
+
assert.strictEqual(isSourceFilePath('types/index.d.ts'), false);
|
|
18
|
+
assert.strictEqual(isSourceFilePath('README.md'), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parseGitStatusOutput: extracts clean file paths from git porcelain output', () => {
|
|
22
|
+
const output = [
|
|
23
|
+
' M src/components/card.tsx',
|
|
24
|
+
'A cli/new-command.js',
|
|
25
|
+
'?? scripts/scratch.ts',
|
|
26
|
+
' D old-file.js',
|
|
27
|
+
'"quoted/path/with space.ts"'
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
const parsed = parseGitStatusOutput(output);
|
|
31
|
+
assert.strictEqual(parsed.length, 5);
|
|
32
|
+
assert.strictEqual(parsed[0], 'src/components/card.tsx');
|
|
33
|
+
assert.strictEqual(parsed[1], 'cli/new-command.js');
|
|
34
|
+
assert.strictEqual(parsed[2], 'scripts/scratch.ts');
|
|
35
|
+
assert.strictEqual(parsed[3], 'old-file.js');
|
|
36
|
+
assert.strictEqual(parsed[4], 'quoted/path/with space.ts');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('detectCandidateDirectories: identifies existing directory targets', () => {
|
|
40
|
+
const detected = detectCandidateDirectories(process.cwd());
|
|
41
|
+
assert.ok(Array.isArray(detected));
|
|
42
|
+
assert.ok(detected.includes('cli'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('runAuditPreflight: non-interactive mode resolves immediately without questions', async () => {
|
|
46
|
+
const result = await runAuditPreflight(['--ci'], {
|
|
47
|
+
defaultDir: 'cli',
|
|
48
|
+
cwd: process.cwd()
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
52
|
+
assert.strictEqual(result.fast, false);
|
|
53
|
+
assert.strictEqual(result.fileList, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('runAuditPreflight: explicit fast and customDir flags bypass questions', async () => {
|
|
57
|
+
const result = await runAuditPreflight(['--fast', '--dir=cli'], {
|
|
58
|
+
customDir: 'cli',
|
|
59
|
+
defaultDir: 'src',
|
|
60
|
+
cwd: process.cwd()
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
64
|
+
assert.strictEqual(result.fast, true);
|
|
65
|
+
assert.strictEqual(result.fileList, null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('runAuditPreflight: yes flag accepts defaults immediately', async () => {
|
|
69
|
+
const result = await runAuditPreflight(['-y'], {
|
|
70
|
+
defaultDir: 'cli',
|
|
71
|
+
cwd: process.cwd()
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
75
|
+
assert.strictEqual(result.fast, false);
|
|
76
|
+
assert.strictEqual(result.fileList, null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('auditCode: fast mode executes line checks and bypasses AST parser', () => {
|
|
80
|
+
const longFile = 'const a = 1;\n'.repeat(600);
|
|
81
|
+
const violations = auditCode(longFile, 'src/monolith.ts', 'src/monolith.ts', { fast: true });
|
|
82
|
+
|
|
83
|
+
assert.ok(violations.length > 0);
|
|
84
|
+
const hasLineBudget = violations.some((v) => v.rule === 'LINE_BUDGET_FILE');
|
|
85
|
+
assert.strictEqual(hasLineBudget, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('scanTree: fileList restricts scanning strictly to specified files', () => {
|
|
89
|
+
const result = scanTree('cli', process.cwd(), {
|
|
90
|
+
fileList: ['cli/installer-templates.js']
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.strictEqual(result.fileStats.length, 1);
|
|
94
|
+
assert.strictEqual(result.fileStats[0].relativePath, 'cli/installer-templates.js');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('runAudit: fast mode returns valid health metrics with zero AST traversal', () => {
|
|
98
|
+
const report = runAudit('cli', { fast: true });
|
|
99
|
+
|
|
100
|
+
assert.ok(report.scannedFiles > 0);
|
|
101
|
+
assert.ok(report.health);
|
|
102
|
+
assert.ok(typeof report.health.score === 'number');
|
|
103
|
+
assert.ok(typeof report.health.grade === 'string');
|
|
104
|
+
});
|
package/cli/audit.js
CHANGED
|
@@ -97,11 +97,48 @@ export const auditFile = (filePath, relativePath) => {
|
|
|
97
97
|
return auditCode(content, filePath, relativePath);
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
const auditFileEntry = (fullPath, relPath, scanOptions) => {
|
|
101
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
102
|
+
const lines = content.split('\n');
|
|
103
|
+
const baseName = path.basename(fullPath);
|
|
104
|
+
const isMolecule = relPath.includes('molecules') || relPath.includes('/m-') || baseName.startsWith('m-');
|
|
105
|
+
|
|
106
|
+
const fileStat = {
|
|
107
|
+
fullPath,
|
|
108
|
+
relativePath: relPath,
|
|
109
|
+
lineCount: lines.length,
|
|
110
|
+
charCount: content.length,
|
|
111
|
+
isMolecule
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const hookMatches = content.match(/\buse[A-Z0-9]\w*\b/g);
|
|
115
|
+
const hookCount = hookMatches ? hookMatches.length : 0;
|
|
116
|
+
const fileViolations = auditCode(content, fullPath, relPath, {
|
|
117
|
+
patternRegistry: scanOptions.patternRegistry,
|
|
118
|
+
fast: scanOptions.fast
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return { fileStat, hookCount, fileViolations };
|
|
122
|
+
};
|
|
123
|
+
|
|
100
124
|
export const scanTree = (targetDir, baseDir, scanOptions = {}) => {
|
|
101
125
|
let violations = [];
|
|
102
126
|
let fileStats = [];
|
|
103
127
|
let totalHooks = 0;
|
|
104
|
-
|
|
128
|
+
|
|
129
|
+
if (scanOptions.fileList && scanOptions.fileList.length > 0) {
|
|
130
|
+
for (const item of scanOptions.fileList) {
|
|
131
|
+
const fullPath = path.isAbsolute(item) ? item : path.resolve(baseDir, item);
|
|
132
|
+
const relPath = path.relative(baseDir, fullPath);
|
|
133
|
+
if (fs.existsSync(fullPath) && isSourceFile(path.basename(fullPath))) {
|
|
134
|
+
const result = auditFileEntry(fullPath, relPath, scanOptions);
|
|
135
|
+
violations = violations.concat(result.fileViolations);
|
|
136
|
+
fileStats.push(result.fileStat);
|
|
137
|
+
totalHooks += result.hookCount;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { violations, fileStats, totalHooks };
|
|
141
|
+
}
|
|
105
142
|
|
|
106
143
|
if (!fs.existsSync(targetDir)) {
|
|
107
144
|
return { violations, fileStats, totalHooks };
|
|
@@ -120,26 +157,10 @@ export const scanTree = (targetDir, baseDir, scanOptions = {}) => {
|
|
|
120
157
|
totalHooks += sub.totalHooks;
|
|
121
158
|
}
|
|
122
159
|
} else if (isSourceFile(entry.name)) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
fileStats.push({
|
|
128
|
-
fullPath,
|
|
129
|
-
relativePath: relPath,
|
|
130
|
-
lineCount: lines.length,
|
|
131
|
-
charCount: content.length,
|
|
132
|
-
isMolecule
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
// Count hook declarations / invocations for metric
|
|
136
|
-
const hookMatches = content.match(/\buse[A-Z0-9]\w*\b/g);
|
|
137
|
-
if (hookMatches) {
|
|
138
|
-
totalHooks += hookMatches.length;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const fileViolations = auditCode(content, fullPath, relPath, { patternRegistry });
|
|
142
|
-
violations = violations.concat(fileViolations);
|
|
160
|
+
const result = auditFileEntry(fullPath, relPath, scanOptions);
|
|
161
|
+
violations = violations.concat(result.fileViolations);
|
|
162
|
+
fileStats.push(result.fileStat);
|
|
163
|
+
totalHooks += result.hookCount;
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
|
|
@@ -155,7 +176,11 @@ export const runAudit = (targetDir = 'src', options = {}) => {
|
|
|
155
176
|
const cwd = process.cwd();
|
|
156
177
|
const absoluteTarget = path.resolve(cwd, targetDir);
|
|
157
178
|
const patternRegistry = createPatternRegistry();
|
|
158
|
-
const { violations, fileStats, totalHooks } = scanTree(absoluteTarget, cwd, {
|
|
179
|
+
const { violations, fileStats, totalHooks } = scanTree(absoluteTarget, cwd, {
|
|
180
|
+
patternRegistry,
|
|
181
|
+
fast: Boolean(options.fast),
|
|
182
|
+
fileList: options.fileList || null
|
|
183
|
+
});
|
|
159
184
|
|
|
160
185
|
const scannedFiles = fileStats.length;
|
|
161
186
|
const totalLoc = fileStats.reduce((acc, f) => acc + f.lineCount, 0);
|
package/cli/help.js
CHANGED
|
@@ -111,6 +111,15 @@ export const printHelp = () => {
|
|
|
111
111
|
` Display this comprehensive CLI documentation and options guide.`,
|
|
112
112
|
'',
|
|
113
113
|
`${BOLD}AUDIT OPTIONS & FLAGS${RESET}`,
|
|
114
|
+
` ${CYAN}--fast${RESET}, ${CYAN}--quick${RESET}`,
|
|
115
|
+
` Fast scan: verify line budgets, token burn, and file metrics, bypassing deep AST traversal.`,
|
|
116
|
+
'',
|
|
117
|
+
` ${CYAN}--git${RESET}, ${CYAN}--changed${RESET}`,
|
|
118
|
+
` Scope audit strictly to modified, staged, or untracked source files in git.`,
|
|
119
|
+
'',
|
|
120
|
+
` ${CYAN}-y${RESET}, ${CYAN}--yes${RESET}`,
|
|
121
|
+
` Accept defaults and bypass interactive pre-flight wizard questions.`,
|
|
122
|
+
'',
|
|
114
123
|
` ${CYAN}--dir=<path>${RESET}`,
|
|
115
124
|
` Directory to analyze. Defaults to ${CYAN}src${RESET} if present, else current working directory.`,
|
|
116
125
|
` Example: ${CYAN}npx chemx audit --dir=apps/web/src${RESET}`,
|
package/cli/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
evaluateAuditFailure,
|
|
31
31
|
isNonInteractiveSession
|
|
32
32
|
} from './audit/rules-predicates.js';
|
|
33
|
+
import { runAuditPreflight, resolveGitAuditScope } from './audit-preflight.js';
|
|
33
34
|
import { runInstallWizard } from './installer.js';
|
|
34
35
|
import { runBadgeCommand } from './badge.js';
|
|
35
36
|
import { runBuildAudit } from './build.js';
|
|
@@ -84,8 +85,42 @@ export const runAudit = async (customDir = null, isCli = false) => {
|
|
|
84
85
|
const costFlag = rawArgs.find((arg) => arg.startsWith('--cost-per-million='));
|
|
85
86
|
const costPerMillion = costFlag ? parseFloat(costFlag.split('=')[1]) : null;
|
|
86
87
|
|
|
87
|
-
const
|
|
88
|
-
const
|
|
88
|
+
const isNonInteractive = isNonInteractiveSession(rawArgs);
|
|
89
|
+
const isInteractive = !isNonInteractive && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
90
|
+
|
|
91
|
+
let targetDir = resolveTargetDir(customDir, dirFlag);
|
|
92
|
+
let isFast = rawArgs.includes('--fast') || rawArgs.includes('--quick');
|
|
93
|
+
let fileList = null;
|
|
94
|
+
|
|
95
|
+
const hasGitFlag = rawArgs.includes('--git') || rawArgs.includes('--changed');
|
|
96
|
+
if (hasGitFlag) {
|
|
97
|
+
const gitScope = resolveGitAuditScope(process.cwd());
|
|
98
|
+
if (gitScope.ok) {
|
|
99
|
+
fileList = gitScope.files;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const shouldRunPreflight = isCli && isInteractive && !isJson && !isMarkdown && !isShare;
|
|
104
|
+
if (shouldRunPreflight) {
|
|
105
|
+
const preflight = await runAuditPreflight(rawArgs, {
|
|
106
|
+
customDir,
|
|
107
|
+
defaultDir: targetDir,
|
|
108
|
+
cwd: process.cwd()
|
|
109
|
+
});
|
|
110
|
+
targetDir = preflight.targetDir;
|
|
111
|
+
isFast = preflight.fast;
|
|
112
|
+
fileList = preflight.fileList;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const auditOptions = {
|
|
116
|
+
outputFile,
|
|
117
|
+
model,
|
|
118
|
+
costPerMillion,
|
|
119
|
+
fast: isFast,
|
|
120
|
+
fileList
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const report = executeAstAudit(targetDir, auditOptions);
|
|
89
124
|
saveAuditSnapshot(report);
|
|
90
125
|
const syncRes = syncSearchIndex(targetDir, process.cwd());
|
|
91
126
|
if (syncRes?.db) {
|
|
@@ -120,12 +155,9 @@ export const runAudit = async (customDir = null, isCli = false) => {
|
|
|
120
155
|
process.exit(0);
|
|
121
156
|
}
|
|
122
157
|
|
|
123
|
-
const isNonInteractive = isNonInteractiveSession(rawArgs);
|
|
124
|
-
const isInteractive = !isNonInteractive && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
125
|
-
|
|
126
158
|
if (isInteractive && !isUnroll) {
|
|
127
159
|
const handleReAudit = () => {
|
|
128
|
-
const refreshed = executeAstAudit(targetDir,
|
|
160
|
+
const refreshed = executeAstAudit(targetDir, auditOptions);
|
|
129
161
|
saveAuditSnapshot(refreshed);
|
|
130
162
|
return refreshed;
|
|
131
163
|
};
|
|
@@ -198,7 +230,9 @@ export {
|
|
|
198
230
|
runBuildAudit,
|
|
199
231
|
runSearch,
|
|
200
232
|
syncSearchIndex,
|
|
201
|
-
|
|
233
|
+
runMcpServer,
|
|
234
|
+
runMcpServer as startMcpServer,
|
|
235
|
+
runMcpInstaller,
|
|
202
236
|
runReaderCli,
|
|
203
237
|
readTokenOptimized,
|
|
204
238
|
runPatcherCli,
|
|
@@ -2,15 +2,46 @@ export const buildPreCommitHookScript = (minGrade = 'B', minScore = 80) => `#!/b
|
|
|
2
2
|
# Chemical X Protocol: Pre-Commit Line Budget & Architecture Gatekeeper
|
|
3
3
|
# Free architectural guardrail preventing context bloat and monolith sprawl.
|
|
4
4
|
|
|
5
|
-
MIN_GRADE="\${CHEMX_MIN_GRADE:-${minGrade}}"
|
|
6
|
-
MIN_SCORE="\${CHEMX_MIN_SCORE:-${minScore}}"
|
|
7
|
-
MAX_LINES="\${CHEMX_MAX_LINES:-500}"
|
|
8
|
-
MAX_MOLECULE_LINES="\${CHEMX_MAX_MOLECULE_LINES:-100}"
|
|
9
|
-
|
|
10
5
|
REPO_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
11
6
|
[ -z "\$REPO_ROOT" ] && exit 0
|
|
12
7
|
cd "\$REPO_ROOT" || exit 1
|
|
13
8
|
|
|
9
|
+
# Delegate to version-controlled script if present
|
|
10
|
+
if [ -f "\$REPO_ROOT/scripts/pre-commit.sh" ]; then
|
|
11
|
+
exec "\$REPO_ROOT/scripts/pre-commit.sh" "\$@"
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# Load thresholds from .chemx/config.json if available
|
|
15
|
+
CONF_MAX_LINES=""
|
|
16
|
+
CONF_MAX_MOL=""
|
|
17
|
+
CONF_MIN_GRADE=""
|
|
18
|
+
CONF_MIN_SCORE=""
|
|
19
|
+
CONFIG_FILE="\$REPO_ROOT/.chemx/config.json"
|
|
20
|
+
|
|
21
|
+
if [ -f "\$CONFIG_FILE" ]; then
|
|
22
|
+
if command -v node >/dev/null 2>&1; then
|
|
23
|
+
eval \$(node -e "
|
|
24
|
+
try {
|
|
25
|
+
const c = JSON.parse(require('fs').readFileSync('\$CONFIG_FILE', 'utf8'));
|
|
26
|
+
if (c.maxLineCount || c.maxLines) console.log('CONF_MAX_LINES=' + (c.maxLineCount || c.maxLines));
|
|
27
|
+
if (c.maxMoleculeLineCount || c.maxMoleculeLines) console.log('CONF_MAX_MOL=' + (c.maxMoleculeLineCount || c.maxMoleculeLines));
|
|
28
|
+
if (c.minGrade) console.log('CONF_MIN_GRADE=' + c.minGrade);
|
|
29
|
+
if (c.minScore) console.log('CONF_MIN_SCORE=' + c.minScore);
|
|
30
|
+
} catch (e) {}
|
|
31
|
+
")
|
|
32
|
+
else
|
|
33
|
+
CONF_MAX_LINES=\$(grep -o '"maxLineCount"[[:space:]]*:[[:space:]]*[0-9]*' "\$CONFIG_FILE" 2>/dev/null | grep -o '[0-9]*\$')
|
|
34
|
+
CONF_MAX_MOL=\$(grep -o '"maxMoleculeLineCount"[[:space:]]*:[[:space:]]*[0-9]*' "\$CONFIG_FILE" 2>/dev/null | grep -o '[0-9]*\$')
|
|
35
|
+
CONF_MIN_GRADE=\$(grep -o '"minGrade"[[:space:]]*:[[:space:]]*"[^"]*"' "\$CONFIG_FILE" 2>/dev/null | sed 's/.*"minGrade"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\\1/')
|
|
36
|
+
CONF_MIN_SCORE=\$(grep -o '"minScore"[[:space:]]*:[[:space:]]*[0-9]*' "\$CONFIG_FILE" 2>/dev/null | grep -o '[0-9]*\$')
|
|
37
|
+
fi
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
MIN_GRADE="\${CHEMX_MIN_GRADE:-\${CONF_MIN_GRADE:-${minGrade}}}"
|
|
41
|
+
MIN_SCORE="\${CHEMX_MIN_SCORE:-\${CONF_MIN_SCORE:-${minScore}}}"
|
|
42
|
+
MAX_LINES="\${CHEMX_MAX_LINES:-\${CONF_MAX_LINES:-500}}"
|
|
43
|
+
MAX_MOLECULE_LINES="\${CHEMX_MAX_MOLECULE_LINES:-\${CONF_MAX_MOL:-100}}"
|
|
44
|
+
|
|
14
45
|
STAGED_FILES=\$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(jsx?|tsx?|vue|svelte)\$' | grep -vE '(\\.(d\\.ts|min\\.|test\\.|spec\\.))')
|
|
15
46
|
[ -z "\$STAGED_FILES" ] && exit 0
|
|
16
47
|
|
|
@@ -29,6 +60,9 @@ done
|
|
|
29
60
|
|
|
30
61
|
if [ "\$FAILED" -eq 1 ]; then
|
|
31
62
|
printf "\\n\\033[1m\\033[31m[Chemical X] Commit Blocked: Staged files exceed architectural line budgets\\033[0m\\n\$ERRORS\\n\\n"
|
|
63
|
+
printf "\\033[33mMonolithic files degrade AI context windows and cause hallucination loops.\\033[0m\\n"
|
|
64
|
+
printf "Decompose large files into single-purpose crystalline capsules or configure thresholds in .chemx/config.json before committing.\\n\\n"
|
|
65
|
+
exit 1
|
|
32
66
|
fi
|
|
33
67
|
|
|
34
68
|
AUDIT_BIN=""
|
|
@@ -51,7 +85,6 @@ if [ -n "\$AUDIT_BIN" ]; then
|
|
|
51
85
|
fi
|
|
52
86
|
fi
|
|
53
87
|
|
|
54
|
-
[ "\$FAILED" -eq 1 ] && exit 1
|
|
55
88
|
printf "\\033[32m✔ [Chemical X] Pre-commit architectural guardrails passed.\\033[0m\\n"
|
|
56
89
|
exit 0
|
|
57
90
|
`;
|