@qball-inc/the-bulwark 1.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/.claude-plugin/plugin.json +43 -0
- package/agents/bulwark-fix-validator.md +633 -0
- package/agents/bulwark-implementer.md +391 -0
- package/agents/bulwark-issue-analyzer.md +308 -0
- package/agents/bulwark-standards-reviewer.md +221 -0
- package/agents/plan-creation-architect.md +323 -0
- package/agents/plan-creation-eng-lead.md +352 -0
- package/agents/plan-creation-po.md +300 -0
- package/agents/plan-creation-qa-critic.md +334 -0
- package/agents/product-ideation-competitive-analyzer.md +298 -0
- package/agents/product-ideation-idea-validator.md +268 -0
- package/agents/product-ideation-market-researcher.md +292 -0
- package/agents/product-ideation-pattern-documenter.md +308 -0
- package/agents/product-ideation-segment-analyzer.md +303 -0
- package/agents/product-ideation-strategist.md +259 -0
- package/agents/statusline-setup.md +97 -0
- package/hooks/hooks.json +59 -0
- package/package.json +45 -0
- package/scripts/hooks/cleanup-stale.sh +13 -0
- package/scripts/hooks/enforce-quality.sh +166 -0
- package/scripts/hooks/implementer-quality.sh +256 -0
- package/scripts/hooks/inject-protocol.sh +52 -0
- package/scripts/hooks/suggest-pipeline.sh +175 -0
- package/scripts/hooks/track-pipeline-start.sh +37 -0
- package/scripts/hooks/track-pipeline-stop.sh +52 -0
- package/scripts/init-rules.sh +35 -0
- package/scripts/init.sh +151 -0
- package/skills/anthropic-validator/SKILL.md +607 -0
- package/skills/anthropic-validator/references/agents-checklist.md +131 -0
- package/skills/anthropic-validator/references/commands-checklist.md +102 -0
- package/skills/anthropic-validator/references/hooks-checklist.md +151 -0
- package/skills/anthropic-validator/references/mcp-checklist.md +136 -0
- package/skills/anthropic-validator/references/plugins-checklist.md +148 -0
- package/skills/anthropic-validator/references/skills-checklist.md +85 -0
- package/skills/assertion-patterns/SKILL.md +296 -0
- package/skills/bug-magnet-data/SKILL.md +284 -0
- package/skills/bug-magnet-data/context/cli-args.md +91 -0
- package/skills/bug-magnet-data/context/db-query.md +104 -0
- package/skills/bug-magnet-data/context/file-contents.md +103 -0
- package/skills/bug-magnet-data/context/http-body.md +91 -0
- package/skills/bug-magnet-data/context/process-spawn.md +123 -0
- package/skills/bug-magnet-data/data/booleans/boundaries.yaml +143 -0
- package/skills/bug-magnet-data/data/collections/arrays.yaml +114 -0
- package/skills/bug-magnet-data/data/collections/objects.yaml +123 -0
- package/skills/bug-magnet-data/data/concurrency/race-conditions.yaml +118 -0
- package/skills/bug-magnet-data/data/concurrency/state-machines.yaml +115 -0
- package/skills/bug-magnet-data/data/dates/boundaries.yaml +137 -0
- package/skills/bug-magnet-data/data/dates/invalid.yaml +132 -0
- package/skills/bug-magnet-data/data/dates/timezone.yaml +118 -0
- package/skills/bug-magnet-data/data/encoding/charset.yaml +79 -0
- package/skills/bug-magnet-data/data/encoding/normalization.yaml +105 -0
- package/skills/bug-magnet-data/data/formats/email.yaml +154 -0
- package/skills/bug-magnet-data/data/formats/json.yaml +187 -0
- package/skills/bug-magnet-data/data/formats/url.yaml +165 -0
- package/skills/bug-magnet-data/data/language-specific/javascript.yaml +182 -0
- package/skills/bug-magnet-data/data/language-specific/python.yaml +174 -0
- package/skills/bug-magnet-data/data/language-specific/rust.yaml +148 -0
- package/skills/bug-magnet-data/data/numbers/boundaries.yaml +161 -0
- package/skills/bug-magnet-data/data/numbers/precision.yaml +89 -0
- package/skills/bug-magnet-data/data/numbers/special.yaml +69 -0
- package/skills/bug-magnet-data/data/strings/boundaries.yaml +109 -0
- package/skills/bug-magnet-data/data/strings/injection.yaml +208 -0
- package/skills/bug-magnet-data/data/strings/special-chars.yaml +190 -0
- package/skills/bug-magnet-data/data/strings/unicode.yaml +139 -0
- package/skills/bug-magnet-data/references/external-lists.md +115 -0
- package/skills/bulwark-brainstorm/SKILL.md +563 -0
- package/skills/bulwark-brainstorm/references/at-teammate-prompts.md +60 -0
- package/skills/bulwark-brainstorm/references/role-critical-analyst.md +78 -0
- package/skills/bulwark-brainstorm/references/role-development-lead.md +66 -0
- package/skills/bulwark-brainstorm/references/role-product-delivery-lead.md +79 -0
- package/skills/bulwark-brainstorm/references/role-product-manager.md +62 -0
- package/skills/bulwark-brainstorm/references/role-project-sme.md +59 -0
- package/skills/bulwark-brainstorm/references/role-technical-architect.md +66 -0
- package/skills/bulwark-research/SKILL.md +298 -0
- package/skills/bulwark-research/references/viewpoint-contrarian.md +63 -0
- package/skills/bulwark-research/references/viewpoint-direct-investigation.md +62 -0
- package/skills/bulwark-research/references/viewpoint-first-principles.md +65 -0
- package/skills/bulwark-research/references/viewpoint-practitioner.md +62 -0
- package/skills/bulwark-research/references/viewpoint-prior-art.md +66 -0
- package/skills/bulwark-scaffold/SKILL.md +330 -0
- package/skills/bulwark-statusline/SKILL.md +161 -0
- package/skills/bulwark-statusline/scripts/statusline.sh +144 -0
- package/skills/bulwark-verify/SKILL.md +519 -0
- package/skills/code-review/SKILL.md +428 -0
- package/skills/code-review/examples/anti-patterns/linting.ts +181 -0
- package/skills/code-review/examples/anti-patterns/security.ts +91 -0
- package/skills/code-review/examples/anti-patterns/standards.ts +195 -0
- package/skills/code-review/examples/anti-patterns/type-safety.ts +108 -0
- package/skills/code-review/examples/recommended/linting.ts +195 -0
- package/skills/code-review/examples/recommended/security.ts +154 -0
- package/skills/code-review/examples/recommended/standards.ts +231 -0
- package/skills/code-review/examples/recommended/type-safety.ts +181 -0
- package/skills/code-review/frameworks/angular.md +218 -0
- package/skills/code-review/frameworks/django.md +235 -0
- package/skills/code-review/frameworks/express.md +207 -0
- package/skills/code-review/frameworks/flask.md +298 -0
- package/skills/code-review/frameworks/generic.md +146 -0
- package/skills/code-review/frameworks/react.md +152 -0
- package/skills/code-review/frameworks/vue.md +244 -0
- package/skills/code-review/references/linting-patterns.md +221 -0
- package/skills/code-review/references/security-patterns.md +125 -0
- package/skills/code-review/references/standards-patterns.md +246 -0
- package/skills/code-review/references/type-safety-patterns.md +130 -0
- package/skills/component-patterns/SKILL.md +131 -0
- package/skills/component-patterns/references/pattern-cli-command.md +118 -0
- package/skills/component-patterns/references/pattern-database.md +166 -0
- package/skills/component-patterns/references/pattern-external-api.md +139 -0
- package/skills/component-patterns/references/pattern-file-parser.md +168 -0
- package/skills/component-patterns/references/pattern-http-server.md +162 -0
- package/skills/component-patterns/references/pattern-process-spawner.md +133 -0
- package/skills/continuous-feedback/SKILL.md +327 -0
- package/skills/continuous-feedback/references/collect-instructions.md +81 -0
- package/skills/continuous-feedback/references/specialize-code-review.md +82 -0
- package/skills/continuous-feedback/references/specialize-general.md +98 -0
- package/skills/continuous-feedback/references/specialize-test-audit.md +81 -0
- package/skills/create-skill/SKILL.md +359 -0
- package/skills/create-skill/references/agent-conventions.md +194 -0
- package/skills/create-skill/references/agent-template.md +195 -0
- package/skills/create-skill/references/content-guidance.md +291 -0
- package/skills/create-skill/references/decision-framework.md +124 -0
- package/skills/create-skill/references/template-pipeline.md +217 -0
- package/skills/create-skill/references/template-reference-heavy.md +111 -0
- package/skills/create-skill/references/template-research.md +210 -0
- package/skills/create-skill/references/template-script-driven.md +172 -0
- package/skills/create-skill/references/template-simple.md +80 -0
- package/skills/create-subagent/SKILL.md +353 -0
- package/skills/create-subagent/references/agent-conventions.md +268 -0
- package/skills/create-subagent/references/content-guidance.md +232 -0
- package/skills/create-subagent/references/decision-framework.md +134 -0
- package/skills/create-subagent/references/template-single-agent.md +192 -0
- package/skills/fix-bug/SKILL.md +241 -0
- package/skills/governance-protocol/SKILL.md +116 -0
- package/skills/init/SKILL.md +341 -0
- package/skills/issue-debugging/SKILL.md +385 -0
- package/skills/issue-debugging/references/anti-patterns.md +245 -0
- package/skills/issue-debugging/references/debug-report-schema.md +227 -0
- package/skills/mock-detection/SKILL.md +511 -0
- package/skills/mock-detection/references/false-positive-prevention.md +402 -0
- package/skills/mock-detection/references/stub-patterns.md +236 -0
- package/skills/pipeline-templates/SKILL.md +215 -0
- package/skills/pipeline-templates/references/code-change-workflow.md +277 -0
- package/skills/pipeline-templates/references/code-review.md +336 -0
- package/skills/pipeline-templates/references/fix-validation.md +421 -0
- package/skills/pipeline-templates/references/new-feature.md +335 -0
- package/skills/pipeline-templates/references/research-brainstorm.md +161 -0
- package/skills/pipeline-templates/references/research-planning.md +257 -0
- package/skills/pipeline-templates/references/test-audit.md +389 -0
- package/skills/pipeline-templates/references/test-execution-fix.md +238 -0
- package/skills/plan-creation/SKILL.md +497 -0
- package/skills/product-ideation/SKILL.md +372 -0
- package/skills/product-ideation/references/analysis-frameworks.md +161 -0
- package/skills/session-handoff/SKILL.md +139 -0
- package/skills/session-handoff/references/examples.md +223 -0
- package/skills/setup-lsp/SKILL.md +312 -0
- package/skills/setup-lsp/references/server-registry.md +85 -0
- package/skills/setup-lsp/references/troubleshooting.md +135 -0
- package/skills/subagent-output-templating/SKILL.md +415 -0
- package/skills/subagent-output-templating/references/examples.md +440 -0
- package/skills/subagent-prompting/SKILL.md +364 -0
- package/skills/subagent-prompting/references/examples.md +342 -0
- package/skills/test-audit/SKILL.md +531 -0
- package/skills/test-audit/references/known-limitations.md +41 -0
- package/skills/test-audit/references/priority-classification.md +30 -0
- package/skills/test-audit/references/prompts/deep-mode-detection.md +83 -0
- package/skills/test-audit/references/prompts/synthesis.md +57 -0
- package/skills/test-audit/references/rewrite-instructions.md +46 -0
- package/skills/test-audit/references/schemas/audit-output.yaml +100 -0
- package/skills/test-audit/references/schemas/diagnostic-output.yaml +49 -0
- package/skills/test-audit/scripts/data-flow-analyzer.ts +509 -0
- package/skills/test-audit/scripts/integration-mock-detector.ts +462 -0
- package/skills/test-audit/scripts/package.json +20 -0
- package/skills/test-audit/scripts/skip-detector.ts +211 -0
- package/skills/test-audit/scripts/verification-counter.ts +295 -0
- package/skills/test-classification/SKILL.md +310 -0
- package/skills/test-fixture-creation/SKILL.md +295 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { Project, Node, CallExpression, SourceFile, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
interface SectionInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
type: 'integration' | 'e2e';
|
|
10
|
+
signal: 'keyword_in_name' | 'comment_header' | 'inherited';
|
|
11
|
+
line_start: number;
|
|
12
|
+
line_end: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface MockLead {
|
|
16
|
+
line: number;
|
|
17
|
+
type: 'T3';
|
|
18
|
+
confidence: 'high' | 'medium';
|
|
19
|
+
mock_pattern: string;
|
|
20
|
+
enclosing_block: string;
|
|
21
|
+
block_type: 'integration' | 'e2e';
|
|
22
|
+
message: string;
|
|
23
|
+
suggestion: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FileResult {
|
|
27
|
+
file: string;
|
|
28
|
+
sections: SectionInfo[];
|
|
29
|
+
leads: MockLead[];
|
|
30
|
+
summary: {
|
|
31
|
+
sections_found: number;
|
|
32
|
+
integration_sections: number;
|
|
33
|
+
e2e_sections: number;
|
|
34
|
+
leads_count: number;
|
|
35
|
+
mock_calls_in_integration: number;
|
|
36
|
+
mock_calls_in_e2e: number;
|
|
37
|
+
file_scope_mocks: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ErrorOutput {
|
|
42
|
+
error: string;
|
|
43
|
+
file: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Constants ---
|
|
47
|
+
|
|
48
|
+
// Keywords that signal integration or e2e test sections (case-insensitive)
|
|
49
|
+
const INTEGRATION_KEYWORDS = /\b(integration|integ)\b/i;
|
|
50
|
+
const E2E_KEYWORDS = /\b(e2e|end[- ]?to[- ]?end|acceptance|system)\b/i;
|
|
51
|
+
|
|
52
|
+
// Comment patterns for section headers
|
|
53
|
+
const COMMENT_INTEGRATION_PATTERN = /\b(integration\s*test|integration)\b/i;
|
|
54
|
+
const COMMENT_E2E_PATTERN = /\b(e2e|end[- ]?to[- ]?end|acceptance\s*test|system\s*test)\b/i;
|
|
55
|
+
|
|
56
|
+
// Mock framework patterns — property access form (obj.method)
|
|
57
|
+
const MOCK_PROPERTY_PATTERNS: Record<string, Record<string, string>> = {
|
|
58
|
+
jest: {
|
|
59
|
+
fn: 'jest.fn()',
|
|
60
|
+
mock: 'jest.mock()',
|
|
61
|
+
spyOn: 'jest.spyOn()',
|
|
62
|
+
},
|
|
63
|
+
vi: {
|
|
64
|
+
fn: 'vi.fn()',
|
|
65
|
+
mock: 'vi.mock()',
|
|
66
|
+
spyOn: 'vi.spyOn()',
|
|
67
|
+
},
|
|
68
|
+
sinon: {
|
|
69
|
+
stub: 'sinon.stub()',
|
|
70
|
+
mock: 'sinon.mock()',
|
|
71
|
+
fake: 'sinon.fake()',
|
|
72
|
+
spy: 'sinon.spy()',
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// --- Section Classification ---
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Classify a describe block's test type based on its name text.
|
|
80
|
+
*/
|
|
81
|
+
function classifyByName(name: string): 'integration' | 'e2e' | null {
|
|
82
|
+
if (E2E_KEYWORDS.test(name)) return 'e2e';
|
|
83
|
+
if (INTEGRATION_KEYWORDS.test(name)) return 'integration';
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check preceding comments/siblings for section header markers.
|
|
89
|
+
* Looks at comments immediately before a describe() call.
|
|
90
|
+
*/
|
|
91
|
+
function classifyByPrecedingComments(node: Node): 'integration' | 'e2e' | null {
|
|
92
|
+
// Walk backward through siblings looking for comments
|
|
93
|
+
const parent = node.getParent();
|
|
94
|
+
if (!parent) return null;
|
|
95
|
+
|
|
96
|
+
const children = parent.getChildren();
|
|
97
|
+
const nodeIndex = children.indexOf(node);
|
|
98
|
+
|
|
99
|
+
// Check up to 3 siblings before this node for comments
|
|
100
|
+
for (let i = nodeIndex - 1; i >= Math.max(0, nodeIndex - 3); i--) {
|
|
101
|
+
const sibling = children[i];
|
|
102
|
+
const kind = sibling.getKind();
|
|
103
|
+
|
|
104
|
+
if (kind === SyntaxKind.SingleLineCommentTrivia ||
|
|
105
|
+
kind === SyntaxKind.MultiLineCommentTrivia) {
|
|
106
|
+
const text = sibling.getText();
|
|
107
|
+
if (COMMENT_E2E_PATTERN.test(text)) return 'e2e';
|
|
108
|
+
if (COMMENT_INTEGRATION_PATTERN.test(text)) return 'integration';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Also check leading comment ranges on the statement containing this node
|
|
113
|
+
const statement = findContainingStatement(node);
|
|
114
|
+
if (statement) {
|
|
115
|
+
const leadingComments = statement.getLeadingCommentRanges();
|
|
116
|
+
for (const comment of leadingComments) {
|
|
117
|
+
const text = comment.getText();
|
|
118
|
+
if (COMMENT_E2E_PATTERN.test(text)) return 'e2e';
|
|
119
|
+
if (COMMENT_INTEGRATION_PATTERN.test(text)) return 'integration';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Find the statement (expression statement) containing a node.
|
|
128
|
+
*/
|
|
129
|
+
function findContainingStatement(node: Node): Node | null {
|
|
130
|
+
let current: Node | undefined = node;
|
|
131
|
+
while (current) {
|
|
132
|
+
if (Node.isExpressionStatement(current)) return current;
|
|
133
|
+
current = current.getParent();
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Mock Detection ---
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if a call expression is a mock framework call.
|
|
142
|
+
* Returns the pattern string (e.g., "jest.fn()") or null.
|
|
143
|
+
*/
|
|
144
|
+
function identifyMockCall(node: CallExpression): string | null {
|
|
145
|
+
const expr = node.getExpression();
|
|
146
|
+
|
|
147
|
+
// Property access: jest.fn(), jest.mock(), sinon.stub(), etc.
|
|
148
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
149
|
+
const objText = expr.getExpression().getText();
|
|
150
|
+
const propText = expr.getName();
|
|
151
|
+
|
|
152
|
+
// Direct match: jest.fn(), vi.mock(), sinon.stub()
|
|
153
|
+
const group = MOCK_PROPERTY_PATTERNS[objText];
|
|
154
|
+
if (group && group[propText]) {
|
|
155
|
+
return group[propText];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Chain detection: jest.fn().mockResolvedValue(), jest.fn().mockImplementation()
|
|
159
|
+
// The outer call is .mockResolvedValue(), inner is jest.fn()
|
|
160
|
+
const innerExpr = expr.getExpression();
|
|
161
|
+
if (Node.isCallExpression(innerExpr)) {
|
|
162
|
+
const innerPattern = identifyMockCall(innerExpr);
|
|
163
|
+
if (innerPattern) {
|
|
164
|
+
return `${innerPattern}.${propText}()`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Describe Block Analysis ---
|
|
173
|
+
|
|
174
|
+
interface DescribeBlock {
|
|
175
|
+
name: string;
|
|
176
|
+
type: 'integration' | 'e2e' | null;
|
|
177
|
+
signal: 'keyword_in_name' | 'comment_header' | 'inherited' | null;
|
|
178
|
+
lineStart: number;
|
|
179
|
+
lineEnd: number;
|
|
180
|
+
node: CallExpression;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract the name from a describe() call's first argument.
|
|
185
|
+
*/
|
|
186
|
+
function getDescribeName(node: CallExpression): string | null {
|
|
187
|
+
const args = node.getArguments();
|
|
188
|
+
if (args.length > 0 && Node.isStringLiteral(args[0])) {
|
|
189
|
+
return args[0].getLiteralText();
|
|
190
|
+
}
|
|
191
|
+
if (args.length > 0 && Node.isNoSubstitutionTemplateLiteral(args[0])) {
|
|
192
|
+
return args[0].getLiteralText();
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if a call expression is a describe() call.
|
|
199
|
+
*/
|
|
200
|
+
function isDescribeCall(node: CallExpression): boolean {
|
|
201
|
+
const expr = node.getExpression();
|
|
202
|
+
if (Node.isIdentifier(expr)) {
|
|
203
|
+
return expr.getText() === 'describe';
|
|
204
|
+
}
|
|
205
|
+
// describe.skip, describe.only
|
|
206
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
207
|
+
const obj = expr.getExpression();
|
|
208
|
+
return Node.isIdentifier(obj) && obj.getText() === 'describe';
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Find all describe blocks and classify them.
|
|
215
|
+
*/
|
|
216
|
+
function findDescribeBlocks(sourceFile: SourceFile): DescribeBlock[] {
|
|
217
|
+
const blocks: DescribeBlock[] = [];
|
|
218
|
+
|
|
219
|
+
sourceFile.forEachDescendant((node) => {
|
|
220
|
+
if (!Node.isCallExpression(node)) return;
|
|
221
|
+
if (!isDescribeCall(node)) return;
|
|
222
|
+
|
|
223
|
+
const name = getDescribeName(node);
|
|
224
|
+
if (!name) return;
|
|
225
|
+
|
|
226
|
+
const lineStart = node.getStartLineNumber();
|
|
227
|
+
const lineEnd = node.getEndLineNumber();
|
|
228
|
+
|
|
229
|
+
// Classify by name first
|
|
230
|
+
let type = classifyByName(name);
|
|
231
|
+
let signal: DescribeBlock['signal'] = type ? 'keyword_in_name' : null;
|
|
232
|
+
|
|
233
|
+
// If not classified by name, check preceding comments
|
|
234
|
+
if (!type) {
|
|
235
|
+
type = classifyByPrecedingComments(node);
|
|
236
|
+
signal = type ? 'comment_header' : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
blocks.push({ name, type, signal, lineStart, lineEnd, node });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return blocks;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Apply inheritance: nested describe blocks inherit parent type.
|
|
247
|
+
*/
|
|
248
|
+
function applyInheritance(blocks: DescribeBlock[]): void {
|
|
249
|
+
for (const block of blocks) {
|
|
250
|
+
if (block.type) continue; // Already classified
|
|
251
|
+
|
|
252
|
+
// Check if this block is nested inside a classified block
|
|
253
|
+
for (const parent of blocks) {
|
|
254
|
+
if (!parent.type) continue;
|
|
255
|
+
if (parent === block) continue;
|
|
256
|
+
|
|
257
|
+
if (block.lineStart >= parent.lineStart && block.lineEnd <= parent.lineEnd) {
|
|
258
|
+
block.type = parent.type;
|
|
259
|
+
block.signal = 'inherited';
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Main Analysis ---
|
|
267
|
+
|
|
268
|
+
function analyzeFile(filePath: string, project: Project): FileResult | ErrorOutput {
|
|
269
|
+
let sourceFile: SourceFile;
|
|
270
|
+
try {
|
|
271
|
+
sourceFile = project.addSourceFileAtPath(filePath);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return {
|
|
274
|
+
error: `Failed to parse: ${err instanceof Error ? err.message : String(err)}`,
|
|
275
|
+
file: filePath,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Step 1: Find and classify describe blocks
|
|
280
|
+
const blocks = findDescribeBlocks(sourceFile);
|
|
281
|
+
applyInheritance(blocks);
|
|
282
|
+
|
|
283
|
+
// Step 2: Collect sections (classified blocks only)
|
|
284
|
+
const sections: SectionInfo[] = blocks
|
|
285
|
+
.filter((b) => b.type !== null)
|
|
286
|
+
.map((b) => ({
|
|
287
|
+
name: b.name,
|
|
288
|
+
type: b.type!,
|
|
289
|
+
signal: b.signal!,
|
|
290
|
+
line_start: b.lineStart,
|
|
291
|
+
line_end: b.lineEnd,
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
// Step 3: Find mock calls within classified sections
|
|
295
|
+
// Only report the outermost call in a chain to avoid duplicates.
|
|
296
|
+
// e.g., jest.fn().mockRejectedValueOnce().mockResolvedValue() is ONE lead, not three.
|
|
297
|
+
const leads: MockLead[] = [];
|
|
298
|
+
|
|
299
|
+
sourceFile.forEachDescendant((node) => {
|
|
300
|
+
if (!Node.isCallExpression(node)) return;
|
|
301
|
+
|
|
302
|
+
const mockPattern = identifyMockCall(node);
|
|
303
|
+
if (!mockPattern) return;
|
|
304
|
+
|
|
305
|
+
// Skip if this CallExpression is consumed by an outer chain call.
|
|
306
|
+
// AST structure: inner CallExpr → PropertyAccessExpr → outer CallExpr
|
|
307
|
+
const parent = node.getParent();
|
|
308
|
+
if (parent && Node.isPropertyAccessExpression(parent)) {
|
|
309
|
+
const grandparent = parent.getParent();
|
|
310
|
+
if (grandparent && Node.isCallExpression(grandparent)) {
|
|
311
|
+
// This node is an inner link in a chain — the outer call will be reported
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const line = node.getStartLineNumber();
|
|
317
|
+
|
|
318
|
+
// Check if this mock call is inside a classified section
|
|
319
|
+
for (const section of sections) {
|
|
320
|
+
if (line >= section.line_start && line <= section.line_end) {
|
|
321
|
+
leads.push({
|
|
322
|
+
line,
|
|
323
|
+
type: 'T3',
|
|
324
|
+
confidence: 'high',
|
|
325
|
+
mock_pattern: mockPattern,
|
|
326
|
+
enclosing_block: section.name,
|
|
327
|
+
block_type: section.type,
|
|
328
|
+
message: `Mock call '${mockPattern}' in ${section.type} test block '${section.name}'`,
|
|
329
|
+
suggestion: `${section.type === 'e2e' ? 'E2E' : 'Integration'} tests should use real operations. Replace mock with actual implementation.`,
|
|
330
|
+
});
|
|
331
|
+
break; // Don't double-count if nested sections overlap
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Step 3b: Detect file-scope mock calls that affect integration/e2e sections
|
|
337
|
+
// File-scope mocks (jest.mock, vi.mock) outside all describe blocks contaminate ALL tests
|
|
338
|
+
const integrationOrE2eSections = sections.filter(
|
|
339
|
+
(s) => s.type === 'integration' || s.type === 'e2e',
|
|
340
|
+
);
|
|
341
|
+
let fileScopeMockCount = 0;
|
|
342
|
+
|
|
343
|
+
if (integrationOrE2eSections.length > 0) {
|
|
344
|
+
const allBlockRanges = blocks.map((b) => ({ start: b.lineStart, end: b.lineEnd }));
|
|
345
|
+
|
|
346
|
+
sourceFile.forEachDescendant((node) => {
|
|
347
|
+
if (!Node.isCallExpression(node)) return;
|
|
348
|
+
|
|
349
|
+
const mockPattern = identifyMockCall(node);
|
|
350
|
+
if (!mockPattern) return;
|
|
351
|
+
|
|
352
|
+
// Skip chain-inner nodes (same dedup as Step 3)
|
|
353
|
+
const parent = node.getParent();
|
|
354
|
+
if (parent && Node.isPropertyAccessExpression(parent)) {
|
|
355
|
+
const grandparent = parent.getParent();
|
|
356
|
+
if (grandparent && Node.isCallExpression(grandparent)) return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const line = node.getStartLineNumber();
|
|
360
|
+
|
|
361
|
+
// Check if this mock is OUTSIDE all describe blocks (file-scope)
|
|
362
|
+
const isInsideDescribe = allBlockRanges.some(
|
|
363
|
+
(r) => line >= r.start && line <= r.end,
|
|
364
|
+
);
|
|
365
|
+
if (isInsideDescribe) return; // Already handled in Step 3
|
|
366
|
+
|
|
367
|
+
fileScopeMockCount++;
|
|
368
|
+
|
|
369
|
+
// File-scope mock found + integration sections exist → T3 lead per section
|
|
370
|
+
for (const section of integrationOrE2eSections) {
|
|
371
|
+
leads.push({
|
|
372
|
+
line,
|
|
373
|
+
type: 'T3',
|
|
374
|
+
confidence: 'high',
|
|
375
|
+
mock_pattern: mockPattern,
|
|
376
|
+
enclosing_block: `FILE_SCOPE → ${section.name}`,
|
|
377
|
+
block_type: section.type,
|
|
378
|
+
message: `File-scope mock '${mockPattern}' at line ${line} affects ${section.type} section '${section.name}' (lines ${section.line_start}-${section.line_end})`,
|
|
379
|
+
suggestion: `Move to separate test file or scope mock to unit test blocks only. ${section.type === 'e2e' ? 'E2E' : 'Integration'} tests should not be affected by file-scope mocks.`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Sort leads by line number
|
|
386
|
+
leads.sort((a, b) => a.line - b.line);
|
|
387
|
+
|
|
388
|
+
// Step 4: Compute summary
|
|
389
|
+
const integrationSections = sections.filter((s) => s.type === 'integration').length;
|
|
390
|
+
const e2eSections = sections.filter((s) => s.type === 'e2e').length;
|
|
391
|
+
const mockInIntegration = leads.filter((l) => l.block_type === 'integration').length;
|
|
392
|
+
const mockInE2e = leads.filter((l) => l.block_type === 'e2e').length;
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
file: filePath,
|
|
396
|
+
sections,
|
|
397
|
+
leads,
|
|
398
|
+
summary: {
|
|
399
|
+
sections_found: sections.length,
|
|
400
|
+
integration_sections: integrationSections,
|
|
401
|
+
e2e_sections: e2eSections,
|
|
402
|
+
leads_count: leads.length,
|
|
403
|
+
mock_calls_in_integration: mockInIntegration,
|
|
404
|
+
mock_calls_in_e2e: mockInE2e,
|
|
405
|
+
file_scope_mocks: fileScopeMockCount,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- Main ---
|
|
411
|
+
|
|
412
|
+
function main(): void {
|
|
413
|
+
const args = process.argv.slice(2);
|
|
414
|
+
|
|
415
|
+
if (args.length === 0) {
|
|
416
|
+
const error: ErrorOutput = {
|
|
417
|
+
error: 'No file paths provided. Usage: integration-mock-detector.ts <file1> [file2] ...',
|
|
418
|
+
file: '',
|
|
419
|
+
};
|
|
420
|
+
process.stderr.write(JSON.stringify(error) + '\n');
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const project = new Project({
|
|
425
|
+
tsConfigFilePath: undefined,
|
|
426
|
+
skipAddingFilesFromTsConfig: true,
|
|
427
|
+
compilerOptions: {
|
|
428
|
+
allowJs: true,
|
|
429
|
+
checkJs: false,
|
|
430
|
+
noEmit: true,
|
|
431
|
+
strict: false,
|
|
432
|
+
skipLibCheck: true,
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Expand directory args to individual test files
|
|
437
|
+
const filePaths: string[] = [];
|
|
438
|
+
for (const arg of args) {
|
|
439
|
+
const resolved = path.resolve(arg);
|
|
440
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
441
|
+
const entries = fs.readdirSync(resolved).filter(f => /\.(test|spec)\.[tj]sx?$/.test(f));
|
|
442
|
+
filePaths.push(...entries.map(f => path.join(resolved, f)));
|
|
443
|
+
} else {
|
|
444
|
+
filePaths.push(resolved);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const results: (FileResult | ErrorOutput)[] = [];
|
|
449
|
+
|
|
450
|
+
for (const filePath of filePaths) {
|
|
451
|
+
const result = analyzeFile(filePath, project);
|
|
452
|
+
results.push(result);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (results.length === 1) {
|
|
456
|
+
process.stdout.write(JSON.stringify(results[0], null, 2) + '\n');
|
|
457
|
+
} else {
|
|
458
|
+
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bulwark/test-audit-scripts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "AST-based test analysis scripts for test-audit skill",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"ts-morph": "^27.0.0"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/jest": "^29.5.0",
|
|
11
|
+
"jest": "^29.7.0",
|
|
12
|
+
"ts-jest": "^29.1.0",
|
|
13
|
+
"tsx": "^4.19.0",
|
|
14
|
+
"typescript": "^5.3.0"
|
|
15
|
+
},
|
|
16
|
+
"jest": {
|
|
17
|
+
"preset": "ts-jest",
|
|
18
|
+
"testEnvironment": "node"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Project, SyntaxKind, Node, CallExpression, SourceFile } from 'ts-morph';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
interface SkipMarker {
|
|
8
|
+
type: string;
|
|
9
|
+
line: number;
|
|
10
|
+
test_name: string;
|
|
11
|
+
severity: 'high' | 'medium' | 'low';
|
|
12
|
+
rule: 'T4';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FileResult {
|
|
16
|
+
file: string;
|
|
17
|
+
markers: SkipMarker[];
|
|
18
|
+
summary: {
|
|
19
|
+
skip_count: number;
|
|
20
|
+
only_count: number;
|
|
21
|
+
todo_count: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ErrorOutput {
|
|
26
|
+
error: string;
|
|
27
|
+
file: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- Constants ---
|
|
31
|
+
|
|
32
|
+
// Maps object.method patterns to marker types and severity
|
|
33
|
+
const PROPERTY_ACCESS_PATTERNS: Record<string, Record<string, { type: string; severity: SkipMarker['severity']; category: 'skip' | 'only' | 'todo' }>> = {
|
|
34
|
+
'describe': {
|
|
35
|
+
'skip': { type: 'describe.skip', severity: 'medium', category: 'skip' },
|
|
36
|
+
'only': { type: 'describe.only', severity: 'high', category: 'only' },
|
|
37
|
+
},
|
|
38
|
+
'it': {
|
|
39
|
+
'skip': { type: 'it.skip', severity: 'medium', category: 'skip' },
|
|
40
|
+
'only': { type: 'it.only', severity: 'high', category: 'only' },
|
|
41
|
+
'todo': { type: 'it.todo', severity: 'low', category: 'todo' },
|
|
42
|
+
},
|
|
43
|
+
'test': {
|
|
44
|
+
'skip': { type: 'test.skip', severity: 'medium', category: 'skip' },
|
|
45
|
+
'only': { type: 'test.only', severity: 'high', category: 'only' },
|
|
46
|
+
'todo': { type: 'test.todo', severity: 'low', category: 'todo' },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Direct call patterns (xdescribe, xit, fdescribe, fit)
|
|
51
|
+
const DIRECT_CALL_PATTERNS: Record<string, { type: string; severity: SkipMarker['severity']; category: 'skip' | 'only' | 'todo' }> = {
|
|
52
|
+
'xdescribe': { type: 'xdescribe', severity: 'medium', category: 'skip' },
|
|
53
|
+
'xit': { type: 'xit', severity: 'medium', category: 'skip' },
|
|
54
|
+
'fdescribe': { type: 'fdescribe', severity: 'high', category: 'only' },
|
|
55
|
+
'fit': { type: 'fit', severity: 'high', category: 'only' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// --- AST Analysis ---
|
|
59
|
+
|
|
60
|
+
function extractTestName(node: CallExpression): string {
|
|
61
|
+
const args = node.getArguments();
|
|
62
|
+
if (args.length > 0 && Node.isStringLiteral(args[0])) {
|
|
63
|
+
return args[0].getLiteralText();
|
|
64
|
+
}
|
|
65
|
+
if (args.length > 0 && Node.isTemplateExpression(args[0])) {
|
|
66
|
+
return args[0].getText();
|
|
67
|
+
}
|
|
68
|
+
if (args.length > 0 && Node.isNoSubstitutionTemplateLiteral(args[0])) {
|
|
69
|
+
return args[0].getLiteralText();
|
|
70
|
+
}
|
|
71
|
+
return '<unnamed>';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function analyzeFile(filePath: string, project: Project): FileResult | ErrorOutput {
|
|
75
|
+
let sourceFile: SourceFile;
|
|
76
|
+
try {
|
|
77
|
+
sourceFile = project.addSourceFileAtPath(filePath);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
error: `Failed to parse: ${err instanceof Error ? err.message : String(err)}`,
|
|
81
|
+
file: filePath,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const markers: SkipMarker[] = [];
|
|
86
|
+
|
|
87
|
+
sourceFile.forEachDescendant((node) => {
|
|
88
|
+
if (!Node.isCallExpression(node)) return;
|
|
89
|
+
|
|
90
|
+
const expr = node.getExpression();
|
|
91
|
+
|
|
92
|
+
// Check property access patterns: describe.skip(), it.only(), test.todo(), etc.
|
|
93
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
94
|
+
const objText = expr.getExpression().getText();
|
|
95
|
+
const propText = expr.getName();
|
|
96
|
+
const patternGroup = PROPERTY_ACCESS_PATTERNS[objText];
|
|
97
|
+
|
|
98
|
+
if (patternGroup && patternGroup[propText]) {
|
|
99
|
+
const pattern = patternGroup[propText];
|
|
100
|
+
markers.push({
|
|
101
|
+
type: pattern.type,
|
|
102
|
+
line: node.getStartLineNumber(),
|
|
103
|
+
test_name: extractTestName(node),
|
|
104
|
+
severity: pattern.severity,
|
|
105
|
+
rule: 'T4',
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check direct call patterns: xdescribe(), xit(), fdescribe(), fit()
|
|
112
|
+
if (Node.isIdentifier(expr)) {
|
|
113
|
+
const name = expr.getText();
|
|
114
|
+
const pattern = DIRECT_CALL_PATTERNS[name];
|
|
115
|
+
|
|
116
|
+
if (pattern) {
|
|
117
|
+
markers.push({
|
|
118
|
+
type: pattern.type,
|
|
119
|
+
line: node.getStartLineNumber(),
|
|
120
|
+
test_name: extractTestName(node),
|
|
121
|
+
severity: pattern.severity,
|
|
122
|
+
rule: 'T4',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Sort markers by line number
|
|
129
|
+
markers.sort((a, b) => a.line - b.line);
|
|
130
|
+
|
|
131
|
+
// Compute summary counts
|
|
132
|
+
let skipCount = 0;
|
|
133
|
+
let onlyCount = 0;
|
|
134
|
+
let todoCount = 0;
|
|
135
|
+
|
|
136
|
+
for (const marker of markers) {
|
|
137
|
+
const markerType = marker.type;
|
|
138
|
+
// Determine category from the pattern tables
|
|
139
|
+
if (markerType.includes('.skip') || markerType.startsWith('x')) {
|
|
140
|
+
skipCount++;
|
|
141
|
+
} else if (markerType.includes('.only') || markerType.startsWith('f')) {
|
|
142
|
+
onlyCount++;
|
|
143
|
+
} else if (markerType.includes('.todo')) {
|
|
144
|
+
todoCount++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
file: filePath,
|
|
150
|
+
markers,
|
|
151
|
+
summary: {
|
|
152
|
+
skip_count: skipCount,
|
|
153
|
+
only_count: onlyCount,
|
|
154
|
+
todo_count: todoCount,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Main ---
|
|
160
|
+
|
|
161
|
+
function main(): void {
|
|
162
|
+
const args = process.argv.slice(2);
|
|
163
|
+
|
|
164
|
+
if (args.length === 0) {
|
|
165
|
+
const error: ErrorOutput = {
|
|
166
|
+
error: 'No file paths provided. Usage: skip-detector.ts <file1> [file2] ...',
|
|
167
|
+
file: '',
|
|
168
|
+
};
|
|
169
|
+
process.stderr.write(JSON.stringify(error) + '\n');
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const project = new Project({
|
|
174
|
+
tsConfigFilePath: undefined,
|
|
175
|
+
skipAddingFilesFromTsConfig: true,
|
|
176
|
+
compilerOptions: {
|
|
177
|
+
allowJs: true,
|
|
178
|
+
checkJs: false,
|
|
179
|
+
noEmit: true,
|
|
180
|
+
strict: false,
|
|
181
|
+
skipLibCheck: true,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Expand directory args to individual test files
|
|
186
|
+
const filePaths: string[] = [];
|
|
187
|
+
for (const arg of args) {
|
|
188
|
+
const resolved = path.resolve(arg);
|
|
189
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
190
|
+
const entries = fs.readdirSync(resolved).filter(f => /\.(test|spec)\.[tj]sx?$/.test(f));
|
|
191
|
+
filePaths.push(...entries.map(f => path.join(resolved, f)));
|
|
192
|
+
} else {
|
|
193
|
+
filePaths.push(resolved);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const results: (FileResult | ErrorOutput)[] = [];
|
|
198
|
+
|
|
199
|
+
for (const filePath of filePaths) {
|
|
200
|
+
const result = analyzeFile(filePath, project);
|
|
201
|
+
results.push(result);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (results.length === 1) {
|
|
205
|
+
process.stdout.write(JSON.stringify(results[0], null, 2) + '\n');
|
|
206
|
+
} else {
|
|
207
|
+
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
main();
|