Explorar el Código

chore: gate TypeScript duplication in CI

Tianyi Cui hace 2 meses
padre
commit
661504b3ec

+ 14 - 0
.jscpd.json

@@ -0,0 +1,14 @@
+{
+  "minTokens": 100,
+  "minLines": 10,
+  "mode": "mild",
+  "format": ["typescript"],
+  "pattern": "**/src/**/*.ts",
+  "ignorePattern": [
+    "(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
+  ],
+  "reporters": ["console"],
+  "exitCode": 1,
+  "noColors": true,
+  "noTips": true
+}

+ 12 - 0
eslint.config.mjs

@@ -1,4 +1,5 @@
 import stylistic from '@stylistic/eslint-plugin'
+import sonarjs from 'eslint-plugin-sonarjs'
 import tseslint from 'typescript-eslint'
 
 /**
@@ -123,6 +124,17 @@ export default tseslint.config(
     },
   },
 
+  // --- file-local duplication (all owned TypeScript) ---------------------
+  {
+    files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'],
+    plugins: { sonarjs },
+    rules: {
+      // Cross-file clones are covered separately by jscpd.
+      'sonarjs/no-identical-functions': 'error',
+      'sonarjs/no-duplicated-branches': 'error',
+    },
+  },
+
   // --- formatting (everything we own) -------------------------------------
   {
     files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'eslint.config.mjs'],

+ 3 - 0
package.json

@@ -17,6 +17,7 @@
     "typecheck": "tsc -b tsconfig.json",
     "lint": "eslint .",
     "lint:fix": "eslint . --fix",
+    "duplication": "jscpd --config .jscpd.json packages",
     "test": "vitest run",
     "test:coverage": "vitest run --coverage",
     "test:e2e": "vitest run --config vitest.e2e.config.ts",
@@ -81,7 +82,9 @@
     "@types/node": "^22.20.0",
     "@vitest/coverage-v8": "^4.1.8",
     "eslint": "^10.4.1",
+    "eslint-plugin-sonarjs": "^4.1.0",
     "fast-check": "^4.8.0",
+    "jscpd": "^5.0.12",
     "jsdom": "29.1.1",
     "knip": "^6.16.1",
     "lefthook": "^2.1.9",

+ 13 - 22
packages/bash/tool-bash/tests/tools.spec.ts

@@ -102,16 +102,7 @@ async function callUntilText(
   throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
 }
 
-class LossyReadBashExecutor extends BashExecutor {
-  private readonly task: BashTask = {
-    id: BashTaskId('bash-lossy'),
-    command: 'fake',
-    status: 'running',
-    exitCode: null,
-    signal: null,
-    done: Promise.resolve(),
-  }
-
+abstract class TestBashExecutor extends BashExecutor {
   resolve(request: BashExecRequest): BashExecSpec {
     return {
       command: request.command,
@@ -122,6 +113,17 @@ class LossyReadBashExecutor extends BashExecutor {
       sandboxMode: request.sandboxMode,
     }
   }
+}
+
+class LossyReadBashExecutor extends TestBashExecutor {
+  private readonly task: BashTask = {
+    id: BashTaskId('bash-lossy'),
+    command: 'fake',
+    status: 'running',
+    exitCode: null,
+    signal: null,
+    done: Promise.resolve(),
+  }
 
   run(): Promise<BashRunResult> {
     return Promise.reject(new Error('not used'))
@@ -1057,7 +1059,7 @@ describe('sandbox rendering', () => {
     // it anyway: an executor that reports no sandboxMode (fields never
     // advertised) whose task nonetheless carries denial facts must render
     // the marker without suggesting a lever the schema does not offer.
-    class FactsOnlyExecutor extends BashExecutor {
+    class FactsOnlyExecutor extends TestBashExecutor {
       private readonly task: BashTask = {
         id: BashTaskId('bash-facts'),
         command: 'fake',
@@ -1068,17 +1070,6 @@ describe('sandbox rendering', () => {
         sandbox: { mode: 'read-only', denied: true },
       }
 
-      resolve(request: BashExecRequest): BashExecSpec {
-        return {
-          command: request.command,
-          workdir: request.workdir ?? process.cwd(),
-          timeoutMs: request.timeoutMs ?? 0,
-          ...request.signal ? { signal: request.signal } : {},
-          owner: request.owner,
-          sandboxMode: request.sandboxMode,
-        }
-      }
-
       run(): Promise<BashRunResult> { return Promise.reject(new Error('not used')) }
       start(): BashTask { return this.task }
       get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined }

+ 4 - 0
packages/hooks/hooks-codex/src/config.ts

@@ -53,6 +53,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
 
   for (const event of CODEX_EVENTS) {
     const rawGroups = hooksMap[event]
+    // Matcher-group parsing remains dialect-local because the supported hook
+    // shapes and skip reasons differ from Claude Code's.
+    /* jscpd:ignore-start */
     if (!Array.isArray(rawGroups)) continue
     const groups: MatcherGroup[] = []
     for (const rawGroup of rawGroups) {
@@ -64,6 +67,7 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
         if (!hook) continue
         const type = typeof hook.type === 'string' ? hook.type : 'command'
         if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue }
+        /* jscpd:ignore-end */
         if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue }
         if (typeof hook.command !== 'string') continue
         // Codex accepts `timeout` or the `timeoutSec` alias.

+ 12 - 0
packages/hooks/hooks-codex/src/index.ts

@@ -153,6 +153,9 @@ export function apply(ctx: Context, config: Config): void {
           output.additionalContext = output.stdout
         }
         outputs.push(output)
+        // Execution and decision mapping remain in each bridge so dialect
+        // differences stay explicit at their owning seam.
+        /* jscpd:ignore-start */
         if (output.systemMessage !== undefined) {
           ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
         }
@@ -202,12 +205,14 @@ export function apply(ctx: Context, config: Config): void {
         if (context) agent.inject(context.content, { source: context.source })
       })
       .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
+    /* jscpd:ignore-end */
   })
 
   // UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
   ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
     const turn = lastTurn(agent)
     const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
+    /* jscpd:ignore-start */
     if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
     // Context alone is not a veto: DELEGATE so a later prompt-submit listener can
     // still block/rewrite, then fold our context onto its decision.
@@ -225,6 +230,7 @@ export function apply(ctx: Context, config: Config): void {
   ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
     const turn = lastTurn(exec.agent)
     const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
+    /* jscpd:ignore-end */
     if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
     return next()
   })
@@ -232,6 +238,7 @@ export function apply(ctx: Context, config: Config): void {
   // PostToolUse → PostToolDecision (block with feedback, or attach context).
   ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
     const turn = lastTurn(exec.agent)
+    /* jscpd:ignore-start */
     const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
     const context = contextFrom(merged)
     if (merged.decision === 'deny') {
@@ -257,6 +264,7 @@ export function apply(ctx: Context, config: Config): void {
   // loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
   ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
     const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
+    /* jscpd:ignore-end */
     if (merged.decision === 'deny') {
       // A blocking Stop hook forces continuation; a block with no reason (exit 2,
       // empty stderr) still forces it — fall back to a generic steering line
@@ -271,6 +279,9 @@ export function apply(ctx: Context, config: Config): void {
 // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
 // turn-scoped events. ---
 
+// These small payload helpers intentionally remain next to the dialect shape;
+// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.
+/* jscpd:ignore-start */
 function lastTurn(agent: Agent | undefined): number {
   if (!agent) return 0
   const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
@@ -283,6 +294,7 @@ function lastTurn(agent: Agent | undefined): number {
 function blocksToText(content: ContentBlock[]): string {
   return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
 }
+/* jscpd:ignore-end */
 
 /** Base fields on every Codex payload (no turn_id). */
 function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {

+ 4 - 0
packages/session-persistence/session-persistence-jsonl/src/index.ts

@@ -88,6 +88,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
     this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
   }
 
+  // Each backend keeps the typed service surface beside its storage hooks;
+  // extracting these trivial forwards would add an inheritance seam.
+  /* jscpd:ignore-start */
   // --- SessionPersistence service surface (delegated to the coordinator) ---
 
   create(meta: SessionHeader): Promise<void> {
@@ -115,6 +118,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
   get inits(): Map<Session, Promise<void>> {
     return this.coordinator.inits
   }
+  /* jscpd:ignore-end */
 
   // --- PersistenceBackend hooks (the file-bytes storage primitives) ---
 

+ 10 - 20
packages/support/llm-replay/tests/llm-replay.spec.ts

@@ -54,6 +54,16 @@ function chunkEvent(seq: number, turn: number, step: number, chunk: StreamChunk)
 let dir: string
 let file: string
 
+/** Write a session log file and return its path. */
+function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
+  let seq = 1
+  const events: SessionEvent[] = []
+  calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
+  const path = join(dir, filename)
+  writeFileSync(path, sessionJsonl(events, header), 'utf8')
+  return path
+}
+
 beforeEach(() => {
   dir = mkdtempSync(join(tmpdir(), 'llm-replay-spec-'))
   file = join(dir, 'session.jsonl')
@@ -391,16 +401,6 @@ describe('parseSessionHeader', () => {
 })
 
 describe('loadSessionScripts', () => {
-  /** Write a session log file and return its path. */
-  function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
-    let seq = 1
-    const events: SessionEvent[] = []
-    calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
-    const path = join(dir, filename)
-    writeFileSync(path, sessionJsonl(events, header), 'utf8')
-    return path
-  }
-
   it('returns one primary script for a single-session scenario', () => {
     const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS])
     const scripts: SessionScript[] = loadSessionScripts({ file: f })
@@ -508,16 +508,6 @@ describe('installLlmReplay (per-session keying)', () => {
     { type: 'finish', reason: { kind: 'stop' } },
   ]
 
-  /** Write a session log file and return its path. */
-  function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
-    let seq = 1
-    const events: SessionEvent[] = []
-    calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
-    const path = join(dir, filename)
-    writeFileSync(path, sessionJsonl(events, header), 'utf8')
-    return path
-  }
-
   const live = (id: string): GenerateOptions =>
     ({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
 

+ 15 - 29
packages/ui/acp/tests/stream-update.spec.ts

@@ -30,6 +30,21 @@ function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
   return { get: name => map.get(name) }
 }
 
+function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
+  const out: SessionNotification['update'][] = []
+  for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
+  return out
+}
+
+async function fsCtx(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(FsLocal)
+  await ctx.plugin(ToolFs)
+  return ctx
+}
+
 function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
   return { type, seq: 0, time: 0, data } as SessionEvent
 }
@@ -181,12 +196,6 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
     }),
   }
 
-  function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
-    const out: SessionNotification['update'][] = []
-    for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
-    return out
-  }
-
   it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
     const presenter = new ToolPresenter(registryOf(bashLike))
     const [update] = updatesWith(presenter, evt('tool/call', {
@@ -627,21 +636,6 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
   // result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
   // the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
   // call-side diff test above.
-  async function fsCtx(): Promise<Context> {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    await ctx.plugin(FsLocal)
-    await ctx.plugin(ToolFs)
-    return ctx
-  }
-
-  function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
-    const out: SessionNotification['update'][] = []
-    for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
-    return out
-  }
-
   it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
     const ctx = await fsCtx()
     const presenter = new ToolPresenter(ctx.tools)
@@ -739,14 +733,6 @@ describe('relative-path display titles (bridge relativizes the title against the
   // diff paths RAW. Drive it with the REAL fs tools so the title/locations come
   // from the shipping presentCall, and pass an ABSOLUTE file path (which a real
   // editor forwards). The presenter is pure/args-only; the cwd is known only here.
-  async function fsCtx(): Promise<Context> {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    await ctx.plugin(FsLocal)
-    await ctx.plugin(ToolFs)
-    return ctx
-  }
   function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
     const presenter = new ToolPresenter(ctx.tools)
     const out: SessionNotification['update'][] = []

+ 154 - 0
pnpm-lock.yaml

@@ -29,9 +29,15 @@ importers:
       eslint:
         specifier: ^10.4.1
         version: 10.5.0(jiti@2.7.0)
+      eslint-plugin-sonarjs:
+        specifier: ^4.1.0
+        version: 4.1.0(eslint@10.5.0(jiti@2.7.0))
       fast-check:
         specifier: ^4.8.0
         version: 4.8.0
+      jscpd:
+        specifier: ^5.0.12
+        version: 5.0.12
       jsdom:
         specifier: 29.1.1
         version: 29.1.1
@@ -3050,6 +3056,14 @@ packages:
   buffer-equal-constant-time@1.0.1:
     resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
 
+  builtin-modules@3.3.0:
+    resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
+    engines: {node: '>=6'}
+
+  bytes@3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+
   cac@7.0.0:
     resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
     engines: {node: '>=20.19.0'}
@@ -3356,6 +3370,11 @@ packages:
     resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
     engines: {node: '>=12'}
 
+  eslint-plugin-sonarjs@4.1.0:
+    resolution: {integrity: sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==}
+    peerDependencies:
+      eslint: ^8.0.0 || ^9.0.0 || ^10.0.0
+
   eslint-scope@9.1.2:
     resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
     engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -3481,6 +3500,9 @@ packages:
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
     os: [darwin]
 
+  functional-red-black-tree@1.0.1:
+    resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
+
   gaxios@7.1.5:
     resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==}
     engines: {node: '>=18'}
@@ -3500,6 +3522,10 @@ packages:
     resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
     engines: {node: '>=10.13.0'}
 
+  globals@17.7.0:
+    resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==}
+    engines: {node: '>=18'}
+
   globrex@0.1.2:
     resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
 
@@ -3606,6 +3632,44 @@ packages:
     resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
     hasBin: true
 
+  jscpd-darwin-arm64@5.0.12:
+    resolution: {integrity: sha512-CQCDYhQY9yOGGeauzkRGYW/QtXurPCjfDkEhUoM/M5UdmpzxfG3i9mnNsaheJ0RdFRf73mL7JaLVRP8U2l5/kA==}
+    cpu: [arm64]
+    os: [darwin]
+
+  jscpd-darwin-x64@5.0.12:
+    resolution: {integrity: sha512-7XNCDfChP9fPkIWLepd0zGnTov7/5mR7rJ5Utc1MdWFdgkN//qz6FAJ1FhQA0E/qjFedy9XTucfUCgLybSBsjw==}
+    cpu: [x64]
+    os: [darwin]
+
+  jscpd-linux-arm64-gnu@5.0.12:
+    resolution: {integrity: sha512-i/usHD0/8twzb2Qo4vFWcnuAD4IDLS6K/mTXwYy1CiJZDw4gmjfH45jtjdKUyoutTAiNs+ZWZgx1gIRljUbFuA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  jscpd-linux-x64-gnu@5.0.12:
+    resolution: {integrity: sha512-TqaeSTaGawcqyXSrQvYJFtLRn4aQeHgphGGsq2ZtVAg+lPeuMmh81c3bl0yi2dL3gDX1RGBQKBVul1XQknOuoA==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  jscpd-linux-x64-musl@5.0.12:
+    resolution: {integrity: sha512-WelugY1/rdoo0Y0sqJ2XQOIvyIZTfRe+vP3MJe4XvEUwPF0v+9SQoS34FnfblRhsddVixyNkgl7x/sHid9UMJw==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  jscpd-windows-x64-msvc@5.0.12:
+    resolution: {integrity: sha512-01x1klKjvfzI6Of5tI9u72x7TIQZQLLG6kMdsEPJKl/BXbskdknrfCepeTXRBbdFPKs25jfcd0klZ4OdDvww5w==}
+    cpu: [x64]
+    os: [win32]
+
+  jscpd@5.0.12:
+    resolution: {integrity: sha512-87dC+akj2mCywlt8p3xnRlDg0B55u4GDbfE9cuwOOeIxbEuWuIoNqGoYi65A0516aJ4EzAjGwBj1Qb/Ahc8WAQ==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   jsdom@29.1.1:
     resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
     engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
@@ -3636,6 +3700,10 @@ packages:
   json-stable-stringify-without-jsonify@1.0.1:
     resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
 
+  jsx-ast-utils-x@0.1.0:
+    resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
   jwa@2.0.1:
     resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
 
@@ -3802,6 +3870,9 @@ packages:
   lodash-es@4.18.1:
     resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
 
+  lodash.merge@4.6.2:
+    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
   long@5.3.2:
     resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
 
@@ -4107,6 +4178,14 @@ packages:
     resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
     engines: {node: '>= 14.18.0'}
 
+  refa@0.12.1:
+    resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
+    engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+  regexp-ast-analysis@0.7.1:
+    resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
+    engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
   require-from-string@2.0.2:
     resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
     engines: {node: '>=0.10.0'}
@@ -4173,6 +4252,10 @@ packages:
   schemastery@3.18.0:
     resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==}
 
+  scslre@0.3.0:
+    resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
+    engines: {node: ^14.0.0 || >=16.0.0}
+
   semver@7.8.4:
     resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
     engines: {node: '>=10'}
@@ -5855,6 +5938,10 @@ snapshots:
 
   buffer-equal-constant-time@1.0.1: {}
 
+  builtin-modules@3.3.0: {}
+
+  bytes@3.1.2: {}
+
   cac@7.0.0: {}
 
   ccount@2.0.1: {}
@@ -6186,6 +6273,23 @@ snapshots:
 
   escape-string-regexp@5.0.0: {}
 
+  eslint-plugin-sonarjs@4.1.0(eslint@10.5.0(jiti@2.7.0)):
+    dependencies:
+      '@eslint-community/regexpp': 4.12.2
+      builtin-modules: 3.3.0
+      bytes: 3.1.2
+      eslint: 10.5.0(jiti@2.7.0)
+      functional-red-black-tree: 1.0.1
+      globals: 17.7.0
+      jsx-ast-utils-x: 0.1.0
+      lodash.merge: 4.6.2
+      minimatch: 10.2.5
+      scslre: 0.3.0
+      semver: 7.8.4
+      ts-api-utils: 2.5.0(typescript@6.0.3)
+      typescript: 6.0.3
+      yaml: 2.9.0
+
   eslint-scope@9.1.2:
     dependencies:
       '@types/esrecurse': 4.3.1
@@ -6330,6 +6434,8 @@ snapshots:
   fsevents@2.3.3:
     optional: true
 
+  functional-red-black-tree@1.0.1: {}
+
   gaxios@7.1.5:
     dependencies:
       extend: 3.0.2
@@ -6358,6 +6464,8 @@ snapshots:
     dependencies:
       is-glob: 4.0.3
 
+  globals@17.7.0: {}
+
   globrex@0.1.2: {}
 
   google-auth-library@10.7.0:
@@ -6452,6 +6560,33 @@ snapshots:
     dependencies:
       argparse: 2.0.1
 
+  jscpd-darwin-arm64@5.0.12:
+    optional: true
+
+  jscpd-darwin-x64@5.0.12:
+    optional: true
+
+  jscpd-linux-arm64-gnu@5.0.12:
+    optional: true
+
+  jscpd-linux-x64-gnu@5.0.12:
+    optional: true
+
+  jscpd-linux-x64-musl@5.0.12:
+    optional: true
+
+  jscpd-windows-x64-msvc@5.0.12:
+    optional: true
+
+  jscpd@5.0.12:
+    optionalDependencies:
+      jscpd-darwin-arm64: 5.0.12
+      jscpd-darwin-x64: 5.0.12
+      jscpd-linux-arm64-gnu: 5.0.12
+      jscpd-linux-x64-gnu: 5.0.12
+      jscpd-linux-x64-musl: 5.0.12
+      jscpd-windows-x64-msvc: 5.0.12
+
   jsdom@29.1.1:
     dependencies:
       '@asamuzakjp/css-color': 5.1.11
@@ -6495,6 +6630,8 @@ snapshots:
 
   json-stable-stringify-without-jsonify@1.0.1: {}
 
+  jsx-ast-utils-x@0.1.0: {}
+
   jwa@2.0.1:
     dependencies:
       buffer-equal-constant-time: 1.0.1
@@ -6639,6 +6776,8 @@ snapshots:
 
   lodash-es@4.18.1: {}
 
+  lodash.merge@4.6.2: {}
+
   long@5.3.2: {}
 
   longest-streak@3.1.0: {}
@@ -7155,6 +7294,15 @@ snapshots:
 
   readdirp@4.1.2: {}
 
+  refa@0.12.1:
+    dependencies:
+      '@eslint-community/regexpp': 4.12.2
+
+  regexp-ast-analysis@0.7.1:
+    dependencies:
+      '@eslint-community/regexpp': 4.12.2
+      refa: 0.12.1
+
   require-from-string@2.0.2: {}
 
   resolve-pkg-maps@1.0.0: {}
@@ -7247,6 +7395,12 @@ snapshots:
       '@standard-schema/spec': 1.1.0
       cosmokit: 1.8.1
 
+  scslre@0.3.0:
+    dependencies:
+      '@eslint-community/regexpp': 4.12.2
+      refa: 0.12.1
+      regexp-ast-analysis: 0.7.1
+
   semver@7.8.4: {}
 
   shebang-command@2.0.0:

+ 3 - 0
scripts/run-gates.ts

@@ -129,6 +129,7 @@ function gatesForMode(selected: Mode): Gate[] {
     case 'ci-lint':
       return [
         lintGate(),
+        pnpmScript('duplication', 'duplication'),
       ]
     case 'ci-coverage':
       return [
@@ -152,6 +153,7 @@ function gatesForMode(selected: Mode): Gate[] {
     case 'pre-push':
       return [
         pnpmScript('test', 'test'),
+        pnpmScript('duplication', 'duplication'),
         pnpmScript('snapshot', 'test:snapshot'),
         pnpmScript('build', 'build'),
         ...hygieneLeafGates({ artifactNeeds: ['build'] }),
@@ -166,6 +168,7 @@ function ciPrimaryGates(): Gate[] {
     pnpmScript('constraints', 'constraints'),
     pnpmScript('typecheck', 'typecheck'),
     lintGate(),
+    pnpmScript('duplication', 'duplication'),
     coverageGate(),
     pnpmScript('snapshot', 'test:snapshot'),
     demoSmokeGate({ needs: ['lint'] }),