@sentry/warden 0.0.0

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 (199) hide show
  1. package/.agents/skills/find-bugs/SKILL.md +75 -0
  2. package/.agents/skills/vercel-react-best-practices/AGENTS.md +2934 -0
  3. package/.agents/skills/vercel-react-best-practices/SKILL.md +136 -0
  4. package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
  5. package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
  6. package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
  7. package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
  8. package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
  9. package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
  10. package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
  11. package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
  12. package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
  13. package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
  14. package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
  15. package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
  16. package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
  17. package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
  18. package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
  19. package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
  20. package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
  21. package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
  22. package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
  23. package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
  24. package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
  25. package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
  26. package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
  27. package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
  28. package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
  29. package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
  30. package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
  31. package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
  32. package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
  33. package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
  34. package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
  35. package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
  36. package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
  37. package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
  38. package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
  39. package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
  40. package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
  41. package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
  42. package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
  43. package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
  44. package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
  45. package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
  46. package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
  47. package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
  48. package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
  49. package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
  50. package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
  51. package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
  52. package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
  53. package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
  54. package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
  55. package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
  56. package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
  57. package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
  58. package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
  59. package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
  60. package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
  61. package/.claude/settings.json +57 -0
  62. package/.claude/settings.local.json +88 -0
  63. package/.claude/skills/agent-prompt/SKILL.md +54 -0
  64. package/.claude/skills/agent-prompt/references/agentic-patterns.md +94 -0
  65. package/.claude/skills/agent-prompt/references/anti-patterns.md +140 -0
  66. package/.claude/skills/agent-prompt/references/context-design.md +124 -0
  67. package/.claude/skills/agent-prompt/references/core-principles.md +75 -0
  68. package/.claude/skills/agent-prompt/references/model-guidance.md +118 -0
  69. package/.claude/skills/agent-prompt/references/output-formats.md +98 -0
  70. package/.claude/skills/agent-prompt/references/skill-structure.md +115 -0
  71. package/.claude/skills/agent-prompt/references/system-prompts.md +115 -0
  72. package/.claude/skills/notseer/SKILL.md +131 -0
  73. package/.claude/skills/skill-writer/SKILL.md +140 -0
  74. package/.claude/skills/testing-guidelines/SKILL.md +132 -0
  75. package/.claude/skills/warden-skill/SKILL.md +250 -0
  76. package/.claude/skills/warden-skill/references/config-schema.md +133 -0
  77. package/.dex/config.toml +2 -0
  78. package/.github/workflows/ci.yml +33 -0
  79. package/.github/workflows/release.yml +54 -0
  80. package/.github/workflows/warden.yml +40 -0
  81. package/AGENTS.md +89 -0
  82. package/CONTRIBUTING.md +60 -0
  83. package/LICENSE +105 -0
  84. package/README.md +43 -0
  85. package/SPEC.md +263 -0
  86. package/action.yml +87 -0
  87. package/assets/favicon.png +0 -0
  88. package/assets/warden-icon-bw.svg +5 -0
  89. package/assets/warden-icon-purple.png +0 -0
  90. package/assets/warden-icon-purple.svg +5 -0
  91. package/docs/.claude/settings.local.json +11 -0
  92. package/docs/astro.config.mjs +43 -0
  93. package/docs/package.json +19 -0
  94. package/docs/pnpm-lock.yaml +4000 -0
  95. package/docs/public/favicon.svg +5 -0
  96. package/docs/src/components/Code.astro +141 -0
  97. package/docs/src/components/PackageManagerTabs.astro +183 -0
  98. package/docs/src/components/Terminal.astro +212 -0
  99. package/docs/src/layouts/Base.astro +380 -0
  100. package/docs/src/pages/cli.astro +167 -0
  101. package/docs/src/pages/config.astro +394 -0
  102. package/docs/src/pages/guide.astro +449 -0
  103. package/docs/src/pages/index.astro +490 -0
  104. package/docs/src/styles/global.css +551 -0
  105. package/docs/tsconfig.json +3 -0
  106. package/docs/vercel.json +5 -0
  107. package/eslint.config.js +33 -0
  108. package/package.json +73 -0
  109. package/src/action/index.ts +1 -0
  110. package/src/action/main.ts +868 -0
  111. package/src/cli/args.test.ts +477 -0
  112. package/src/cli/args.ts +415 -0
  113. package/src/cli/commands/add.ts +447 -0
  114. package/src/cli/commands/init.test.ts +136 -0
  115. package/src/cli/commands/init.ts +132 -0
  116. package/src/cli/commands/setup-app/browser.ts +38 -0
  117. package/src/cli/commands/setup-app/credentials.ts +45 -0
  118. package/src/cli/commands/setup-app/manifest.ts +48 -0
  119. package/src/cli/commands/setup-app/server.ts +172 -0
  120. package/src/cli/commands/setup-app.ts +156 -0
  121. package/src/cli/commands/sync.ts +114 -0
  122. package/src/cli/context.ts +131 -0
  123. package/src/cli/files.test.ts +155 -0
  124. package/src/cli/files.ts +89 -0
  125. package/src/cli/fix.test.ts +310 -0
  126. package/src/cli/fix.ts +387 -0
  127. package/src/cli/git.test.ts +119 -0
  128. package/src/cli/git.ts +318 -0
  129. package/src/cli/index.ts +14 -0
  130. package/src/cli/main.ts +672 -0
  131. package/src/cli/output/box.ts +235 -0
  132. package/src/cli/output/formatters.test.ts +187 -0
  133. package/src/cli/output/formatters.ts +269 -0
  134. package/src/cli/output/icons.ts +13 -0
  135. package/src/cli/output/index.ts +44 -0
  136. package/src/cli/output/ink-runner.tsx +337 -0
  137. package/src/cli/output/jsonl.test.ts +347 -0
  138. package/src/cli/output/jsonl.ts +126 -0
  139. package/src/cli/output/reporter.ts +435 -0
  140. package/src/cli/output/tasks.ts +374 -0
  141. package/src/cli/output/tty.test.ts +117 -0
  142. package/src/cli/output/tty.ts +60 -0
  143. package/src/cli/output/verbosity.test.ts +40 -0
  144. package/src/cli/output/verbosity.ts +31 -0
  145. package/src/cli/terminal.test.ts +148 -0
  146. package/src/cli/terminal.ts +301 -0
  147. package/src/config/index.ts +3 -0
  148. package/src/config/loader.test.ts +313 -0
  149. package/src/config/loader.ts +103 -0
  150. package/src/config/schema.ts +168 -0
  151. package/src/config/writer.test.ts +119 -0
  152. package/src/config/writer.ts +84 -0
  153. package/src/diff/classify.test.ts +162 -0
  154. package/src/diff/classify.ts +92 -0
  155. package/src/diff/coalesce.test.ts +208 -0
  156. package/src/diff/coalesce.ts +133 -0
  157. package/src/diff/context.test.ts +226 -0
  158. package/src/diff/context.ts +201 -0
  159. package/src/diff/index.ts +4 -0
  160. package/src/diff/parser.test.ts +212 -0
  161. package/src/diff/parser.ts +149 -0
  162. package/src/event/context.ts +132 -0
  163. package/src/event/index.ts +2 -0
  164. package/src/event/schedule-context.ts +101 -0
  165. package/src/examples/examples.integration.test.ts +66 -0
  166. package/src/examples/index.test.ts +101 -0
  167. package/src/examples/index.ts +122 -0
  168. package/src/examples/setup.ts +25 -0
  169. package/src/index.ts +115 -0
  170. package/src/output/dedup.test.ts +419 -0
  171. package/src/output/dedup.ts +607 -0
  172. package/src/output/github-checks.test.ts +300 -0
  173. package/src/output/github-checks.ts +476 -0
  174. package/src/output/github-issues.ts +329 -0
  175. package/src/output/index.ts +5 -0
  176. package/src/output/issue-renderer.ts +197 -0
  177. package/src/output/renderer.test.ts +727 -0
  178. package/src/output/renderer.ts +217 -0
  179. package/src/output/stale.test.ts +375 -0
  180. package/src/output/stale.ts +155 -0
  181. package/src/output/types.ts +34 -0
  182. package/src/sdk/index.ts +1 -0
  183. package/src/sdk/runner.test.ts +806 -0
  184. package/src/sdk/runner.ts +1232 -0
  185. package/src/skills/index.ts +36 -0
  186. package/src/skills/loader.test.ts +300 -0
  187. package/src/skills/loader.ts +423 -0
  188. package/src/skills/remote.test.ts +704 -0
  189. package/src/skills/remote.ts +604 -0
  190. package/src/triggers/matcher.test.ts +277 -0
  191. package/src/triggers/matcher.ts +152 -0
  192. package/src/types/index.ts +194 -0
  193. package/src/utils/async.ts +18 -0
  194. package/src/utils/index.test.ts +84 -0
  195. package/src/utils/index.ts +50 -0
  196. package/tsconfig.json +25 -0
  197. package/vitest.config.ts +8 -0
  198. package/vitest.integration.config.ts +11 -0
  199. package/warden.toml +19 -0
@@ -0,0 +1,84 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import type { Trigger } from './schema.js';
3
+
4
+ /**
5
+ * Generate TOML representation of a trigger.
6
+ */
7
+ export function generateTriggerToml(trigger: Trigger): string {
8
+ const lines: string[] = ['[[triggers]]'];
9
+ lines.push(`name = "${trigger.name}"`);
10
+ lines.push(`event = "${trigger.event}"`);
11
+
12
+ // Format actions array (optional for schedule events)
13
+ if (trigger.actions && trigger.actions.length > 0) {
14
+ const actionsStr = trigger.actions.map((a) => `"${a}"`).join(', ');
15
+ lines.push(`actions = [${actionsStr}]`);
16
+ }
17
+
18
+ lines.push(`skill = "${trigger.skill}"`);
19
+
20
+ if (trigger.remote) {
21
+ lines.push(`remote = "${trigger.remote}"`);
22
+ }
23
+
24
+ // Optional fields
25
+ if (trigger.filters) {
26
+ if (trigger.filters.paths && trigger.filters.paths.length > 0) {
27
+ lines.push('');
28
+ lines.push('[triggers.filters]');
29
+ const pathsStr = trigger.filters.paths.map((p) => `"${p}"`).join(', ');
30
+ lines.push(`paths = [${pathsStr}]`);
31
+ }
32
+ if (trigger.filters.ignorePaths && trigger.filters.ignorePaths.length > 0) {
33
+ if (!trigger.filters.paths) {
34
+ lines.push('');
35
+ lines.push('[triggers.filters]');
36
+ }
37
+ const ignoreStr = trigger.filters.ignorePaths.map((p) => `"${p}"`).join(', ');
38
+ lines.push(`ignorePaths = [${ignoreStr}]`);
39
+ }
40
+ }
41
+
42
+ if (trigger.output) {
43
+ lines.push('');
44
+ lines.push('[triggers.output]');
45
+ if (trigger.output.failOn) {
46
+ lines.push(`failOn = "${trigger.output.failOn}"`);
47
+ }
48
+ if (trigger.output.commentOn) {
49
+ lines.push(`commentOn = "${trigger.output.commentOn}"`);
50
+ }
51
+ if (trigger.output.maxFindings) {
52
+ lines.push(`maxFindings = ${trigger.output.maxFindings}`);
53
+ }
54
+ }
55
+
56
+ if (trigger.model) {
57
+ lines.push(`model = "${trigger.model}"`);
58
+ }
59
+
60
+ return lines.join('\n');
61
+ }
62
+
63
+ /**
64
+ * Append a trigger to the warden.toml configuration file.
65
+ * Preserves existing content and formatting by appending to the end.
66
+ */
67
+ export function appendTrigger(configPath: string, trigger: Trigger): void {
68
+ const existingContent = readFileSync(configPath, 'utf-8');
69
+
70
+ // Ensure proper spacing before the new trigger
71
+ let separator: string;
72
+ if (existingContent.endsWith('\n\n')) {
73
+ separator = '';
74
+ } else if (existingContent.endsWith('\n')) {
75
+ separator = '\n';
76
+ } else {
77
+ separator = '\n\n';
78
+ }
79
+
80
+ const triggerToml = generateTriggerToml(trigger);
81
+ const newContent = existingContent + separator + triggerToml + '\n';
82
+
83
+ writeFileSync(configPath, newContent, 'utf-8');
84
+ }
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ classifyFile,
4
+ shouldSkipFile,
5
+ BUILTIN_SKIP_PATTERNS,
6
+ } from './classify.js';
7
+ import type { FilePattern } from '../config/schema.js';
8
+
9
+ describe('classifyFile', () => {
10
+ describe('built-in skip patterns', () => {
11
+ it.each([
12
+ 'pnpm-lock.yaml',
13
+ 'package-lock.json',
14
+ 'yarn.lock',
15
+ 'Cargo.lock',
16
+ 'go.sum',
17
+ 'poetry.lock',
18
+ 'composer.lock',
19
+ 'Gemfile.lock',
20
+ 'Pipfile.lock',
21
+ 'bun.lockb',
22
+ ])('skips lock file: %s', (filename) => {
23
+ expect(classifyFile(filename)).toBe('skip');
24
+ });
25
+
26
+ it.each([
27
+ 'src/pnpm-lock.yaml',
28
+ 'packages/web/package-lock.json',
29
+ 'nested/deep/yarn.lock',
30
+ ])('skips nested lock file: %s', (filename) => {
31
+ expect(classifyFile(filename)).toBe('skip');
32
+ });
33
+
34
+ it.each([
35
+ 'bundle.min.js',
36
+ 'styles.min.css',
37
+ 'vendor.bundle.js',
38
+ 'app.bundle.css',
39
+ ])('skips minified/bundled file: %s', (filename) => {
40
+ expect(classifyFile(filename)).toBe('skip');
41
+ });
42
+
43
+ it.each([
44
+ 'dist/index.js',
45
+ 'build/main.js',
46
+ 'node_modules/lodash/index.js',
47
+ '.next/static/chunks/main.js',
48
+ 'out/index.html',
49
+ 'coverage/lcov.info',
50
+ ])('skips build artifacts: %s', (filename) => {
51
+ expect(classifyFile(filename)).toBe('skip');
52
+ });
53
+
54
+ it.each([
55
+ 'types.generated.ts',
56
+ 'schema.g.ts',
57
+ 'model.g.dart',
58
+ 'generated/api.ts',
59
+ '__generated__/graphql.ts',
60
+ ])('skips generated files: %s', (filename) => {
61
+ expect(classifyFile(filename)).toBe('skip');
62
+ });
63
+ });
64
+
65
+ describe('non-skipped files', () => {
66
+ it.each([
67
+ 'src/index.ts',
68
+ 'lib/utils.js',
69
+ 'app/page.tsx',
70
+ 'server/routes.py',
71
+ 'main.go',
72
+ 'Cargo.toml', // toml, not lock
73
+ 'package.json', // json, not lock
74
+ 'README.md',
75
+ ])('processes normal source file: %s', (filename) => {
76
+ expect(classifyFile(filename)).toBe('per-hunk');
77
+ });
78
+ });
79
+
80
+ describe('user patterns', () => {
81
+ it('allows user pattern to override built-in skip', () => {
82
+ const userPatterns: FilePattern[] = [
83
+ { pattern: '**/pnpm-lock.yaml', mode: 'per-hunk' },
84
+ ];
85
+ expect(classifyFile('pnpm-lock.yaml', userPatterns)).toBe('per-hunk');
86
+ });
87
+
88
+ it('allows user pattern to skip custom files', () => {
89
+ const userPatterns: FilePattern[] = [
90
+ { pattern: '**/fixtures/**', mode: 'skip' },
91
+ ];
92
+ expect(classifyFile('src/fixtures/data.json', userPatterns)).toBe('skip');
93
+ });
94
+
95
+ it('supports whole-file mode', () => {
96
+ const userPatterns: FilePattern[] = [
97
+ { pattern: '**/*.sql', mode: 'whole-file' },
98
+ ];
99
+ expect(classifyFile('migrations/001.sql', userPatterns)).toBe('whole-file');
100
+ });
101
+
102
+ it('user patterns take precedence over built-ins', () => {
103
+ const userPatterns: FilePattern[] = [
104
+ { pattern: '**/dist/**', mode: 'per-hunk' }, // override built-in skip
105
+ ];
106
+ expect(classifyFile('dist/index.js', userPatterns)).toBe('per-hunk');
107
+ });
108
+
109
+ it('checks user patterns in order', () => {
110
+ const userPatterns: FilePattern[] = [
111
+ { pattern: '**/*.ts', mode: 'skip' },
112
+ { pattern: '**/index.ts', mode: 'per-hunk' },
113
+ ];
114
+ // First matching pattern wins
115
+ expect(classifyFile('src/index.ts', userPatterns)).toBe('skip');
116
+ });
117
+
118
+ it('falls back to built-ins if no user pattern matches', () => {
119
+ const userPatterns: FilePattern[] = [
120
+ { pattern: '**/*.custom', mode: 'skip' },
121
+ ];
122
+ expect(classifyFile('pnpm-lock.yaml', userPatterns)).toBe('skip');
123
+ });
124
+ });
125
+ });
126
+
127
+ describe('shouldSkipFile', () => {
128
+ it('returns true for skipped files', () => {
129
+ expect(shouldSkipFile('pnpm-lock.yaml')).toBe(true);
130
+ expect(shouldSkipFile('dist/bundle.js')).toBe(true);
131
+ });
132
+
133
+ it('returns false for non-skipped files', () => {
134
+ expect(shouldSkipFile('src/index.ts')).toBe(false);
135
+ expect(shouldSkipFile('package.json')).toBe(false);
136
+ });
137
+
138
+ it('respects user patterns', () => {
139
+ const userPatterns: FilePattern[] = [
140
+ { pattern: '**/pnpm-lock.yaml', mode: 'per-hunk' },
141
+ ];
142
+ expect(shouldSkipFile('pnpm-lock.yaml', userPatterns)).toBe(false);
143
+ });
144
+ });
145
+
146
+ describe('BUILTIN_SKIP_PATTERNS', () => {
147
+ it('includes common lock files', () => {
148
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/pnpm-lock.yaml');
149
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/package-lock.json');
150
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/yarn.lock');
151
+ });
152
+
153
+ it('includes minified files', () => {
154
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/*.min.js');
155
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/*.min.css');
156
+ });
157
+
158
+ it('includes build directories', () => {
159
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/dist/**');
160
+ expect(BUILTIN_SKIP_PATTERNS).toContain('**/node_modules/**');
161
+ });
162
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * File classification for chunking - determines how files should be processed
3
+ */
4
+
5
+ import { matchGlob } from '../triggers/matcher.js';
6
+ import type { FilePattern } from '../config/schema.js';
7
+
8
+ /** Processing mode for a file */
9
+ export type FileMode = 'per-hunk' | 'whole-file' | 'skip';
10
+
11
+ /**
12
+ * Built-in patterns that are always applied before user patterns.
13
+ * These skip common lock files, minified code, and build artifacts.
14
+ */
15
+ export const BUILTIN_SKIP_PATTERNS = [
16
+ // Package manager lock files
17
+ '**/pnpm-lock.yaml',
18
+ '**/package-lock.json',
19
+ '**/yarn.lock',
20
+ '**/Cargo.lock',
21
+ '**/go.sum',
22
+ '**/poetry.lock',
23
+ '**/composer.lock',
24
+ '**/Gemfile.lock',
25
+ '**/Pipfile.lock',
26
+ '**/bun.lockb',
27
+
28
+ // Minified/bundled code
29
+ '**/*.min.js',
30
+ '**/*.min.css',
31
+ '**/*.bundle.js',
32
+ '**/*.bundle.css',
33
+
34
+ // Build artifacts
35
+ '**/dist/**',
36
+ '**/build/**',
37
+ '**/node_modules/**',
38
+ '**/.next/**',
39
+ '**/out/**',
40
+ '**/coverage/**',
41
+
42
+ // Generated code
43
+ '**/*.generated.*',
44
+ '**/*.g.ts',
45
+ '**/*.g.dart',
46
+ '**/generated/**',
47
+ '**/__generated__/**',
48
+ ];
49
+
50
+ /**
51
+ * Classify a file to determine how it should be processed.
52
+ *
53
+ * @param filename - The file path to classify
54
+ * @param userPatterns - Optional user-defined patterns (can override built-ins)
55
+ * @returns The processing mode: 'per-hunk', 'whole-file', or 'skip'
56
+ *
57
+ * Order of precedence:
58
+ * 1. User patterns are checked first (higher priority, allows overriding built-ins)
59
+ * 2. Built-in skip patterns are checked second
60
+ * 3. Default is 'per-hunk' if no patterns match
61
+ */
62
+ export function classifyFile(
63
+ filename: string,
64
+ userPatterns?: FilePattern[]
65
+ ): FileMode {
66
+ // Check user patterns first (allows overriding built-in skips)
67
+ for (const { pattern, mode } of userPatterns ?? []) {
68
+ if (matchGlob(pattern, filename)) {
69
+ return mode;
70
+ }
71
+ }
72
+
73
+ // Check built-in skip patterns
74
+ for (const pattern of BUILTIN_SKIP_PATTERNS) {
75
+ if (matchGlob(pattern, filename)) {
76
+ return 'skip';
77
+ }
78
+ }
79
+
80
+ // Default: process per-hunk
81
+ return 'per-hunk';
82
+ }
83
+
84
+ /**
85
+ * Check if a file should be skipped based on classification.
86
+ */
87
+ export function shouldSkipFile(
88
+ filename: string,
89
+ userPatterns?: FilePattern[]
90
+ ): boolean {
91
+ return classifyFile(filename, userPatterns) === 'skip';
92
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { coalesceHunks, wouldCoalesceReduce } from './coalesce.js';
3
+ import type { DiffHunk } from './parser.js';
4
+
5
+ function makeHunk(
6
+ newStart: number,
7
+ newCount: number,
8
+ content: string,
9
+ options: { oldStart?: number; oldCount?: number; header?: string } = {}
10
+ ): DiffHunk {
11
+ const { oldStart = newStart, oldCount = newCount, header } = options;
12
+ return {
13
+ oldStart,
14
+ oldCount,
15
+ newStart,
16
+ newCount,
17
+ content,
18
+ lines: content.split('\n'),
19
+ header,
20
+ };
21
+ }
22
+
23
+ describe('coalesceHunks', () => {
24
+ describe('edge cases', () => {
25
+ it('returns empty array for empty input', () => {
26
+ expect(coalesceHunks([])).toEqual([]);
27
+ });
28
+
29
+ it('returns single hunk unchanged', () => {
30
+ const hunk = makeHunk(1, 5, 'test content');
31
+ expect(coalesceHunks([hunk])).toEqual([hunk]);
32
+ });
33
+ });
34
+
35
+ describe('merging nearby hunks', () => {
36
+ it('merges two adjacent hunks within gap limit', () => {
37
+ const hunk1 = makeHunk(1, 5, 'first');
38
+ const hunk2 = makeHunk(10, 5, 'second');
39
+
40
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 10 });
41
+ const [merged] = result;
42
+
43
+ expect(result).toHaveLength(1);
44
+ expect(merged!.newStart).toBe(1);
45
+ expect(merged!.newCount).toBe(14);
46
+ expect(merged!.content).toContain('first');
47
+ expect(merged!.content).toContain('...');
48
+ expect(merged!.content).toContain('second');
49
+ });
50
+
51
+ it('does not merge hunks beyond gap limit', () => {
52
+ const hunk1 = makeHunk(1, 5, 'first');
53
+ const hunk2 = makeHunk(50, 5, 'second');
54
+
55
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 30 });
56
+
57
+ expect(result).toHaveLength(2);
58
+ });
59
+
60
+ it('does not merge when combined size exceeds limit', () => {
61
+ const hunk1 = makeHunk(1, 5, 'a'.repeat(5000));
62
+ const hunk2 = makeHunk(10, 5, 'b'.repeat(5000));
63
+
64
+ const result = coalesceHunks([hunk1, hunk2], { maxChunkSize: 8000 });
65
+
66
+ expect(result).toHaveLength(2);
67
+ });
68
+
69
+ it('merges multiple hunks into one when all within limits', () => {
70
+ const hunks = [
71
+ makeHunk(1, 3, 'a'),
72
+ makeHunk(10, 3, 'b'),
73
+ makeHunk(20, 3, 'c'),
74
+ makeHunk(30, 3, 'd'),
75
+ ];
76
+
77
+ const result = coalesceHunks(hunks, { maxGapLines: 15, maxChunkSize: 10000 });
78
+
79
+ expect(result).toHaveLength(1);
80
+ expect(result[0]!.content).toContain('a');
81
+ expect(result[0]!.content).toContain('d');
82
+ });
83
+
84
+ it('creates multiple chunks when limits are reached', () => {
85
+ const hunks = [
86
+ makeHunk(1, 3, 'a'.repeat(3000)),
87
+ makeHunk(10, 3, 'b'.repeat(3000)),
88
+ makeHunk(20, 3, 'c'.repeat(3000)),
89
+ makeHunk(30, 3, 'd'.repeat(3000)),
90
+ ];
91
+
92
+ const result = coalesceHunks(hunks, { maxGapLines: 15, maxChunkSize: 8000 });
93
+
94
+ // First two can merge (6000 chars), third can't fit (9000 > 8000)
95
+ // So result should be: [a+b], [c+d]
96
+ expect(result).toHaveLength(2);
97
+ });
98
+ });
99
+
100
+ describe('sorting', () => {
101
+ it('sorts hunks by start line before merging', () => {
102
+ const hunks = [
103
+ makeHunk(20, 3, 'third'),
104
+ makeHunk(1, 3, 'first'),
105
+ makeHunk(10, 3, 'second'),
106
+ ];
107
+
108
+ const result = coalesceHunks(hunks, { maxGapLines: 15, maxChunkSize: 10000 });
109
+
110
+ expect(result).toHaveLength(1);
111
+ // Should be merged in order: first, second, third
112
+ const content = result[0]!.content;
113
+ const firstPos = content.indexOf('first');
114
+ const secondPos = content.indexOf('second');
115
+ const thirdPos = content.indexOf('third');
116
+ expect(firstPos).toBeLessThan(secondPos);
117
+ expect(secondPos).toBeLessThan(thirdPos);
118
+ });
119
+ });
120
+
121
+ describe('merged hunk properties', () => {
122
+ it('calculates correct line ranges', () => {
123
+ const hunk1 = makeHunk(10, 5, 'first', { oldStart: 8, oldCount: 5 });
124
+ const hunk2 = makeHunk(25, 10, 'second', { oldStart: 23, oldCount: 10 });
125
+
126
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 20 });
127
+
128
+ expect(result).toHaveLength(1);
129
+ expect(result[0]!.newStart).toBe(10);
130
+ expect(result[0]!.newCount).toBe(25); // 10 to 35 (25 + 10)
131
+ expect(result[0]!.oldStart).toBe(8);
132
+ expect(result[0]!.oldCount).toBe(25); // 8 to 33 (23 + 10)
133
+ });
134
+
135
+ it('combines different headers from both hunks', () => {
136
+ const hunk1 = makeHunk(1, 3, 'first', { header: 'function foo()' });
137
+ const hunk2 = makeHunk(10, 3, 'second', { header: 'function bar()' });
138
+
139
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 20 });
140
+
141
+ expect(result[0]!.header).toBe('function foo() → function bar()');
142
+ });
143
+
144
+ it('preserves single header when only first hunk has one', () => {
145
+ const hunk1 = makeHunk(1, 3, 'first', { header: 'function foo()' });
146
+ const hunk2 = makeHunk(10, 3, 'second');
147
+
148
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 20 });
149
+
150
+ expect(result[0]!.header).toBe('function foo()');
151
+ });
152
+
153
+ it('preserves single header when only second hunk has one', () => {
154
+ const hunk1 = makeHunk(1, 3, 'first');
155
+ const hunk2 = makeHunk(10, 3, 'second', { header: 'function bar()' });
156
+
157
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 20 });
158
+
159
+ expect(result[0]!.header).toBe('function bar()');
160
+ });
161
+
162
+ it('preserves header when both hunks have identical headers', () => {
163
+ const hunk1 = makeHunk(1, 3, 'first', { header: 'function foo()' });
164
+ const hunk2 = makeHunk(10, 3, 'second', { header: 'function foo()' });
165
+
166
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 20 });
167
+
168
+ expect(result[0]!.header).toBe('function foo()');
169
+ });
170
+
171
+ it('combines lines from all merged hunks', () => {
172
+ const hunk1 = makeHunk(1, 3, 'line1\nline2');
173
+ const hunk2 = makeHunk(10, 3, 'line3\nline4');
174
+
175
+ const result = coalesceHunks([hunk1, hunk2], { maxGapLines: 20 });
176
+
177
+ expect(result[0]!.lines).toEqual(['line1', 'line2', 'line3', 'line4']);
178
+ });
179
+ });
180
+
181
+ });
182
+
183
+ describe('wouldCoalesceReduce', () => {
184
+ it('returns false for empty array', () => {
185
+ expect(wouldCoalesceReduce([])).toBe(false);
186
+ });
187
+
188
+ it('returns false for single hunk', () => {
189
+ const hunk = makeHunk(1, 5, 'test');
190
+ expect(wouldCoalesceReduce([hunk])).toBe(false);
191
+ });
192
+
193
+ it('returns true when coalescing would reduce count', () => {
194
+ const hunks = [
195
+ makeHunk(1, 3, 'a'),
196
+ makeHunk(10, 3, 'b'),
197
+ ];
198
+ expect(wouldCoalesceReduce(hunks, { maxGapLines: 20 })).toBe(true);
199
+ });
200
+
201
+ it('returns false when coalescing would not reduce count', () => {
202
+ const hunks = [
203
+ makeHunk(1, 3, 'a'),
204
+ makeHunk(100, 3, 'b'), // Too far apart
205
+ ];
206
+ expect(wouldCoalesceReduce(hunks, { maxGapLines: 10 })).toBe(false);
207
+ });
208
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Hunk coalescing - merges nearby hunks into fewer, larger chunks
3
+ * to reduce the number of LLM API calls while keeping chunk sizes manageable.
4
+ */
5
+
6
+ import type { DiffHunk } from './parser.js';
7
+
8
+ /** Default maximum gap in lines between hunks to merge */
9
+ export const DEFAULT_MAX_GAP_LINES = 30;
10
+
11
+ /** Default maximum chunk size in characters */
12
+ export const DEFAULT_MAX_CHUNK_SIZE = 8000;
13
+
14
+ /**
15
+ * Options for coalescing hunks.
16
+ */
17
+ export interface CoalesceOptions {
18
+ /** Max lines gap between hunks to merge (default: 30) */
19
+ maxGapLines?: number;
20
+ /** Target max size per chunk in characters (default: 8000) */
21
+ maxChunkSize?: number;
22
+ }
23
+
24
+ /**
25
+ * Merge two adjacent hunks into one.
26
+ *
27
+ * The merged hunk spans from the start of the first hunk to the end of the second,
28
+ * with content combined using '...' as a visual separator. When both hunks have
29
+ * different headers (indicating different function/class scopes), both are preserved.
30
+ */
31
+ function mergeHunks(a: DiffHunk, b: DiffHunk): DiffHunk {
32
+ // Calculate the new range that spans both hunks
33
+ const newStart = Math.min(a.newStart, b.newStart);
34
+ const newEnd = Math.max(a.newStart + a.newCount, b.newStart + b.newCount);
35
+ const oldStart = Math.min(a.oldStart, b.oldStart);
36
+ const oldEnd = Math.max(a.oldStart + a.oldCount, b.oldStart + b.oldCount);
37
+
38
+ // Combine headers when both exist and are different
39
+ let header: string | undefined;
40
+ if (a.header && b.header && a.header !== b.header) {
41
+ header = `${a.header} → ${b.header}`;
42
+ } else {
43
+ header = a.header ?? b.header;
44
+ }
45
+
46
+ return {
47
+ oldStart,
48
+ oldCount: oldEnd - oldStart,
49
+ newStart,
50
+ newCount: newEnd - newStart,
51
+ header,
52
+ content: a.content + '\n...\n' + b.content,
53
+ lines: [...a.lines, ...b.lines],
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Calculate the gap in lines between two hunks.
59
+ * Returns the number of lines between the end of hunk A and the start of hunk B.
60
+ */
61
+ function calculateGap(a: DiffHunk, b: DiffHunk): number {
62
+ const aEnd = a.newStart + a.newCount;
63
+ return b.newStart - aEnd;
64
+ }
65
+
66
+ /**
67
+ * Coalesce hunks that are close together into larger chunks.
68
+ *
69
+ * This reduces the number of LLM API calls by merging nearby hunks,
70
+ * while respecting size limits to keep chunks manageable.
71
+ *
72
+ * @param hunks - Array of hunks to coalesce
73
+ * @param options - Coalescing options (maxGapLines, maxChunkSize)
74
+ * @returns Array of coalesced hunks (may be smaller than input)
75
+ *
76
+ * Algorithm:
77
+ * 1. Sort hunks by start line
78
+ * 2. For each hunk, check if it can be merged with the previous:
79
+ * - Gap between hunks <= maxGapLines
80
+ * - Combined size <= maxChunkSize
81
+ * 3. If both conditions are met, merge; otherwise start a new chunk
82
+ */
83
+ export function coalesceHunks(
84
+ hunks: DiffHunk[],
85
+ options: CoalesceOptions = {}
86
+ ): DiffHunk[] {
87
+ const { maxGapLines = DEFAULT_MAX_GAP_LINES, maxChunkSize = DEFAULT_MAX_CHUNK_SIZE } = options;
88
+
89
+ // Nothing to coalesce with 0 or 1 hunks
90
+ if (hunks.length <= 1) {
91
+ return hunks;
92
+ }
93
+
94
+ // Sort hunks by start line to ensure we process them in order
95
+ const sorted = [...hunks].sort((a, b) => a.newStart - b.newStart);
96
+
97
+ const result: DiffHunk[] = [];
98
+ // sorted[0] is guaranteed to exist since we checked hunks.length > 1 above
99
+ let current = sorted[0] as DiffHunk;
100
+
101
+ for (let i = 1; i < sorted.length; i++) {
102
+ const next = sorted[i] as DiffHunk;
103
+ const gap = calculateGap(current, next);
104
+ const combinedSize = current.content.length + next.content.length;
105
+
106
+ // Merge if: close enough AND combined size under limit
107
+ if (gap <= maxGapLines && combinedSize <= maxChunkSize) {
108
+ current = mergeHunks(current, next);
109
+ } else {
110
+ // Can't merge - save current and start a new chunk
111
+ result.push(current);
112
+ current = next;
113
+ }
114
+ }
115
+
116
+ // Don't forget the last chunk
117
+ result.push(current);
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Check if coalescing would reduce the number of hunks.
124
+ * Useful for deciding whether to show coalescing stats.
125
+ */
126
+ export function wouldCoalesceReduce(
127
+ hunks: DiffHunk[],
128
+ options: CoalesceOptions = {}
129
+ ): boolean {
130
+ if (hunks.length <= 1) return false;
131
+ const coalesced = coalesceHunks(hunks, options);
132
+ return coalesced.length < hunks.length;
133
+ }