@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.
- package/.agents/skills/find-bugs/SKILL.md +75 -0
- package/.agents/skills/vercel-react-best-practices/AGENTS.md +2934 -0
- package/.agents/skills/vercel-react-best-practices/SKILL.md +136 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
- package/.claude/settings.json +57 -0
- package/.claude/settings.local.json +88 -0
- package/.claude/skills/agent-prompt/SKILL.md +54 -0
- package/.claude/skills/agent-prompt/references/agentic-patterns.md +94 -0
- package/.claude/skills/agent-prompt/references/anti-patterns.md +140 -0
- package/.claude/skills/agent-prompt/references/context-design.md +124 -0
- package/.claude/skills/agent-prompt/references/core-principles.md +75 -0
- package/.claude/skills/agent-prompt/references/model-guidance.md +118 -0
- package/.claude/skills/agent-prompt/references/output-formats.md +98 -0
- package/.claude/skills/agent-prompt/references/skill-structure.md +115 -0
- package/.claude/skills/agent-prompt/references/system-prompts.md +115 -0
- package/.claude/skills/notseer/SKILL.md +131 -0
- package/.claude/skills/skill-writer/SKILL.md +140 -0
- package/.claude/skills/testing-guidelines/SKILL.md +132 -0
- package/.claude/skills/warden-skill/SKILL.md +250 -0
- package/.claude/skills/warden-skill/references/config-schema.md +133 -0
- package/.dex/config.toml +2 -0
- package/.github/workflows/ci.yml +33 -0
- package/.github/workflows/release.yml +54 -0
- package/.github/workflows/warden.yml +40 -0
- package/AGENTS.md +89 -0
- package/CONTRIBUTING.md +60 -0
- package/LICENSE +105 -0
- package/README.md +43 -0
- package/SPEC.md +263 -0
- package/action.yml +87 -0
- package/assets/favicon.png +0 -0
- package/assets/warden-icon-bw.svg +5 -0
- package/assets/warden-icon-purple.png +0 -0
- package/assets/warden-icon-purple.svg +5 -0
- package/docs/.claude/settings.local.json +11 -0
- package/docs/astro.config.mjs +43 -0
- package/docs/package.json +19 -0
- package/docs/pnpm-lock.yaml +4000 -0
- package/docs/public/favicon.svg +5 -0
- package/docs/src/components/Code.astro +141 -0
- package/docs/src/components/PackageManagerTabs.astro +183 -0
- package/docs/src/components/Terminal.astro +212 -0
- package/docs/src/layouts/Base.astro +380 -0
- package/docs/src/pages/cli.astro +167 -0
- package/docs/src/pages/config.astro +394 -0
- package/docs/src/pages/guide.astro +449 -0
- package/docs/src/pages/index.astro +490 -0
- package/docs/src/styles/global.css +551 -0
- package/docs/tsconfig.json +3 -0
- package/docs/vercel.json +5 -0
- package/eslint.config.js +33 -0
- package/package.json +73 -0
- package/src/action/index.ts +1 -0
- package/src/action/main.ts +868 -0
- package/src/cli/args.test.ts +477 -0
- package/src/cli/args.ts +415 -0
- package/src/cli/commands/add.ts +447 -0
- package/src/cli/commands/init.test.ts +136 -0
- package/src/cli/commands/init.ts +132 -0
- package/src/cli/commands/setup-app/browser.ts +38 -0
- package/src/cli/commands/setup-app/credentials.ts +45 -0
- package/src/cli/commands/setup-app/manifest.ts +48 -0
- package/src/cli/commands/setup-app/server.ts +172 -0
- package/src/cli/commands/setup-app.ts +156 -0
- package/src/cli/commands/sync.ts +114 -0
- package/src/cli/context.ts +131 -0
- package/src/cli/files.test.ts +155 -0
- package/src/cli/files.ts +89 -0
- package/src/cli/fix.test.ts +310 -0
- package/src/cli/fix.ts +387 -0
- package/src/cli/git.test.ts +119 -0
- package/src/cli/git.ts +318 -0
- package/src/cli/index.ts +14 -0
- package/src/cli/main.ts +672 -0
- package/src/cli/output/box.ts +235 -0
- package/src/cli/output/formatters.test.ts +187 -0
- package/src/cli/output/formatters.ts +269 -0
- package/src/cli/output/icons.ts +13 -0
- package/src/cli/output/index.ts +44 -0
- package/src/cli/output/ink-runner.tsx +337 -0
- package/src/cli/output/jsonl.test.ts +347 -0
- package/src/cli/output/jsonl.ts +126 -0
- package/src/cli/output/reporter.ts +435 -0
- package/src/cli/output/tasks.ts +374 -0
- package/src/cli/output/tty.test.ts +117 -0
- package/src/cli/output/tty.ts +60 -0
- package/src/cli/output/verbosity.test.ts +40 -0
- package/src/cli/output/verbosity.ts +31 -0
- package/src/cli/terminal.test.ts +148 -0
- package/src/cli/terminal.ts +301 -0
- package/src/config/index.ts +3 -0
- package/src/config/loader.test.ts +313 -0
- package/src/config/loader.ts +103 -0
- package/src/config/schema.ts +168 -0
- package/src/config/writer.test.ts +119 -0
- package/src/config/writer.ts +84 -0
- package/src/diff/classify.test.ts +162 -0
- package/src/diff/classify.ts +92 -0
- package/src/diff/coalesce.test.ts +208 -0
- package/src/diff/coalesce.ts +133 -0
- package/src/diff/context.test.ts +226 -0
- package/src/diff/context.ts +201 -0
- package/src/diff/index.ts +4 -0
- package/src/diff/parser.test.ts +212 -0
- package/src/diff/parser.ts +149 -0
- package/src/event/context.ts +132 -0
- package/src/event/index.ts +2 -0
- package/src/event/schedule-context.ts +101 -0
- package/src/examples/examples.integration.test.ts +66 -0
- package/src/examples/index.test.ts +101 -0
- package/src/examples/index.ts +122 -0
- package/src/examples/setup.ts +25 -0
- package/src/index.ts +115 -0
- package/src/output/dedup.test.ts +419 -0
- package/src/output/dedup.ts +607 -0
- package/src/output/github-checks.test.ts +300 -0
- package/src/output/github-checks.ts +476 -0
- package/src/output/github-issues.ts +329 -0
- package/src/output/index.ts +5 -0
- package/src/output/issue-renderer.ts +197 -0
- package/src/output/renderer.test.ts +727 -0
- package/src/output/renderer.ts +217 -0
- package/src/output/stale.test.ts +375 -0
- package/src/output/stale.ts +155 -0
- package/src/output/types.ts +34 -0
- package/src/sdk/index.ts +1 -0
- package/src/sdk/runner.test.ts +806 -0
- package/src/sdk/runner.ts +1232 -0
- package/src/skills/index.ts +36 -0
- package/src/skills/loader.test.ts +300 -0
- package/src/skills/loader.ts +423 -0
- package/src/skills/remote.test.ts +704 -0
- package/src/skills/remote.ts +604 -0
- package/src/triggers/matcher.test.ts +277 -0
- package/src/triggers/matcher.ts +152 -0
- package/src/types/index.ts +194 -0
- package/src/utils/async.ts +18 -0
- package/src/utils/index.test.ts +84 -0
- package/src/utils/index.ts +50 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +8 -0
- package/vitest.integration.config.ts +11 -0
- package/warden.toml +19 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
matchGlob,
|
|
4
|
+
matchTrigger,
|
|
5
|
+
shouldFail,
|
|
6
|
+
countFindingsAtOrAbove,
|
|
7
|
+
countSeverity,
|
|
8
|
+
clearGlobCache,
|
|
9
|
+
getGlobCacheSize,
|
|
10
|
+
} from './matcher.js';
|
|
11
|
+
import type { Trigger } from '../config/schema.js';
|
|
12
|
+
import { SEVERITY_ORDER } from '../types/index.js';
|
|
13
|
+
import type { EventContext, SkillReport } from '../types/index.js';
|
|
14
|
+
|
|
15
|
+
/** Test helper to create a SkillReport with given severities */
|
|
16
|
+
function makeReport(severities: string[]): SkillReport {
|
|
17
|
+
return {
|
|
18
|
+
skill: 'test',
|
|
19
|
+
summary: 'Test report',
|
|
20
|
+
findings: severities.map((s, i) => ({
|
|
21
|
+
id: `finding-${i}`,
|
|
22
|
+
severity: s as 'critical' | 'high' | 'medium' | 'low' | 'info',
|
|
23
|
+
title: `Finding ${i}`,
|
|
24
|
+
description: 'Test finding',
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('matchGlob', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
clearGlobCache();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('matches exact paths', () => {
|
|
35
|
+
expect(matchGlob('src/index.ts', 'src/index.ts')).toBe(true);
|
|
36
|
+
expect(matchGlob('src/index.ts', 'src/other.ts')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('matches single wildcard', () => {
|
|
40
|
+
expect(matchGlob('src/*.ts', 'src/index.ts')).toBe(true);
|
|
41
|
+
expect(matchGlob('src/*.ts', 'src/foo/index.ts')).toBe(false);
|
|
42
|
+
expect(matchGlob('*.ts', 'index.ts')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('matches double wildcard (globstar)', () => {
|
|
46
|
+
expect(matchGlob('src/**/*.ts', 'src/index.ts')).toBe(true);
|
|
47
|
+
expect(matchGlob('src/**/*.ts', 'src/foo/index.ts')).toBe(true);
|
|
48
|
+
expect(matchGlob('src/**/*.ts', 'src/foo/bar/index.ts')).toBe(true);
|
|
49
|
+
expect(matchGlob('**/*.ts', 'src/index.ts')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('matches question mark wildcard', () => {
|
|
53
|
+
expect(matchGlob('src/?.ts', 'src/a.ts')).toBe(true);
|
|
54
|
+
expect(matchGlob('src/?.ts', 'src/ab.ts')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('caches compiled patterns', () => {
|
|
58
|
+
matchGlob('src/*.ts', 'src/index.ts');
|
|
59
|
+
expect(getGlobCacheSize()).toBe(1);
|
|
60
|
+
|
|
61
|
+
// Same pattern should not increase cache size
|
|
62
|
+
matchGlob('src/*.ts', 'src/other.ts');
|
|
63
|
+
expect(getGlobCacheSize()).toBe(1);
|
|
64
|
+
|
|
65
|
+
// Different pattern should increase cache size
|
|
66
|
+
matchGlob('lib/*.js', 'lib/index.js');
|
|
67
|
+
expect(getGlobCacheSize()).toBe(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('evicts oldest entry when cache exceeds max size', () => {
|
|
71
|
+
// Fill cache with 1000 patterns
|
|
72
|
+
for (let i = 0; i < 1000; i++) {
|
|
73
|
+
matchGlob(`pattern${i}/*.ts`, `pattern${i}/file.ts`);
|
|
74
|
+
}
|
|
75
|
+
expect(getGlobCacheSize()).toBe(1000);
|
|
76
|
+
|
|
77
|
+
// Adding one more should evict the oldest
|
|
78
|
+
matchGlob('newpattern/*.ts', 'newpattern/file.ts');
|
|
79
|
+
expect(getGlobCacheSize()).toBe(1000);
|
|
80
|
+
|
|
81
|
+
// The first pattern should be evicted (cache miss will re-add it)
|
|
82
|
+
// We can verify this by checking the cache size doesn't increase
|
|
83
|
+
// when we add it back
|
|
84
|
+
const sizeBefore = getGlobCacheSize();
|
|
85
|
+
matchGlob('pattern0/*.ts', 'pattern0/file.ts');
|
|
86
|
+
expect(getGlobCacheSize()).toBe(sizeBefore);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('maintains LRU order by refreshing accessed entries', () => {
|
|
90
|
+
// Add patterns 0, 1, 2
|
|
91
|
+
matchGlob('pattern0/*.ts', 'pattern0/file.ts');
|
|
92
|
+
matchGlob('pattern1/*.ts', 'pattern1/file.ts');
|
|
93
|
+
matchGlob('pattern2/*.ts', 'pattern2/file.ts');
|
|
94
|
+
|
|
95
|
+
// Access pattern0 to make it most recently used
|
|
96
|
+
matchGlob('pattern0/*.ts', 'pattern0/file.ts');
|
|
97
|
+
|
|
98
|
+
// Fill cache to max (997 more patterns needed to reach 1000)
|
|
99
|
+
for (let i = 3; i < 1000; i++) {
|
|
100
|
+
matchGlob(`pattern${i}/*.ts`, `pattern${i}/file.ts`);
|
|
101
|
+
}
|
|
102
|
+
expect(getGlobCacheSize()).toBe(1000);
|
|
103
|
+
|
|
104
|
+
// Add one more - should evict pattern1 (oldest not-accessed)
|
|
105
|
+
matchGlob('newpattern/*.ts', 'newpattern/file.ts');
|
|
106
|
+
expect(getGlobCacheSize()).toBe(1000);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('matchTrigger', () => {
|
|
111
|
+
const baseContext: EventContext = {
|
|
112
|
+
eventType: 'pull_request',
|
|
113
|
+
action: 'opened',
|
|
114
|
+
repository: {
|
|
115
|
+
owner: 'test',
|
|
116
|
+
name: 'repo',
|
|
117
|
+
fullName: 'test/repo',
|
|
118
|
+
defaultBranch: 'main',
|
|
119
|
+
},
|
|
120
|
+
pullRequest: {
|
|
121
|
+
number: 1,
|
|
122
|
+
title: 'Test PR',
|
|
123
|
+
body: 'Test body',
|
|
124
|
+
author: 'user',
|
|
125
|
+
baseBranch: 'main',
|
|
126
|
+
headBranch: 'feature',
|
|
127
|
+
headSha: 'abc123',
|
|
128
|
+
files: [
|
|
129
|
+
{ filename: 'src/index.ts', status: 'modified', additions: 10, deletions: 5 },
|
|
130
|
+
{ filename: 'README.md', status: 'modified', additions: 2, deletions: 0 },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
repoPath: '/test/repo',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const baseTrigger: Trigger = {
|
|
137
|
+
name: 'test-trigger',
|
|
138
|
+
event: 'pull_request',
|
|
139
|
+
actions: ['opened', 'synchronize'],
|
|
140
|
+
skill: 'test-skill',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
it('matches when event and action match', () => {
|
|
144
|
+
expect(matchTrigger(baseTrigger, baseContext)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not match wrong event type', () => {
|
|
148
|
+
const trigger = { ...baseTrigger, event: 'issues' as const };
|
|
149
|
+
expect(matchTrigger(trigger, baseContext)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not match wrong action', () => {
|
|
153
|
+
const trigger = { ...baseTrigger, actions: ['closed'] };
|
|
154
|
+
expect(matchTrigger(trigger, baseContext)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('matches with path filter', () => {
|
|
158
|
+
const trigger = { ...baseTrigger, filters: { paths: ['src/**/*.ts'] } };
|
|
159
|
+
expect(matchTrigger(trigger, baseContext)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not match when no files match path filter', () => {
|
|
163
|
+
const trigger = { ...baseTrigger, filters: { paths: ['lib/**/*.ts'] } };
|
|
164
|
+
expect(matchTrigger(trigger, baseContext)).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('ignores files matching ignorePaths', () => {
|
|
168
|
+
const context = {
|
|
169
|
+
...baseContext,
|
|
170
|
+
pullRequest: {
|
|
171
|
+
...baseContext.pullRequest!,
|
|
172
|
+
files: [{ filename: 'README.md', status: 'modified' as const, additions: 1, deletions: 0 }],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const trigger = { ...baseTrigger, filters: { ignorePaths: ['*.md'] } };
|
|
176
|
+
expect(matchTrigger(trigger, context)).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('fails when path filters defined but filenames undefined', () => {
|
|
180
|
+
const context = {
|
|
181
|
+
...baseContext,
|
|
182
|
+
pullRequest: undefined,
|
|
183
|
+
};
|
|
184
|
+
const trigger = { ...baseTrigger, filters: { paths: ['src/**/*.ts'] } };
|
|
185
|
+
expect(matchTrigger(trigger, context)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('fails when ignorePaths defined but filenames undefined', () => {
|
|
189
|
+
const context = {
|
|
190
|
+
...baseContext,
|
|
191
|
+
pullRequest: undefined,
|
|
192
|
+
};
|
|
193
|
+
const trigger = { ...baseTrigger, filters: { ignorePaths: ['*.md'] } };
|
|
194
|
+
expect(matchTrigger(trigger, context)).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('fails when path filters defined but files array empty', () => {
|
|
198
|
+
const context = {
|
|
199
|
+
...baseContext,
|
|
200
|
+
pullRequest: {
|
|
201
|
+
...baseContext.pullRequest!,
|
|
202
|
+
files: [],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const trigger = { ...baseTrigger, filters: { paths: ['src/**/*.ts'] } };
|
|
206
|
+
expect(matchTrigger(trigger, context)).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('matches when no filters defined and filenames unavailable', () => {
|
|
210
|
+
const context = {
|
|
211
|
+
...baseContext,
|
|
212
|
+
pullRequest: undefined,
|
|
213
|
+
};
|
|
214
|
+
expect(matchTrigger(baseTrigger, context)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('shouldFail', () => {
|
|
219
|
+
it('returns true when findings meet threshold', () => {
|
|
220
|
+
expect(shouldFail(makeReport(['high']), 'high')).toBe(true);
|
|
221
|
+
expect(shouldFail(makeReport(['critical']), 'high')).toBe(true);
|
|
222
|
+
expect(shouldFail(makeReport(['medium']), 'medium')).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns false when findings below threshold', () => {
|
|
226
|
+
expect(shouldFail(makeReport(['low']), 'high')).toBe(false);
|
|
227
|
+
expect(shouldFail(makeReport(['info']), 'medium')).toBe(false);
|
|
228
|
+
expect(shouldFail(makeReport([]), 'info')).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('countFindingsAtOrAbove', () => {
|
|
233
|
+
it('counts findings at or above threshold', () => {
|
|
234
|
+
const report = makeReport(['critical', 'high', 'medium', 'low', 'info']);
|
|
235
|
+
expect(countFindingsAtOrAbove(report, 'critical')).toBe(1);
|
|
236
|
+
expect(countFindingsAtOrAbove(report, 'high')).toBe(2);
|
|
237
|
+
expect(countFindingsAtOrAbove(report, 'medium')).toBe(3);
|
|
238
|
+
expect(countFindingsAtOrAbove(report, 'low')).toBe(4);
|
|
239
|
+
expect(countFindingsAtOrAbove(report, 'info')).toBe(5);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('countSeverity', () => {
|
|
244
|
+
it('counts findings of specific severity across reports', () => {
|
|
245
|
+
const reports: SkillReport[] = [
|
|
246
|
+
{
|
|
247
|
+
skill: 'test1',
|
|
248
|
+
summary: 'Test',
|
|
249
|
+
findings: [
|
|
250
|
+
{ id: '1', severity: 'high', title: 'High 1', description: 'desc' },
|
|
251
|
+
{ id: '2', severity: 'medium', title: 'Medium 1', description: 'desc' },
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
skill: 'test2',
|
|
256
|
+
summary: 'Test',
|
|
257
|
+
findings: [
|
|
258
|
+
{ id: '3', severity: 'high', title: 'High 2', description: 'desc' },
|
|
259
|
+
{ id: '4', severity: 'high', title: 'High 3', description: 'desc' },
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
expect(countSeverity(reports, 'high')).toBe(3);
|
|
265
|
+
expect(countSeverity(reports, 'medium')).toBe(1);
|
|
266
|
+
expect(countSeverity(reports, 'low')).toBe(0);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('SEVERITY_ORDER', () => {
|
|
271
|
+
it('has correct ordering (lower = more severe)', () => {
|
|
272
|
+
expect(SEVERITY_ORDER.critical).toBeLessThan(SEVERITY_ORDER.high);
|
|
273
|
+
expect(SEVERITY_ORDER.high).toBeLessThan(SEVERITY_ORDER.medium);
|
|
274
|
+
expect(SEVERITY_ORDER.medium).toBeLessThan(SEVERITY_ORDER.low);
|
|
275
|
+
expect(SEVERITY_ORDER.low).toBeLessThan(SEVERITY_ORDER.info);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Trigger } from '../config/schema.js';
|
|
2
|
+
import { SEVERITY_ORDER } from '../types/index.js';
|
|
3
|
+
import type { EventContext, Severity, SeverityThreshold, SkillReport } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
/** Maximum number of patterns to cache (LRU eviction when exceeded) */
|
|
6
|
+
const GLOB_CACHE_MAX_SIZE = 1000;
|
|
7
|
+
|
|
8
|
+
/** Cache for compiled glob patterns with LRU eviction */
|
|
9
|
+
const globCache = new Map<string, RegExp>();
|
|
10
|
+
|
|
11
|
+
/** Clear the glob cache (useful for testing) */
|
|
12
|
+
export function clearGlobCache(): void {
|
|
13
|
+
globCache.clear();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Get current cache size (useful for testing) */
|
|
17
|
+
export function getGlobCacheSize(): number {
|
|
18
|
+
return globCache.size;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert a glob pattern to a regex (cached with LRU eviction).
|
|
23
|
+
*/
|
|
24
|
+
function globToRegex(pattern: string): RegExp {
|
|
25
|
+
const cached = globCache.get(pattern);
|
|
26
|
+
if (cached) {
|
|
27
|
+
// Move to end for LRU ordering (delete and re-add)
|
|
28
|
+
globCache.delete(pattern);
|
|
29
|
+
globCache.set(pattern, cached);
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Use placeholders to avoid replacement conflicts
|
|
34
|
+
let regexPattern = pattern
|
|
35
|
+
// First, replace glob patterns with placeholders
|
|
36
|
+
.replace(/\*\*\//g, '\0GLOBSTAR_SLASH\0')
|
|
37
|
+
.replace(/\*\*/g, '\0GLOBSTAR\0')
|
|
38
|
+
.replace(/\*/g, '\0STAR\0')
|
|
39
|
+
.replace(/\?/g, '\0QUESTION\0');
|
|
40
|
+
|
|
41
|
+
// Escape regex special characters
|
|
42
|
+
regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
43
|
+
|
|
44
|
+
// Replace placeholders with regex patterns
|
|
45
|
+
regexPattern = regexPattern
|
|
46
|
+
.replace(/\0GLOBSTAR_SLASH\0/g, '(?:.*/)?') // **/ matches zero or more directories
|
|
47
|
+
.replace(/\0GLOBSTAR\0/g, '.*') // ** matches anything
|
|
48
|
+
.replace(/\0STAR\0/g, '[^/]*') // * matches anything except /
|
|
49
|
+
.replace(/\0QUESTION\0/g, '[^/]'); // ? matches single char except /
|
|
50
|
+
|
|
51
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
52
|
+
|
|
53
|
+
// Evict oldest entry if cache is full
|
|
54
|
+
if (globCache.size >= GLOB_CACHE_MAX_SIZE) {
|
|
55
|
+
const oldestKey = globCache.keys().next().value;
|
|
56
|
+
if (oldestKey !== undefined) {
|
|
57
|
+
globCache.delete(oldestKey);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
globCache.set(pattern, regex);
|
|
62
|
+
return regex;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Match a glob pattern against a file path.
|
|
67
|
+
* Supports ** for recursive matching and * for single directory matching.
|
|
68
|
+
*/
|
|
69
|
+
export function matchGlob(pattern: string, path: string): boolean {
|
|
70
|
+
return globToRegex(pattern).test(path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a trigger matches the given event context.
|
|
75
|
+
*/
|
|
76
|
+
export function matchTrigger(trigger: Trigger, context: EventContext): boolean {
|
|
77
|
+
if (trigger.event !== context.eventType) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Schedule events don't have actions - they match based on whether
|
|
82
|
+
// any files match the paths filter (context was already built with matching files)
|
|
83
|
+
if (trigger.event === 'schedule') {
|
|
84
|
+
return (context.pullRequest?.files.length ?? 0) > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// For non-schedule events, actions must match
|
|
88
|
+
if (!trigger.actions?.includes(context.action)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const filenames = context.pullRequest?.files.map((f) => f.filename);
|
|
93
|
+
const pathPatterns = trigger.filters?.paths;
|
|
94
|
+
const ignorePatterns = trigger.filters?.ignorePaths;
|
|
95
|
+
|
|
96
|
+
// Fail trigger match when path filters are defined but filenames unavailable
|
|
97
|
+
// This prevents filters from being silently bypassed on API failures
|
|
98
|
+
if ((pathPatterns || ignorePatterns) && (!filenames || filenames.length === 0)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (pathPatterns && filenames) {
|
|
103
|
+
const hasMatch = filenames.some((file) =>
|
|
104
|
+
pathPatterns.some((pattern) => matchGlob(pattern, file))
|
|
105
|
+
);
|
|
106
|
+
if (!hasMatch) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (ignorePatterns && filenames) {
|
|
112
|
+
const allIgnored = filenames.every((file) =>
|
|
113
|
+
ignorePatterns.some((pattern) => matchGlob(pattern, file))
|
|
114
|
+
);
|
|
115
|
+
if (allIgnored) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a report has any findings at or above the given severity threshold.
|
|
125
|
+
* Returns false if failOn is 'off' (disabled).
|
|
126
|
+
*/
|
|
127
|
+
export function shouldFail(report: SkillReport, failOn: SeverityThreshold): boolean {
|
|
128
|
+
if (failOn === 'off') return false;
|
|
129
|
+
const threshold = SEVERITY_ORDER[failOn];
|
|
130
|
+
return report.findings.some((f) => SEVERITY_ORDER[f.severity] <= threshold);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Count findings at or above the given severity threshold.
|
|
135
|
+
* Returns 0 if failOn is 'off' (disabled).
|
|
136
|
+
*/
|
|
137
|
+
export function countFindingsAtOrAbove(report: SkillReport, failOn: SeverityThreshold): number {
|
|
138
|
+
if (failOn === 'off') return 0;
|
|
139
|
+
const threshold = SEVERITY_ORDER[failOn];
|
|
140
|
+
return report.findings.filter((f) => SEVERITY_ORDER[f.severity] <= threshold).length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Count findings of a specific severity across multiple reports.
|
|
145
|
+
*/
|
|
146
|
+
export function countSeverity(reports: SkillReport[], severity: Severity): number {
|
|
147
|
+
return reports.reduce(
|
|
148
|
+
(count, report) =>
|
|
149
|
+
count + report.findings.filter((f) => f.severity === severity).length,
|
|
150
|
+
0
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Severity levels for findings
|
|
4
|
+
export const SeveritySchema = z.enum(['critical', 'high', 'medium', 'low', 'info']);
|
|
5
|
+
export type Severity = z.infer<typeof SeveritySchema>;
|
|
6
|
+
|
|
7
|
+
// Confidence levels for findings
|
|
8
|
+
export const ConfidenceSchema = z.enum(['high', 'medium', 'low']);
|
|
9
|
+
export type Confidence = z.infer<typeof ConfidenceSchema>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Confidence order for comparison (lower = more confident).
|
|
13
|
+
* Single source of truth for confidence ordering across the codebase.
|
|
14
|
+
*/
|
|
15
|
+
export const CONFIDENCE_ORDER: Record<Confidence, number> = {
|
|
16
|
+
high: 0,
|
|
17
|
+
medium: 1,
|
|
18
|
+
low: 2,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Severity threshold for config options (includes 'off' to disable)
|
|
22
|
+
export const SeverityThresholdSchema = z.enum(['off', 'critical', 'high', 'medium', 'low', 'info']);
|
|
23
|
+
export type SeverityThreshold = z.infer<typeof SeverityThresholdSchema>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Severity order for comparison (lower = more severe).
|
|
27
|
+
* Single source of truth for severity ordering across the codebase.
|
|
28
|
+
*/
|
|
29
|
+
export const SEVERITY_ORDER: Record<Severity, number> = {
|
|
30
|
+
critical: 0,
|
|
31
|
+
high: 1,
|
|
32
|
+
medium: 2,
|
|
33
|
+
low: 3,
|
|
34
|
+
info: 4,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Filter findings to only include those at or above the given severity threshold.
|
|
39
|
+
* If no threshold is provided, returns all findings unchanged.
|
|
40
|
+
* If threshold is 'off', returns empty array (disabled).
|
|
41
|
+
*/
|
|
42
|
+
export function filterFindingsBySeverity(findings: Finding[], threshold?: SeverityThreshold): Finding[] {
|
|
43
|
+
if (!threshold) return findings;
|
|
44
|
+
if (threshold === 'off') return [];
|
|
45
|
+
const thresholdOrder = SEVERITY_ORDER[threshold];
|
|
46
|
+
return findings.filter((f) => SEVERITY_ORDER[f.severity] <= thresholdOrder);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Location within a file
|
|
50
|
+
export const LocationSchema = z.object({
|
|
51
|
+
path: z.string(),
|
|
52
|
+
startLine: z.number().int().positive(),
|
|
53
|
+
endLine: z.number().int().positive().optional(),
|
|
54
|
+
});
|
|
55
|
+
export type Location = z.infer<typeof LocationSchema>;
|
|
56
|
+
|
|
57
|
+
// Suggested fix with diff
|
|
58
|
+
export const SuggestedFixSchema = z.object({
|
|
59
|
+
description: z.string(),
|
|
60
|
+
diff: z.string(),
|
|
61
|
+
});
|
|
62
|
+
export type SuggestedFix = z.infer<typeof SuggestedFixSchema>;
|
|
63
|
+
|
|
64
|
+
// Individual finding from a skill
|
|
65
|
+
export const FindingSchema = z.object({
|
|
66
|
+
id: z.string(),
|
|
67
|
+
severity: SeveritySchema,
|
|
68
|
+
confidence: ConfidenceSchema.optional(),
|
|
69
|
+
title: z.string(),
|
|
70
|
+
description: z.string(),
|
|
71
|
+
location: LocationSchema.optional(),
|
|
72
|
+
suggestedFix: SuggestedFixSchema.optional(),
|
|
73
|
+
elapsedMs: z.number().nonnegative().optional(),
|
|
74
|
+
});
|
|
75
|
+
export type Finding = z.infer<typeof FindingSchema>;
|
|
76
|
+
|
|
77
|
+
// Usage statistics from SDK
|
|
78
|
+
export const UsageStatsSchema = z.object({
|
|
79
|
+
inputTokens: z.number().int().nonnegative(),
|
|
80
|
+
outputTokens: z.number().int().nonnegative(),
|
|
81
|
+
cacheReadInputTokens: z.number().int().nonnegative().optional(),
|
|
82
|
+
cacheCreationInputTokens: z.number().int().nonnegative().optional(),
|
|
83
|
+
costUSD: z.number().nonnegative(),
|
|
84
|
+
});
|
|
85
|
+
export type UsageStats = z.infer<typeof UsageStatsSchema>;
|
|
86
|
+
|
|
87
|
+
// Skipped file info for chunking
|
|
88
|
+
export const SkippedFileSchema = z.object({
|
|
89
|
+
filename: z.string(),
|
|
90
|
+
reason: z.enum(['pattern', 'builtin']),
|
|
91
|
+
pattern: z.string().optional(),
|
|
92
|
+
});
|
|
93
|
+
export type SkippedFile = z.infer<typeof SkippedFileSchema>;
|
|
94
|
+
|
|
95
|
+
// Skill report output
|
|
96
|
+
export const SkillReportSchema = z.object({
|
|
97
|
+
skill: z.string(),
|
|
98
|
+
summary: z.string(),
|
|
99
|
+
findings: z.array(FindingSchema),
|
|
100
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
101
|
+
durationMs: z.number().nonnegative().optional(),
|
|
102
|
+
usage: UsageStatsSchema.optional(),
|
|
103
|
+
/** Files that were skipped due to chunking patterns */
|
|
104
|
+
skippedFiles: z.array(SkippedFileSchema).optional(),
|
|
105
|
+
/** Number of hunks that failed to analyze (SDK errors, API errors, etc.) */
|
|
106
|
+
failedHunks: z.number().int().nonnegative().optional(),
|
|
107
|
+
});
|
|
108
|
+
export type SkillReport = z.infer<typeof SkillReportSchema>;
|
|
109
|
+
|
|
110
|
+
// GitHub event types
|
|
111
|
+
export const GitHubEventTypeSchema = z.enum([
|
|
112
|
+
'pull_request',
|
|
113
|
+
'issues',
|
|
114
|
+
'issue_comment',
|
|
115
|
+
'pull_request_review',
|
|
116
|
+
'pull_request_review_comment',
|
|
117
|
+
'schedule',
|
|
118
|
+
]);
|
|
119
|
+
export type GitHubEventType = z.infer<typeof GitHubEventTypeSchema>;
|
|
120
|
+
|
|
121
|
+
// Pull request actions
|
|
122
|
+
export const PullRequestActionSchema = z.enum([
|
|
123
|
+
'opened',
|
|
124
|
+
'synchronize',
|
|
125
|
+
'reopened',
|
|
126
|
+
'closed',
|
|
127
|
+
]);
|
|
128
|
+
export type PullRequestAction = z.infer<typeof PullRequestActionSchema>;
|
|
129
|
+
|
|
130
|
+
// File change info
|
|
131
|
+
export const FileChangeSchema = z.object({
|
|
132
|
+
filename: z.string(),
|
|
133
|
+
status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']),
|
|
134
|
+
additions: z.number().int().nonnegative(),
|
|
135
|
+
deletions: z.number().int().nonnegative(),
|
|
136
|
+
patch: z.string().optional(),
|
|
137
|
+
chunks: z.number().int().nonnegative().optional(),
|
|
138
|
+
});
|
|
139
|
+
export type FileChange = z.infer<typeof FileChangeSchema>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Count the number of chunks/hunks in a patch string.
|
|
143
|
+
* Each chunk starts with @@ -X,Y +A,B @@
|
|
144
|
+
*/
|
|
145
|
+
export function countPatchChunks(patch: string | undefined): number {
|
|
146
|
+
if (!patch) return 0;
|
|
147
|
+
const matches = patch.match(/^@@\s/gm);
|
|
148
|
+
return matches?.length ?? 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Pull request context
|
|
152
|
+
export const PullRequestContextSchema = z.object({
|
|
153
|
+
number: z.number().int().positive(),
|
|
154
|
+
title: z.string(),
|
|
155
|
+
body: z.string().nullable(),
|
|
156
|
+
author: z.string(),
|
|
157
|
+
baseBranch: z.string(),
|
|
158
|
+
headBranch: z.string(),
|
|
159
|
+
headSha: z.string(),
|
|
160
|
+
files: z.array(FileChangeSchema),
|
|
161
|
+
});
|
|
162
|
+
export type PullRequestContext = z.infer<typeof PullRequestContextSchema>;
|
|
163
|
+
|
|
164
|
+
// Repository context
|
|
165
|
+
export const RepositoryContextSchema = z.object({
|
|
166
|
+
owner: z.string(),
|
|
167
|
+
name: z.string(),
|
|
168
|
+
fullName: z.string(),
|
|
169
|
+
defaultBranch: z.string(),
|
|
170
|
+
});
|
|
171
|
+
export type RepositoryContext = z.infer<typeof RepositoryContextSchema>;
|
|
172
|
+
|
|
173
|
+
// Full event context
|
|
174
|
+
export const EventContextSchema = z.object({
|
|
175
|
+
eventType: GitHubEventTypeSchema,
|
|
176
|
+
action: z.string(),
|
|
177
|
+
repository: RepositoryContextSchema,
|
|
178
|
+
pullRequest: PullRequestContextSchema.optional(),
|
|
179
|
+
repoPath: z.string(),
|
|
180
|
+
});
|
|
181
|
+
export type EventContext = z.infer<typeof EventContextSchema>;
|
|
182
|
+
|
|
183
|
+
// Retry configuration for SDK calls
|
|
184
|
+
export const RetryConfigSchema = z.object({
|
|
185
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
186
|
+
maxRetries: z.number().int().nonnegative().default(3),
|
|
187
|
+
/** Initial delay in milliseconds before first retry (default: 1000) */
|
|
188
|
+
initialDelayMs: z.number().int().positive().default(1000),
|
|
189
|
+
/** Multiplier for exponential backoff (default: 2) */
|
|
190
|
+
backoffMultiplier: z.number().positive().default(2),
|
|
191
|
+
/** Maximum delay in milliseconds between retries (default: 30000) */
|
|
192
|
+
maxDelayMs: z.number().int().positive().default(30000),
|
|
193
|
+
});
|
|
194
|
+
export type RetryConfig = z.infer<typeof RetryConfigSchema>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process items with limited concurrency using chunked batches.
|
|
3
|
+
*/
|
|
4
|
+
export async function processInBatches<T, R>(
|
|
5
|
+
items: T[],
|
|
6
|
+
fn: (item: T) => Promise<R>,
|
|
7
|
+
batchSize: number
|
|
8
|
+
): Promise<R[]> {
|
|
9
|
+
const results: R[] = [];
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
12
|
+
const batch = items.slice(i, i + batchSize);
|
|
13
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
14
|
+
results.push(...batchResults);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return results;
|
|
18
|
+
}
|