diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000000..86b7343d5c --- /dev/null +++ b/.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 +} diff --git a/eslint.config.mjs b/eslint.config.mjs index c62d3e9739..955e0fc83e 100644 --- a/eslint.config.mjs +++ b/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'], diff --git a/package.json b/package.json index cdc78f1828..b453bf3791 100644 --- a/package.json +++ b/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", @@ -82,7 +83,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", diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 38576b6f28..12b4a53958 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/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 { 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 { 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 } diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 505f8d2bda..278dae96e3 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/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. diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index e164d98a70..3347447d86 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/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 => { 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 => { 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 => { 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 => { 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 => 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 { diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a69c979756..bd31e0ae47 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -88,6 +88,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi this.coordinator = new PersistenceCoordinator(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 { @@ -115,6 +118,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi get inits(): Map> { return this.coordinator.inits } + /* jscpd:ignore-end */ // --- PersistenceBackend hooks (the file-bytes storage primitives) --- diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 87cc62d165..3b4e8c5fee 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/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 }) diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cb3eab3545..0a9cf3e82e 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -30,6 +30,21 @@ function registryOf(...tools: ToolDefinition[]): Pick { 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 { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + return ctx +} + function evt(type: T, data: Extract['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 { - 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 { - 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'][] = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2e74d552b..d02b4a9ca6 100644 --- a/pnpm-lock.yaml +++ b/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 @@ -3292,6 +3298,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'} @@ -3598,6 +3612,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} @@ -3723,6 +3742,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'} @@ -3742,6 +3764,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==} @@ -3848,6 +3874,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} @@ -3878,6 +3942,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==} @@ -4044,6 +4112,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==} @@ -4349,6 +4420,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'} @@ -4415,6 +4494,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'} @@ -6105,6 +6188,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: {} @@ -6444,6 +6531,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 @@ -6588,6 +6692,8 @@ snapshots: fsevents@2.3.3: optional: true + functional-red-black-tree@1.0.1: {} + gaxios@7.1.5: dependencies: extend: 3.0.2 @@ -6616,6 +6722,8 @@ snapshots: dependencies: is-glob: 4.0.3 + globals@17.7.0: {} + globrex@0.1.2: {} google-auth-library@10.7.0: @@ -6710,6 +6818,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 @@ -6753,6 +6888,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 @@ -6897,6 +7034,8 @@ snapshots: lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} + long@5.3.2: {} longest-streak@3.1.0: {} @@ -7413,6 +7552,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: {} @@ -7505,6 +7653,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: diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c8a0e0d359..131501abb0 100644 --- a/scripts/run-gates.ts +++ b/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 [ @@ -153,6 +154,7 @@ function gatesForMode(selected: Mode): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('test', 'test'), + pnpmScript('duplication', 'duplication'), pnpmScript('snapshot', 'test:snapshot'), pnpmScript('build', 'build'), ...hygieneLeafGates({ artifactNeeds: ['build'] }), @@ -168,6 +170,7 @@ function ciPrimaryGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), lintGate(), + pnpmScript('duplication', 'duplication'), coverageGate(), pnpmScript('snapshot', 'test:snapshot'), demoSmokeGate({ needs: ['lint'] }),