code-auditor-mcp 3.6.0 → 3.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +73 -0
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js +12 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.js +122 -43
- package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +2 -2
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +6 -7
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
- package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
- package/dist/analyzers/ruleRegistry.js +152 -20
- package/dist/analyzers/ruleRegistry.js.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +38 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js +437 -48
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +6 -0
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +117 -35
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +86 -15
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +32 -7
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +104 -23
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSecretsAnalyzer.d.ts +55 -0
- package/dist/analyzers/universal/UniversalSecretsAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/universal/UniversalSecretsAnalyzer.js +318 -0
- package/dist/analyzers/universal/UniversalSecretsAnalyzer.js.map +1 -0
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +2 -8
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js +54 -45
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/functionConcerns.d.ts +57 -0
- package/dist/analyzers/universal/functionConcerns.d.ts.map +1 -0
- package/dist/analyzers/universal/functionConcerns.js +239 -0
- package/dist/analyzers/universal/functionConcerns.js.map +1 -0
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -1
- package/dist/analyzers/universal/schema/codeAnalysis.js +13 -7
- package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -1
- package/dist/analyzers/universal/schema/jsonSchema.js +124 -11
- package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -1
- package/dist/auditRouter.d.ts.map +1 -1
- package/dist/auditRouter.js +4 -6
- package/dist/auditRouter.js.map +1 -1
- package/dist/auditRunner.d.ts.map +1 -1
- package/dist/auditRunner.js +74 -48
- package/dist/auditRunner.js.map +1 -1
- package/dist/cli.js +321 -98
- package/dist/cli.js.map +1 -1
- package/dist/config/configLoader.d.ts.map +1 -1
- package/dist/config/configLoader.js +23 -0
- package/dist/config/configLoader.js.map +1 -1
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +45 -10
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/effectiveConfig.d.ts.map +1 -1
- package/dist/config/effectiveConfig.js +2 -0
- package/dist/config/effectiveConfig.js.map +1 -1
- package/dist/conventions/conventionMiner.d.ts +18 -3
- package/dist/conventions/conventionMiner.d.ts.map +1 -1
- package/dist/conventions/conventionMiner.js +130 -12
- package/dist/conventions/conventionMiner.js.map +1 -1
- package/dist/daemon/core.d.ts +128 -0
- package/dist/daemon/core.d.ts.map +1 -0
- package/dist/daemon/core.js +700 -0
- package/dist/daemon/core.js.map +1 -0
- package/dist/daemon/lspServer.d.ts +51 -0
- package/dist/daemon/lspServer.d.ts.map +1 -0
- package/dist/daemon/lspServer.js +168 -0
- package/dist/daemon/lspServer.js.map +1 -0
- package/dist/daemon/main.d.ts +34 -0
- package/dist/daemon/main.d.ts.map +1 -0
- package/dist/daemon/main.js +147 -0
- package/dist/daemon/main.js.map +1 -0
- package/dist/daemon/resolve.d.ts +41 -0
- package/dist/daemon/resolve.d.ts.map +1 -0
- package/dist/daemon/resolve.js +90 -0
- package/dist/daemon/resolve.js.map +1 -0
- package/dist/daemon/socketClient.d.ts +21 -0
- package/dist/daemon/socketClient.d.ts.map +1 -0
- package/dist/daemon/socketClient.js +60 -0
- package/dist/daemon/socketClient.js.map +1 -0
- package/dist/daemon/socketServer.d.ts +33 -0
- package/dist/daemon/socketServer.d.ts.map +1 -0
- package/dist/daemon/socketServer.js +106 -0
- package/dist/daemon/socketServer.js.map +1 -0
- package/dist/daemon/types.d.ts +78 -0
- package/dist/daemon/types.d.ts.map +1 -0
- package/dist/daemon/types.js +10 -0
- package/dist/daemon/types.js.map +1 -0
- package/dist/dataPaths.d.ts +21 -0
- package/dist/dataPaths.d.ts.map +1 -1
- package/dist/dataPaths.js +27 -0
- package/dist/dataPaths.js.map +1 -1
- package/dist/graph/importGraph.d.ts +11 -0
- package/dist/graph/importGraph.d.ts.map +1 -1
- package/dist/graph/importGraph.js +11 -3
- package/dist/graph/importGraph.js.map +1 -1
- package/dist/hooks/core.d.ts.map +1 -1
- package/dist/hooks/core.js +21 -4
- package/dist/hooks/core.js.map +1 -1
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +2 -5
- package/dist/installer.js.map +1 -1
- package/dist/languages/RuntimeManager.js +10 -0
- package/dist/languages/RuntimeManager.js.map +1 -1
- package/dist/languages/go/analyzer +0 -0
- package/dist/languages/go/analyzer-src/analyzer.go +166 -35
- package/dist/languages/go/analyzer-src/solid.go +80 -172
- package/dist/languages/go/analyzer-src/types.go +18 -18
- package/dist/ledger.d.ts +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +3 -3
- package/dist/ledger.js.map +1 -1
- package/dist/mcp.js +1 -1
- package/dist/mcp.js.map +1 -1
- package/dist/mcpAuditJobs.js +1 -1
- package/dist/mcpAuditJobs.js.map +1 -1
- package/dist/nextFileIncremental.d.ts +9 -3
- package/dist/nextFileIncremental.d.ts.map +1 -1
- package/dist/nextFileIncremental.js +32 -6
- package/dist/nextFileIncremental.js.map +1 -1
- package/dist/pipeline.d.ts +26 -0
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +86 -10
- package/dist/pipeline.js.map +1 -1
- package/dist/pipelineAdapters.d.ts +2 -1
- package/dist/pipelineAdapters.d.ts.map +1 -1
- package/dist/pipelineAdapters.js +228 -5
- package/dist/pipelineAdapters.js.map +1 -1
- package/dist/reporting/csvReportGenerator.js +1 -1
- package/dist/ruleAliases.d.ts.map +1 -1
- package/dist/ruleAliases.js +41 -2
- package/dist/ruleAliases.js.map +1 -1
- package/dist/styles/tailwindProbe.d.ts +8 -0
- package/dist/styles/tailwindProbe.d.ts.map +1 -1
- package/dist/styles/tailwindProbe.js +11 -1
- package/dist/styles/tailwindProbe.js.map +1 -1
- package/dist/types/crossLanguage.d.ts +1 -0
- package/dist/types/crossLanguage.d.ts.map +1 -1
- package/dist/types.d.ts +30 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +7 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/code-auditor/SKILL-RULE-KINDS.md +4 -2
- package/plugin/skills/code-auditor/SKILL.md +19 -11
|
@@ -17,7 +17,21 @@ export const DEFAULT_DRY_CONFIG = {
|
|
|
17
17
|
// R3.2: floor raised from 5 → 15
|
|
18
18
|
minLineThreshold: 15,
|
|
19
19
|
similarityThreshold: 0.85,
|
|
20
|
-
|
|
20
|
+
// Test files and test directories are excluded by convention in any language:
|
|
21
|
+
// `.test.*`/`.spec.*` in TS/TSX/JS/JSX, plus `test/` and `tests/` directories.
|
|
22
|
+
// Expected-output fixtures and repeated setup are not production duplication,
|
|
23
|
+
// and a JS library (knex) names its tests `.js` under `test/`, which the
|
|
24
|
+
// earlier TS-only patterns missed (132 fixture-object findings). `__tests__/`
|
|
25
|
+
// is deliberately NOT excluded: this very repo keeps analyzable source
|
|
26
|
+
// fixtures under `__tests__/fixtures/`, and Jest's `__tests__` files are
|
|
27
|
+
// still `.test.*`/`.spec.*` named, so the filename patterns cover them.
|
|
28
|
+
excludePatterns: [
|
|
29
|
+
'**/*.test.ts', '**/*.spec.ts',
|
|
30
|
+
'**/*.test.tsx', '**/*.spec.tsx',
|
|
31
|
+
'**/*.test.js', '**/*.spec.js',
|
|
32
|
+
'**/*.test.jsx', '**/*.spec.jsx',
|
|
33
|
+
'**/test/**', '**/tests/**',
|
|
34
|
+
],
|
|
21
35
|
// R4.1: sub-rules disabled by default
|
|
22
36
|
checkImports: false,
|
|
23
37
|
checkStrings: false,
|
|
@@ -25,6 +39,15 @@ export const DEFAULT_DRY_CONFIG = {
|
|
|
25
39
|
ignoreWhitespace: true,
|
|
26
40
|
// R4.2: structural similarity off by default
|
|
27
41
|
checkStructuralSimilarity: false,
|
|
42
|
+
// #132: expression similarity ON by default. Fluent library/builder chains
|
|
43
|
+
// (query builders, schema builders, Zod validators, commander, promises, DOM
|
|
44
|
+
// and stdlib method chains) are filtered out of the signal — they are the
|
|
45
|
+
// library's API surface, not duplicated logic — and unrelated schema literals
|
|
46
|
+
// are excluded by requiring object literals to target the same identifier. So
|
|
47
|
+
// the rule fires on real duplication — `resultSummary` built twice — without
|
|
48
|
+
// flooding a default audit.
|
|
49
|
+
checkExpressionSimilarity: true,
|
|
50
|
+
minShapeNames: 4,
|
|
28
51
|
};
|
|
29
52
|
// ── R3.1: Span-overlap helpers ──────────────────────────────────────
|
|
30
53
|
/**
|
|
@@ -86,6 +109,248 @@ function deduplicateBlocks(blocks) {
|
|
|
86
109
|
}
|
|
87
110
|
return result;
|
|
88
111
|
}
|
|
112
|
+
/** True when `outer`'s span fully contains `inner`'s span in the same file. */
|
|
113
|
+
function shapeSpansContain(outer, inner) {
|
|
114
|
+
if (outer.file !== inner.file)
|
|
115
|
+
return false;
|
|
116
|
+
const startLte = outer.start.line < inner.start.line ||
|
|
117
|
+
(outer.start.line === inner.start.line && outer.start.column <= inner.start.column);
|
|
118
|
+
const endGte = outer.end.line > inner.end.line ||
|
|
119
|
+
(outer.end.line === inner.end.line && outer.end.column >= inner.end.column);
|
|
120
|
+
return startLte && endGte;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Drop nested fragments, keeping the outermost. A fluent chain visits its outer
|
|
124
|
+
* call first, then each suffix chain (`a().b()` vs `a().b().c()` share a start
|
|
125
|
+
* position but the outer span is longer); a nested object literal is fully
|
|
126
|
+
* contained in its parent. Sort puts the widest span first so the inner suffix
|
|
127
|
+
* is skipped as contained.
|
|
128
|
+
*/
|
|
129
|
+
function dedupeShapeFragments(fragments) {
|
|
130
|
+
const sorted = [...fragments].sort((a, b) => {
|
|
131
|
+
if (a.file !== b.file)
|
|
132
|
+
return a.file.localeCompare(b.file);
|
|
133
|
+
if (a.start.line !== b.start.line)
|
|
134
|
+
return a.start.line - b.start.line;
|
|
135
|
+
if (a.start.column !== b.start.column)
|
|
136
|
+
return a.start.column - b.start.column;
|
|
137
|
+
// Same start → outermost (largest end) first.
|
|
138
|
+
if (a.end.line !== b.end.line)
|
|
139
|
+
return b.end.line - a.end.line;
|
|
140
|
+
return b.end.column - a.end.column;
|
|
141
|
+
});
|
|
142
|
+
const kept = [];
|
|
143
|
+
for (const f of sorted) {
|
|
144
|
+
if (!kept.some((k) => shapeSpansContain(k, f)))
|
|
145
|
+
kept.push(f);
|
|
146
|
+
}
|
|
147
|
+
return kept;
|
|
148
|
+
}
|
|
149
|
+
/** Strip one layer of quotes from a string-literal object key. */
|
|
150
|
+
function bareKeyName(key, getText) {
|
|
151
|
+
let t = getText(key).trim();
|
|
152
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
153
|
+
t = t.slice(1, -1);
|
|
154
|
+
}
|
|
155
|
+
return t;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Field names of an object literal, in source order. Handles `{ a: 1 }`
|
|
159
|
+
* (`pair` → key child) and `{ a }` (`shorthand_property_identifier`).
|
|
160
|
+
*/
|
|
161
|
+
function objectFieldNames(node, getText) {
|
|
162
|
+
const names = [];
|
|
163
|
+
for (const child of node.children ?? []) {
|
|
164
|
+
if (child.type === 'pair') {
|
|
165
|
+
const key = child.children?.[0];
|
|
166
|
+
if (key) {
|
|
167
|
+
const name = bareKeyName(key, getText);
|
|
168
|
+
if (name)
|
|
169
|
+
names.push(name);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else if (child.type === 'shorthand_property_identifier') {
|
|
173
|
+
const name = getText(child).trim();
|
|
174
|
+
if (name)
|
|
175
|
+
names.push(name);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return names;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Method names of a fluent call chain (`db.update().set().where().returning()`),
|
|
182
|
+
* in call order. Walks `call_expression` → `member_expression` callee chain,
|
|
183
|
+
* collecting each `.method` name and recursing into the chain's receiver until
|
|
184
|
+
* a bare identifier is reached. Returns [] for a bare (non-member) call.
|
|
185
|
+
*/
|
|
186
|
+
function callChainMethodNames(node, getText) {
|
|
187
|
+
const names = [];
|
|
188
|
+
let current = node;
|
|
189
|
+
while (current && current.type === 'call_expression') {
|
|
190
|
+
const callee = current.children?.[0];
|
|
191
|
+
if (!callee)
|
|
192
|
+
break;
|
|
193
|
+
if (callee.type === 'member_expression') {
|
|
194
|
+
const prop = callee.children?.find((c) => c.type === 'property_identifier');
|
|
195
|
+
if (!prop)
|
|
196
|
+
break;
|
|
197
|
+
names.push(getText(prop).trim());
|
|
198
|
+
current = callee.children?.[0];
|
|
199
|
+
}
|
|
200
|
+
else if (callee.type === 'call_expression') {
|
|
201
|
+
current = callee; // curried/IIFE — skip the anonymous level, keep walking
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
break; // bare identifier callee — end of the chain
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return names.reverse();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Method names from well-known fluent APIs. A chain built from these verbs is
|
|
211
|
+
* "structurally similar by design" — it is the library's public surface, not
|
|
212
|
+
* duplicated domain logic — so the default-on rule stays quiet on it.
|
|
213
|
+
*
|
|
214
|
+
* The first version excluded only `select`/`selectDistinct` on the theory that
|
|
215
|
+
* read queries are idiomatic but mutation chains (`update().set().where()`)
|
|
216
|
+
* are the real signal. Corpus measurement disproved that theory: the chains
|
|
217
|
+
* that actually fire are Zod validators (`string().trim().min().max()`),
|
|
218
|
+
* query/schema builders (`insert().onConflict().ignore()`,
|
|
219
|
+
* `integer().unsigned().references()`), commander registrations
|
|
220
|
+
* (`command().option().action()`), promise flows (`then().then().catch()`),
|
|
221
|
+
* DOM/JQuery traversal (`closest().find().first().text()`), and stdlib
|
|
222
|
+
* array/string method chains — all library API, none duplicated logic. The
|
|
223
|
+
* object-literal half of the rule (a *specific* object built twice, keyed by
|
|
224
|
+
* target) is where the real signal lives and is unaffected by this exclusion.
|
|
225
|
+
*/
|
|
226
|
+
const FLUENT_CHAIN_METHODS = new Set([
|
|
227
|
+
// SQL query builders (knex, Kysely, Drizzle, …)
|
|
228
|
+
'select', 'selectDistinct', 'from', 'where', 'andWhere', 'orWhere', 'whereRaw',
|
|
229
|
+
'whereIn', 'whereNotIn', 'whereNull', 'whereNotNull', 'whereExists', 'whereBetween',
|
|
230
|
+
'orderBy', 'groupBy', 'having', 'join', 'innerJoin', 'leftJoin', 'rightJoin',
|
|
231
|
+
'crossJoin', 'fullOuterJoin', 'limit', 'offset', 'distinct', 'count', 'sum', 'avg',
|
|
232
|
+
'first', 'pluck', 'forUpdate', 'forShare', 'skipLocked', 'union', 'unionAll',
|
|
233
|
+
'insert', 'update', 'del', 'delete', 'into', 'returning', 'onConflict', 'ignore',
|
|
234
|
+
'merge', 'increment', 'decrement', 'transacting', 'using', 'updateFrom', 'testSql',
|
|
235
|
+
'toSQL', 'toQuery', 'raw', 'table', 'schemaBuilder', 'queryBuilder', 'partitionBy',
|
|
236
|
+
// Schema builders (knex `table.integer().unsigned().references()`)
|
|
237
|
+
'createTable', 'alterTable', 'dropTable', 'dropTableIfExists', 'renameTable',
|
|
238
|
+
'renameColumn', 'dropColumn', 'integer', 'bigInteger', 'text', 'boolean', 'float',
|
|
239
|
+
'double', 'decimal', 'date', 'dateTime', 'timestamp', 'timestamps', 'time',
|
|
240
|
+
'binary', 'json', 'jsonb', 'uuid', 'unsigned', 'references', 'inTable', 'defaultTo',
|
|
241
|
+
'index', 'unique', 'primary', 'comment', 'foreign', 'onDelete', 'onUpdate',
|
|
242
|
+
'deferrable', 'withKeyName', 'notNullable', 'collate', 'check',
|
|
243
|
+
// Zod / Valibot schema validators
|
|
244
|
+
'trim', 'min', 'max', 'length', 'int', 'positive', 'nonnegative', 'negative',
|
|
245
|
+
'regex', 'email', 'url', 'datetime', 'optional', 'nullish', 'nullable', 'default',
|
|
246
|
+
'describe', 'refine', 'superRefine', 'transform', 'safeParse', 'parse', 'array',
|
|
247
|
+
'object', 'enum', 'record', 'union', 'intersection', 'tuple', 'literal', 'number',
|
|
248
|
+
'string', 'nativeEnum', 'lazy', 'preprocess', 'brand',
|
|
249
|
+
// commander / CLI builders
|
|
250
|
+
'command', 'description', 'option', 'requiredOption', 'action', 'argument',
|
|
251
|
+
'version', 'usage', 'name', 'alias', 'allowUnknownOption', 'exitOverride',
|
|
252
|
+
// Promises
|
|
253
|
+
'then', 'catch', 'finally',
|
|
254
|
+
// DOM / jQuery traversal and JS stdlib array/string method chains
|
|
255
|
+
'closest', 'find', 'text', 'map', 'filter', 'reduce', 'forEach', 'slice', 'split',
|
|
256
|
+
'join', 'replace', 'replaceAll', 'toLowerCase', 'toUpperCase', 'flatMap', 'concat',
|
|
257
|
+
]);
|
|
258
|
+
/** True when the chain is a fluent library/builder API (not duplicated logic). */
|
|
259
|
+
function isFluentChain(names) {
|
|
260
|
+
return names.some((n) => FLUENT_CHAIN_METHODS.has(n));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Extract shape fragments (object literals + fluent call chains) from the AST.
|
|
264
|
+
*
|
|
265
|
+
* Object literals are only collected as the *direct value* of a declaration
|
|
266
|
+
* (`const x = {...}`) or assignment (`info.resultSummary = {...}`), and carry
|
|
267
|
+
* that target. Two literals must target the same identifier to be compared —
|
|
268
|
+
* this keeps `pgTable('users', {...})` / `pgTable('orders', {...})` (different
|
|
269
|
+
* targets, shared `id`/`createdAt` column names) out of the "built twice" set.
|
|
270
|
+
*
|
|
271
|
+
* Chains are collected from every `call_expression`, excluding fluent
|
|
272
|
+
* library/builder APIs. Fragments are filtered to `minShapeNames` names and
|
|
273
|
+
* deduplicated to the outermost span.
|
|
274
|
+
*/
|
|
275
|
+
function extractShapeFragments(ctx, minShapeNames) {
|
|
276
|
+
const fragments = [];
|
|
277
|
+
const getText = (node) => ctx.adapter.getNodeText(node, ctx.sourceCode);
|
|
278
|
+
const collectObject = (node, target) => {
|
|
279
|
+
const names = objectFieldNames(node, getText);
|
|
280
|
+
if (names.length < minShapeNames)
|
|
281
|
+
return;
|
|
282
|
+
fragments.push({
|
|
283
|
+
file: ctx.ast.filePath,
|
|
284
|
+
start: node.location.start,
|
|
285
|
+
end: node.location.end,
|
|
286
|
+
kind: 'object',
|
|
287
|
+
target,
|
|
288
|
+
names,
|
|
289
|
+
text: getText(node),
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
walkAST(ctx.ast.root, (node) => {
|
|
293
|
+
if (node.type === 'variable_declarator') {
|
|
294
|
+
// `const name: T = value` — target is the declarator name, value the last child.
|
|
295
|
+
const name = node.children?.[0];
|
|
296
|
+
const value = node.children?.[node.children.length - 1];
|
|
297
|
+
if (name && value?.type === 'object')
|
|
298
|
+
collectObject(value, getText(name));
|
|
299
|
+
}
|
|
300
|
+
else if (node.type === 'assignment_expression') {
|
|
301
|
+
// `target = value` — target is the left-hand side, value the last child.
|
|
302
|
+
const left = node.children?.[0];
|
|
303
|
+
const value = node.children?.[node.children.length - 1];
|
|
304
|
+
if (left && value?.type === 'object')
|
|
305
|
+
collectObject(value, getText(left));
|
|
306
|
+
}
|
|
307
|
+
else if (node.type === 'call_expression') {
|
|
308
|
+
const names = callChainMethodNames(node, getText);
|
|
309
|
+
if (names.length < minShapeNames || isFluentChain(names))
|
|
310
|
+
return;
|
|
311
|
+
fragments.push({
|
|
312
|
+
file: ctx.ast.filePath,
|
|
313
|
+
start: node.location.start,
|
|
314
|
+
end: node.location.end,
|
|
315
|
+
kind: 'chain',
|
|
316
|
+
names,
|
|
317
|
+
text: getText(node),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
return dedupeShapeFragments(fragments);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Longest common subsequence of two string arrays, returned as the actual
|
|
325
|
+
* shared sequence (so the violation message can name the shared fields/methods).
|
|
326
|
+
*/
|
|
327
|
+
function longestCommonSubsequence(a, b) {
|
|
328
|
+
const m = a.length;
|
|
329
|
+
const n = b.length;
|
|
330
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
331
|
+
for (let i = 1; i <= m; i++) {
|
|
332
|
+
for (let j = 1; j <= n; j++) {
|
|
333
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const seq = [];
|
|
337
|
+
let i = m;
|
|
338
|
+
let j = n;
|
|
339
|
+
while (i > 0 && j > 0) {
|
|
340
|
+
if (a[i - 1] === b[j - 1]) {
|
|
341
|
+
seq.push(a[i - 1]);
|
|
342
|
+
i--;
|
|
343
|
+
j--;
|
|
344
|
+
}
|
|
345
|
+
else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
346
|
+
i--;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
j--;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return seq.reverse();
|
|
353
|
+
}
|
|
89
354
|
// ── R3.3: Structural similarity helpers ──────────────────────────────
|
|
90
355
|
/**
|
|
91
356
|
* Group blocks by a key field into a map of key→blocks[].
|
|
@@ -216,7 +481,13 @@ function computePairFingerprint(original, block) {
|
|
|
216
481
|
* Find a node by its location via BFS.
|
|
217
482
|
*/
|
|
218
483
|
function findNodeByLocation(root, location) {
|
|
219
|
-
|
|
484
|
+
// Search from the root's children, never the root itself. The root is a
|
|
485
|
+
// whole-file wrapper (`program`/`source_file`) whose start location collides
|
|
486
|
+
// with its first top-level child (a non-`export`ed declaration starts at
|
|
487
|
+
// column 1, the same as the wrapper). Returning the wrapper for the first
|
|
488
|
+
// top-level declaration made `deduplicateBlocks` absorb it as an outer block,
|
|
489
|
+
// silently dropping the file's first declaration from comparison.
|
|
490
|
+
const queue = [...(root.children ?? [])];
|
|
220
491
|
while (queue.length > 0) {
|
|
221
492
|
const node = queue.shift();
|
|
222
493
|
if (node.location.start.line === location.line &&
|
|
@@ -230,13 +501,48 @@ function findNodeByLocation(root, location) {
|
|
|
230
501
|
return null;
|
|
231
502
|
}
|
|
232
503
|
/**
|
|
233
|
-
*
|
|
504
|
+
* Translate a minimal glob (`*`, `**`, `?`) to an anchored regex. A globstar
|
|
505
|
+
* (`**`) matches any run of characters including the path separator, and a
|
|
506
|
+
* globstar followed by a slash becomes an optional segment prefix so it also
|
|
507
|
+
* matches zero path segments (e.g. `test/` at the root of a relative path).
|
|
508
|
+
* `*` and `?` match within a single segment.
|
|
234
509
|
*/
|
|
510
|
+
function globToRegExp(pattern) {
|
|
511
|
+
const out = ['^'];
|
|
512
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
513
|
+
const ch = pattern[i];
|
|
514
|
+
if (ch === '*') {
|
|
515
|
+
if (pattern[i + 1] === '*') {
|
|
516
|
+
i += 1; // consume the second `*`
|
|
517
|
+
if (pattern[i + 1] === '/') {
|
|
518
|
+
i += 1; // fold a trailing `/` into the globstar
|
|
519
|
+
out.push('(?:.*/)?');
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
out.push('.*');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
out.push('[^/]*');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (ch === '?') {
|
|
530
|
+
out.push('[^/]');
|
|
531
|
+
}
|
|
532
|
+
else if ('^$\\.+?()[]{}|'.includes(ch)) {
|
|
533
|
+
out.push('\\', ch);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
out.push(ch);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
out.push('$');
|
|
540
|
+
return new RegExp(out.join(''));
|
|
541
|
+
}
|
|
542
|
+
/** Check whether a file path matches any of the given glob-ish exclude patterns. */
|
|
235
543
|
function isExcluded(filePath, patterns) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return regex.test(filePath);
|
|
239
|
-
});
|
|
544
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
545
|
+
return patterns.some(pattern => globToRegExp(pattern).test(normalized));
|
|
240
546
|
}
|
|
241
547
|
/**
|
|
242
548
|
* Walk the AST depth-first, invoking the callback on every node.
|
|
@@ -269,8 +575,9 @@ function createCodeBlock(ctx, node) {
|
|
|
269
575
|
return null;
|
|
270
576
|
const normalizedText = normalizeCode(text, ctx.config);
|
|
271
577
|
const lineCount = countLines(text);
|
|
272
|
-
// R3.3: Compute structural hash from token-kind sequence
|
|
273
|
-
const
|
|
578
|
+
// R3.3: Compute structural hash + skeleton from token-kind sequence
|
|
579
|
+
const structuralSkeleton = normalizeCodeForStructure(text, ctx.config);
|
|
580
|
+
const structuralHash = hashCode(structuralSkeleton);
|
|
274
581
|
return {
|
|
275
582
|
file: ctx.ast.filePath,
|
|
276
583
|
start: node.location.start,
|
|
@@ -279,6 +586,7 @@ function createCodeBlock(ctx, node) {
|
|
|
279
586
|
normalizedText,
|
|
280
587
|
hash: hashCode(normalizedText),
|
|
281
588
|
structuralHash,
|
|
589
|
+
structuralSkeleton,
|
|
282
590
|
nodeType: node.type,
|
|
283
591
|
lineCount
|
|
284
592
|
};
|
|
@@ -353,7 +661,12 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
|
|
|
353
661
|
const deduped = deduplicateBlocks(blocks);
|
|
354
662
|
this.reportExactDuplicates(deduped, violations);
|
|
355
663
|
if (finalConfig.checkStructuralSimilarity) {
|
|
356
|
-
this.reportStructuralDuplicates(deduped, violations);
|
|
664
|
+
this.reportStructuralDuplicates(deduped, finalConfig, violations);
|
|
665
|
+
}
|
|
666
|
+
// #132: near-identical object literals and fluent call chains
|
|
667
|
+
if (finalConfig.checkExpressionSimilarity) {
|
|
668
|
+
const fragments = extractShapeFragments(ctx, finalConfig.minShapeNames || 4);
|
|
669
|
+
this.reportExpressionSimilarities(fragments, finalConfig, violations);
|
|
357
670
|
}
|
|
358
671
|
this.reportCrossFileDuplicates(blocks, finalConfig, violations);
|
|
359
672
|
// Check for duplicate string literals if enabled
|
|
@@ -404,39 +717,114 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
|
|
|
404
717
|
});
|
|
405
718
|
}
|
|
406
719
|
/**
|
|
407
|
-
* Report
|
|
720
|
+
* Report structurally-similar duplicates (dry/structural-similarity, suggestion).
|
|
721
|
+
*
|
|
722
|
+
* Compares every pair of blocks by the Jaccard similarity of their token-kind
|
|
723
|
+
* skeletons (identifiers→ID, literals→LIT) and reports those at or above
|
|
724
|
+
* `similarityThreshold`. This is the honest version of the rule: it detects
|
|
725
|
+
* "≥ N% structurally similar" (as the registry's `{similarity}%` message and
|
|
726
|
+
* `similarityThreshold` claim), not merely "structurally identical" — and it
|
|
727
|
+
* reports the structural percentage, not a text percentage computed over the
|
|
728
|
+
* raw identifiers/literals.
|
|
408
729
|
*/
|
|
409
|
-
reportStructuralDuplicates(deduped, violations) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
730
|
+
reportStructuralDuplicates(deduped, config, violations) {
|
|
731
|
+
withRuleTiming('dry/structural-similarity', () => {
|
|
732
|
+
const threshold = config.similarityThreshold ?? 0.85;
|
|
733
|
+
// deduped is sorted by (file, line); iterate the upper triangle so each
|
|
734
|
+
// pair is considered exactly once, earlier block first.
|
|
735
|
+
for (let i = 0; i < deduped.length; i++) {
|
|
736
|
+
const original = deduped[i];
|
|
737
|
+
for (let j = i + 1; j < deduped.length; j++) {
|
|
738
|
+
const block = deduped[j];
|
|
739
|
+
// Skip if these are already exact duplicates (reported above)
|
|
740
|
+
if (original.hash === block.hash)
|
|
741
|
+
continue;
|
|
742
|
+
// R3.1: Span-overlap check
|
|
743
|
+
if (spansOverlap(original, block))
|
|
744
|
+
continue;
|
|
745
|
+
// Structural Jaccard over the token-kind skeletons — the percentage the
|
|
746
|
+
// registry message promises, and the gate the similarityThreshold sets.
|
|
747
|
+
const similarity = computeJaccardSimilarity(original.structuralSkeleton, block.structuralSkeleton);
|
|
748
|
+
if (similarity < threshold)
|
|
749
|
+
continue;
|
|
750
|
+
const violation = this.createViolation(block.file, block.start, `Structurally similar code block detected (${Math.round(similarity * 100)}% similar). ` +
|
|
751
|
+
`First occurrence at ${original.file}:${original.start.line}`, { severity: 'suggestion', rule: 'dry/structural-similarity', symbol: block.hash } // R7
|
|
752
|
+
);
|
|
753
|
+
violation.fix = {
|
|
754
|
+
oldText: block.text,
|
|
755
|
+
newText: `// Consider extracting to a shared function`
|
|
756
|
+
};
|
|
757
|
+
violations.push(violation);
|
|
758
|
+
// Spec 13 R5 — seed pair for diverging-clone tracking
|
|
759
|
+
this.seedPair(original, block, similarity, 'dry/structural-similarity');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Report near-identical expression shapes (dry/similar-expression, suggestion).
|
|
766
|
+
*
|
|
767
|
+
* Two fragments are "near-identical" when their field/method-name sequence
|
|
768
|
+
* shares a common subsequence of at least `minShapeNames` names — the same
|
|
769
|
+
* `resultSummary` object built twice, the same `.update().set().where()`
|
|
770
|
+
* chain repeated across switch arms. Each later fragment is reported at most
|
|
771
|
+
* once, against the earliest fragment it resembles.
|
|
772
|
+
*/
|
|
773
|
+
reportExpressionSimilarities(fragments, config, violations) {
|
|
774
|
+
withRuleTiming('dry/similar-expression', () => {
|
|
775
|
+
const min = config.minShapeNames || 4;
|
|
776
|
+
const reported = new Set();
|
|
777
|
+
for (let j = 1; j < fragments.length; j++) {
|
|
778
|
+
if (reported.has(j))
|
|
423
779
|
continue;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
780
|
+
for (let i = 0; i < j; i++) {
|
|
781
|
+
// Only compare like-with-like: a field list and a method chain are
|
|
782
|
+
// different shapes and should never be flagged as "near-identical".
|
|
783
|
+
if (fragments[i].kind !== fragments[j].kind)
|
|
784
|
+
continue;
|
|
785
|
+
// Object literals must target the same identifier (`info.resultSummary`
|
|
786
|
+
// built twice), not merely two unrelated literals that share column
|
|
787
|
+
// names. Chains have no target (undefined === undefined).
|
|
788
|
+
if (fragments[i].target !== fragments[j].target)
|
|
789
|
+
continue;
|
|
790
|
+
const shared = longestCommonSubsequence(fragments[i].names, fragments[j].names);
|
|
791
|
+
if (shared.length < min)
|
|
792
|
+
continue;
|
|
793
|
+
violations.push(this.buildSimilarityViolation(fragments[j], fragments[i], shared));
|
|
794
|
+
reported.add(j);
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
438
797
|
}
|
|
439
|
-
}
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Build the `dry/similar-expression` violation for `fragment` (the later
|
|
802
|
+
* fragment) resembling `first` (the earliest).
|
|
803
|
+
*/
|
|
804
|
+
buildSimilarityViolation(fragment, first, shared) {
|
|
805
|
+
const isObject = fragment.kind === 'object';
|
|
806
|
+
const label = isObject ? 'object literal' : 'call chain';
|
|
807
|
+
const unit = isObject ? 'fields' : 'methods';
|
|
808
|
+
const targetClause = isObject && fragment.target
|
|
809
|
+
? ` built for "${fragment.target}"`
|
|
810
|
+
: '';
|
|
811
|
+
const violation = this.createViolation(fragment.file, fragment.start, `Near-identical ${label}${targetClause} detected (${shared.length} shared ${unit}: ${shared.join(', ')}). ` +
|
|
812
|
+
`First occurrence at ${first.file}:${first.start.line}`, {
|
|
813
|
+
severity: 'suggestion',
|
|
814
|
+
rule: 'dry/similar-expression',
|
|
815
|
+
symbol: shared.join('.'),
|
|
816
|
+
resolution: {
|
|
817
|
+
action: 'extract-shared-expression',
|
|
818
|
+
summary: `Extract the shared ${isObject ? 'field list' : 'method chain'} (${shared.join(', ')}) into a shared helper, builder, or constant both sites use.`,
|
|
819
|
+
files: [fragment.file, first.file],
|
|
820
|
+
lines: [fragment.start.line, first.start.line],
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
violation.fix = {
|
|
824
|
+
oldText: fragment.text,
|
|
825
|
+
newText: `// Consider extracting the shared ${unit} into a shared helper`,
|
|
826
|
+
};
|
|
827
|
+
return violation;
|
|
440
828
|
}
|
|
441
829
|
/**
|
|
442
830
|
* Report blocks that duplicate a function body from the full codebase index.
|
|
@@ -531,18 +919,19 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
|
|
|
531
919
|
*/
|
|
532
920
|
checkDuplicateImports(ast, adapter) {
|
|
533
921
|
const violations = [];
|
|
534
|
-
|
|
922
|
+
// source → locations of every import of that source, in encounter order.
|
|
923
|
+
const importLocs = new Map();
|
|
535
924
|
// Find all import statements
|
|
536
925
|
const imports = adapter.extractImports(ast);
|
|
537
926
|
for (const imp of imports) {
|
|
538
|
-
const
|
|
539
|
-
|
|
927
|
+
const locs = importLocs.get(imp.source) ?? [];
|
|
928
|
+
locs.push(imp.location.start);
|
|
929
|
+
importLocs.set(imp.source, locs);
|
|
540
930
|
}
|
|
541
|
-
// Report duplicates
|
|
542
|
-
for (const [source,
|
|
543
|
-
if (
|
|
544
|
-
violations.push(this.createViolation(ast.filePath,
|
|
545
|
-
`Module "${source}" is imported ${count} times`, { severity: 'warning', rule: 'duplicate-import', symbol: source }));
|
|
931
|
+
// Report duplicates at the real first-import location (not a fabricated 1:1).
|
|
932
|
+
for (const [source, locs] of importLocs) {
|
|
933
|
+
if (locs.length > 1) {
|
|
934
|
+
violations.push(this.createViolation(ast.filePath, locs[0], `Module "${source}" is imported ${locs.length} times`, { severity: 'warning', rule: 'duplicate-import', symbol: source }));
|
|
546
935
|
}
|
|
547
936
|
}
|
|
548
937
|
return violations;
|