@ryuenn3123/agentic-senior-core 6.5.1 → 6.5.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-senior-core",
3
- "version": "6.5.1",
3
+ "version": "6.5.3",
4
4
  "description": "Universal AI coding rules. Write code like a staff engineer.",
5
5
  "skills": "./skills/",
6
6
  "hooks": "./hooks/hooks.json",
@@ -116,18 +116,20 @@ process.stdin.on('data', chunk => {
116
116
  const config = loadDedupConfig();
117
117
  if (!isQualifyingEdit(toolName, toolInput, config)) { process.exit(0); return; }
118
118
 
119
+ if (hasValidInlineIgnore(filePath)) { process.exit(0); return; }
120
+
119
121
  const scanDir = resolveScanDir(filePath, config);
120
122
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asc-dedup-'));
121
123
 
122
124
  const ignoreFlags = (config.ignoreDirs || []).map(function (d) { return '--ignore "' + d + '"'; }).join(' ');
123
- const minTokens = config.minTokens || 50;
125
+ const minTokens = config.minTokens || 30;
124
126
  const scanCmd = ' "' + scanDir + '" --min-tokens ' + minTokens
125
127
  + ' --reporters json --silent --output "' + tmpDir + '" ' + ignoreFlags;
126
128
 
127
129
  const report = runJscpdScan(scanCmd, tmpDir);
128
130
  if (!report) { cleanup(tmpDir); process.exit(0); return; }
129
131
 
130
- const finding = checkForDuplicates(report, filePath);
132
+ const finding = checkForDuplicates(report, filePath, config);
131
133
  if (!finding) { cleanup(tmpDir); process.exit(0); return; }
132
134
 
133
135
  const nudge = '[ASC Dedup] ' + path.basename(filePath) + ' looks similar to '
@@ -260,7 +262,7 @@ function loadDedupConfig() {
260
262
  }
261
263
  return {
262
264
  mode: 'advisory',
263
- minTokens: 50,
265
+ minTokens: 30,
264
266
  ignoreDirs: [
265
267
  'tests', 'test', '__tests__', 'migrations', 'generated', 'node_modules',
266
268
  'dist', 'build', '.next', '.nuxt', '.expo', 'coverage', '.storybook',
@@ -298,10 +300,44 @@ function loadReport(tmpDir) {
298
300
  }
299
301
  }
300
302
 
301
- function checkForDuplicates(report, filePath) {
303
+ function hasValidInlineIgnore(filePath) {
304
+ try {
305
+ if (!fs.existsSync(filePath)) return false;
306
+ var content = fs.readFileSync(filePath, 'utf8');
307
+ if (content.indexOf('jscpd:ignore-start') !== -1) return true;
308
+ var match = content.match(/asc-dedup:ignore\s*--\s*(.+)/i);
309
+ return Boolean(match && match[1] && match[1].trim().length > 0);
310
+ } catch (_) {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ function isAllowedDuplicate(fileA, fileB, allowedDuplicates) {
316
+ if (!Array.isArray(allowedDuplicates) || allowedDuplicates.length === 0) return false;
317
+ var normA = path.resolve(fileA).replace(/\\/g, '/').toLowerCase();
318
+ var normB = path.resolve(fileB).replace(/\\/g, '/').toLowerCase();
319
+ var baseA = path.basename(fileA).toLowerCase();
320
+ var baseB = path.basename(fileB).toLowerCase();
321
+
322
+ for (var i = 0; i < allowedDuplicates.length; i++) {
323
+ var pair = allowedDuplicates[i];
324
+ if (!Array.isArray(pair) || pair.length < 2) continue;
325
+ var p0Norm = path.resolve(pair[0]).replace(/\\/g, '/').toLowerCase();
326
+ var p1Norm = path.resolve(pair[1]).replace(/\\/g, '/').toLowerCase();
327
+ var p0Base = path.basename(pair[0]).toLowerCase();
328
+ var p1Base = path.basename(pair[1]).toLowerCase();
329
+
330
+ if ((normA === p0Norm && normB === p1Norm) || (normA === p1Norm && normB === p0Norm)) return true;
331
+ if ((baseA === p0Base && baseB === p1Base) || (baseA === p1Base && baseB === p0Base)) return true;
332
+ }
333
+ return false;
334
+ }
335
+
336
+ function checkForDuplicates(report, filePath, config) {
302
337
  var duplicates = report.duplicates || [];
303
338
  if (duplicates.length === 0) return null;
304
339
 
340
+ var allowedDuplicates = config && config.allowedDuplicates;
305
341
  var normalizedTarget = path.resolve(filePath).replace(/\\/g, '/').toLowerCase();
306
342
  var targetBasename = path.basename(filePath);
307
343
 
@@ -314,6 +350,10 @@ function checkForDuplicates(report, filePath) {
314
350
  var otherRaw = firstName === normalizedTarget ? dup.secondFile.name : dup.firstFile.name;
315
351
  var otherBasename = path.basename(otherRaw);
316
352
 
353
+ if (isAllowedDuplicate(filePath, otherRaw, allowedDuplicates)) {
354
+ continue;
355
+ }
356
+
317
357
  // Skip framework-conventional filenames in different directories — identical names
318
358
  // are mandated by the framework (e.g. Next.js page.tsx, Angular *.component.ts),
319
359
  // not copy-paste duplication.
@@ -327,9 +367,19 @@ function checkForDuplicates(report, filePath) {
327
367
  var cwd = process.cwd();
328
368
  var matchedFile = path.relative(cwd, path.resolve(otherRaw)).replace(/\\/g, '/');
329
369
  var lines = dup.lines || 0;
330
- // jscpd v5 reports fragments; estimate overlap percentage from line count
331
- var totalLines = (report.statistics && report.statistics.total && report.statistics.total.lines) || 1;
332
- var percent = Math.round((lines / totalLines) * 100);
370
+ var totalFileLines = 1;
371
+ try {
372
+ totalFileLines = fs.readFileSync(path.resolve(filePath), 'utf8').split('\n').length || 1;
373
+ } catch (_) {
374
+ totalFileLines = dup.firstFile.lines || dup.secondFile.lines || lines || 1;
375
+ }
376
+ var percent = Math.round((lines / totalFileLines) * 100);
377
+
378
+ // Skip trivial matches (import boilerplate, small overlaps)
379
+ if (lines < 10 && percent < 10) {
380
+ continue;
381
+ }
382
+
333
383
  return { matchedFile: matchedFile, lines: lines, percent: percent };
334
384
  }
335
385
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-senior-core",
3
- "version": "6.5.1",
3
+ "version": "6.5.3",
4
4
  "description": "Universal AI coding rules. Because your AI writes code like it gets paid by the line.",
5
5
  "contextFileName": "rules/agentic-senior-core.md",
6
6
  "rules": [
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "mode": "advisory",
3
- "minTokens": 50,
3
+ "minTokens": 30,
4
4
  "scanRoot": null,
5
+ "allowedDuplicates": [],
5
6
  "ignoreDirs": [
6
7
  "tests", "test", "__tests__", "migrations", "generated", "node_modules",
7
8
  "dist", "build", ".next", ".nuxt", ".expo", "coverage", ".storybook",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-senior-core",
3
- "version": "6.5.1",
3
+ "version": "6.5.3",
4
4
  "description": "Universal AI coding rules. Write code like a staff engineer.",
5
5
  "author": "fatidaprilian",
6
6
  "license": "MIT",
@@ -27,6 +27,84 @@ const SOURCE_EXTENSIONS = new Set([${extensionsList}]);
27
27
 
28
28
  const JSCPD_TIMEOUT_MS = 10000;
29
29
 
30
+ // Framework-conventional filenames that MUST be identical across directories by design.
31
+ const FRAMEWORK_CONVENTIONAL_BASENAMES = new Set([
32
+ 'page.tsx', 'page.jsx', 'page.ts', 'page.js',
33
+ 'layout.tsx', 'layout.jsx', 'layout.ts', 'layout.js',
34
+ 'loading.tsx', 'loading.jsx', 'loading.ts', 'loading.js',
35
+ 'error.tsx', 'error.jsx', 'error.ts', 'error.js',
36
+ 'not-found.tsx', 'not-found.jsx', 'not-found.ts', 'not-found.js',
37
+ 'template.tsx', 'template.jsx', 'template.ts', 'template.js',
38
+ 'route.tsx', 'route.ts', 'route.js',
39
+ 'default.tsx', 'default.jsx', 'default.ts', 'default.js',
40
+ 'root.tsx', 'root.jsx', 'root.ts', 'root.js',
41
+ 'entry.server.tsx', 'entry.server.ts', 'entry.client.tsx', 'entry.client.ts',
42
+ '_layout.tsx', '_layout.jsx', '_layout.ts', '_layout.js',
43
+ 'index.vue', 'app.vue',
44
+ '+page.svelte', '+layout.svelte', '+page.server.ts', '+page.server.js',
45
+ '+error.svelte', '+layout.server.ts', '+layout.server.js',
46
+ 'index.ts', 'index.js', 'index.tsx', 'index.jsx',
47
+ 'types.ts', 'types.d.ts',
48
+ 'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs',
49
+ 'postcss.config.js', 'postcss.config.mjs', 'postcss.config.cjs',
50
+ 'next.config.ts', 'next.config.js', 'next.config.mjs',
51
+ 'vite.config.ts', 'vite.config.js', 'vite.config.mjs',
52
+ 'tsconfig.json', 'jest.config.ts', 'jest.config.js', 'vitest.config.ts',
53
+ ]);
54
+
55
+ const FRAMEWORK_CONVENTIONAL_SUFFIXES = [
56
+ '.component.ts', '.component.js', '.module.ts', '.service.ts',
57
+ '.pipe.ts', '.directive.ts', '.guard.ts', '.resolver.ts',
58
+ '.stories.tsx', '.stories.jsx', '.stories.ts', '.stories.js',
59
+ '.spec.ts', '.spec.tsx', '.spec.js', '.spec.jsx',
60
+ '.test.ts', '.test.tsx', '.test.js', '.test.jsx',
61
+ ];
62
+
63
+ function isFrameworkConventional(basename) {
64
+ if (FRAMEWORK_CONVENTIONAL_BASENAMES.has(basename)) return true;
65
+ for (var i = 0; i < FRAMEWORK_CONVENTIONAL_SUFFIXES.length; i++) {
66
+ if (basename.endsWith(FRAMEWORK_CONVENTIONAL_SUFFIXES[i])) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ // Minimum blocking thresholds for Git pre-commit gate
72
+ const MIN_BLOCKING_LINES = 15;
73
+ const MIN_BLOCKING_PERCENT = 25;
74
+
75
+ function hasValidInlineIgnore(filePath) {
76
+ try {
77
+ if (!fs.existsSync(filePath)) return false;
78
+ const content = fs.readFileSync(filePath, 'utf8');
79
+ if (content.includes('jscpd:ignore-start')) return true;
80
+ const match = content.match(/asc-dedup:ignore\\s*--\\s*(.+)/i);
81
+ return Boolean(match && match[1] && match[1].trim().length > 0);
82
+ } catch (_) {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function isAllowedDuplicate(fileA, fileB, allowedDuplicates) {
88
+ if (!Array.isArray(allowedDuplicates) || allowedDuplicates.length === 0) return false;
89
+ const normA = path.resolve(fileA).replace(/\\\\/g, '/').toLowerCase();
90
+ const normB = path.resolve(fileB).replace(/\\\\/g, '/').toLowerCase();
91
+ const baseA = path.basename(fileA).toLowerCase();
92
+ const baseB = path.basename(fileB).toLowerCase();
93
+
94
+ for (let i = 0; i < allowedDuplicates.length; i++) {
95
+ const pair = allowedDuplicates[i];
96
+ if (!Array.isArray(pair) || pair.length < 2) continue;
97
+ const p0Norm = path.resolve(pair[0]).replace(/\\\\/g, '/').toLowerCase();
98
+ const p1Norm = path.resolve(pair[1]).replace(/\\\\/g, '/').toLowerCase();
99
+ const p0Base = path.basename(pair[0]).toLowerCase();
100
+ const p1Base = path.basename(pair[1]).toLowerCase();
101
+
102
+ if ((normA === p0Norm && normB === p1Norm) || (normA === p1Norm && normB === p0Norm)) return true;
103
+ if ((baseA === p0Base && baseB === p1Base) || (baseA === p1Base && baseB === p0Base)) return true;
104
+ }
105
+ return false;
106
+ }
107
+
30
108
  function getStagedFiles(cwd) {
31
109
  try {
32
110
  const output = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8', cwd });
@@ -48,7 +126,14 @@ function loadDedupConfig(cwd) {
48
126
  }
49
127
  } catch (_) {}
50
128
  }
51
- return { minTokens: 30, ignoreDirs: ['tests', 'migrations', 'generated', 'node_modules'] };
129
+ return {
130
+ minTokens: 30,
131
+ ignoreDirs: [
132
+ 'tests', 'test', '__tests__', 'migrations', 'generated', 'node_modules',
133
+ 'dist', 'build', '.next', '.nuxt', '.expo', 'coverage', '.storybook',
134
+ 'prisma/migrations', 'android', 'ios',
135
+ ],
136
+ };
52
137
  }
53
138
 
54
139
  function runEslintAutoFix(cwd, stagedFiles) {
@@ -106,10 +191,11 @@ function loadReport(tmpDir) {
106
191
  }
107
192
  }
108
193
 
109
- function checkForDuplicates(report, stagedSourceFiles, cwd) {
194
+ function checkForDuplicates(report, stagedSourceFiles, cwd, config) {
110
195
  const duplicates = report.duplicates || [];
111
196
  if (duplicates.length === 0) return null;
112
197
 
198
+ const allowedDuplicates = config && config.allowedDuplicates;
113
199
  const normalizedStagedMap = new Map();
114
200
  for (const f of stagedSourceFiles) {
115
201
  const norm = path.resolve(cwd, f).replace(/\\\\/g, '/').toLowerCase();
@@ -126,10 +212,45 @@ function checkForDuplicates(report, stagedSourceFiles, cwd) {
126
212
 
127
213
  if (isFirstStaged || isSecondStaged) {
128
214
  const stagedFile = isFirstStaged ? normalizedStagedMap.get(firstName) : normalizedStagedMap.get(secondName);
129
- const matchedFile = isFirstStaged ? path.basename(dup.secondFile.name) : path.basename(dup.firstFile.name);
215
+ const otherRaw = isFirstStaged ? dup.secondFile.name : dup.firstFile.name;
216
+ const stagedPath = path.resolve(cwd, stagedFile);
217
+ const otherPath = path.resolve(cwd, otherRaw);
218
+
219
+ if (hasValidInlineIgnore(stagedPath) || hasValidInlineIgnore(otherPath)) {
220
+ continue;
221
+ }
222
+
223
+ if (isAllowedDuplicate(stagedPath, otherPath, allowedDuplicates)) {
224
+ continue;
225
+ }
226
+
227
+ const otherBasename = path.basename(otherRaw);
228
+ const stagedBasename = path.basename(stagedFile);
229
+
230
+ // Skip framework-conventional filenames in different directories
231
+ if (isFrameworkConventional(stagedBasename)
232
+ && isFrameworkConventional(otherBasename)
233
+ && path.dirname(stagedPath) !== path.dirname(otherPath)) {
234
+ continue;
235
+ }
236
+
130
237
  const lines = dup.lines || 0;
131
- const totalLines = (report.statistics && report.statistics.total && report.statistics.total.lines) || 1;
132
- const percent = Math.round((lines / totalLines) * 100);
238
+ // Calculate percent relative to the actual staged file size
239
+ let totalFileLines = 1;
240
+ try {
241
+ totalFileLines = fs.readFileSync(stagedPath, 'utf8').split('\\n').length || 1;
242
+ } catch (_) {
243
+ totalFileLines = dup.firstFile.lines || dup.secondFile.lines || lines || 1;
244
+ }
245
+ const percent = Math.round((lines / totalFileLines) * 100);
246
+
247
+ // Skip matches below blocking threshold
248
+ if (lines < MIN_BLOCKING_LINES || percent < MIN_BLOCKING_PERCENT) {
249
+ continue;
250
+ }
251
+
252
+ // Show relative path for actionable messages
253
+ const matchedFile = path.relative(cwd, otherPath).replace(/\\\\/g, '/');
133
254
  return { stagedFile, matchedFile, lines, percent };
134
255
  }
135
256
  }
@@ -185,8 +306,11 @@ function runPreCommitGate() {
185
306
  })));
186
307
 
187
308
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asc-git-dedup-'));
188
- const ignoreFlags = (config.ignoreDirs || ['tests', 'migrations', 'generated', 'node_modules'])
189
- .map(d => \`--ignore "\${d}"\`).join(' ');
309
+ const ignoreFlags = (config.ignoreDirs || [
310
+ 'tests', 'test', '__tests__', 'migrations', 'generated', 'node_modules',
311
+ 'dist', 'build', '.next', '.nuxt', '.expo', 'coverage', '.storybook',
312
+ 'prisma/migrations', 'android', 'ios',
313
+ ]).map(d => \`--ignore "\${d}"\`).join(' ');
190
314
  const minTokens = config.minTokens || 30;
191
315
 
192
316
  let finding = null;
@@ -195,7 +319,7 @@ function runPreCommitGate() {
195
319
  const scanCmd = \` "\${scanDir}" --min-tokens \${minTokens} --reporters json --silent --output "\${tmpDir}" \${ignoreFlags}\`;
196
320
  const report = runJscpdScan(scanCmd, cwd, tmpDir);
197
321
  if (report) {
198
- finding = checkForDuplicates(report, currentStagedSource, cwd);
322
+ finding = checkForDuplicates(report, currentStagedSource, cwd, config);
199
323
  if (finding) break;
200
324
  }
201
325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuenn3123/agentic-senior-core",
3
- "version": "6.5.1",
3
+ "version": "6.5.3",
4
4
  "type": "module",
5
5
  "description": "Agentic Senior Core: Universal AI coding rules and workflows. Write code like a staff engineer, not a junior.",
6
6
  "bin": {
package/plugin.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: agentic-senior-core
2
- version: 6.5.1
2
+ version: 6.5.3
3
3
  description: Universal AI coding rules. Write code like a staff engineer.
4
4
  author: fatidaprilian
5
5
  provides_hooks: