@0xcraft/powershot 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/LICENSE +202 -0
- package/README.md +306 -0
- package/dist/agents.js +82 -0
- package/dist/bench.js +179 -0
- package/dist/budget.js +59 -0
- package/dist/bundle.js +173 -0
- package/dist/cache.js +155 -0
- package/dist/cli/agent-command.js +27 -0
- package/dist/cli/app.js +31 -0
- package/dist/cli/args.js +76 -0
- package/dist/cli/bench-command.js +89 -0
- package/dist/cli/dismiss-command.js +42 -0
- package/dist/cli/environment.js +32 -0
- package/dist/cli/reports.js +64 -0
- package/dist/cli/review-command.js +268 -0
- package/dist/cli/session-command.js +62 -0
- package/dist/cli.js +7 -0
- package/dist/config.js +130 -0
- package/dist/delegate.js +84 -0
- package/dist/dismissed.js +130 -0
- package/dist/fspolicy.js +62 -0
- package/dist/git.js +238 -0
- package/dist/ground.js +286 -0
- package/dist/judges/judge.js +85 -0
- package/dist/judges/llm.js +234 -0
- package/dist/judges/prompts.js +86 -0
- package/dist/judges/tools.js +125 -0
- package/dist/lang/packs.js +557 -0
- package/dist/lang/pyright.js +108 -0
- package/dist/lang/python-deps.js +174 -0
- package/dist/lang/ruby-deps.js +77 -0
- package/dist/langtest.js +248 -0
- package/dist/manifest.js +209 -0
- package/dist/otel.js +75 -0
- package/dist/package-meta.js +13 -0
- package/dist/package-smoke.js +110 -0
- package/dist/plan.js +134 -0
- package/dist/position.js +94 -0
- package/dist/report/ansi.js +18 -0
- package/dist/report/codequality.js +19 -0
- package/dist/report/compact.js +15 -0
- package/dist/report/highlight.js +54 -0
- package/dist/report/markdown.js +113 -0
- package/dist/report/sarif.js +66 -0
- package/dist/report/terminal.js +170 -0
- package/dist/report/viewer.js +148 -0
- package/dist/review.js +355 -0
- package/dist/scan.js +67 -0
- package/dist/selftest.js +1928 -0
- package/dist/session.js +140 -0
- package/dist/snapshot.js +101 -0
- package/dist/text.js +50 -0
- package/dist/types.js +2 -0
- package/dist/verifiers/assertion-drift.js +137 -0
- package/dist/verifiers/contract-drift.js +140 -0
- package/dist/verifiers/copy-paste-drift.js +106 -0
- package/dist/verifiers/dead-on-arrival.js +92 -0
- package/dist/verifiers/dropped-guard.js +144 -0
- package/dist/verifiers/foreign-contract-drift.js +114 -0
- package/dist/verifiers/foreign-copy-paste-drift.js +83 -0
- package/dist/verifiers/foreign-dropped-guard.js +78 -0
- package/dist/verifiers/foreign-phantom-api.js +36 -0
- package/dist/verifiers/foreign-phantom-config.js +40 -0
- package/dist/verifiers/foreign-phantom-dep.js +82 -0
- package/dist/verifiers/foreign-reinvented.js +65 -0
- package/dist/verifiers/foreign-scope-creep.js +42 -0
- package/dist/verifiers/foreign-swallowed-error.js +36 -0
- package/dist/verifiers/foreign-tests.js +143 -0
- package/dist/verifiers/foreign-tokens.js +94 -0
- package/dist/verifiers/foreign.js +16 -0
- package/dist/verifiers/index.js +38 -0
- package/dist/verifiers/lying-comment.js +90 -0
- package/dist/verifiers/phantom-api.js +88 -0
- package/dist/verifiers/phantom-config.js +93 -0
- package/dist/verifiers/phantom-dep.js +110 -0
- package/dist/verifiers/reinvented.js +74 -0
- package/dist/verifiers/scope-creep.js +77 -0
- package/dist/verifiers/swallowed-error.js +110 -0
- package/dist/verifiers/vacuous-test.js +138 -0
- package/docs/architecture.md +191 -0
- package/docs/assets/cli-preview.svg +68 -0
- package/docs/assets/powershot-logo.png +0 -0
- package/docs/ci.md +151 -0
- package/examples/github-actions/action.yml +23 -0
- package/examples/github-actions/cli.yml +43 -0
- package/examples/gitlab/.gitlab-ci.yml +21 -0
- package/package.json +65 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/** Shared defaults; a pack overrides only what its grammar spells differently. */
|
|
4
|
+
const COMMON_NODES = {
|
|
5
|
+
identifier: ['identifier'],
|
|
6
|
+
comment: ['comment', 'line_comment', 'block_comment'],
|
|
7
|
+
ifCondition: 'condition',
|
|
8
|
+
ifBody: 'consequence',
|
|
9
|
+
declarationName: 'name',
|
|
10
|
+
block: ['block'],
|
|
11
|
+
};
|
|
12
|
+
/** Pull the key out of `getenv("HOME")` / `environ["HOME"]` style reads. */
|
|
13
|
+
function envKeysFrom(root, callPattern, callTypes) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const call of nodesOfType(root, callTypes)) {
|
|
16
|
+
if (!callPattern.test(call.text))
|
|
17
|
+
continue;
|
|
18
|
+
const key = /["'`]([A-Z_][A-Z0-9_]*)["'`]/.exec(call.text);
|
|
19
|
+
if (key?.[1])
|
|
20
|
+
out.push({ name: key[1], node: call });
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** depth-first walk, used by every pack */
|
|
25
|
+
export function walk(node, visit) {
|
|
26
|
+
visit(node);
|
|
27
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
28
|
+
const child = node.child(i);
|
|
29
|
+
if (child)
|
|
30
|
+
walk(child, visit);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function nodesOfType(root, types) {
|
|
34
|
+
const out = [];
|
|
35
|
+
walk(root, (n) => {
|
|
36
|
+
if (types.includes(n.type))
|
|
37
|
+
out.push(n);
|
|
38
|
+
});
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A comment inside a handler is the author saying "I meant this", in every language.
|
|
43
|
+
* Deliberate silence is not a defect, so it suppresses the finding — the same rule
|
|
44
|
+
* the TypeScript checks already follow.
|
|
45
|
+
*/
|
|
46
|
+
function hasComment(node) {
|
|
47
|
+
if (!node)
|
|
48
|
+
return false;
|
|
49
|
+
let found = false;
|
|
50
|
+
walk(node, (n) => {
|
|
51
|
+
if (n.type === 'comment' || n.type === 'line_comment' || n.type === 'block_comment')
|
|
52
|
+
found = true;
|
|
53
|
+
});
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/** A body is meaningless when it does nothing at all, or only says "pass". */
|
|
57
|
+
function isInertBlock(block, inertTypes) {
|
|
58
|
+
if (!block)
|
|
59
|
+
return false;
|
|
60
|
+
if (hasComment(block))
|
|
61
|
+
return false;
|
|
62
|
+
const statements = block.namedChildren.filter((c) => c.type !== 'comment');
|
|
63
|
+
if (statements.length === 0)
|
|
64
|
+
return true;
|
|
65
|
+
return statements.every((s) => inertTypes.includes(s.type));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The try/catch family. Java, C++, C#, PHP, Kotlin, Swift and Scala all discard a
|
|
69
|
+
* failure the same way — a handler whose body does nothing, or only logs — so the
|
|
70
|
+
* rule is written once and each pack supplies its grammar's spelling.
|
|
71
|
+
*/
|
|
72
|
+
function catchBased(catchTypes, bodyField, logPattern) {
|
|
73
|
+
return (root) => {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const clause of nodesOfType(root, catchTypes)) {
|
|
76
|
+
const body = (bodyField ? clause.childForFieldName(bodyField) : undefined) ??
|
|
77
|
+
clause.namedChildren.find((c) => /block|compound_statement|statements/.test(c.type)) ??
|
|
78
|
+
null;
|
|
79
|
+
// Kotlin and Swift put the braces straight on the catch clause, so an empty
|
|
80
|
+
// handler has no body node at all to inspect — only a `{}` at the end
|
|
81
|
+
if (!body) {
|
|
82
|
+
if (/\{\s*\}\s*$/.test(clause.text) && !hasComment(clause)) {
|
|
83
|
+
out.push({ node: clause, what: 'catch block does nothing' });
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (isInertBlock(body, [])) {
|
|
88
|
+
out.push({ node: clause, what: 'catch block does nothing' });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (hasComment(body))
|
|
92
|
+
continue;
|
|
93
|
+
const statements = (body?.namedChildren ?? []).filter((c) => !COMMON_NODES.comment.includes(c.type));
|
|
94
|
+
if (statements.length > 0 && statements.every((st) => logPattern.test(st.text.trim()))) {
|
|
95
|
+
out.push({ node: clause, what: 'catch block only logs' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const PYTHON = {
|
|
102
|
+
name: 'python',
|
|
103
|
+
extensions: ['.py', '.pyi'],
|
|
104
|
+
grammar: 'tree-sitter-python',
|
|
105
|
+
nodes: {
|
|
106
|
+
...COMMON_NODES,
|
|
107
|
+
ifStatement: ['if_statement'],
|
|
108
|
+
ifBody: 'consequence',
|
|
109
|
+
bail: ['return_statement', 'raise_statement', 'continue_statement', 'break_statement'],
|
|
110
|
+
declaration: ['function_definition', 'class_definition'],
|
|
111
|
+
},
|
|
112
|
+
envReads(root) {
|
|
113
|
+
return envKeysFrom(root, /os\.environ|getenv/, ['call', 'subscript']);
|
|
114
|
+
},
|
|
115
|
+
imports(root) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const stmt of nodesOfType(root, ['import_statement', 'import_from_statement'])) {
|
|
118
|
+
// `import a.b`, `from a.b import c`, `import a as x` — the module is the first
|
|
119
|
+
// dotted name, and a relative `from . import x` has none
|
|
120
|
+
const first = stmt.namedChildren.find((c) => c.type === 'dotted_name' || c.type === 'aliased_import');
|
|
121
|
+
const dotted = first?.type === 'aliased_import' ? first.namedChildren[0] : first;
|
|
122
|
+
if (!dotted || stmt.text.includes('from .'))
|
|
123
|
+
continue;
|
|
124
|
+
out.push({ name: dotted.text, node: stmt });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
signatures(root) {
|
|
129
|
+
const out = new Map();
|
|
130
|
+
for (const fn of nodesOfType(root, ['function_definition'])) {
|
|
131
|
+
const name = fn.childForFieldName('name');
|
|
132
|
+
const params = fn.childForFieldName('parameters');
|
|
133
|
+
if (!name || !params)
|
|
134
|
+
continue;
|
|
135
|
+
const listed = [];
|
|
136
|
+
let required = 0;
|
|
137
|
+
for (const p of params.namedChildren) {
|
|
138
|
+
// *args and **kwargs demand nothing of a caller, and self/cls are bound
|
|
139
|
+
if (p.type === 'list_splat_pattern' || p.type === 'dictionary_splat_pattern')
|
|
140
|
+
continue;
|
|
141
|
+
const label = p.text;
|
|
142
|
+
if (/^(self|cls)\b/.test(label))
|
|
143
|
+
continue;
|
|
144
|
+
listed.push(label);
|
|
145
|
+
// a default makes it optional; a bare or annotated name does not
|
|
146
|
+
if (p.type === 'identifier' || p.type === 'typed_parameter')
|
|
147
|
+
required++;
|
|
148
|
+
}
|
|
149
|
+
out.set(name.text, { required, params: listed, node: fn });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
},
|
|
153
|
+
tests(root) {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const fn of nodesOfType(root, ['function_definition'])) {
|
|
156
|
+
const name = fn.childForFieldName('name')?.text;
|
|
157
|
+
if (!name || !name.startsWith('test'))
|
|
158
|
+
continue;
|
|
159
|
+
const body = fn.childForFieldName('body');
|
|
160
|
+
if (!body)
|
|
161
|
+
continue;
|
|
162
|
+
const assertions = [];
|
|
163
|
+
for (const stmt of nodesOfType(body, ['assert_statement'])) {
|
|
164
|
+
// `assert subject == expected` is what can drift; a bare `assert x` cannot
|
|
165
|
+
const comparison = stmt.namedChildren.find((c) => c.type === 'comparison_operator');
|
|
166
|
+
const left = comparison?.namedChildren[0];
|
|
167
|
+
const right = comparison?.namedChildren[1];
|
|
168
|
+
if (comparison && left && right) {
|
|
169
|
+
assertions.push({ subject: left.text, expected: right.text, node: stmt, expectedNode: right });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// unittest style: self.assertEqual(subject, expected)
|
|
173
|
+
for (const call of nodesOfType(body, ['call'])) {
|
|
174
|
+
const fnName = call.childForFieldName('function')?.text ?? '';
|
|
175
|
+
if (!/(^|\.)assert[A-Z_]/.test(fnName))
|
|
176
|
+
continue;
|
|
177
|
+
const args = call.childForFieldName('arguments')?.namedChildren ?? [];
|
|
178
|
+
if (args.length >= 2 && args[0] && args[1]) {
|
|
179
|
+
assertions.push({ subject: args[0].text, expected: args[1].text, node: call, expectedNode: args[1] });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// includes mock's family — `publish.assert_not_awaited()` is how most async
|
|
183
|
+
// suites assert, and missing it called 25 good tests vacuous on a real repo
|
|
184
|
+
const asserts = nodesOfType(body, ['assert_statement']).length > 0 ||
|
|
185
|
+
nodesOfType(body, ['call']).some((c) => /(^|\.)(assert[A-Z_a-z]|fail$|raises$)/.test(c.childForFieldName('function')?.text ?? '')) ||
|
|
186
|
+
nodesOfType(body, ['with_statement']).some((w) => /raises|assertRaises|pytest\.warns/.test(w.text));
|
|
187
|
+
const runsSomething = nodesOfType(body, ['call']).length > 0;
|
|
188
|
+
// a body that is only `pass` or a docstring is a placeholder, not a lie
|
|
189
|
+
const isPlaceholder = body.namedChildren.every((c) => c.type === 'pass_statement' || c.type === 'expression_statement' && /^["']/.test(c.text.trim()));
|
|
190
|
+
out.push({ name, node: fn, assertions, provesNothing: runsSomething && !asserts && !isPlaceholder });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
},
|
|
194
|
+
documentedParams(root) {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const fn of nodesOfType(root, ['function_definition'])) {
|
|
197
|
+
const name = fn.childForFieldName('name')?.text;
|
|
198
|
+
const params = fn.childForFieldName('parameters');
|
|
199
|
+
const body = fn.childForFieldName('body');
|
|
200
|
+
if (!name || !params || !body)
|
|
201
|
+
continue;
|
|
202
|
+
// the docstring is the first statement, if it is a bare string
|
|
203
|
+
const first = body.namedChildren[0];
|
|
204
|
+
if (!first || first.type !== 'expression_statement' || !/^[ru]?["']/.test(first.text.trim()))
|
|
205
|
+
continue;
|
|
206
|
+
const declared = params.namedChildren
|
|
207
|
+
.filter((p) => p.type !== 'list_splat_pattern' && p.type !== 'dictionary_splat_pattern')
|
|
208
|
+
.map((p) => (p.text.split(/[:=]/)[0] ?? '').trim())
|
|
209
|
+
.filter((n) => n !== 'self' && n !== 'cls' && n !== '');
|
|
210
|
+
const documented = [];
|
|
211
|
+
const text = first.text;
|
|
212
|
+
// reST `:param name:`. A line at a time: across the whole string the optional
|
|
213
|
+
// type group swallows the name and matches the *next* directive's colon.
|
|
214
|
+
for (const line of text.split('\n')) {
|
|
215
|
+
const m = /^\s*:param\s+(?:[^\s:]+\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(line);
|
|
216
|
+
if (m?.[1])
|
|
217
|
+
documented.push({ name: m[1], node: first });
|
|
218
|
+
}
|
|
219
|
+
// Google: an `Args:` block, one `name:` or `name (type):` per line
|
|
220
|
+
const args = /\n\s*Args:\s*\n([\s\S]*?)(\n\s*(?:Returns|Raises|Yields|Examples?|Notes?):|["']{3})/.exec(text);
|
|
221
|
+
if (args?.[1]) {
|
|
222
|
+
for (const line of args[1].split('\n')) {
|
|
223
|
+
const m = /^\s{2,}([A-Za-z_][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*:/.exec(line);
|
|
224
|
+
if (m?.[1])
|
|
225
|
+
documented.push({ name: m[1], node: first });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (documented.length > 0)
|
|
229
|
+
out.push({ fn: name, declared, documented });
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
},
|
|
233
|
+
swallowedError(root) {
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const clause of nodesOfType(root, ['except_clause'])) {
|
|
236
|
+
const block = clause.namedChildren.find((c) => c.type === 'block') ?? null;
|
|
237
|
+
// `except: pass` is the canonical Python spelling of discarding a failure
|
|
238
|
+
if (isInertBlock(block, ['pass_statement', 'ellipsis'])) {
|
|
239
|
+
out.push({ node: clause, what: 'except block does nothing' });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (hasComment(block))
|
|
243
|
+
continue;
|
|
244
|
+
const statements = (block?.namedChildren ?? []).filter((c) => c.type !== 'comment');
|
|
245
|
+
const onlyLogs = statements.length > 0 &&
|
|
246
|
+
statements.every((s) => s.type === 'expression_statement' &&
|
|
247
|
+
/^(print|log|logger|logging)\s*[.(]/.test(s.text.trim()));
|
|
248
|
+
if (onlyLogs)
|
|
249
|
+
out.push({ node: clause, what: 'except block only logs' });
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const GO = {
|
|
255
|
+
name: 'go',
|
|
256
|
+
extensions: ['.go'],
|
|
257
|
+
grammar: 'tree-sitter-go',
|
|
258
|
+
nodes: {
|
|
259
|
+
...COMMON_NODES,
|
|
260
|
+
ifStatement: ['if_statement'],
|
|
261
|
+
bail: ['return_statement', 'continue_statement', 'break_statement', 'goto_statement'],
|
|
262
|
+
declaration: ['function_declaration', 'method_declaration', 'type_declaration'],
|
|
263
|
+
},
|
|
264
|
+
envReads(root) {
|
|
265
|
+
return envKeysFrom(root, /os\.Getenv|os\.LookupEnv/, ['call_expression']);
|
|
266
|
+
},
|
|
267
|
+
swallowedError(root) {
|
|
268
|
+
const out = [];
|
|
269
|
+
// Go has no catch: the idiom is `if err != nil { ... }`, and the failure is
|
|
270
|
+
// discarded when that body does nothing
|
|
271
|
+
for (const stmt of nodesOfType(root, ['if_statement'])) {
|
|
272
|
+
const cond = stmt.childForFieldName('condition');
|
|
273
|
+
if (!cond || !/\berr\b\s*!=\s*nil/.test(cond.text))
|
|
274
|
+
continue;
|
|
275
|
+
const body = stmt.childForFieldName('consequence');
|
|
276
|
+
if (isInertBlock(body, []))
|
|
277
|
+
out.push({ node: stmt, what: 'error is checked and then ignored' });
|
|
278
|
+
}
|
|
279
|
+
// `_ = f()` deliberately NOT reported: Go compiles without assigning at all, so
|
|
280
|
+
// the blank identifier is someone saying "I know". An empty `if err != nil` isn't.
|
|
281
|
+
return out;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const JAVA = {
|
|
285
|
+
name: 'java',
|
|
286
|
+
extensions: ['.java'],
|
|
287
|
+
grammar: 'tree-sitter-java',
|
|
288
|
+
nodes: {
|
|
289
|
+
...COMMON_NODES,
|
|
290
|
+
ifStatement: ['if_statement'],
|
|
291
|
+
bail: ['return_statement', 'throw_statement', 'continue_statement', 'break_statement'],
|
|
292
|
+
declaration: ['method_declaration', 'class_declaration', 'interface_declaration', 'record_declaration'],
|
|
293
|
+
},
|
|
294
|
+
envReads(root) {
|
|
295
|
+
return envKeysFrom(root, /System\.getenv/, ['method_invocation']);
|
|
296
|
+
},
|
|
297
|
+
swallowedError: catchBased(['catch_clause'], 'body', /^(System\.(out|err)|log|logger|LOG|LOGGER)\s*\./),
|
|
298
|
+
};
|
|
299
|
+
const RUST = {
|
|
300
|
+
name: 'rust',
|
|
301
|
+
extensions: ['.rs'],
|
|
302
|
+
grammar: 'tree-sitter-rust',
|
|
303
|
+
nodes: {
|
|
304
|
+
...COMMON_NODES,
|
|
305
|
+
ifStatement: ['if_expression'],
|
|
306
|
+
ifCondition: 'condition',
|
|
307
|
+
ifBody: 'consequence',
|
|
308
|
+
bail: ['return_expression', 'break_expression', 'continue_expression'],
|
|
309
|
+
declaration: ['function_item', 'struct_item', 'enum_item', 'trait_item'],
|
|
310
|
+
},
|
|
311
|
+
envReads(root) {
|
|
312
|
+
return envKeysFrom(root, /env::var|env::var_os/, ['call_expression']);
|
|
313
|
+
},
|
|
314
|
+
swallowedError(root) {
|
|
315
|
+
const out = [];
|
|
316
|
+
// `let _ = fallible()` deliberately NOT reported: it is Rust's own "on purpose",
|
|
317
|
+
// written to silence #[must_use]. Measured, 59 findings over 19 commits, all
|
|
318
|
+
// idiomatic. An empty `Err(..)` arm has no such marker.
|
|
319
|
+
for (const arm of nodesOfType(root, ['match_arm'])) {
|
|
320
|
+
const pattern = arm.childForFieldName('pattern');
|
|
321
|
+
if (!pattern || !/^Err\b/.test(pattern.text.trim()))
|
|
322
|
+
continue;
|
|
323
|
+
const value = arm.childForFieldName('value');
|
|
324
|
+
if (value && (value.text.trim() === '{}' || isInertBlock(value, []))) {
|
|
325
|
+
out.push({ node: arm, what: 'Err arm does nothing' });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
const CLIKE_LOG = /^(std::(cout|cerr)|printf|fprintf|Console\.|Log|log|logger|error_log|print_r|var_dump)/;
|
|
332
|
+
const CPP = {
|
|
333
|
+
name: 'cpp',
|
|
334
|
+
extensions: ['.cpp', '.cc', '.cxx', '.hpp', '.hh', '.h'],
|
|
335
|
+
grammar: 'tree-sitter-cpp',
|
|
336
|
+
nodes: {
|
|
337
|
+
...COMMON_NODES,
|
|
338
|
+
ifStatement: ['if_statement'],
|
|
339
|
+
bail: ['return_statement', 'throw_statement', 'break_statement', 'continue_statement', 'goto_statement'],
|
|
340
|
+
declaration: ['function_definition'],
|
|
341
|
+
declarationName: 'declarator',
|
|
342
|
+
block: ['compound_statement'],
|
|
343
|
+
},
|
|
344
|
+
envReads: (root) => envKeysFrom(root, /getenv|GetEnvironmentVariable/, ['call_expression']),
|
|
345
|
+
swallowedError: catchBased(['catch_clause'], 'body', CLIKE_LOG),
|
|
346
|
+
};
|
|
347
|
+
const C = {
|
|
348
|
+
name: 'c',
|
|
349
|
+
extensions: ['.c'],
|
|
350
|
+
grammar: 'tree-sitter-c',
|
|
351
|
+
nodes: {
|
|
352
|
+
...COMMON_NODES,
|
|
353
|
+
ifStatement: ['if_statement'],
|
|
354
|
+
bail: ['return_statement', 'break_statement', 'continue_statement', 'goto_statement'],
|
|
355
|
+
declaration: ['function_definition'],
|
|
356
|
+
declarationName: 'declarator',
|
|
357
|
+
block: ['compound_statement'],
|
|
358
|
+
},
|
|
359
|
+
envReads: (root) => envKeysFrom(root, /getenv/, ['call_expression']),
|
|
360
|
+
// C has no exceptions; its error handling is return codes, which cannot be told
|
|
361
|
+
// from ordinary control flow without types. The other checks still apply.
|
|
362
|
+
};
|
|
363
|
+
const CSHARP = {
|
|
364
|
+
name: 'c#',
|
|
365
|
+
extensions: ['.cs'],
|
|
366
|
+
grammar: 'tree-sitter-c_sharp',
|
|
367
|
+
nodes: {
|
|
368
|
+
...COMMON_NODES,
|
|
369
|
+
ifStatement: ['if_statement'],
|
|
370
|
+
bail: ['return_statement', 'throw_statement', 'break_statement', 'continue_statement'],
|
|
371
|
+
declaration: ['method_declaration', 'class_declaration', 'interface_declaration', 'record_declaration'],
|
|
372
|
+
block: ['block', 'declaration_list'],
|
|
373
|
+
},
|
|
374
|
+
envReads: (root) => envKeysFrom(root, /GetEnvironmentVariable/, ['invocation_expression']),
|
|
375
|
+
swallowedError: catchBased(['catch_clause'], 'body', CLIKE_LOG),
|
|
376
|
+
};
|
|
377
|
+
const PHP = {
|
|
378
|
+
name: 'php',
|
|
379
|
+
extensions: ['.php'],
|
|
380
|
+
grammar: 'tree-sitter-php',
|
|
381
|
+
nodes: {
|
|
382
|
+
...COMMON_NODES,
|
|
383
|
+
ifStatement: ['if_statement'],
|
|
384
|
+
bail: ['return_statement', 'throw_expression', 'break_statement', 'continue_statement'],
|
|
385
|
+
declaration: ['function_definition', 'method_declaration', 'class_declaration'],
|
|
386
|
+
block: ['compound_statement'],
|
|
387
|
+
},
|
|
388
|
+
envReads: (root) => envKeysFrom(root, /getenv|\$_ENV/, ['function_call_expression', 'subscript_expression']),
|
|
389
|
+
swallowedError: catchBased(['catch_clause'], 'body', CLIKE_LOG),
|
|
390
|
+
};
|
|
391
|
+
const KOTLIN = {
|
|
392
|
+
name: 'kotlin',
|
|
393
|
+
extensions: ['.kt', '.kts'],
|
|
394
|
+
grammar: 'tree-sitter-kotlin',
|
|
395
|
+
nodes: {
|
|
396
|
+
...COMMON_NODES,
|
|
397
|
+
identifier: ['simple_identifier'],
|
|
398
|
+
ifStatement: ['if_expression'],
|
|
399
|
+
bail: ['jump_expression'],
|
|
400
|
+
declaration: ['function_declaration', 'class_declaration', 'object_declaration'],
|
|
401
|
+
block: ['statements', 'block'],
|
|
402
|
+
},
|
|
403
|
+
envReads: (root) => envKeysFrom(root, /System\.getenv|getenv/, ['call_expression']),
|
|
404
|
+
swallowedError: catchBased(['catch_block'], undefined, /^(println|print|log|logger|Log)\b/),
|
|
405
|
+
};
|
|
406
|
+
/*
|
|
407
|
+
* Swift is deliberately absent.
|
|
408
|
+
*
|
|
409
|
+
* The pack was written and all of its checks passed — and then the process died
|
|
410
|
+
* every time, inside V8's background compilation of that grammar, with a signal no
|
|
411
|
+
* `try` can catch. It is not a bug in the rule: the grammar reliably takes the
|
|
412
|
+
* runtime down after the work is done.
|
|
413
|
+
*
|
|
414
|
+
* The rule this project holds to is that an unsupported language goes unreviewed and
|
|
415
|
+
* never crashes the run, so shipping a pack that kills the process would break the
|
|
416
|
+
* guarantee it exists to serve. Restore it when the grammar or the runtime changes;
|
|
417
|
+
* the definition is kept here so nobody rewrites it from scratch.
|
|
418
|
+
*/
|
|
419
|
+
const SWIFT_DISABLED = {
|
|
420
|
+
name: 'swift',
|
|
421
|
+
extensions: ['.swift'],
|
|
422
|
+
grammar: 'tree-sitter-swift',
|
|
423
|
+
nodes: {
|
|
424
|
+
...COMMON_NODES,
|
|
425
|
+
identifier: ['simple_identifier'],
|
|
426
|
+
ifStatement: ['if_statement'],
|
|
427
|
+
bail: ['control_transfer_statement'],
|
|
428
|
+
declaration: ['function_declaration', 'class_declaration', 'protocol_declaration'],
|
|
429
|
+
block: ['statements', 'function_body'],
|
|
430
|
+
},
|
|
431
|
+
envReads: (root) => envKeysFrom(root, /ProcessInfo|environment/, ['call_expression', 'subscript_expression']),
|
|
432
|
+
swallowedError: catchBased(['catch_block'], undefined, /^(print|NSLog|os_log|logger)\b/),
|
|
433
|
+
};
|
|
434
|
+
const RUBY = {
|
|
435
|
+
name: 'ruby',
|
|
436
|
+
extensions: ['.rb', '.rake'],
|
|
437
|
+
grammar: 'tree-sitter-ruby',
|
|
438
|
+
nodes: {
|
|
439
|
+
...COMMON_NODES,
|
|
440
|
+
ifStatement: ['if', 'if_modifier'],
|
|
441
|
+
ifCondition: 'condition',
|
|
442
|
+
ifBody: 'consequence',
|
|
443
|
+
bail: ['return', 'break', 'next'],
|
|
444
|
+
declaration: ['method', 'singleton_method', 'class', 'module'],
|
|
445
|
+
block: ['body_statement', 'do_block', 'block'],
|
|
446
|
+
},
|
|
447
|
+
envReads: (root) => envKeysFrom(root, /ENV/, ['element_reference', 'call']),
|
|
448
|
+
imports(root) {
|
|
449
|
+
const out = [];
|
|
450
|
+
for (const call of nodesOfType(root, ['call'])) {
|
|
451
|
+
const method = call.childForFieldName('method')?.text;
|
|
452
|
+
// require_relative always names this repository's own files
|
|
453
|
+
if (method !== 'require')
|
|
454
|
+
continue;
|
|
455
|
+
const literal = /["']([^"']+)["']/.exec(call.text);
|
|
456
|
+
if (literal?.[1])
|
|
457
|
+
out.push({ name: literal[1], node: call });
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
},
|
|
461
|
+
swallowedError(root) {
|
|
462
|
+
const out = [];
|
|
463
|
+
for (const rescue of nodesOfType(root, ['rescue'])) {
|
|
464
|
+
// a rescue holds its handler inline, so an empty one has no body_statement
|
|
465
|
+
if (hasComment(rescue))
|
|
466
|
+
continue;
|
|
467
|
+
const body = rescue.namedChildren.find((c) => c.type === 'body_statement' || c.type === 'then');
|
|
468
|
+
const statements = (body?.namedChildren ?? []).filter((c) => !COMMON_NODES.comment.includes(c.type));
|
|
469
|
+
if (statements.length === 0) {
|
|
470
|
+
out.push({ node: rescue, what: 'rescue block does nothing' });
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (statements.every((st) => /^(puts|print|p|log|logger|Rails\.logger)\b/.test(st.text.trim()))) {
|
|
474
|
+
out.push({ node: rescue, what: 'rescue block only logs' });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
void SWIFT_DISABLED;
|
|
481
|
+
const SOLIDITY = {
|
|
482
|
+
name: 'solidity',
|
|
483
|
+
extensions: ['.sol'],
|
|
484
|
+
grammar: 'tree-sitter-solidity',
|
|
485
|
+
nodes: {
|
|
486
|
+
...COMMON_NODES,
|
|
487
|
+
ifStatement: ['if_statement'],
|
|
488
|
+
// revert and require are how a contract refuses, alongside plain return
|
|
489
|
+
bail: ['return_statement', 'revert_statement', 'break_statement', 'continue_statement'],
|
|
490
|
+
declaration: ['function_definition', 'contract_declaration', 'modifier_definition'],
|
|
491
|
+
block: ['block_statement', 'function_body', 'contract_body'],
|
|
492
|
+
},
|
|
493
|
+
swallowedError: catchBased(['catch_clause'], 'body', /^(emit|console\.log)/),
|
|
494
|
+
};
|
|
495
|
+
export const PACKS = [PYTHON, GO, JAVA, RUST, CPP, C, CSHARP, PHP, KOTLIN, RUBY, SOLIDITY];
|
|
496
|
+
export function packFor(path) {
|
|
497
|
+
return PACKS.find((p) => p.extensions.some((e) => path.endsWith(e)));
|
|
498
|
+
}
|
|
499
|
+
let ready;
|
|
500
|
+
const parsers = new Map();
|
|
501
|
+
/**
|
|
502
|
+
* Measured, not guessed, and the measurement is worth writing down because the naive
|
|
503
|
+
* one is misleading. Loading grammars is cheap — all eleven load for ~143MB. Parsing
|
|
504
|
+
* with them is not: V8 tiers up each wasm module in the background, and RSS climbed
|
|
505
|
+
* 63 → 690MB across eleven before the process died inside that compilation. Six
|
|
506
|
+
* grammars sat at ~131MB and were comfortable.
|
|
507
|
+
*/
|
|
508
|
+
const MAX_GRAMMARS = 6;
|
|
509
|
+
export const skippedLanguages = [];
|
|
510
|
+
/**
|
|
511
|
+
* Grammars load lazily and once. A repository with no Python pays nothing for
|
|
512
|
+
* Python, and the wasm runtime is only initialised when a foreign file appears.
|
|
513
|
+
*/
|
|
514
|
+
async function parserFor(pack) {
|
|
515
|
+
const cached = parsers.get(pack.name);
|
|
516
|
+
if (cached)
|
|
517
|
+
return cached;
|
|
518
|
+
if (parsers.size >= MAX_GRAMMARS) {
|
|
519
|
+
if (!skippedLanguages.includes(pack.name))
|
|
520
|
+
skippedLanguages.push(pack.name);
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
if (!ready) {
|
|
525
|
+
ready = (async () => {
|
|
526
|
+
const mod = (await import('web-tree-sitter'));
|
|
527
|
+
const Parser = (mod.default ?? mod);
|
|
528
|
+
await Parser.init();
|
|
529
|
+
return Parser;
|
|
530
|
+
})();
|
|
531
|
+
}
|
|
532
|
+
const Parser = await ready;
|
|
533
|
+
const require_ = createRequire(import.meta.url);
|
|
534
|
+
const wasmDir = join(dirname(require_.resolve('tree-sitter-wasms/package.json')), 'out');
|
|
535
|
+
const language = await Parser.Language.load(join(wasmDir, pack.grammar + '.wasm'));
|
|
536
|
+
const parser = new Parser();
|
|
537
|
+
parser.setLanguage(language);
|
|
538
|
+
parsers.set(pack.name, parser);
|
|
539
|
+
return parser;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
// a missing grammar means this language is simply not reviewed — never a crash
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
export async function parse(pack, source) {
|
|
547
|
+
const parser = await parserFor(pack);
|
|
548
|
+
if (!parser)
|
|
549
|
+
return undefined;
|
|
550
|
+
try {
|
|
551
|
+
return parser.parse(source);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
//# sourceMappingURL=packs.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { decode } from '#app/text.js';
|
|
3
|
+
const HALLUCINATION_RULES = new Map([
|
|
4
|
+
['reportAttributeAccessIssue', 'attribute does not exist on the type'],
|
|
5
|
+
['reportCallIssue', 'call does not match the signature'],
|
|
6
|
+
['reportArgumentType', 'argument type does not match the parameter'],
|
|
7
|
+
['reportIndexIssue', 'type does not support this indexing'],
|
|
8
|
+
['reportNoOverloadImplementation', 'no overload matches this call'],
|
|
9
|
+
['reportRedeclaration', 'redeclared with a different type'],
|
|
10
|
+
['reportUndefinedVariable', 'name is not defined'],
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* Where pyright might be — on PATH, put there by whoever runs this.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not the reviewed repository's `node_modules`. Resolving it there let a
|
|
16
|
+
* repository supply the binary that reviews it, which is arbitrary code execution
|
|
17
|
+
* dressed as a dev dependency, and it happens before a single finding is reported.
|
|
18
|
+
*/
|
|
19
|
+
const CANDIDATES = [
|
|
20
|
+
{ cmd: 'pyright', prefix: [] },
|
|
21
|
+
{ cmd: 'basedpyright', prefix: [] },
|
|
22
|
+
];
|
|
23
|
+
let resolved;
|
|
24
|
+
export function pyrightCommand(root) {
|
|
25
|
+
if (resolved !== undefined)
|
|
26
|
+
return resolved ?? undefined;
|
|
27
|
+
for (const candidate of CANDIDATES) {
|
|
28
|
+
try {
|
|
29
|
+
execFileSync(candidate.cmd, [...candidate.prefix, '--version'], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
32
|
+
timeout: 30_000,
|
|
33
|
+
});
|
|
34
|
+
resolved = candidate;
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// try the next place it could live
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
resolved = null;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
export function pyrightAvailable(root) {
|
|
45
|
+
return pyrightCommand(root) !== undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run pyright over the given files and keep only the diagnostics that mean something
|
|
49
|
+
* was invented. Type-strictness complaints are deliberately not reported: a review
|
|
50
|
+
* that argues about optional-ness on every line is a review nobody reads.
|
|
51
|
+
*/
|
|
52
|
+
export function pyrightDiagnostics(root, files) {
|
|
53
|
+
if (files.length === 0)
|
|
54
|
+
return [];
|
|
55
|
+
const command = pyrightCommand(root);
|
|
56
|
+
if (!command)
|
|
57
|
+
return [];
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = decode(execFileSync(command.cmd, [...command.prefix, '--outputjson', ...files], {
|
|
61
|
+
cwd: root,
|
|
62
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
63
|
+
// pyright exits non-zero when it finds anything, which is the normal case
|
|
64
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
65
|
+
timeout: 180_000,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
const err = e;
|
|
70
|
+
// pyright exits non-zero whenever it finds something, which is the normal case and
|
|
71
|
+
// still writes its report. What is not normal is dying without one: killed by a
|
|
72
|
+
// signal, or out of time. Returning [] there would report "nothing found" about a
|
|
73
|
+
// checker that never looked, so it is raised and becomes a recorded failure.
|
|
74
|
+
// an empty Buffer is truthy, which is how the first attempt at this check missed
|
|
75
|
+
// the very case it was written for
|
|
76
|
+
if (!err.stdout || err.stdout.length === 0) {
|
|
77
|
+
throw new Error(err.signal
|
|
78
|
+
? 'pyright was killed by ' + err.signal
|
|
79
|
+
: 'pyright could not run (' + (err.status ?? 'no exit code') + ')');
|
|
80
|
+
}
|
|
81
|
+
raw = decode(err.stdout);
|
|
82
|
+
}
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const d of parsed.generalDiagnostics ?? []) {
|
|
92
|
+
if (d.severity !== 'error')
|
|
93
|
+
continue;
|
|
94
|
+
const meaning = d.rule ? HALLUCINATION_RULES.get(d.rule) : undefined;
|
|
95
|
+
if (!meaning || !d.file)
|
|
96
|
+
continue;
|
|
97
|
+
out.push({
|
|
98
|
+
file: d.file,
|
|
99
|
+
line: (d.range?.start?.line ?? 0) + 1,
|
|
100
|
+
column: (d.range?.start?.character ?? 0) + 1,
|
|
101
|
+
rule: d.rule ?? '',
|
|
102
|
+
message: (d.message ?? '').split('\n')[0] ?? '',
|
|
103
|
+
meaning,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=pyright.js.map
|