@ryuenn3123/agentic-senior-core 6.16.2 → 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.
Files changed (47) hide show
  1. package/.agents/plugins/agentic-senior-core/.codex-plugin/plugin.json +1 -1
  2. package/.agents/plugins/agentic-senior-core/commands/asc-pentest.md +2 -0
  3. package/.agents/plugins/agentic-senior-core/commands/asc-pentest.toml +3 -0
  4. package/.agents/plugins/agentic-senior-core/hooks/dedup-gate.js +137 -128
  5. package/.agents/plugins/agentic-senior-core/hooks/hooks.json +97 -0
  6. package/.agents/plugins/agentic-senior-core/hooks/ladder-pulse.js +53 -36
  7. package/.agents/plugins/agentic-senior-core/hooks/lib/known-ui-slop-patterns.json +20 -0
  8. package/.agents/plugins/agentic-senior-core/hooks/path-util.cjs +118 -4
  9. package/.agents/plugins/agentic-senior-core/hooks/post-edit-enforce.js +98 -81
  10. package/.agents/plugins/agentic-senior-core/hooks/pre-tool-dependency-gate.js +168 -191
  11. package/.agents/plugins/agentic-senior-core/hooks/session-pulse.js +48 -35
  12. package/.agents/plugins/agentic-senior-core/hooks/session-start.js +62 -11
  13. package/.agents/plugins/agentic-senior-core/hooks.json +4 -94
  14. package/.agents/plugins/agentic-senior-core/plugin.json +5 -13
  15. package/.agents/plugins/agentic-senior-core/rules/agentic-senior-core.md +6 -1
  16. package/.agents/plugins/agentic-senior-core/skills/asc/SKILL.md +5 -2
  17. package/.agents/plugins/agentic-senior-core/skills/asc-develop/SKILL.md +3 -3
  18. package/.agents/plugins/agentic-senior-core/skills/asc-pentest/SKILL.md +97 -0
  19. package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/api-security-idor.md +105 -0
  20. package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/dast-baseline.md +78 -0
  21. package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/fuzzing-dsl.md +66 -0
  22. package/.agents/plugins/agentic-senior-core/skills/asc-pentest/references/pentest-task-tree.md +70 -0
  23. package/.agents/plugins/agentic-senior-core/skills/asc-review/SKILL.md +2 -2
  24. package/.agents/plugins/agentic-senior-core/skills/asc-test/SKILL.md +8 -1
  25. package/.agents/plugins/agentic-senior-core/skills/asc-test/references/mutation-testing.md +55 -0
  26. package/.agents/plugins/agentic-senior-core/skills/asc-test/references/performance-budgets.md +67 -0
  27. package/.agents/plugins/agentic-senior-core/skills/asc-test/references/property-testing.md +72 -0
  28. package/.agents/plugins/agentic-senior-core/skills/asc-ui/SKILL.md +17 -14
  29. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/copy-i18n.md +56 -8
  30. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/craft/color-surfaces.md +25 -0
  31. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/craft/themes-catalog.md +198 -0
  32. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/divergence.md +34 -2
  33. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/modes/genre-calibration.md +89 -0
  34. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/modes/operate.md +26 -0
  35. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/modes/persuade.md +10 -9
  36. package/.agents/plugins/agentic-senior-core/skills/asc-ui/references/redesign-audit.md +4 -9
  37. package/bin/agentic-senior-core.js +19 -4
  38. package/gemini-extension.json +2 -2
  39. package/lib/cli/ascx/runtime.mjs +5 -27
  40. package/lib/cli/ascx/windows-spawn.mjs +348 -0
  41. package/lib/cli/commands/bootstrap.mjs +56 -0
  42. package/lib/cli/commands/dedup.mjs +500 -0
  43. package/lib/cli/commands/git-hook-generator.mjs +3 -2
  44. package/lib/cli/commands/global.mjs +109 -53
  45. package/lib/cli/commands/preview.mjs +18 -9
  46. package/package.json +1 -1
  47. 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
- // Try next binary
243
+ const partial = loadReport(tmpDir);
244
+ if (partial) return partial;
244
245
  }
245
246
  }
246
247
  return null;