@chemx/starter-kit 26.9.26-253 → 26.9.28-1234

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.
@@ -1,5 +1,7 @@
1
1
  import { PILLARS } from './rules.js';
2
2
 
3
+ export const isSlopViolation = (v) => Boolean(v.isAiSlop || (v.rule && v.rule.startsWith('AI_SLOP_')));
4
+
3
5
  export const calculateMolecularHealthScore = (violations, totalFiles) => {
4
6
  if (totalFiles === 0) {
5
7
  return { score: 100, grade: 'A+', label: 'Crystalline Molecular' };
@@ -7,7 +9,7 @@ export const calculateMolecularHealthScore = (violations, totalFiles) => {
7
9
 
8
10
  let penalty = 0;
9
11
  for (const v of violations) {
10
- if (v.isAiSlop) continue;
12
+ if (isSlopViolation(v)) continue;
11
13
  if (v.severity === 'CRITICAL') penalty += 8;
12
14
  else if (v.severity === 'HIGH') penalty += 4;
13
15
  else if (v.severity === 'MEDIUM') penalty += 2;
@@ -57,7 +59,7 @@ export const calculatePillarBreakdown = (violations) => {
57
59
  }
58
60
 
59
61
  for (const v of violations) {
60
- if (v.isAiSlop) continue;
62
+ if (isSlopViolation(v)) continue;
61
63
  const pillarName = v.pillar || PILLARS.PILLAR_1;
62
64
  if (!breakdown[pillarName]) {
63
65
  breakdown[pillarName] = {
@@ -181,7 +183,7 @@ export const calculateHotspots = (violations, fileStats, limit = 5) => {
181
183
  export const calculateQuantumHealthScore = calculateMolecularHealthScore;
182
184
 
183
185
  export const calculateAiSlopScore = (violations, totalFiles) => {
184
- const slopViolations = violations.filter((v) => v.isAiSlop);
186
+ const slopViolations = violations.filter(isSlopViolation);
185
187
  if (totalFiles === 0) {
186
188
  return {
187
189
  score: 100,
@@ -128,7 +128,7 @@ export const generateMarkdownReport = (report) => {
128
128
  }
129
129
  }
130
130
 
131
- const slopViolations = violations.filter((v) => v.isAiSlop);
131
+ const slopViolations = violations.filter((v) => v.isAiSlop || (v.rule && v.rule.startsWith('AI_SLOP_')));
132
132
  lines.push('---');
133
133
  lines.push('');
134
134
  lines.push('## 5. AI Slop & Code Authenticity Audit');
@@ -27,7 +27,7 @@ import {
27
27
  } from './prompts.js';
28
28
  import { resolveFirstDefined } from './rules-predicates.js';
29
29
 
30
- const isSlopViolation = (v) => Boolean(v.isAiSlop);
30
+ const isSlopViolation = (v) => Boolean(v.isAiSlop || (v.rule && v.rule.startsWith('AI_SLOP_')));
31
31
 
32
32
  const formatHotspotItem = (h, idx) => {
33
33
  const monolithBadge = resolveHotspotBadge(h.lineCount);
@@ -17,7 +17,13 @@ export const countLogicalOperators = (node) => {
17
17
  export const extractParseableCode = (content, ext) => {
18
18
  if (ext === '.vue') {
19
19
  const scriptMatch = content.match(/<script\b[^>]*>([\s\S]*?)<\/script>/i);
20
- return scriptMatch ? scriptMatch[1] : '';
20
+ if (!scriptMatch) return '';
21
+ const scriptStartIndex = scriptMatch.index || 0;
22
+ const preScript = content.slice(0, scriptStartIndex);
23
+ const openTag = scriptMatch[0].match(/<script\b[^>]*>/i)?.[0] || '';
24
+ const preContent = preScript + openTag;
25
+ const leadingNewlines = preContent.split('\n').length - 1;
26
+ return '\n'.repeat(leadingNewlines) + scriptMatch[1];
21
27
  }
22
28
  return content;
23
29
  };
@@ -242,5 +242,15 @@ export const RULE_REGISTRY = {
242
242
  pillar: PILLARS.PILLAR_4,
243
243
  severity: 'MEDIUM',
244
244
  directive: 'Encapsulate flat primitive props into a cohesive domain type or composable'
245
+ },
246
+ LAYER_VIOLATION_CONTROLLER: {
247
+ pillar: PILLARS.PILLAR_4,
248
+ severity: 'CRITICAL',
249
+ directive: 'Do not inject DbContext or raw persistence into presentation controllers; route through MediatR/CQRS handlers'
250
+ },
251
+ COUPLING_EXCESSIVE_INJECTION: {
252
+ pillar: PILLARS.PILLAR_1,
253
+ severity: 'HIGH',
254
+ directive: 'Decompose high-coupling controllers; reduce injected services or route via MediatR'
245
255
  }
246
256
  };
@@ -107,11 +107,11 @@ export const auditCode = (content, filePath, relativePath, options = {}) => {
107
107
  checkSlopTextPatterns(content, lines, relativePath, violations);
108
108
 
109
109
  // Pillars 8-11: Extended Fast Checks (A11y, Security secrets, Fake tests, Co-located specs)
110
- checkExtendedTextPatterns(content, lines, relativePath, filePath, violations);
110
+ checkExtendedTextPatterns(content, lines, relativePath, filePath, violations, config);
111
111
 
112
112
  const lang = getLanguageForFile(filePath);
113
113
  if (lang?.id === 'csharp') {
114
- analyzeCSharpCode(content, relativePath, violations);
114
+ analyzeCSharpCode(content, relativePath, violations, config);
115
115
  }
116
116
 
117
117
  if (options.fast || !isBabelParsable(filePath)) {
package/cli/audit.js CHANGED
@@ -84,12 +84,12 @@ const IGNORED_DIRS = new Set([
84
84
  'out'
85
85
  ]);
86
86
 
87
- const isSourceFile = (name) => {
88
- return isPolyglotSourceFile(name, { includeTests: false });
87
+ const isSourceFile = (name, options = {}) => {
88
+ return isPolyglotSourceFile(name, { includeTests: false, ...options });
89
89
  };
90
90
 
91
91
  export const auditFile = (filePath, relativePath) => {
92
- if (!isSourceFile(path.basename(filePath))) return [];
92
+ if (!isSourceFile(path.basename(filePath), { includeTests: true })) return [];
93
93
  const content = fs.readFileSync(filePath, 'utf-8');
94
94
  return auditCode(content, filePath, relativePath);
95
95
  };
@@ -125,12 +125,18 @@ export const scanTree = (targetDir, baseDir, scanOptions = {}) => {
125
125
  let fileStats = [];
126
126
  let totalHooks = 0;
127
127
 
128
+ const targetRel = path.relative(baseDir, targetDir);
129
+ const isTargetingTests = /(?:^|[\\/])(?:tests?|specs?)(?:[\\/]|$)/i.test(targetRel) ||
130
+ /(?:^|[\\/])(?:tests?|specs?)(?:[\\/]|$)/i.test(targetDir);
131
+ const includeTests = Boolean(scanOptions.includeTests || isTargetingTests);
132
+ const effectiveScanOptions = { ...scanOptions, includeTests };
133
+
128
134
  if (scanOptions.fileList && scanOptions.fileList.length > 0) {
129
135
  for (const item of scanOptions.fileList) {
130
136
  const fullPath = path.isAbsolute(item) ? item : path.resolve(baseDir, item);
131
137
  const relPath = path.relative(baseDir, fullPath);
132
- if (fs.existsSync(fullPath) && isSourceFile(path.basename(fullPath))) {
133
- const result = auditFileEntry(fullPath, relPath, scanOptions);
138
+ if (fs.existsSync(fullPath) && isSourceFile(path.basename(fullPath), { includeTests: true })) {
139
+ const result = auditFileEntry(fullPath, relPath, effectiveScanOptions);
134
140
  violations = violations.concat(result.fileViolations);
135
141
  fileStats.push(result.fileStat);
136
142
  totalHooks += result.hookCount;
@@ -150,13 +156,13 @@ export const scanTree = (targetDir, baseDir, scanOptions = {}) => {
150
156
 
151
157
  if (entry.isDirectory()) {
152
158
  if (!IGNORED_DIRS.has(entry.name)) {
153
- const sub = scanTree(fullPath, baseDir, scanOptions);
159
+ const sub = scanTree(fullPath, baseDir, effectiveScanOptions);
154
160
  violations = violations.concat(sub.violations);
155
161
  fileStats = fileStats.concat(sub.fileStats);
156
162
  totalHooks += sub.totalHooks;
157
163
  }
158
- } else if (isSourceFile(entry.name)) {
159
- const result = auditFileEntry(fullPath, relPath, scanOptions);
164
+ } else if (isSourceFile(entry.name, { includeTests })) {
165
+ const result = auditFileEntry(fullPath, relPath, effectiveScanOptions);
160
166
  violations = violations.concat(result.fileViolations);
161
167
  fileStats.push(result.fileStat);
162
168
  totalHooks += result.hookCount;
@@ -177,11 +183,13 @@ export const runAudit = (targetDir = 'src', options = {}) => {
177
183
  const patternRegistry = createPatternRegistry();
178
184
  const hookRegistry = createHookShapeRegistry();
179
185
  const config = options.config || loadProjectConfig(cwd);
186
+ const includeTests = Boolean(options.includeTests);
180
187
  const { violations, fileStats, totalHooks } = scanTree(absoluteTarget, cwd, {
181
188
  patternRegistry,
182
189
  hookRegistry,
183
190
  fast: Boolean(options.fast),
184
191
  fileList: options.fileList || null,
192
+ includeTests,
185
193
  config
186
194
  });
187
195
 
@@ -101,7 +101,8 @@ export const runAudit = async (customDir, isCli, rawArgs, loadProjectConfig) =>
101
101
  fileList = preflight.fileList;
102
102
  }
103
103
 
104
- const auditOptions = { outputFile, model, costPerMillion, fast: isFast, fileList, stage, config: projectConfig };
104
+ const includeTests = rawArgs.includes('--include-tests') || rawArgs.includes('--tests');
105
+ const auditOptions = { outputFile, model, costPerMillion, fast: isFast, fileList, stage, config: projectConfig, includeTests };
105
106
  const report = executeAstAudit(targetDir, auditOptions);
106
107
  saveAuditSnapshot(report);
107
108
  try {
@@ -112,7 +113,8 @@ export const runAudit = async (customDir, isCli, rawArgs, loadProjectConfig) =>
112
113
  const shouldTriage = !rawArgs.includes('--no-triage');
113
114
  if (shouldTriage) {
114
115
  const createdTasks = autoGenerateTasksFromAudit(syncRes.db, { cwd: process.cwd(), targetDir });
115
- if (isCli && !isJson && createdTasks.length > 0) {
116
+ const shouldLogTriage = isCli && !isJson && createdTasks.length > 0;
117
+ if (shouldLogTriage) {
116
118
  process.stdout.write(`\x1b[32m✔\x1b[0m Auto-triage synchronized ${createdTasks.length} team task(s) in SQLite backlog.\n`);
117
119
  }
118
120
  }
@@ -141,8 +143,8 @@ export const runAudit = async (customDir, isCli, rawArgs, loadProjectConfig) =>
141
143
  }
142
144
  }
143
145
  }
144
- } catch {
145
- // Gracefully bypass indexing when database is readonly in sandboxed environment
146
+ } catch (err) {
147
+ if (process.env.DEBUG) process.stderr.write(`[debug] Indexing bypassed: ${err?.message}\n`);
146
148
  }
147
149
 
148
150
  // Stage 1: Atomic failure predicates
@@ -25,6 +25,7 @@ const normalizeRuleKeys = (rules = {}) => {
25
25
  'max-prop-count': 'maxPropCount',
26
26
  'prefer-design-tokens': 'preferDesignTokens',
27
27
  'enforce-file-length': 'enforceFileLength',
28
+ 'enforce-colocated-tests': 'enforceColocatedTests',
28
29
  'rule-of-three-abstractions': 'ruleOfThreeAbstractions',
29
30
  'ai-slop-detection': 'aiSlopDetection',
30
31
  'max-line-count-warning': 'maxLineCountWarning'
@@ -14,6 +14,7 @@ export const PROFILES = {
14
14
  maxPropCount: 7,
15
15
  preferDesignTokens: 'warning',
16
16
  enforceFileLength: false,
17
+ enforceColocatedTests: false,
17
18
  ruleOfThreeAbstractions: true,
18
19
  aiSlopDetection: 'critical',
19
20
  maxLineCountWarning: 250
@@ -28,6 +29,7 @@ export const PROFILES = {
28
29
  maxPropCount: 5,
29
30
  preferDesignTokens: 'error',
30
31
  enforceFileLength: true,
32
+ enforceColocatedTests: true,
31
33
  ruleOfThreeAbstractions: false,
32
34
  aiSlopDetection: 'critical',
33
35
  maxLineCountWarning: 100
@@ -42,6 +44,7 @@ export const PROFILES = {
42
44
  maxPropCount: 12,
43
45
  preferDesignTokens: 'off',
44
46
  enforceFileLength: false,
47
+ enforceColocatedTests: false,
45
48
  ruleOfThreeAbstractions: true,
46
49
  aiSlopDetection: 'medium',
47
50
  maxLineCountWarning: 500
@@ -0,0 +1,96 @@
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 { handleChemx } from './mcp/tools.js';
6
+ import { auditCode } from './audit/rules.js';
7
+
8
+ test('Friction 1: team_task add and team task add persist task rows', async () => {
9
+ const resCmd = await handleChemx({ command: 'team task add CLI Task Test' });
10
+ assert.ok(resCmd && resCmd.id, 'Expected task object with id from team task add');
11
+ assert.strictEqual(resCmd.title, 'CLI Task Test');
12
+
13
+ const resCmd2 = await handleChemx({ command: 'team_task add Direct SubCmd Task' });
14
+ assert.ok(resCmd2 && resCmd2.id, 'Expected task object with id from team_task add');
15
+ assert.strictEqual(resCmd2.title, 'Direct SubCmd Task');
16
+
17
+ const resInfer = await handleChemx({ action: 'team_task', params: { title: 'Inferred Add Task' } });
18
+ assert.ok(resInfer && resInfer.id, 'Expected task object with id when title is given');
19
+ assert.strictEqual(resInfer.title, 'Inferred Add Task');
20
+ });
21
+
22
+ test('Friction 2: patch handles all parameter aliases and command strings', async () => {
23
+ const testFile = path.resolve('test-patch-spec.txt');
24
+ fs.writeFileSync(testFile, 'line 1\nOLD_STRING\nline 3\n');
25
+
26
+ try {
27
+ await handleChemx({ action: 'patch', params: { path: 'test-patch-spec.txt', target: 'OLD_STRING', replace: 'NEW_STRING_1' } });
28
+ assert.strictEqual(fs.readFileSync(testFile, 'utf8'), 'line 1\nNEW_STRING_1\nline 3\n');
29
+
30
+ await handleChemx({ action: 'patch', params: { path: 'test-patch-spec.txt', search: 'NEW_STRING_1', replacement: 'NEW_STRING_2' } });
31
+ assert.strictEqual(fs.readFileSync(testFile, 'utf8'), 'line 1\nNEW_STRING_2\nline 3\n');
32
+
33
+ await handleChemx({ action: 'patch', params: { path: 'test-patch-spec.txt', targetContent: 'NEW_STRING_2', replacementContent: 'FINAL_STRING' } });
34
+ assert.strictEqual(fs.readFileSync(testFile, 'utf8'), 'line 1\nFINAL_STRING\nline 3\n');
35
+ } finally {
36
+ if (fs.existsSync(testFile)) fs.unlinkSync(testFile);
37
+ }
38
+ });
39
+
40
+ test('Friction 3: Vue file AST line numbers match file line numbers exactly', () => {
41
+ const vueContent = [
42
+ '<template>',
43
+ ' <div>',
44
+ ' <p>Line 3</p>',
45
+ ' <p>Line 4</p>',
46
+ ' <p>Line 5</p>',
47
+ ' <p>Line 6</p>',
48
+ ' <p>Line 7</p>',
49
+ ' <p>Line 8</p>',
50
+ ' <p>Line 9</p>',
51
+ ' <p>Line 10</p>',
52
+ ' </div>',
53
+ '</template>',
54
+ '',
55
+ '<script setup lang="ts">',
56
+ 'const cond = a && b && c;',
57
+ '</script>'
58
+ ].join('\n');
59
+
60
+ const recorded = [];
61
+ const fakeReg = { record: (type, sig, loc) => recorded.push({ type, sig, loc }) };
62
+ auditCode(vueContent, 'SampleVue.vue', 'SampleVue.vue', { patternRegistry: fakeReg });
63
+
64
+ const condRec = recorded.find((r) => r.type === 'PREDICATE_LOGIC');
65
+ assert.ok(condRec, 'Expected PREDICATE_LOGIC recorded');
66
+ assert.strictEqual(condRec.loc.line, 15, 'Expected condition on line 15');
67
+ });
68
+
69
+ test('Friction 4: HOOK_SATURATION excludes canonical controller return wrappers', () => {
70
+ const hookCode = [
71
+ 'export function useMyComposable() {',
72
+ ' const a = useA();',
73
+ ' const b = useB();',
74
+ ' const c = useC();',
75
+ ' const d = useD();',
76
+ ' const e = useE();',
77
+ ' return useSharedStatusController({ a, b, c, d, e });',
78
+ '}'
79
+ ].join('\n');
80
+
81
+ const hookViolations = auditCode(hookCode, 'useMyComposable.ts', 'src/useMyComposable.ts');
82
+ const hookSat = hookViolations.filter((v) => v.rule === 'HOOK_SATURATION');
83
+ assert.strictEqual(hookSat.length, 0, 'useSharedStatusController should not trigger HOOK_SATURATION');
84
+ });
85
+
86
+ test('Friction 5: TEST_MISSING_COLOCATED is gated by profile', () => {
87
+ const compCode = '<template><button>Click</button></template>';
88
+
89
+ const defaultViolations = auditCode(compCode, 'src/components/molecules/m-button.vue', 'src/components/molecules/m-button.vue', { config: { profile: 'pragmatic' } });
90
+ const testMissingDefault = defaultViolations.filter((v) => v.rule === 'TEST_MISSING_COLOCATED');
91
+ assert.strictEqual(testMissingDefault.length, 0, 'Pragmatic profile should not flag TEST_MISSING_COLOCATED');
92
+
93
+ const strictViolations = auditCode(compCode, 'src/components/molecules/m-button.vue', 'src/components/molecules/m-button.vue', { config: { profile: 'atomic-strict' } });
94
+ const testMissingStrict = strictViolations.filter((v) => v.rule === 'TEST_MISSING_COLOCATED');
95
+ assert.strictEqual(testMissingStrict.length, 1, 'Atomic-strict profile should flag TEST_MISSING_COLOCATED');
96
+ });
@@ -62,27 +62,47 @@ MIN_SCORE="\${CHEMX_MIN_SCORE:-\${CONF_MIN_SCORE:-${minScore}}}"
62
62
  MAX_LINES="\${CHEMX_MAX_LINES:-\${CONF_MAX_LINES:-500}}"
63
63
  MAX_MOLECULE_LINES="\${CHEMX_MAX_MOLECULE_LINES:-\${CONF_MAX_MOL:-100}}"
64
64
 
65
- STAGED_FILES=\$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(jsx?|tsx?|vue|svelte)\$' | grep -vE '(\\.(d\\.ts|min\\.|test\\.|spec\\.))')
65
+ STAGED_FILES=\$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(jsx?|tsx?|vue|svelte|cs|py|go)\$' | grep -vE '(\\.(d\\.ts|min\\.|test\\.|spec\\.))')
66
66
  [ -z "\$STAGED_FILES" ] && exit 0
67
67
 
68
68
  FAILED=0
69
69
  ERRORS=""
70
+ EXCEEDED_FILES=""
70
71
  for F in \$STAGED_FILES; do
71
72
  [ ! -f "\$F" ] && continue
72
73
  L=\$(wc -l < "\$F" | tr -d ' ')
73
74
  case "\$F" in
74
75
  *molecules*|*/m-*|m-*)
75
- [ "\$L" -gt "\$MAX_MOLECULE_LINES" ] && FAILED=1 && ERRORS="\${ERRORS}\\n \${C_RED}✕\${C_RESET} \$F (\$L LOC > \$MAX_MOLECULE_LINES molecule limit)" ;;
76
+ if [ "\$L" -gt "\$MAX_MOLECULE_LINES" ]; then
77
+ FAILED=1
78
+ ERRORS="\${ERRORS}\\n \${C_RED}✕\${C_RESET} \$F (\$L LOC > \$MAX_MOLECULE_LINES molecule limit)"
79
+ EXCEEDED_FILES="\${EXCEEDED_FILES}\\n- \$F (\$L LOC > \$MAX_MOLECULE_LINES limit)"
80
+ fi
81
+ ;;
76
82
  *)
77
- [ "\$L" -gt "\$MAX_LINES" ] && FAILED=1 && ERRORS="\${ERRORS}\\n \${C_RED}✕\${C_RESET} \$F (\$L LOC > \$MAX_LINES file budget)" ;;
83
+ if [ "\$L" -gt "\$MAX_LINES" ]; then
84
+ FAILED=1
85
+ ERRORS="\${ERRORS}\\n \${C_RED}✕\${C_RESET} \$F (\$L LOC > \$MAX_LINES file budget)"
86
+ EXCEEDED_FILES="\${EXCEEDED_FILES}\\n- \$F (\$L LOC > \$MAX_LINES limit)"
87
+ fi
88
+ ;;
78
89
  esac
79
90
  done
80
91
 
81
92
  if [ "\$FAILED" -eq 1 ]; then
82
93
  printf "\\n%s%s[Chemical X] Commit Blocked: Staged files exceed architectural line budgets%s\\n" "\$C_BOLD" "\$C_RED" "\$C_RESET"
83
94
  printf "%b\\n\\n" "\$ERRORS"
84
- printf "%sMonolithic files degrade AI context windows and cause hallucination loops.%s\\n" "\$C_YELLOW" "\$C_RESET"
85
- printf "Decompose large files into single-purpose crystalline capsules or configure thresholds in .chemx/config.json before committing.\\n\\n"
95
+ printf "%s╭──────────────────────────────────────────────────────────────────────────╮%s\\n" "\$C_CYAN" "\$C_RESET"
96
+ printf "%s│ 🤖 AI REFACTOR PROMPT (Copy & paste into your AI assistant): │%s\\n" "\$C_CYAN" "\$C_RESET"
97
+ printf "%s╰──────────────────────────────────────────────────────────────────────────╯%s\\n" "\$C_CYAN" "\$C_RESET"
98
+ printf "Please refactor the following files that exceed Chemical X line budgets:%b\\n\\n" "\$EXCEEDED_FILES"
99
+ printf "Refactor Directives:\\n"
100
+ printf "1. Decompose monolithic logic into crystalline single-purpose modules (< %s lines for files, < %s lines for molecules).\\n" "\$MAX_LINES" "\$MAX_MOLECULE_LINES"
101
+ printf "2. Extract presentation into Table-of-Contents views and business state into composables/services.\\n"
102
+ printf "3. Preserve all existing symbols, exports, and public API contracts.\\n"
103
+ printf "4. Decompose complex inline booleans and flatten nested control flow.\\n"
104
+ printf "%s────────────────────────────────────────────────────────────────────────────%s\\n\\n" "\$C_CYAN" "\$C_RESET"
105
+ printf "%s💡 Tip: To bypass line budgets temporarily: CHEMX_SKIP_PRECOMMIT=1 git commit%s\\n\\n" "\$C_YELLOW" "\$C_RESET"
86
106
  exit 1
87
107
  fi
88
108
 
@@ -104,7 +124,18 @@ if [ -n "\$AUDIT_BIN" ]; then
104
124
  if ! AUDIT_OUT=\$(eval "\$AUDIT_BIN audit --git --min-grade=\$MIN_GRADE --min-score=\$MIN_SCORE --non-interactive" < /dev/null 2>&1); then
105
125
  printf "\\n%s%s[Chemical X] Commit Blocked: Architectural health verification failed%s\\n" "\$C_BOLD" "\$C_RED" "\$C_RESET"
106
126
  printf "%s\\n\\n" "\$AUDIT_OUT"
107
- printf "%s💡 Tip: Want crystalline drop-in templates? Run 'npm create chemx' or sponsor at https://github.com/sponsors/Chemical-X-Protocol%s\\n\\n" "\$C_CYAN" "\$C_RESET"
127
+ printf "%s╭──────────────────────────────────────────────────────────────────────────╮%s\\n" "\$C_CYAN" "\$C_RESET"
128
+ printf "%s│ 🤖 AI REFACTOR PROMPT (Copy & paste into your AI assistant): │%s\\n" "\$C_CYAN" "\$C_RESET"
129
+ printf "%s╰──────────────────────────────────────────────────────────────────────────╯%s\\n" "\$C_CYAN" "\$C_RESET"
130
+ printf "Please fix the Chemical X architectural hazards reported above in staged files.\\n\\n"
131
+ printf "Refactor Directives:\\n"
132
+ printf "1. Surgically resolve each flagged Critical and High severity hazard.\\n"
133
+ printf "2. Decompose monoliths into single-purpose crystalline capsules.\\n"
134
+ printf "3. Preserve all existing symbols, exports, and test contracts.\\n"
135
+ printf "4. Verify with 'chemx audit' after making changes.\\n"
136
+ printf "%s────────────────────────────────────────────────────────────────────────────%s\\n\\n" "\$C_CYAN" "\$C_RESET"
137
+ printf "%s💡 Tip: Run 'chemx audit' locally to inspect details or run autofixes.%s\\n" "\$C_CYAN" "\$C_RESET"
138
+ printf " To bypass this check temporarily: CHEMX_SKIP_PRECOMMIT=1 git commit\\n\\n"
108
139
  exit 1
109
140
  fi
110
141
  fi
@@ -303,10 +303,15 @@ export const SUB_TOOLS = [
303
303
  properties: {
304
304
  path: { type: 'string', description: 'Target file path' },
305
305
  targetContent: { type: 'string', description: 'Exact string chunk to replace' },
306
+ target: { type: 'string', description: 'Alias for target text chunk to replace' },
307
+ search: { type: 'string', description: 'Alias for target text chunk to replace' },
306
308
  replacementContent: { type: 'string', description: 'New replacement content' },
307
- allowMultiple: { type: 'boolean', description: 'Allow multiple replacements' }
309
+ replacement: { type: 'string', description: 'Alias for replacement content' },
310
+ replace: { type: 'string', description: 'Alias for replacement content' },
311
+ allowMultiple: { type: 'boolean', description: 'Allow multiple replacements' },
312
+ multiple: { type: 'boolean', description: 'Alias for allowMultiple' }
308
313
  },
309
- required: ['path', 'targetContent', 'replacementContent']
314
+ required: ['path']
310
315
  }
311
316
  },
312
317
  {
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { syncSingleFileIndex } from '../search.js';
3
3
  import { patchFile } from '../patcher.js';
4
4
  import { handleCheckCommand } from '../search-commands.js';
5
+ import { resolveSafePath } from '../path-scope.js';
5
6
 
6
7
  export const isSevereViolation = (v) => {
7
8
  const isCritical = v.severity === 'CRITICAL';
@@ -36,10 +37,10 @@ export const handleChemxPatch = (args = {}, cwd = process.cwd()) => {
36
37
  const hasRequiredArgs = hasPath && hasTargetContent && hasReplacementContent;
37
38
 
38
39
  if (!hasRequiredArgs) {
39
- throw new Error('chemx_patch requires "path", "targetContent", and "replacementContent" arguments.');
40
+ throw new Error('chemx_patch requires "path", target content ("targetContent", "target", or "search"), and replacement content ("replacementContent", "replacement", or "replace").');
40
41
  }
41
42
 
42
- const targetPath = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
43
+ const targetPath = resolveSafePath(args.path, cwd);
43
44
  const result = patchFile(targetPath, {
44
45
  targetContent,
45
46
  replacementContent,
@@ -69,6 +70,6 @@ export const handleChemxCheck = (args = {}, cwd = process.cwd()) => {
69
70
  if (!args.path) {
70
71
  throw new Error('chemx_check requires "path" argument.');
71
72
  }
72
- const targetPath = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
73
+ const targetPath = resolveSafePath(args.path, cwd);
73
74
  return handleCheckCommand(targetPath, { isJson: true, isCli: false });
74
75
  };
@@ -6,6 +6,7 @@ import {
6
6
  } from '../search-queries.js';
7
7
  import { findSimilarSymbols } from '../search-queries-similar.js';
8
8
  import { readTokenOptimized } from '../reader.js';
9
+ import { resolveSafePath } from '../path-scope.js';
9
10
  import { EXT_LANG_MAP, resolveTargetCwd } from './tools-search-util.js';
10
11
 
11
12
  const buildConnectionCard = (db, symbol, targetPath) => {
@@ -72,8 +73,9 @@ export const handleChemxRead = (args = {}, cwd = process.cwd()) => {
72
73
  if (endLine === undefined && colonMatch[3]) endLine = parseInt(colonMatch[3], 10);
73
74
  }
74
75
 
75
- const targetPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(targetCwd, rawPath);
76
+ const targetPath = resolveSafePath(rawPath, targetCwd);
76
77
  const res = readTokenOptimized(targetPath, {
78
+ cwd: targetCwd,
77
79
  outline: args.outline,
78
80
  logic: args.logic,
79
81
  template: args.template,
@@ -6,23 +6,30 @@ import {
6
6
  getFileLockStatus
7
7
  } from '../team/team-db.js';
8
8
  import { handleError } from '../errors/index.js';
9
+ import { isPathTraversal } from '../path-scope.js';
9
10
 
10
11
  export const handleChemxTeamLock = async (args = {}, cwd = process.cwd()) => {
11
12
  const db = openIndexDb(cwd);
12
13
  if (!db) return { error: 'sqlite_unavailable' };
13
14
  const action = args.action || 'status';
14
15
 
16
+ if (args.filePath && isPathTraversal(args.filePath, cwd)) {
17
+ return { error: 'path_traversal' };
18
+ }
19
+
15
20
  if (action === 'acquire') {
16
21
  return requestFileLock(db, args.filePath, args.agentId, {
17
22
  purpose: args.purpose,
18
- ttlMs: args.ttlMs
23
+ ttlMs: args.ttlMs,
24
+ cwd
19
25
  });
20
26
  }
21
27
  if (action === 'release') {
22
- return releaseFileLock(db, args.filePath, args.agentId);
28
+ return releaseFileLock(db, args.filePath, args.agentId, { cwd });
23
29
  }
24
30
  if (action === 'status') {
25
- const status = getFileLockStatus(db, args.filePath);
31
+ const status = getFileLockStatus(db, args.filePath, { cwd });
32
+ if (!status) return null;
26
33
  return {
27
34
  lease: status.lease,
28
35
  waiters: toColumnar(status.waiters, ['id', 'agent_id', 'priority', 'status', 'requested_at'])
@@ -12,7 +12,10 @@ import { freezeReleaseTrain } from '../team/team-release-train.js';
12
12
  export const handleChemxTeamTask = async (args = {}, cwd = process.cwd()) => {
13
13
  const db = openIndexDb(cwd);
14
14
  if (!db) return { error: 'sqlite_unavailable' };
15
- const action = args.action || args.subAction || 'list';
15
+ let action = args.action || args.subAction;
16
+ if (!action) {
17
+ action = Boolean(args.title) ? 'add' : 'list';
18
+ }
16
19
 
17
20
  if (action === 'list') {
18
21
  const parentId = args.parentId !== undefined ? args.parentId : args.parent;
@@ -53,7 +56,7 @@ export const handleChemxTeamTask = async (args = {}, cwd = process.cwd()) => {
53
56
  const parentId = args.parentId !== undefined ? args.parentId : args.parent;
54
57
  const task = createTask(db, {
55
58
  title: args.title,
56
- target_path: args.targetPath,
59
+ target_path: args.targetPath || args.target,
57
60
  tier: args.tier || 'molecule',
58
61
  priority: args.priority || 2,
59
62
  assigned_agent_id: args.assignedAgentId || null,
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { syncSingleFileIndex } from '../search.js';
3
3
  import { writeFile } from '../patcher.js';
4
4
  import { formatPatchWarnings } from './tools-patch.js';
5
+ import { resolveSafePath } from '../path-scope.js';
5
6
 
6
7
  export const handleChemxWrite = (args = {}, cwd = process.cwd()) => {
7
8
  const hasPath = Boolean(args.path);
@@ -12,7 +13,7 @@ export const handleChemxWrite = (args = {}, cwd = process.cwd()) => {
12
13
  throw new Error('chemx_write requires "path" and "content" arguments.');
13
14
  }
14
15
 
15
- const targetPath = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
16
+ const targetPath = resolveSafePath(args.path, cwd);
16
17
  const result = writeFile(targetPath, {
17
18
  content: args.content,
18
19
  cwd
package/cli/mcp/tools.js CHANGED
@@ -76,10 +76,53 @@ const parseCommand = (command, params) => {
76
76
  }
77
77
  };
78
78
  }
79
+ if (subCmd === 'patch') {
80
+ return {
81
+ action: 'patch',
82
+ params: {
83
+ path: parts[1] || params?.path,
84
+ target: params?.target || params?.targetContent || params?.search,
85
+ replacement: params?.replacement || params?.replacementContent || params?.replace,
86
+ ...params
87
+ }
88
+ };
89
+ }
79
90
  if (subCmd === 'q' || subCmd === 'search') return { action: 'q', params: { query: parts.slice(1).join(' '), ...params } };
80
91
  if (subCmd === 'team') {
81
92
  const TEAM_ACTIONS = { status: 'team_status', feed: 'team_feed', task: 'team_task', lock: 'team_lock', inbox: 'team_inbox', dm: 'team_dm' };
82
- return { action: TEAM_ACTIONS[parts[1] || 'status'] || 'team_task', params };
93
+ const sub = parts[1] || 'status';
94
+ const action = TEAM_ACTIONS[sub] || 'team_task';
95
+ const parsedParams = { ...params };
96
+ const isTaskSub = sub === 'task' || !TEAM_ACTIONS[parts[1]];
97
+ if (isTaskSub) {
98
+ const taskSub = TEAM_ACTIONS[parts[1]] ? parts[2] : parts[1];
99
+ const isCreate = ['add', 'create', 'new'].includes(taskSub);
100
+ if (isCreate) {
101
+ parsedParams.subAction = 'add';
102
+ const titleTokens = parts.slice(TEAM_ACTIONS[parts[1]] ? 3 : 2);
103
+ if (titleTokens.length > 0 && !parsedParams.title) {
104
+ parsedParams.title = titleTokens.join(' ').replace(/^["']|["']$/g, '');
105
+ }
106
+ } else if (taskSub) {
107
+ parsedParams.subAction = taskSub;
108
+ }
109
+ }
110
+ return { action, params: parsedParams };
111
+ }
112
+ if (subCmd === 'team_task') {
113
+ const parsedParams = { ...params };
114
+ const taskSub = parts[1];
115
+ const isCreate = ['add', 'create', 'new'].includes(taskSub);
116
+ if (isCreate) {
117
+ parsedParams.subAction = 'add';
118
+ const titleTokens = parts.slice(2);
119
+ if (titleTokens.length > 0 && !parsedParams.title) {
120
+ parsedParams.title = titleTokens.join(' ').replace(/^["']|["']$/g, '');
121
+ }
122
+ } else if (taskSub) {
123
+ parsedParams.subAction = taskSub;
124
+ }
125
+ return { action: 'team_task', params: parsedParams };
83
126
  }
84
127
  if (subCmd === 'project' || subCmd === 'coordinator') {
85
128
  const subAction = parts[1] || 'status';
package/cli/patcher.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { ANSI } from './theme.js';
4
4
  import { syncSingleFileIndex } from './search.js';
5
5
  import { auditFile } from './audit.js';
6
+ import { resolveSafePath } from './path-scope.js';
6
7
 
7
8
  /**
8
9
  * Surgically applies a search-and-replace block to a file.
@@ -37,7 +38,7 @@ export const patchFile = (targetPath, params = {}) => {
37
38
  throw new Error('replacementContent is required for patching');
38
39
  }
39
40
 
40
- const resolvedPath = path.resolve(cwd, targetPath);
41
+ const resolvedPath = resolveSafePath(targetPath, cwd);
41
42
  if (!fs.existsSync(resolvedPath)) {
42
43
  throw new Error(`File not found: ${targetPath}`);
43
44
  }
@@ -253,7 +254,7 @@ export const writeFile = (targetPath, params = {}) => {
253
254
  throw new Error('content is required for writeFile');
254
255
  }
255
256
 
256
- const resolvedPath = path.resolve(cwd, targetPath);
257
+ const resolvedPath = resolveSafePath(targetPath, cwd);
257
258
  const dir = path.dirname(resolvedPath);
258
259
  if (!fs.existsSync(dir)) {
259
260
  fs.mkdirSync(dir, { recursive: true });