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.
Files changed (153) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +73 -0
  3. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
  4. package/dist/analyzers/cross-language/DependencyGraphBuilder.js +12 -1
  5. package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
  6. package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
  7. package/dist/analyzers/cross-language/SchemaValidator.js +122 -43
  8. package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
  9. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +2 -2
  10. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +6 -7
  11. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
  12. package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
  13. package/dist/analyzers/ruleRegistry.js +152 -20
  14. package/dist/analyzers/ruleRegistry.js.map +1 -1
  15. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +38 -1
  16. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
  17. package/dist/analyzers/universal/UniversalDRYAnalyzer.js +437 -48
  18. package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
  19. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +6 -0
  20. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
  21. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +117 -35
  22. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
  23. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
  24. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +86 -15
  25. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
  26. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +32 -7
  27. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
  28. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +104 -23
  29. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
  30. package/dist/analyzers/universal/UniversalSecretsAnalyzer.d.ts +55 -0
  31. package/dist/analyzers/universal/UniversalSecretsAnalyzer.d.ts.map +1 -0
  32. package/dist/analyzers/universal/UniversalSecretsAnalyzer.js +318 -0
  33. package/dist/analyzers/universal/UniversalSecretsAnalyzer.js.map +1 -0
  34. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +2 -8
  35. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
  36. package/dist/analyzers/universal/UniversalStylesAnalyzer.js +54 -45
  37. package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
  38. package/dist/analyzers/universal/functionConcerns.d.ts +57 -0
  39. package/dist/analyzers/universal/functionConcerns.d.ts.map +1 -0
  40. package/dist/analyzers/universal/functionConcerns.js +239 -0
  41. package/dist/analyzers/universal/functionConcerns.js.map +1 -0
  42. package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -1
  43. package/dist/analyzers/universal/schema/codeAnalysis.js +13 -7
  44. package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -1
  45. package/dist/analyzers/universal/schema/jsonSchema.js +124 -11
  46. package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -1
  47. package/dist/auditRouter.d.ts.map +1 -1
  48. package/dist/auditRouter.js +4 -6
  49. package/dist/auditRouter.js.map +1 -1
  50. package/dist/auditRunner.d.ts.map +1 -1
  51. package/dist/auditRunner.js +74 -48
  52. package/dist/auditRunner.js.map +1 -1
  53. package/dist/cli.js +321 -98
  54. package/dist/cli.js.map +1 -1
  55. package/dist/config/configLoader.d.ts.map +1 -1
  56. package/dist/config/configLoader.js +23 -0
  57. package/dist/config/configLoader.js.map +1 -1
  58. package/dist/config/defaults.d.ts +6 -0
  59. package/dist/config/defaults.d.ts.map +1 -1
  60. package/dist/config/defaults.js +45 -10
  61. package/dist/config/defaults.js.map +1 -1
  62. package/dist/config/effectiveConfig.d.ts.map +1 -1
  63. package/dist/config/effectiveConfig.js +2 -0
  64. package/dist/config/effectiveConfig.js.map +1 -1
  65. package/dist/conventions/conventionMiner.d.ts +18 -3
  66. package/dist/conventions/conventionMiner.d.ts.map +1 -1
  67. package/dist/conventions/conventionMiner.js +130 -12
  68. package/dist/conventions/conventionMiner.js.map +1 -1
  69. package/dist/daemon/core.d.ts +128 -0
  70. package/dist/daemon/core.d.ts.map +1 -0
  71. package/dist/daemon/core.js +700 -0
  72. package/dist/daemon/core.js.map +1 -0
  73. package/dist/daemon/lspServer.d.ts +51 -0
  74. package/dist/daemon/lspServer.d.ts.map +1 -0
  75. package/dist/daemon/lspServer.js +168 -0
  76. package/dist/daemon/lspServer.js.map +1 -0
  77. package/dist/daemon/main.d.ts +34 -0
  78. package/dist/daemon/main.d.ts.map +1 -0
  79. package/dist/daemon/main.js +147 -0
  80. package/dist/daemon/main.js.map +1 -0
  81. package/dist/daemon/resolve.d.ts +41 -0
  82. package/dist/daemon/resolve.d.ts.map +1 -0
  83. package/dist/daemon/resolve.js +90 -0
  84. package/dist/daemon/resolve.js.map +1 -0
  85. package/dist/daemon/socketClient.d.ts +21 -0
  86. package/dist/daemon/socketClient.d.ts.map +1 -0
  87. package/dist/daemon/socketClient.js +60 -0
  88. package/dist/daemon/socketClient.js.map +1 -0
  89. package/dist/daemon/socketServer.d.ts +33 -0
  90. package/dist/daemon/socketServer.d.ts.map +1 -0
  91. package/dist/daemon/socketServer.js +106 -0
  92. package/dist/daemon/socketServer.js.map +1 -0
  93. package/dist/daemon/types.d.ts +78 -0
  94. package/dist/daemon/types.d.ts.map +1 -0
  95. package/dist/daemon/types.js +10 -0
  96. package/dist/daemon/types.js.map +1 -0
  97. package/dist/dataPaths.d.ts +21 -0
  98. package/dist/dataPaths.d.ts.map +1 -1
  99. package/dist/dataPaths.js +27 -0
  100. package/dist/dataPaths.js.map +1 -1
  101. package/dist/graph/importGraph.d.ts +11 -0
  102. package/dist/graph/importGraph.d.ts.map +1 -1
  103. package/dist/graph/importGraph.js +11 -3
  104. package/dist/graph/importGraph.js.map +1 -1
  105. package/dist/hooks/core.d.ts.map +1 -1
  106. package/dist/hooks/core.js +21 -4
  107. package/dist/hooks/core.js.map +1 -1
  108. package/dist/installer.d.ts.map +1 -1
  109. package/dist/installer.js +2 -5
  110. package/dist/installer.js.map +1 -1
  111. package/dist/languages/RuntimeManager.js +10 -0
  112. package/dist/languages/RuntimeManager.js.map +1 -1
  113. package/dist/languages/go/analyzer +0 -0
  114. package/dist/languages/go/analyzer-src/analyzer.go +166 -35
  115. package/dist/languages/go/analyzer-src/solid.go +80 -172
  116. package/dist/languages/go/analyzer-src/types.go +18 -18
  117. package/dist/ledger.d.ts +1 -1
  118. package/dist/ledger.d.ts.map +1 -1
  119. package/dist/ledger.js +3 -3
  120. package/dist/ledger.js.map +1 -1
  121. package/dist/mcp.js +1 -1
  122. package/dist/mcp.js.map +1 -1
  123. package/dist/mcpAuditJobs.js +1 -1
  124. package/dist/mcpAuditJobs.js.map +1 -1
  125. package/dist/nextFileIncremental.d.ts +9 -3
  126. package/dist/nextFileIncremental.d.ts.map +1 -1
  127. package/dist/nextFileIncremental.js +32 -6
  128. package/dist/nextFileIncremental.js.map +1 -1
  129. package/dist/pipeline.d.ts +26 -0
  130. package/dist/pipeline.d.ts.map +1 -1
  131. package/dist/pipeline.js +86 -10
  132. package/dist/pipeline.js.map +1 -1
  133. package/dist/pipelineAdapters.d.ts +2 -1
  134. package/dist/pipelineAdapters.d.ts.map +1 -1
  135. package/dist/pipelineAdapters.js +228 -5
  136. package/dist/pipelineAdapters.js.map +1 -1
  137. package/dist/reporting/csvReportGenerator.js +1 -1
  138. package/dist/ruleAliases.d.ts.map +1 -1
  139. package/dist/ruleAliases.js +41 -2
  140. package/dist/ruleAliases.js.map +1 -1
  141. package/dist/styles/tailwindProbe.d.ts +8 -0
  142. package/dist/styles/tailwindProbe.d.ts.map +1 -1
  143. package/dist/styles/tailwindProbe.js +11 -1
  144. package/dist/styles/tailwindProbe.js.map +1 -1
  145. package/dist/types/crossLanguage.d.ts +1 -0
  146. package/dist/types/crossLanguage.d.ts.map +1 -1
  147. package/dist/types.d.ts +30 -1
  148. package/dist/types.d.ts.map +1 -1
  149. package/dist/types.js.map +1 -1
  150. package/package.json +7 -3
  151. package/plugin/.claude-plugin/plugin.json +1 -1
  152. package/plugin/skills/code-auditor/SKILL-RULE-KINDS.md +4 -2
  153. 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
- excludePatterns: ['**/*.test.ts', '**/*.spec.ts'],
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
- const queue = [root];
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
- * Check whether a file path matches any of the given glob-ish exclude patterns.
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
- return patterns.some(pattern => {
237
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
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 structuralHash = hashCode(normalizeCodeForStructure(text, ctx.config));
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 token-kind-identical duplicates (dry/structural-similarity, suggestion).
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
- const structuralHashmap = groupByHash(deduped, 'structuralHash');
411
- for (const [, group] of structuralHashmap) {
412
- if (group.length < 2)
413
- continue;
414
- const sorted = [...group].sort(byFileAndLine);
415
- const original = sorted[0];
416
- for (let i = 1; i < sorted.length; i++) {
417
- const block = sorted[i];
418
- // Skip if these are already exact duplicates (reported above)
419
- if (original.hash === block.hash)
420
- continue;
421
- // R3.1: Span-overlap check
422
- if (spansOverlap(original, block))
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
- // Compute the Jaccard similarity up front so the message can render the
425
- // actual percentage ({similarity}% in the registry template), rather
426
- // than a line count standing in for it.
427
- const jaccardSim = computeJaccardSimilarity(original.normalizedText, block.normalizedText);
428
- const violation = this.createViolation(block.file, block.start, `Structurally similar code block detected (${Math.round(jaccardSim * 100)}% similar). ` +
429
- `First occurrence at ${original.file}:${original.start.line}`, { severity: 'suggestion', rule: 'dry/structural-similarity', symbol: block.hash } // R7
430
- );
431
- violation.fix = {
432
- oldText: block.text,
433
- newText: `// Consider extracting to a shared function`
434
- };
435
- violations.push(violation);
436
- // Spec 13 R5 — seed pair for diverging-clone tracking
437
- this.seedPair(original, block, jaccardSim, 'dry/structural-similarity');
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
- const importMap = new Map();
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 count = importMap.get(imp.source) || 0;
539
- importMap.set(imp.source, count + 1);
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, count] of importMap) {
543
- if (count > 1) {
544
- violations.push(this.createViolation(ast.filePath, { line: 1, column: 1 }, // Import section is typically at the top
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;