@ryuenn3123/agentic-senior-core 6.16.3 → 6.16.5
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/.agents/plugins/agentic-senior-core/.codex-plugin/plugin.json +1 -1
- package/.agents/plugins/agentic-senior-core/commands/asc-pentest.md +2 -0
- package/.agents/plugins/agentic-senior-core/commands/asc-pentest.toml +3 -0
- package/.agents/plugins/agentic-senior-core/hooks/dedup-gate.js +137 -128
- package/.agents/plugins/agentic-senior-core/hooks/hooks.json +97 -0
- package/.agents/plugins/agentic-senior-core/hooks/ladder-pulse.js +53 -36
- package/.agents/plugins/agentic-senior-core/hooks/path-util.cjs +118 -4
- package/.agents/plugins/agentic-senior-core/hooks/post-edit-enforce.js +98 -81
- package/.agents/plugins/agentic-senior-core/hooks/pre-tool-dependency-gate.js +168 -191
- package/.agents/plugins/agentic-senior-core/hooks/session-pulse.js +48 -35
- package/.agents/plugins/agentic-senior-core/hooks/session-start.js +62 -11
- package/.agents/plugins/agentic-senior-core/hooks.json +4 -94
- package/.agents/plugins/agentic-senior-core/plugin.json +5 -13
- package/.agents/plugins/agentic-senior-core/rules/agentic-senior-core.md +1 -0
- package/.agents/plugins/agentic-senior-core/skills/asc/SKILL.md +5 -2
- package/.agents/plugins/agentic-senior-core/skills/asc-develop/SKILL.md +1 -1
- package/.agents/plugins/agentic-senior-core/skills/asc-pentest/SKILL.md +97 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/api-security-idor.md +105 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/dast-baseline.md +78 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/fuzzing-dsl.md +66 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/pentest-task-tree.md +70 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-review/SKILL.md +1 -1
- package/.agents/plugins/agentic-senior-core/skills/asc-test/SKILL.md +8 -1
- package/.agents/plugins/agentic-senior-core/skills/asc-test/references/mutation-testing.md +55 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-test/references/performance-budgets.md +67 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-test/references/property-testing.md +72 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/SKILL.md +17 -14
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/craft/color-surfaces.md +25 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/craft/themes-catalog.md +198 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/divergence.md +34 -2
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/modes/genre-calibration.md +89 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/modes/operate.md +26 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/modes/persuade.md +10 -9
- package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/redesign-audit.md +4 -9
- package/bin/agentic-senior-core.js +19 -4
- package/gemini-extension.json +2 -2
- package/lib/cli/ascx/runtime.mjs +5 -27
- package/lib/cli/ascx/windows-spawn.mjs +348 -0
- package/lib/cli/commands/bootstrap.mjs +56 -0
- package/lib/cli/commands/dedup.mjs +500 -0
- package/lib/cli/commands/git-hook-generator.mjs +3 -2
- package/lib/cli/commands/global.mjs +109 -53
- package/lib/cli/commands/preview.mjs +18 -9
- package/package.json +1 -1
- package/plugin.yaml +3 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
7
|
+
'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx',
|
|
8
|
+
'py', 'go', 'rs', 'java', 'kt', 'cs', 'rb', 'php', 'swift', 'vue', 'svelte',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const DEFAULT_IGNORE_DIRS = new Set([
|
|
12
|
+
'.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.expo',
|
|
13
|
+
'coverage', '.storybook', 'migrations', 'generated', 'vendor', 'target',
|
|
14
|
+
'.venv', 'venv', '__pycache__', '.asc', '.agents', '.opencode', '.roo', '.kilo',
|
|
15
|
+
'tests', 'test', '__tests__', 'android', 'ios',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const FRAMEWORK_CONVENTIONAL_BASENAMES = new Set([
|
|
19
|
+
'page.tsx', 'page.jsx', 'page.ts', 'page.js',
|
|
20
|
+
'layout.tsx', 'layout.jsx', 'layout.ts', 'layout.js',
|
|
21
|
+
'loading.tsx', 'loading.jsx', 'loading.ts', 'loading.js',
|
|
22
|
+
'error.tsx', 'error.jsx', 'error.ts', 'error.js',
|
|
23
|
+
'not-found.tsx', 'not-found.jsx', 'not-found.ts', 'not-found.js',
|
|
24
|
+
'template.tsx', 'template.jsx', 'template.ts', 'template.js',
|
|
25
|
+
'route.tsx', 'route.ts', 'route.js',
|
|
26
|
+
'default.tsx', 'default.jsx', 'default.ts', 'default.js',
|
|
27
|
+
'root.tsx', 'root.jsx', 'root.ts', 'root.js',
|
|
28
|
+
'_layout.tsx', '_layout.jsx', '_layout.ts', '_layout.js',
|
|
29
|
+
'index.vue', 'app.vue',
|
|
30
|
+
'+page.svelte', '+layout.svelte', '+page.server.ts', '+page.server.js',
|
|
31
|
+
'+error.svelte', '+layout.server.ts', '+layout.server.js',
|
|
32
|
+
'index.ts', 'index.js', 'index.tsx', 'index.jsx', 'index.mjs',
|
|
33
|
+
'types.ts', 'types.d.ts',
|
|
34
|
+
'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs',
|
|
35
|
+
'postcss.config.js', 'postcss.config.mjs', 'postcss.config.cjs',
|
|
36
|
+
'next.config.ts', 'next.config.js', 'next.config.mjs',
|
|
37
|
+
'vite.config.ts', 'vite.config.js', 'vite.config.mjs',
|
|
38
|
+
'tsconfig.json', 'jest.config.ts', 'jest.config.js', 'vitest.config.ts',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const FRAMEWORK_CONVENTIONAL_SUFFIXES = [
|
|
42
|
+
'.component.ts', '.component.js', '.module.ts', '.service.ts',
|
|
43
|
+
'.pipe.ts', '.directive.ts', '.guard.ts', '.resolver.ts',
|
|
44
|
+
'.stories.tsx', '.stories.jsx', '.stories.ts', '.stories.js',
|
|
45
|
+
'.spec.ts', '.spec.tsx', '.spec.js', '.spec.jsx', '.spec.mjs',
|
|
46
|
+
'.test.ts', '.test.tsx', '.test.js', '.test.jsx', '.test.mjs',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function isFrameworkConventional(basename) {
|
|
50
|
+
if (FRAMEWORK_CONVENTIONAL_BASENAMES.has(basename)) return true;
|
|
51
|
+
return FRAMEWORK_CONVENTIONAL_SUFFIXES.some(suffix => basename.endsWith(suffix));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadDedupConfig(cwd) {
|
|
55
|
+
const candidates = [
|
|
56
|
+
path.join(cwd, '.asc', 'dedup-config.json'),
|
|
57
|
+
path.join(cwd, '.agents', 'dedup-config.json'),
|
|
58
|
+
];
|
|
59
|
+
for (const file of candidates) {
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(file)) {
|
|
62
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore malformed config and use defaults
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasInlineIgnore(content) {
|
|
72
|
+
if (content.includes('jscpd:ignore-start')) return true;
|
|
73
|
+
const match = content.match(/asc-dedup:ignore\s*--\s*(.+)/i);
|
|
74
|
+
return Boolean(match && match[1] && match[1].trim().length > 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isAllowedDuplicatePair(fileA, fileB, allowedDuplicates = [], cwd = process.cwd()) {
|
|
78
|
+
if (!Array.isArray(allowedDuplicates) || allowedDuplicates.length === 0) return false;
|
|
79
|
+
const normA = path.resolve(cwd, fileA).replace(/\\/g, '/').toLowerCase();
|
|
80
|
+
const normB = path.resolve(cwd, fileB).replace(/\\/g, '/').toLowerCase();
|
|
81
|
+
const baseA = path.basename(fileA).toLowerCase();
|
|
82
|
+
const baseB = path.basename(fileB).toLowerCase();
|
|
83
|
+
|
|
84
|
+
for (const pair of allowedDuplicates) {
|
|
85
|
+
if (!Array.isArray(pair) || pair.length < 2) continue;
|
|
86
|
+
const p0Norm = path.resolve(cwd, pair[0]).replace(/\\/g, '/').toLowerCase();
|
|
87
|
+
const p1Norm = path.resolve(cwd, pair[1]).replace(/\\/g, '/').toLowerCase();
|
|
88
|
+
const p0Base = path.basename(pair[0]).toLowerCase();
|
|
89
|
+
const p1Base = path.basename(pair[1]).toLowerCase();
|
|
90
|
+
if ((normA === p0Norm && normB === p1Norm) || (normA === p1Norm && normB === p0Norm)) return true;
|
|
91
|
+
if ((baseA === p0Base && baseB === p1Base) || (baseA === p1Base && baseB === p0Base)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function collectSourceFiles(targetPath, ignoreDirs, includeTests = false) {
|
|
97
|
+
const files = [];
|
|
98
|
+
const resolved = path.resolve(targetPath);
|
|
99
|
+
if (!fs.existsSync(resolved)) return files;
|
|
100
|
+
|
|
101
|
+
const stat = fs.statSync(resolved);
|
|
102
|
+
if (stat.isFile()) {
|
|
103
|
+
const ext = path.extname(resolved).slice(1).toLowerCase();
|
|
104
|
+
if (SOURCE_EXTENSIONS.has(ext)) files.push(resolved);
|
|
105
|
+
return files;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const walk = (dir) => {
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (entry.isDirectory()) {
|
|
117
|
+
if (ignoreDirs.has(entry.name)) continue;
|
|
118
|
+
if (!includeTests && ['tests', 'test', '__tests__'].includes(entry.name)) continue;
|
|
119
|
+
walk(path.join(dir, entry.name));
|
|
120
|
+
} else if (entry.isFile()) {
|
|
121
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
122
|
+
if (!SOURCE_EXTENSIONS.has(ext)) continue;
|
|
123
|
+
files.push(path.join(dir, entry.name));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
walk(resolved);
|
|
129
|
+
return files;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Extracts normalized meaningful source lines while retaining original 1-based line numbers
|
|
134
|
+
* and counting lexical tokens to avoid trivial closing-brace false positives.
|
|
135
|
+
*/
|
|
136
|
+
function normalizeSourceLines(content) {
|
|
137
|
+
const rawLines = content.split(/\r?\n/);
|
|
138
|
+
const result = [];
|
|
139
|
+
let inBlockComment = false;
|
|
140
|
+
|
|
141
|
+
for (let idx = 0; idx < rawLines.length; idx++) {
|
|
142
|
+
let line = rawLines[idx];
|
|
143
|
+
|
|
144
|
+
if (inBlockComment) {
|
|
145
|
+
const endIdx = line.indexOf('*/');
|
|
146
|
+
if (endIdx === -1) continue;
|
|
147
|
+
line = line.slice(endIdx + 2);
|
|
148
|
+
inBlockComment = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Remove inline /* ... */ segments
|
|
152
|
+
line = line.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
153
|
+
const openBlock = line.indexOf('/*');
|
|
154
|
+
if (openBlock !== -1) {
|
|
155
|
+
line = line.slice(0, openBlock);
|
|
156
|
+
inBlockComment = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Strip // or # full-line comments (avoid stripping URLs http://)
|
|
160
|
+
line = line.replace(/(^|\s)\/\/.*$/, '$1');
|
|
161
|
+
if (/^\s*#(?!!)/.test(line)) continue;
|
|
162
|
+
|
|
163
|
+
const trimmed = line.trim().replace(/\s+/g, ' ');
|
|
164
|
+
if (!trimmed) continue;
|
|
165
|
+
// Skip import/require/package header lines so shared imports do not trigger clone noise
|
|
166
|
+
if (/^(?:import\s|export\s+\*\s+from\s|from\s+\S+\s+import\s|package\s|using\s)/.test(trimmed)) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tokens = trimmed.match(/[A-Za-z0-9_$]+|[{}()[\];.,<>=!+-/*%&|^~?:]+/g) || [];
|
|
171
|
+
// Skip lines that are purely punctuation like `}` or `);` when standalone, keep token count accurate
|
|
172
|
+
result.push({
|
|
173
|
+
lineNo: idx + 1,
|
|
174
|
+
norm: trimmed,
|
|
175
|
+
tokenCount: tokens.length,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Built-in zero-dependency structural clone scanner.
|
|
184
|
+
*/
|
|
185
|
+
export function scanCodeClones(targetPath, options = {}) {
|
|
186
|
+
const cwd = options.cwd || process.cwd();
|
|
187
|
+
const config = loadDedupConfig(cwd);
|
|
188
|
+
const minLines = options.minLines ?? config.minLines ?? 12;
|
|
189
|
+
const minTokens = options.minTokens ?? config.minTokens ?? Math.max(30, minLines * 3);
|
|
190
|
+
const includeTests = Boolean(options.includeTests);
|
|
191
|
+
|
|
192
|
+
const ignoreDirs = new Set([
|
|
193
|
+
...DEFAULT_IGNORE_DIRS,
|
|
194
|
+
...(Array.isArray(config.ignoreDirs) ? config.ignoreDirs : []),
|
|
195
|
+
]);
|
|
196
|
+
if (includeTests) {
|
|
197
|
+
ignoreDirs.delete('tests');
|
|
198
|
+
ignoreDirs.delete('test');
|
|
199
|
+
ignoreDirs.delete('__tests__');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const files = collectSourceFiles(targetPath, ignoreDirs, includeTests);
|
|
203
|
+
const parsedFiles = [];
|
|
204
|
+
|
|
205
|
+
for (const filePath of files) {
|
|
206
|
+
let content;
|
|
207
|
+
try {
|
|
208
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (hasInlineIgnore(content)) continue;
|
|
213
|
+
const normLines = normalizeSourceLines(content);
|
|
214
|
+
if (normLines.length < minLines) continue;
|
|
215
|
+
parsedFiles.push({
|
|
216
|
+
filePath,
|
|
217
|
+
relPath: path.relative(cwd, filePath).replace(/\\/g, '/') || path.basename(filePath),
|
|
218
|
+
basename: path.basename(filePath),
|
|
219
|
+
lines: normLines,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Map window fingerprint -> list of occurrences { fileIdx, normIdx }
|
|
224
|
+
const windowIndex = new Map();
|
|
225
|
+
|
|
226
|
+
for (let fIdx = 0; fIdx < parsedFiles.length; fIdx++) {
|
|
227
|
+
const { lines } = parsedFiles[fIdx];
|
|
228
|
+
let rollingTokens = 0;
|
|
229
|
+
for (let k = 0; k < minLines; k++) {
|
|
230
|
+
rollingTokens += lines[k].tokenCount;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i <= lines.length - minLines; i++) {
|
|
234
|
+
if (i > 0) {
|
|
235
|
+
rollingTokens += lines[i + minLines - 1].tokenCount - lines[i - 1].tokenCount;
|
|
236
|
+
}
|
|
237
|
+
if (rollingTokens >= minTokens) {
|
|
238
|
+
let key = lines[i].norm;
|
|
239
|
+
for (let k = 1; k < minLines; k++) {
|
|
240
|
+
key += '\n' + lines[i + k].norm;
|
|
241
|
+
}
|
|
242
|
+
let bucket = windowIndex.get(key);
|
|
243
|
+
if (!bucket) {
|
|
244
|
+
bucket = [];
|
|
245
|
+
windowIndex.set(key, bucket);
|
|
246
|
+
}
|
|
247
|
+
bucket.push({ fIdx, startIdx: i });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Track visited starting pairs so contiguous windows merge into maximal clone blocks
|
|
253
|
+
const visitedPairs = new Set();
|
|
254
|
+
const clones = [];
|
|
255
|
+
|
|
256
|
+
for (const occurrences of windowIndex.values()) {
|
|
257
|
+
if (occurrences.length < 2 || occurrences.length > 50) continue;
|
|
258
|
+
|
|
259
|
+
for (let a = 0; a < occurrences.length; a++) {
|
|
260
|
+
for (let b = a + 1; b < occurrences.length; b++) {
|
|
261
|
+
const occA = occurrences[a];
|
|
262
|
+
const occB = occurrences[b];
|
|
263
|
+
|
|
264
|
+
// Avoid overlapping self-matches in the same file
|
|
265
|
+
if (occA.fIdx === occB.fIdx && Math.abs(occA.startIdx - occB.startIdx) < minLines) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const prevPairKey = `${occA.fIdx}:${occA.startIdx - 1}|${occB.fIdx}:${occB.startIdx - 1}`;
|
|
270
|
+
if (visitedPairs.has(prevPairKey)) {
|
|
271
|
+
visitedPairs.add(`${occA.fIdx}:${occA.startIdx}|${occB.fIdx}:${occB.startIdx}`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const fileA = parsedFiles[occA.fIdx];
|
|
276
|
+
const fileB = parsedFiles[occB.fIdx];
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
occA.fIdx !== occB.fIdx
|
|
280
|
+
&& fileA.basename === fileB.basename
|
|
281
|
+
&& isFrameworkConventional(fileA.basename)
|
|
282
|
+
) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
occA.fIdx !== occB.fIdx
|
|
288
|
+
&& isAllowedDuplicatePair(fileA.filePath, fileB.filePath, config.allowedDuplicates, cwd)
|
|
289
|
+
) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Extend clone span maximally forward
|
|
294
|
+
let length = minLines;
|
|
295
|
+
while (
|
|
296
|
+
occA.startIdx + length < fileA.lines.length
|
|
297
|
+
&& occB.startIdx + length < fileB.lines.length
|
|
298
|
+
&& (occA.fIdx !== occB.fIdx || occA.startIdx + length < occB.startIdx)
|
|
299
|
+
&& fileA.lines[occA.startIdx + length].norm === fileB.lines[occB.startIdx + length].norm
|
|
300
|
+
) {
|
|
301
|
+
visitedPairs.add(`${occA.fIdx}:${occA.startIdx + (length - minLines + 1)}|${occB.fIdx}:${occB.startIdx + (length - minLines + 1)}`);
|
|
302
|
+
length++;
|
|
303
|
+
}
|
|
304
|
+
visitedPairs.add(`${occA.fIdx}:${occA.startIdx}|${occB.fIdx}:${occB.startIdx}`);
|
|
305
|
+
|
|
306
|
+
let tokens = 0;
|
|
307
|
+
for (let k = 0; k < length; k++) {
|
|
308
|
+
tokens += fileA.lines[occA.startIdx + k].tokenCount;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const snippetSignature = fileA.lines
|
|
312
|
+
.slice(occA.startIdx, occA.startIdx + Math.min(length, minLines))
|
|
313
|
+
.map(l => l.norm)
|
|
314
|
+
.join('\n');
|
|
315
|
+
|
|
316
|
+
clones.push({
|
|
317
|
+
signature: snippetSignature,
|
|
318
|
+
normalizedLines: length,
|
|
319
|
+
tokens,
|
|
320
|
+
first: {
|
|
321
|
+
file: fileA.relPath,
|
|
322
|
+
startLine: fileA.lines[occA.startIdx].lineNo,
|
|
323
|
+
endLine: fileA.lines[occA.startIdx + length - 1].lineNo,
|
|
324
|
+
},
|
|
325
|
+
second: {
|
|
326
|
+
file: fileB.relPath,
|
|
327
|
+
startLine: fileB.lines[occB.startIdx].lineNo,
|
|
328
|
+
endLine: fileB.lines[occB.startIdx + length - 1].lineNo,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Group by signature to detect Rule-of-Three (>= 3 distinct locations)
|
|
336
|
+
const clusterMap = new Map();
|
|
337
|
+
for (const clone of clones) {
|
|
338
|
+
let cluster = clusterMap.get(clone.signature);
|
|
339
|
+
if (!cluster) {
|
|
340
|
+
cluster = {
|
|
341
|
+
normalizedLines: clone.normalizedLines,
|
|
342
|
+
tokens: clone.tokens,
|
|
343
|
+
locations: new Map(),
|
|
344
|
+
};
|
|
345
|
+
clusterMap.set(clone.signature, cluster);
|
|
346
|
+
}
|
|
347
|
+
cluster.normalizedLines = Math.max(cluster.normalizedLines, clone.normalizedLines);
|
|
348
|
+
cluster.tokens = Math.max(cluster.tokens, clone.tokens);
|
|
349
|
+
const locKeyA = `${clone.first.file}:${clone.first.startLine}`;
|
|
350
|
+
const locKeyB = `${clone.second.file}:${clone.second.startLine}`;
|
|
351
|
+
cluster.locations.set(locKeyA, clone.first);
|
|
352
|
+
cluster.locations.set(locKeyB, clone.second);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const clusters = Array.from(clusterMap.values())
|
|
356
|
+
.map(c => ({
|
|
357
|
+
instances: c.locations.size,
|
|
358
|
+
ruleOfThreeViolation: c.locations.size >= 3,
|
|
359
|
+
normalizedLines: c.normalizedLines,
|
|
360
|
+
tokens: c.tokens,
|
|
361
|
+
locations: Array.from(c.locations.values()),
|
|
362
|
+
}))
|
|
363
|
+
.sort((a, b) => (b.instances - a.instances) || (b.tokens - a.tokens));
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
scannedFiles: files.length,
|
|
367
|
+
analyzedFiles: parsedFiles.length,
|
|
368
|
+
minLines,
|
|
369
|
+
minTokens,
|
|
370
|
+
mode: config.mode || 'advisory',
|
|
371
|
+
clusters,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function runOptionalJscpd(targetPath, minTokens) {
|
|
376
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asc-dedup-cli-'));
|
|
377
|
+
const reportPath = path.join(tmpDir, 'jscpd-report.json');
|
|
378
|
+
const binaries = ['bunx jscpd', 'npx --yes jscpd@5'];
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
for (const bin of binaries) {
|
|
382
|
+
try {
|
|
383
|
+
execSync(`${bin} "${targetPath}" --min-tokens ${minTokens} --reporters json --silent --output "${tmpDir}"`, {
|
|
384
|
+
stdio: 'pipe',
|
|
385
|
+
timeout: 30000,
|
|
386
|
+
});
|
|
387
|
+
} catch {
|
|
388
|
+
// jscpd may exit non-zero when threshold triggers even after writing report
|
|
389
|
+
}
|
|
390
|
+
if (fs.existsSync(reportPath)) {
|
|
391
|
+
return JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
} finally {
|
|
396
|
+
try {
|
|
397
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
398
|
+
} catch {
|
|
399
|
+
// Ignore cleanup error
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function runDedupCommand(args = []) {
|
|
405
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
406
|
+
console.log('Agentic Senior Core -- Code Clone & Duplication Scanner\n');
|
|
407
|
+
console.log('Usage: asc dedup [path] [--min-lines <n>] [--min-tokens <n>] [--include-tests] [--strict] [--json] [--jscpd]\n');
|
|
408
|
+
console.log('Options:');
|
|
409
|
+
console.log(' --min-lines <n> Minimum normalized lines per clone block (default: 12)');
|
|
410
|
+
console.log(' --min-tokens <n> Minimum lexical tokens per clone block (default: 36)');
|
|
411
|
+
console.log(' --include-tests Include test directories in scan');
|
|
412
|
+
console.log(' --strict Exit with code 1 if Rule-of-Three (>= 3 instances) clones are found');
|
|
413
|
+
console.log(' --json Emit machine-readable JSON report');
|
|
414
|
+
console.log(' --jscpd Delegate scan to external jscpd runner (bunx / npx --yes jscpd@5)');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let targetArg = '.';
|
|
419
|
+
let minLines;
|
|
420
|
+
let minTokens;
|
|
421
|
+
const includeTests = args.includes('--include-tests');
|
|
422
|
+
const strict = args.includes('--strict');
|
|
423
|
+
const asJson = args.includes('--json');
|
|
424
|
+
const useJscpd = args.includes('--jscpd');
|
|
425
|
+
|
|
426
|
+
for (let i = 0; i < args.length; i++) {
|
|
427
|
+
const arg = args[i];
|
|
428
|
+
if (arg === '--min-lines' && args[i + 1]) {
|
|
429
|
+
minLines = Number.parseInt(args[++i], 10);
|
|
430
|
+
} else if (arg === '--min-tokens' && args[i + 1]) {
|
|
431
|
+
minTokens = Number.parseInt(args[++i], 10);
|
|
432
|
+
} else if (!arg.startsWith('-')) {
|
|
433
|
+
targetArg = arg;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const startTime = performance.now();
|
|
438
|
+
const resolvedTarget = path.resolve(process.cwd(), targetArg);
|
|
439
|
+
|
|
440
|
+
if (!fs.existsSync(resolvedTarget)) {
|
|
441
|
+
console.error(`Target path not found: ${resolvedTarget}`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (useJscpd) {
|
|
446
|
+
const externalReport = runOptionalJscpd(resolvedTarget, minTokens || 30);
|
|
447
|
+
if (externalReport) {
|
|
448
|
+
if (asJson) {
|
|
449
|
+
console.log(JSON.stringify(externalReport, null, 2));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const dups = externalReport.duplicates || [];
|
|
453
|
+
console.log(`ASC Dedup (jscpd) -- found ${dups.length} duplicate pair(s).`);
|
|
454
|
+
for (const dup of dups.slice(0, 25)) {
|
|
455
|
+
console.log(` - ${dup.firstFile.name}:${dup.firstFile.start}-${dup.firstFile.end} <-> ${dup.secondFile.name}:${dup.secondFile.start}-${dup.secondFile.end} (${dup.lines} lines)`);
|
|
456
|
+
}
|
|
457
|
+
if (strict && dups.length > 0) process.exit(1);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
console.warn('[asc dedup] External jscpd unavailable; using built-in zero-dependency clone engine.');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const report = scanCodeClones(resolvedTarget, {
|
|
464
|
+
cwd: process.cwd(),
|
|
465
|
+
minLines,
|
|
466
|
+
minTokens,
|
|
467
|
+
includeTests,
|
|
468
|
+
});
|
|
469
|
+
const elapsedMs = (performance.now() - startTime).toFixed(1);
|
|
470
|
+
|
|
471
|
+
if (asJson) {
|
|
472
|
+
console.log(JSON.stringify({ ...report, durationMs: Number(elapsedMs) }, null, 2));
|
|
473
|
+
} else {
|
|
474
|
+
console.log(`ASC Dedup -- scanned ${report.scannedFiles} source file(s) in ${elapsedMs}ms\n`);
|
|
475
|
+
|
|
476
|
+
if (report.clusters.length === 0) {
|
|
477
|
+
console.log(' Zero duplicate code blocks detected.');
|
|
478
|
+
} else {
|
|
479
|
+
const ruleOfThreeCount = report.clusters.filter(c => c.ruleOfThreeViolation).length;
|
|
480
|
+
console.log(` Found ${report.clusters.length} clone cluster(s) (${ruleOfThreeCount} Rule-of-Three violation(s) >= 3 instances):\n`);
|
|
481
|
+
|
|
482
|
+
for (const cluster of report.clusters.slice(0, 20)) {
|
|
483
|
+
const badge = cluster.ruleOfThreeViolation ? '[CONSOLIDATE >=3]' : '[ADVISORY 2x]';
|
|
484
|
+
console.log(` ${badge} ${cluster.normalizedLines} lines (${cluster.tokens} tokens) across ${cluster.instances} locations:`);
|
|
485
|
+
for (const loc of cluster.locations) {
|
|
486
|
+
console.log(` - ${loc.file}:${loc.startLine}-${loc.endLine}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (report.clusters.length > 20) {
|
|
490
|
+
console.log(`\n ... and ${report.clusters.length - 20} additional cluster(s). Use --json for full output.`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const hasBlockingViolation = report.clusters.some(c => c.ruleOfThreeViolation);
|
|
496
|
+
if ((strict || report.mode === 'block') && hasBlockingViolation) {
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
@@ -230,7 +230,7 @@ function checkStubsAndTodos(stagedFiles, cwd) {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
function runJscpdScan(scanCmd, cwd, tmpDir) {
|
|
233
|
-
const binaries = ['bunx jscpd', 'npx jscpd@5', 'npx jscpd'];
|
|
233
|
+
const binaries = ['bunx jscpd', 'npx --yes jscpd@5', 'npx --yes jscpd'];
|
|
234
234
|
for (let i = 0; i < binaries.length; i++) {
|
|
235
235
|
try {
|
|
236
236
|
execSync(binaries[i] + scanCmd, {
|
|
@@ -240,7 +240,8 @@ function runJscpdScan(scanCmd, cwd, tmpDir) {
|
|
|
240
240
|
});
|
|
241
241
|
return loadReport(tmpDir);
|
|
242
242
|
} catch (_) {
|
|
243
|
-
|
|
243
|
+
const partial = loadReport(tmpDir);
|
|
244
|
+
if (partial) return partial;
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
return null;
|