@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.
- package/cli/audit/ast-visitors.js +10 -17
- package/cli/audit/hook-shape-validator.js +312 -0
- package/cli/audit/rules-registry.js +6 -1
- package/cli/audit/rules.js +10 -2
- package/cli/audit/rules.spec.js +129 -0
- package/cli/audit.js +9 -3
- package/cli/mcp/tools.js +20 -12
- package/cli/team/team-db-task-helpers.js +7 -3
- package/cli/team/team-db-tasks.js +8 -3
- package/cli/team/team-schema.js +6 -2
- package/cli/team/team-triage.js +29 -2
- package/cli/team/team-verification-gate.spec.js +192 -0
- package/cli/ui-actions-tasks.js +13 -5
- package/cli/ui-actions.js +10 -2
- package/cli/ui-client-api.js +2 -2
- package/cli/ui-client-script.js +12 -15
- package/cli/ui-index.html +230 -7
- package/cli/ui-template-kanban.js +15 -1
- package/cli/verify.js +19 -8
- package/package.json +1 -1
|
@@ -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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
violations
|
|
54
|
-
|
|
55
|
-
|
|
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: '
|
|
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,
|
package/cli/audit/rules.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/cli/audit/rules.spec.js
CHANGED
|
@@ -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
|
|
56
|
-
const
|
|
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
|
|
117
|
-
const
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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);
|