@chemx/starter-kit 26.9.21-227 → 26.9.21-432

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.
@@ -7,8 +7,9 @@ import {
7
7
  isZeroDelayTimeout,
8
8
  isUnguardedConsoleCall
9
9
  } from './rules-predicates.js';
10
+ import { validateHookReturnShape } from './hook-shape-validator.js';
10
11
 
11
- export const createAstVisitors = ({ relativePath, violations }) => {
12
+ export const createAstVisitors = ({ relativePath, violations, hookRegistry }) => {
12
13
  return {
13
14
  Function(astPath) {
14
15
  const isCustomHook = isCustomHookFunction(astPath);
@@ -45,22 +46,14 @@ export const createAstVisitors = ({ relativePath, violations }) => {
45
46
  if (isCustomHook) {
46
47
  astPath.traverse({
47
48
  ReturnStatement(retPath) {
48
- if (retPath.getFunctionParent() === astPath && t.isObjectExpression(retPath.node.argument)) {
49
- const propCount = retPath.node.argument.properties.length;
50
- if (propCount > 5) {
51
- const line = retPath.node.loc?.start.line || 1;
52
- const meta = RULE_REGISTRY.HOOK_RETURN_OVERLOAD;
53
- violations.push({
54
- filePath: relativePath,
55
- line,
56
- column: retPath.node.loc?.start.column || 1,
57
- hazard: `Hook return saturation (${propCount} properties > 5 limit)`,
58
- rule: 'HOOK_RETURN_OVERLOAD',
59
- severity: meta.severity,
60
- pillar: meta.pillar,
61
- directive: meta.directive
62
- });
63
- }
49
+ if (retPath.getFunctionParent() === astPath) {
50
+ validateHookReturnShape({
51
+ retPath,
52
+ astPath,
53
+ relativePath,
54
+ violations,
55
+ hookRegistry
56
+ });
64
57
  }
65
58
  }
66
59
  });
@@ -0,0 +1,312 @@
1
+ import * as t from '@babel/types';
2
+ import { RULE_REGISTRY } from './rules-registry.js';
3
+
4
+ const VERB_PREFIX_REGEX = /^(add|remove|toggle|set|get|fetch|load|save|delete|clear|reset|refresh|update|create|edit|send|sync|init|dispose|trigger|post|put|patch|apply|dismiss|select|deselect|filter|sort|search|open|close|show|hide|start|stop|cancel|execute|run|retry|submit|validate|mutate|next|prev|step|increment|decrement|insert|upsert|drop|enable|disable|register|unregister|login|logout|checkout|publish|archive|discard|confirm|undo|redo|claim|complete|acquire|release|download|upload|export|import|handle)([A-Z0-9_].*)?$/;
5
+
6
+ const STATUS_HEURISTIC_REGEX = /^(is[A-Z]|has[A-Z]|can[A-Z]|should[A-Z])|.*(Error|Status)$/;
7
+ const STATUS_EXACT_NAMES = new Set(['status', 'error', 'loading', 'pending', 'ready']);
8
+ const ACTION_WRAPPER_KEYS = new Set(['actions', 'handlers', 'methods', 'operations']);
9
+
10
+ const RAW_DOM_REF_REGEX = /^(dom|element|container|input|button|target|root|node)?Ref$|.*(Element|Node|Dom)Ref$/i;
11
+ const INTERMEDIATE_VAL_REGEX = /^(temp|draft|raw|partial|interim|scratch|memoized)([A-Z_].*)?$/i;
12
+ const UNGROUPED_CALLBACK_REGEX = /^(on[A-Z]|callback$|.*Callback$)/;
13
+
14
+ const MUTUALLY_EXCLUSIVE_STATUSES = new Set([
15
+ 'isloading', 'issuccess', 'iserror', 'isidle', 'isloaded', 'isfetching', 'ispending',
16
+ 'loading', 'success', 'idle'
17
+ ]);
18
+
19
+ export const SEMANTIC_STATUS_CLUSTERS = {
20
+ LOADING: {
21
+ canonical: 'isLoading',
22
+ variants: new Set(['isloading', 'loading', 'ispending', 'pending', 'isfetching', 'fetching'])
23
+ },
24
+ ERROR: {
25
+ canonical: 'error',
26
+ variants: new Set(['error', 'iserror', 'haserror', 'err', 'errormessage'])
27
+ },
28
+ READY: {
29
+ canonical: 'isReady',
30
+ variants: new Set(['isready', 'ready', 'isinitialized', 'initialized'])
31
+ },
32
+ STATUS: {
33
+ canonical: 'status',
34
+ variants: new Set(['status', 'state', 'phase', 'stage'])
35
+ }
36
+ };
37
+
38
+ const resolvePropKey = (prop) => {
39
+ if (t.isIdentifier(prop.key)) return prop.key.name;
40
+ if (t.isStringLiteral(prop.key)) return prop.key.value;
41
+ return null;
42
+ };
43
+
44
+ const isFunctionNode = (node, scope) => {
45
+ const isDirectFunction = t.isArrowFunctionExpression(node) || t.isFunctionExpression(node);
46
+ if (isDirectFunction) return true;
47
+
48
+ const isIdentifierNode = t.isIdentifier(node);
49
+ const canInspectScope = isIdentifierNode && Boolean(scope);
50
+ if (!canInspectScope) return false;
51
+
52
+ const binding = scope.getBinding(node.name);
53
+ if (!binding) return false;
54
+
55
+ if (binding.path.isFunctionDeclaration()) return true;
56
+
57
+ if (binding.path.isVariableDeclarator()) {
58
+ const init = binding.path.node.init;
59
+ const isInitFunction = t.isArrowFunctionExpression(init) || t.isFunctionExpression(init);
60
+ if (isInitFunction) return true;
61
+ const isCallbackHook = t.isCallExpression(init) && t.isIdentifier(init.callee, { name: 'useCallback' });
62
+ if (isCallbackHook) return true;
63
+ }
64
+
65
+ return false;
66
+ };
67
+
68
+ const hasNestedActions = (objectExpr, scope) => {
69
+ if (!t.isObjectExpression(objectExpr)) return false;
70
+ return objectExpr.properties.some((p) => {
71
+ if (t.isObjectMethod(p)) return true;
72
+ if (t.isObjectProperty(p)) {
73
+ const name = resolvePropKey(p);
74
+ const isVerbNamed = Boolean(name && VERB_PREFIX_REGEX.test(name));
75
+ if (isVerbNamed) return true;
76
+ if (isFunctionNode(p.value, scope)) return true;
77
+ }
78
+ return false;
79
+ });
80
+ };
81
+
82
+ export const createHookShapeRegistry = () => {
83
+ const entries = [];
84
+
85
+ const recordHook = (hookInfo) => {
86
+ entries.push(hookInfo);
87
+ };
88
+
89
+ const validateCrossHookConsistency = () => {
90
+ const consistencyViolations = [];
91
+ if (entries.length < 2) return consistencyViolations;
92
+
93
+ for (const [clusterKey, cluster] of Object.entries(SEMANTIC_STATUS_CLUSTERS)) {
94
+ const occurrences = [];
95
+ for (const entry of entries) {
96
+ for (const prop of entry.statusProps) {
97
+ const lower = prop.name.toLowerCase();
98
+ if (cluster.variants.has(lower)) {
99
+ occurrences.push({ ...prop, hookName: entry.hookName, filePath: entry.filePath });
100
+ }
101
+ }
102
+ }
103
+
104
+ const uniqueNames = Array.from(new Set(occurrences.map((o) => o.name)));
105
+ if (uniqueNames.length > 1) {
106
+ for (const occ of occurrences) {
107
+ if (occ.name !== cluster.canonical) {
108
+ const meta = RULE_REGISTRY.HOOK_SHAPE_CONTRACT;
109
+ consistencyViolations.push({
110
+ filePath: occ.filePath,
111
+ line: occ.line,
112
+ column: occ.column,
113
+ hazard: `Inconsistent ${clusterKey.toLowerCase()} status property "${occ.name}". Rename to "${cluster.canonical}" to match hook shape conventions.`,
114
+ rule: 'HOOK_SHAPE_CONTRACT',
115
+ severity: meta.severity,
116
+ pillar: meta.pillar,
117
+ directive: meta.directive
118
+ });
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ return consistencyViolations;
125
+ };
126
+
127
+ return { recordHook, validateCrossHookConsistency, entries };
128
+ };
129
+
130
+ export const validateHookReturnShape = ({ retPath, astPath, relativePath, violations, hookRegistry }) => {
131
+ if (!t.isObjectExpression(retPath.node.argument)) return;
132
+
133
+ const properties = retPath.node.argument.properties;
134
+ const propCount = properties.length;
135
+ const hookName = astPath.node.id?.name || astPath.parentPath?.node?.id?.name || 'anonymousHook';
136
+ const meta = RULE_REGISTRY.HOOK_SHAPE_CONTRACT;
137
+ const overloadMeta = RULE_REGISTRY.HOOK_RETURN_OVERLOAD;
138
+
139
+ if (propCount > 5) {
140
+ violations.push({
141
+ filePath: relativePath,
142
+ line: retPath.node.loc?.start.line || 1,
143
+ column: retPath.node.loc?.start.column || 1,
144
+ hazard: `Hook return saturation (${propCount} properties > 5 limit; deprecated rule: migrate to HOOK_SHAPE_CONTRACT)`,
145
+ rule: 'HOOK_RETURN_OVERLOAD',
146
+ severity: overloadMeta.severity,
147
+ pillar: overloadMeta.pillar,
148
+ directive: overloadMeta.directive
149
+ });
150
+ }
151
+
152
+ const statusProps = [];
153
+ const mutualStatuses = [];
154
+
155
+ for (const prop of properties) {
156
+ if (t.isSpreadElement(prop)) continue;
157
+
158
+ const keyName = resolvePropKey(prop);
159
+ if (!keyName) continue;
160
+
161
+ const line = prop.loc?.start.line || retPath.node.loc?.start.line || 1;
162
+ const column = prop.loc?.start.column || retPath.node.loc?.start.column || 1;
163
+
164
+ const isActionWrapperKey = ACTION_WRAPPER_KEYS.has(keyName);
165
+ if (isActionWrapperKey) {
166
+ violations.push({
167
+ filePath: relativePath,
168
+ line,
169
+ column,
170
+ hazard: `Nested action wrapper detected in hook return ("${keyName}"). Actions must be flat at top level per HOOK_SHAPE_CONTRACT.`,
171
+ rule: 'HOOK_SHAPE_CONTRACT',
172
+ severity: meta.severity,
173
+ pillar: meta.pillar,
174
+ directive: meta.directive
175
+ });
176
+ continue;
177
+ }
178
+
179
+ const isPropObject = t.isObjectProperty(prop) && hasNestedActions(prop.value, astPath.scope);
180
+ if (isPropObject) {
181
+ violations.push({
182
+ filePath: relativePath,
183
+ line,
184
+ column,
185
+ hazard: `Action wrapper detected in property "${keyName}". Actions must be flat at top level per HOOK_SHAPE_CONTRACT.`,
186
+ rule: 'HOOK_SHAPE_CONTRACT',
187
+ severity: meta.severity,
188
+ pillar: meta.pillar,
189
+ directive: meta.directive
190
+ });
191
+ continue;
192
+ }
193
+
194
+ const isMethod = t.isObjectMethod(prop);
195
+ const isPropFunc = t.isObjectProperty(prop) && isFunctionNode(prop.value, astPath.scope);
196
+ const isFunction = isMethod || isPropFunc;
197
+
198
+ if (isFunction) {
199
+ const isStatusPredicate = STATUS_HEURISTIC_REGEX.test(keyName);
200
+ if (isStatusPredicate) {
201
+ statusProps.push({ name: keyName, line, column });
202
+ if (MUTUALLY_EXCLUSIVE_STATUSES.has(keyName.toLowerCase())) {
203
+ mutualStatuses.push(keyName);
204
+ }
205
+ continue;
206
+ }
207
+
208
+ const isUngrouped = UNGROUPED_CALLBACK_REGEX.test(keyName);
209
+ if (isUngrouped) {
210
+ violations.push({
211
+ filePath: relativePath,
212
+ line,
213
+ column,
214
+ hazard: `Property "${keyName}" is an invalid hook action. Actions must be verb-prefixed (e.g. addItem, toggleItem) rather than event handlers or callbacks.`,
215
+ rule: 'HOOK_SHAPE_CONTRACT',
216
+ severity: meta.severity,
217
+ pillar: meta.pillar,
218
+ directive: meta.directive
219
+ });
220
+ } else {
221
+ const isVerbPrefixed = VERB_PREFIX_REGEX.test(keyName);
222
+ if (!isVerbPrefixed) {
223
+ violations.push({
224
+ filePath: relativePath,
225
+ line,
226
+ column,
227
+ hazard: `Function "${keyName}" in hook return is missing an action verb prefix (e.g. addItem, toggleItem).`,
228
+ rule: 'HOOK_SHAPE_CONTRACT',
229
+ severity: meta.severity,
230
+ pillar: meta.pillar,
231
+ directive: meta.directive
232
+ });
233
+ }
234
+ }
235
+ continue;
236
+ }
237
+
238
+ const isDomRef = RAW_DOM_REF_REGEX.test(keyName) || keyName === 'ref';
239
+ if (isDomRef) {
240
+ violations.push({
241
+ filePath: relativePath,
242
+ line,
243
+ column,
244
+ hazard: `Property "${keyName}" is a raw DOM ref. Hooks must not return raw DOM element refs.`,
245
+ rule: 'HOOK_SHAPE_CONTRACT',
246
+ severity: meta.severity,
247
+ pillar: meta.pillar,
248
+ directive: meta.directive
249
+ });
250
+ continue;
251
+ }
252
+
253
+ const isIntermediate = INTERMEDIATE_VAL_REGEX.test(keyName);
254
+ if (isIntermediate) {
255
+ violations.push({
256
+ filePath: relativePath,
257
+ line,
258
+ column,
259
+ hazard: `Property "${keyName}" is an intermediate or temporary value. Hooks must return finished domain state.`,
260
+ rule: 'HOOK_SHAPE_CONTRACT',
261
+ severity: meta.severity,
262
+ pillar: meta.pillar,
263
+ directive: meta.directive
264
+ });
265
+ continue;
266
+ }
267
+
268
+ const isCallback = UNGROUPED_CALLBACK_REGEX.test(keyName);
269
+ if (isCallback) {
270
+ violations.push({
271
+ filePath: relativePath,
272
+ line,
273
+ column,
274
+ hazard: `Property "${keyName}" is an ungrouped callback or handler. Actions must be verb-prefixed (e.g. addItem).`,
275
+ rule: 'HOOK_SHAPE_CONTRACT',
276
+ severity: meta.severity,
277
+ pillar: meta.pillar,
278
+ directive: meta.directive
279
+ });
280
+ continue;
281
+ }
282
+
283
+ const isStatus = STATUS_HEURISTIC_REGEX.test(keyName) || STATUS_EXACT_NAMES.has(keyName.toLowerCase());
284
+ if (isStatus) {
285
+ statusProps.push({ name: keyName, line, column });
286
+ if (MUTUALLY_EXCLUSIVE_STATUSES.has(keyName.toLowerCase())) {
287
+ mutualStatuses.push(keyName);
288
+ }
289
+ }
290
+ }
291
+
292
+ if (mutualStatuses.length >= 3) {
293
+ violations.push({
294
+ filePath: relativePath,
295
+ line: retPath.node.loc?.start.line || 1,
296
+ column: retPath.node.loc?.start.column || 1,
297
+ hazard: `Mutually exclusive lifecycle states returned as multiple independent booleans (${mutualStatuses.join(', ')}). Prefer a single discriminated status field per Directive 2.B.`,
298
+ rule: 'HOOK_SHAPE_CONTRACT',
299
+ severity: meta.severity,
300
+ pillar: meta.pillar,
301
+ directive: meta.directive
302
+ });
303
+ }
304
+
305
+ if (hookRegistry) {
306
+ hookRegistry.recordHook({
307
+ hookName,
308
+ filePath: relativePath,
309
+ statusProps
310
+ });
311
+ }
312
+ };
@@ -117,9 +117,14 @@ export const RULE_REGISTRY = {
117
117
  directive: 'Extract related state and effects into dedicated domain hooks'
118
118
  },
119
119
  HOOK_RETURN_OVERLOAD: {
120
+ pillar: PILLARS.PILLAR_3,
121
+ severity: 'MEDIUM',
122
+ directive: 'Limit hook return values to 3 to 5 properties (Deprecated: use HOOK_SHAPE_CONTRACT)'
123
+ },
124
+ HOOK_SHAPE_CONTRACT: {
120
125
  pillar: PILLARS.PILLAR_3,
121
126
  severity: 'HIGH',
122
- directive: 'Limit hook return values to 3 to 5 properties (State + Status + Actions)'
127
+ directive: 'Structure hook returns into flat State, Status, and verb-prefixed Actions buckets'
123
128
  },
124
129
  CONTROLLER_VIEW_MISMATCH: {
125
130
  pillar: PILLARS.PILLAR_3,
@@ -19,8 +19,9 @@ import {
19
19
  createExtendedVisitors
20
20
  } from './extended-visitors.js';
21
21
  import { createPatternVisitors, recordTemplatePatterns } from './pattern-detector.js';
22
+ import { createHookShapeRegistry } from './hook-shape-validator.js';
22
23
 
23
- export { PILLARS, RULE_REGISTRY };
24
+ export { PILLARS, RULE_REGISTRY, createHookShapeRegistry };
24
25
 
25
26
  export const auditCode = (content, filePath, relativePath, options = {}) => {
26
27
  const violations = [];
@@ -145,7 +146,8 @@ export const auditCode = (content, filePath, relativePath, options = {}) => {
145
146
  }
146
147
 
147
148
  const traverseFn = traverse.default || traverse;
148
- const visitors = createAstVisitors({ relativePath, violations });
149
+ const hookRegistry = options.hookRegistry || createHookShapeRegistry();
150
+ const visitors = createAstVisitors({ relativePath, violations, hookRegistry });
149
151
  const slopVisitors = createAiSlopVisitors({ relativePath, violations });
150
152
  const extendedVisitors = createExtendedVisitors({ relativePath, violations });
151
153
  const patternVisitors = options.patternRegistry
@@ -169,5 +171,11 @@ export const auditCode = (content, filePath, relativePath, options = {}) => {
169
171
 
170
172
  traverseFn(ast, mergedVisitors);
171
173
 
174
+ const shouldRunLocalConsistency = !options.hookRegistry;
175
+ if (shouldRunLocalConsistency) {
176
+ const consistencyViolations = hookRegistry.validateCrossHookConsistency();
177
+ violations.push(...consistencyViolations);
178
+ }
179
+
172
180
  return violations;
173
181
  };
@@ -203,3 +203,132 @@ export const debugCount = (itemCount: number) => {
203
203
  assert.strictEqual(violation, undefined);
204
204
  });
205
205
 
206
+ test("Audit Hook Shape Contract: passes flat hook with 4 actions, 2 state fields, 1 status field (7 total)", () => {
207
+ const code = `
208
+ export const useTodoList = () => {
209
+ const items = ref([]);
210
+ const filter = ref("all");
211
+ const isLoading = ref(false);
212
+
213
+ const addItem = (item) => { items.value.push(item); };
214
+ const toggleItem = (id) => { /* toggle */ };
215
+ const removeItem = (id) => { /* remove */ };
216
+ const setFilter = (next) => { filter.value = next; };
217
+
218
+ return {
219
+ items,
220
+ filter,
221
+ isLoading,
222
+ addItem,
223
+ toggleItem,
224
+ removeItem,
225
+ setFilter
226
+ };
227
+ };
228
+ `;
229
+ const violations = auditCode(code, "src/useTodoList.ts", "src/useTodoList.ts");
230
+ const shapeViolation = violations.find((v) => v.rule === "HOOK_SHAPE_CONTRACT");
231
+ assert.strictEqual(shapeViolation, undefined, "Expected HOOK_SHAPE_CONTRACT to pass for flat 7-property hook");
232
+
233
+ const overloadViolation = violations.find((v) => v.rule === "HOOK_RETURN_OVERLOAD");
234
+ assert.ok(overloadViolation, "Expected deprecated HOOK_RETURN_OVERLOAD warning");
235
+ assert.strictEqual(overloadViolation.severity, "MEDIUM", "Expected downgraded warning severity for HOOK_RETURN_OVERLOAD");
236
+ });
237
+
238
+ test("Audit Hook Shape Contract: fails hook with actions nested under actions wrapper", () => {
239
+ const code = `
240
+ export const useTodoList = () => {
241
+ const items = ref([]);
242
+ const filter = ref("all");
243
+ const isLoading = ref(false);
244
+
245
+ const addItem = (item) => { items.value.push(item); };
246
+ const toggleItem = (id) => { /* toggle */ };
247
+ const removeItem = (id) => { /* remove */ };
248
+ const setFilter = (next) => { filter.value = next; };
249
+
250
+ return {
251
+ items,
252
+ filter,
253
+ isLoading,
254
+ actions: {
255
+ addItem,
256
+ toggleItem,
257
+ removeItem,
258
+ setFilter
259
+ }
260
+ };
261
+ };
262
+ `;
263
+ const violations = auditCode(code, "src/useTodoList.ts", "src/useTodoList.ts");
264
+ const shapeViolation = violations.find((v) => v.rule === "HOOK_SHAPE_CONTRACT");
265
+ assert.ok(shapeViolation, "Expected HOOK_SHAPE_CONTRACT failure for nested action wrapper");
266
+ assert.strictEqual(shapeViolation.severity, "HIGH");
267
+ assert.ok(shapeViolation.hazard.includes("actions"));
268
+ });
269
+
270
+ test("Audit Hook Shape Contract: flags raw DOM ref in hook return", () => {
271
+ const code = `
272
+ export const useDropdown = () => {
273
+ const isOpen = ref(false);
274
+ const containerRef = ref(null);
275
+ const toggleDropdown = () => { isOpen.value = !isOpen.value; };
276
+
277
+ return {
278
+ isOpen,
279
+ containerRef,
280
+ toggleDropdown
281
+ };
282
+ };
283
+ `;
284
+ const violations = auditCode(code, "src/useDropdown.ts", "src/useDropdown.ts");
285
+ const shapeViolation = violations.find((v) => v.rule === "HOOK_SHAPE_CONTRACT");
286
+ assert.ok(shapeViolation, "Expected HOOK_SHAPE_CONTRACT failure for raw DOM ref");
287
+ assert.strictEqual(shapeViolation.severity, "HIGH");
288
+ assert.ok(shapeViolation.hazard.includes("containerRef"));
289
+ });
290
+
291
+ test("Audit Hook Shape Contract: flags non-verb action handler in hook return", () => {
292
+ const code = `
293
+ export const useCounter = () => {
294
+ const count = ref(0);
295
+ const onToggle = () => { count.value += 1; };
296
+
297
+ return {
298
+ count,
299
+ onToggle
300
+ };
301
+ };
302
+ `;
303
+ const violations = auditCode(code, "src/useCounter.ts", "src/useCounter.ts");
304
+ const shapeViolation = violations.find((v) => v.rule === "HOOK_SHAPE_CONTRACT");
305
+ assert.ok(shapeViolation, "Expected HOOK_SHAPE_CONTRACT failure for onToggle");
306
+ assert.strictEqual(shapeViolation.severity, "HIGH");
307
+ assert.ok(shapeViolation.hazard.includes("onToggle"));
308
+ });
309
+
310
+ test("Audit Hook Shape Contract: flags cross-hook naming inconsistency for loading state", () => {
311
+ const code = `
312
+ export const useFirst = () => {
313
+ const items = ref([]);
314
+ const isLoading = ref(false);
315
+ const fetchItems = () => {};
316
+ return { items, isLoading, fetchItems };
317
+ };
318
+
319
+ export const useSecond = () => {
320
+ const user = ref(null);
321
+ const loading = ref(false);
322
+ const fetchUser = () => {};
323
+ return { user, loading, fetchUser };
324
+ };
325
+ `;
326
+ const violations = auditCode(code, "src/useMulti.ts", "src/useMulti.ts");
327
+ const inconsistentViolation = violations.find(
328
+ (v) => v.rule === "HOOK_SHAPE_CONTRACT" && v.hazard.includes("loading")
329
+ );
330
+ assert.ok(inconsistentViolation, "Expected cross-hook naming inconsistency failure for loading vs isLoading");
331
+ assert.strictEqual(inconsistentViolation.severity, "HIGH");
332
+ assert.ok(inconsistentViolation.hazard.includes("isLoading"));
333
+ });
334
+
package/cli/audit.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { auditCode, PILLARS, RULE_REGISTRY } from './audit/rules.js';
3
+ import { auditCode, PILLARS, RULE_REGISTRY, createHookShapeRegistry } from './audit/rules.js';
4
4
  import { createPatternRegistry } from './audit/pattern-detector.js';
5
5
  import {
6
6
  buildRemediationRoadmap,
@@ -116,6 +116,7 @@ const auditFileEntry = (fullPath, relPath, scanOptions) => {
116
116
  const hookCount = hookMatches ? hookMatches.length : 0;
117
117
  const fileViolations = auditCode(content, fullPath, relPath, {
118
118
  patternRegistry: scanOptions.patternRegistry,
119
+ hookRegistry: scanOptions.hookRegistry,
119
120
  fast: scanOptions.fast
120
121
  });
121
122
 
@@ -174,15 +175,20 @@ export const scanDirectory = (targetDir, baseDir) => {
174
175
  };
175
176
 
176
177
  export const runAudit = (targetDir = 'src', options = {}) => {
177
- const cwd = process.cwd();
178
- const absoluteTarget = path.resolve(cwd, targetDir);
178
+ const cwd = options.cwd || process.cwd();
179
+ const absoluteTarget = path.isAbsolute(targetDir) ? targetDir : path.resolve(cwd, targetDir);
179
180
  const patternRegistry = createPatternRegistry();
181
+ const hookRegistry = createHookShapeRegistry();
180
182
  const { violations, fileStats, totalHooks } = scanTree(absoluteTarget, cwd, {
181
183
  patternRegistry,
184
+ hookRegistry,
182
185
  fast: Boolean(options.fast),
183
186
  fileList: options.fileList || null
184
187
  });
185
188
 
189
+ const crossHookViolations = hookRegistry.validateCrossHookConsistency();
190
+ violations.push(...crossHookViolations);
191
+
186
192
  const scannedFiles = fileStats.length;
187
193
  const totalLoc = fileStats.reduce((acc, f) => acc + f.lineCount, 0);
188
194
  const avgLoc = scannedFiles > 0 ? Math.round(totalLoc / scannedFiles) : 0;
package/cli/mcp/tools.js CHANGED
@@ -52,8 +52,10 @@ export {
52
52
  };
53
53
 
54
54
  const handleQueryPatterns = (args = {}, cwd = process.cwd()) => {
55
- const targetDir = args.dir || (fs.existsSync(path.resolve(cwd, 'src')) ? 'src' : '.');
56
- const report = executeAstAudit(targetDir, {});
55
+ const baseCwd = resolveTargetCwd(cwd);
56
+ const targetDir = args.dir || (fs.existsSync(path.resolve(baseCwd, 'src')) ? 'src' : '.');
57
+ const resolvedTarget = path.isAbsolute(targetDir) ? targetDir : path.resolve(baseCwd, targetDir);
58
+ const report = executeAstAudit(resolvedTarget, { cwd: baseCwd });
57
59
  const rawPatterns = report.patterns || [];
58
60
  const filterType = args.type || 'ALL';
59
61
  const minOccurrences = typeof args.minOccurrences === 'number' ? args.minOccurrences : 2;
@@ -113,12 +115,13 @@ const handleQueryPatterns = (args = {}, cwd = process.cwd()) => {
113
115
  };
114
116
 
115
117
  const handleAudit = (args = {}, cwd = process.cwd()) => {
116
- const rawTarget = args.path || (fs.existsSync(path.resolve(cwd, 'src')) ? 'src' : '.');
117
- const resolvedTarget = path.resolve(cwd, rawTarget);
118
+ const baseCwd = resolveTargetCwd(cwd);
119
+ const rawTarget = args.path || args.dir || (fs.existsSync(path.resolve(baseCwd, 'src')) ? 'src' : '.');
120
+ const resolvedTarget = path.isAbsolute(rawTarget) ? rawTarget : path.resolve(baseCwd, rawTarget);
118
121
  const isFile = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isFile();
119
122
 
120
123
  if (isFile) {
121
- const relPath = path.relative(cwd, resolvedTarget);
124
+ const relPath = path.relative(baseCwd, resolvedTarget);
122
125
  const violations = auditFile(resolvedTarget, relPath);
123
126
  return {
124
127
  type: 'file',
@@ -129,21 +132,22 @@ const handleAudit = (args = {}, cwd = process.cwd()) => {
129
132
  }
130
133
 
131
134
  const options = {
135
+ cwd: baseCwd,
132
136
  model: args.model || 'blended',
133
137
  outputFile: null
134
138
  };
135
139
 
136
- const report = executeAstAudit(rawTarget, options);
140
+ const report = executeAstAudit(resolvedTarget, options);
137
141
 
138
142
  // Sync AST audit results to SQLite index database
139
143
  try {
140
- const syncRes = syncSearchIndex(rawTarget, cwd);
144
+ const syncRes = syncSearchIndex(resolvedTarget, baseCwd);
141
145
  if (syncRes?.db) {
142
146
  syncViolationsIndex(syncRes.db, report.violations);
143
147
  recordAuditSnapshot(syncRes.db, report);
144
148
  }
145
- } catch {
146
- // Continue if SQLite synchronization fails
149
+ } catch (syncError) {
150
+ process.stderr.write(`[chemx] Search index sync bypassed: ${syncError?.message || String(syncError)}\n`);
147
151
  }
148
152
 
149
153
  const isSevereViolation = (v) => {
@@ -239,7 +243,8 @@ const handleGetRefactorPrompt = (args = {}, cwd = process.cwd()) => {
239
243
  };
240
244
 
241
245
  const handleAuditBuild = async (args = {}, cwd = process.cwd()) => {
242
- const targetCwd = args.dir ? path.resolve(cwd, args.dir) : cwd;
246
+ const baseCwd = resolveTargetCwd(cwd);
247
+ const targetCwd = args.dir ? path.resolve(baseCwd, args.dir) : baseCwd;
243
248
  const rawArgs = ['--json'];
244
249
  if (args.command) {
245
250
  rawArgs.push('--', args.command);
@@ -248,10 +253,13 @@ const handleAuditBuild = async (args = {}, cwd = process.cwd()) => {
248
253
  };
249
254
 
250
255
  const handleAutofix = (args = {}, cwd = process.cwd()) => {
251
- return runAutofix(args.path, {
256
+ const baseCwd = resolveTargetCwd(cwd);
257
+ const rawTarget = args.path || args.dir || (fs.existsSync(path.resolve(baseCwd, 'src')) ? 'src' : '.');
258
+ const targetDir = path.isAbsolute(rawTarget) ? rawTarget : path.resolve(baseCwd, rawTarget);
259
+ return runAutofix(targetDir, {
252
260
  dryRun: Boolean(args.dryRun),
253
261
  rules: args.rules,
254
- cwd
262
+ cwd: baseCwd
255
263
  });
256
264
  };
257
265
 
@@ -15,7 +15,9 @@ export const parseTaskRow = (row) => {
15
15
  return {
16
16
  ...row,
17
17
  dependencies: JSON.parse(row.dependencies || '[]'),
18
- result_payload: JSON.parse(row.result_payload || '{}')
18
+ result_payload: JSON.parse(row.result_payload || '{}'),
19
+ violation_snapshot: typeof row.violation_snapshot === 'string' ? JSON.parse(row.violation_snapshot || '{}') : (row.violation_snapshot || {}),
20
+ diff_receipt: typeof row.diff_receipt === 'string' ? JSON.parse(row.diff_receipt || '{}') : (row.diff_receipt || {})
19
21
  };
20
22
  };
21
23
 
@@ -59,8 +61,10 @@ export const executeStatusUpdate = (db, taskId, status, options, task) => {
59
61
  const payloadStr = options.resultPayload
60
62
  ? JSON.stringify(options.resultPayload)
61
63
  : JSON.stringify(task.result_payload);
62
- db.prepare('UPDATE agent_tasks SET status = ?, blocked_reason = ?, result_payload = ?, updated_at = ? WHERE id = ?')
63
- .run(status, options.blockedReason || '', payloadStr, now, Number(taskId));
64
+ const receiptObj = options.diffReceipt || options.resultPayload?.receipt || task.diff_receipt || {};
65
+ const diffReceiptStr = JSON.stringify(receiptObj);
66
+ db.prepare('UPDATE agent_tasks SET status = ?, blocked_reason = ?, result_payload = ?, diff_receipt = ?, updated_at = ? WHERE id = ?')
67
+ .run(status, options.blockedReason || '', payloadStr, diffReceiptStr, now, Number(taskId));
64
68
 
65
69
  const isTerminal = ['done', 'failed', 'queued'].includes(status);
66
70
  const hasAgent = Boolean(task.assigned_agent_id);