@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,65 @@
|
|
|
1
|
+
import { finding, topLevelDeclarations } from './foreign-tokens.js';
|
|
2
|
+
/** Names too common to mean anything across files, as in the TypeScript version. */
|
|
3
|
+
const GENERIC = new Set([
|
|
4
|
+
'render', 'handler', 'handle', 'create', 'update', 'remove', 'delete', 'insert',
|
|
5
|
+
'process', 'execute', 'convert', 'default', 'discover', 'initialize', 'configure',
|
|
6
|
+
'validate', 'serialize', 'deserialize', 'to_string', 'from_str', 'builder', 'build',
|
|
7
|
+
]);
|
|
8
|
+
/**
|
|
9
|
+
* Test modules are excluded on purpose.
|
|
10
|
+
*
|
|
11
|
+
* A fixture builder repeated in two test files is a deliberate trade — a test that
|
|
12
|
+
* reads on its own beats one coupled to a shared helper — and two test functions
|
|
13
|
+
* describing the same scenario naturally carry the same name. Measured on a real
|
|
14
|
+
* repository this was the single largest source of noise.
|
|
15
|
+
*/
|
|
16
|
+
const TESTISH = /(^|\/)(tests?|spec|__tests__)\/|(^|\/)(test_[^/]+|[^/]+_test|[^/]+\.(test|spec))\.[a-z]+$/;
|
|
17
|
+
export const foreignReinvented = {
|
|
18
|
+
name: 'reinvented',
|
|
19
|
+
needs: ['syntax'],
|
|
20
|
+
run(g) {
|
|
21
|
+
const findings = [];
|
|
22
|
+
if (g.foreign.length < 2)
|
|
23
|
+
return findings;
|
|
24
|
+
// Keyed by language as well as name: a Ruby `charge` and a C++ `charge` are two
|
|
25
|
+
// unrelated functions that happen to share a word, and calling that duplication
|
|
26
|
+
// would be nonsense — nothing can be reused across the boundary anyway.
|
|
27
|
+
const index = new Map();
|
|
28
|
+
for (const file of g.foreign) {
|
|
29
|
+
if (TESTISH.test(file.path))
|
|
30
|
+
continue;
|
|
31
|
+
for (const [name, decl] of topLevelDeclarations(file.tree.rootNode, file.pack)) {
|
|
32
|
+
const key = file.pack.name + '|' + name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
33
|
+
const list = index.get(key) ?? [];
|
|
34
|
+
list.push({ file: file.path, line: decl.startPosition.row + 1 });
|
|
35
|
+
index.set(key, list);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const file of g.foreign) {
|
|
39
|
+
if (TESTISH.test(file.path))
|
|
40
|
+
continue;
|
|
41
|
+
for (const [name, decl] of topLevelDeclarations(file.tree.rootNode, file.pack)) {
|
|
42
|
+
const line = decl.startPosition.row + 1;
|
|
43
|
+
if (!file.changed.added.has(line))
|
|
44
|
+
continue;
|
|
45
|
+
if (name.length < 6 || GENERIC.has(name.toLowerCase()))
|
|
46
|
+
continue;
|
|
47
|
+
const key = file.pack.name + '|' + name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
48
|
+
const match = (index.get(key) ?? []).find((e) => e.file !== file.path);
|
|
49
|
+
// report the pair once, from the file that declares it later
|
|
50
|
+
if (!match || match.file < file.path)
|
|
51
|
+
continue;
|
|
52
|
+
findings.push(finding(file, decl, {
|
|
53
|
+
check: 'reinvented',
|
|
54
|
+
severity: 'medium',
|
|
55
|
+
confidence: 'firm',
|
|
56
|
+
title: name + '() duplicates a declaration in ' + match.file,
|
|
57
|
+
evidence: { oracle: file.pack.name + ' declarations', detail: 'already declared at ' + match.file + ':' + match.line },
|
|
58
|
+
fix: 'Reuse the existing one instead of redeclaring it',
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return findings;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=foreign-reinvented.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { commentText, documentsSomething, finding, same, tokensFor } from './foreign-tokens.js';
|
|
2
|
+
/**
|
|
3
|
+
* Token-only, so it is the same check in every language: git says the file changed,
|
|
4
|
+
* the token stream says the program did not.
|
|
5
|
+
*/
|
|
6
|
+
export const foreignScopeCreep = {
|
|
7
|
+
name: 'scope-creep',
|
|
8
|
+
needs: ['syntax', 'base'],
|
|
9
|
+
run(g) {
|
|
10
|
+
const findings = [];
|
|
11
|
+
for (const file of g.foreign) {
|
|
12
|
+
if (!file.beforeTree)
|
|
13
|
+
continue;
|
|
14
|
+
const after = file.tree.rootNode;
|
|
15
|
+
const before = file.beforeTree.rootNode;
|
|
16
|
+
if (after.text === before.text)
|
|
17
|
+
continue;
|
|
18
|
+
if (!same(tokensFor(before, file.pack), tokensFor(after, file.pack)))
|
|
19
|
+
continue;
|
|
20
|
+
// Documentation is content. A change to `///` in Rust, a docstring in Python or
|
|
21
|
+
// a doc block anywhere is published API documentation someone meant to write,
|
|
22
|
+
// and telling them to revert it is wrong advice — so only pure layout churn is
|
|
23
|
+
// reported as something to undo.
|
|
24
|
+
const commentsChanged = commentText(before, file.pack) !== commentText(after, file.pack);
|
|
25
|
+
if (commentsChanged && documentsSomething(after, file.pack))
|
|
26
|
+
continue;
|
|
27
|
+
findings.push(finding(file, after, {
|
|
28
|
+
check: 'scope-creep',
|
|
29
|
+
severity: 'low',
|
|
30
|
+
confidence: 'proven',
|
|
31
|
+
title: 'This file changes only ' + (commentsChanged ? 'comments' : 'formatting') +
|
|
32
|
+
' — the program it produces is identical',
|
|
33
|
+
evidence: { oracle: file.pack.name + ' token stream', detail: 'before and after tokenize identically' },
|
|
34
|
+
fix: commentsChanged
|
|
35
|
+
? 'Keep it if the comments are the point; otherwise drop the file from the change'
|
|
36
|
+
: 'Revert the reformatting so the diff shows the actual change',
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
return findings;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=foreign-scope-creep.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error handling that defends nothing, in languages the TypeScript compiler cannot
|
|
3
|
+
* read. The rule is per-language because the idiom is: Python discards a failure with
|
|
4
|
+
* `except: pass`, Go with an `if err != nil` whose body does nothing. Each pack knows
|
|
5
|
+
* its own spelling; this verifier only asks.
|
|
6
|
+
*/
|
|
7
|
+
export const foreignSwallowedError = {
|
|
8
|
+
name: 'swallowed-error',
|
|
9
|
+
needs: ['syntax'],
|
|
10
|
+
supports: (file) => file.pack.swallowedError !== undefined,
|
|
11
|
+
run(g) {
|
|
12
|
+
const findings = [];
|
|
13
|
+
for (const file of g.foreign) {
|
|
14
|
+
for (const hit of file.pack.swallowedError?.(file.tree.rootNode) ?? []) {
|
|
15
|
+
const line = hit.node.startPosition.row + 1;
|
|
16
|
+
if (!file.changed.added.has(line))
|
|
17
|
+
continue;
|
|
18
|
+
findings.push({
|
|
19
|
+
id: '',
|
|
20
|
+
class: 'verified',
|
|
21
|
+
check: 'swallowed-error',
|
|
22
|
+
severity: hit.what.includes('only logs') ? 'medium' : 'high',
|
|
23
|
+
confidence: hit.what.includes('only logs') ? 'firm' : 'proven',
|
|
24
|
+
file: file.path,
|
|
25
|
+
line,
|
|
26
|
+
span: { column: hit.node.startPosition.column + 1, length: Math.min(hit.node.text.split('\n')[0]?.length ?? 1, 80) },
|
|
27
|
+
title: hit.what.charAt(0).toUpperCase() + hit.what.slice(1) + ' — the failure is discarded',
|
|
28
|
+
evidence: { oracle: file.pack.name + ' AST', detail: 'parsed by tree-sitter; no type information required' },
|
|
29
|
+
fix: 'Handle it, return it, or say in a comment why ignoring it is safe',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return findings;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=foreign-swallowed-error.js.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const TEST_FILE = /(^|\/)(test_[^/]+|[^/]+_test)\.[a-z]+$|(^|\/)tests?\//;
|
|
2
|
+
function at(file, node, f) {
|
|
3
|
+
return {
|
|
4
|
+
id: '',
|
|
5
|
+
class: 'verified',
|
|
6
|
+
file: file.path,
|
|
7
|
+
line: node.startPosition.row + 1,
|
|
8
|
+
span: { column: node.startPosition.column + 1, length: Math.min(node.text.split('\n')[0]?.length ?? 1, 80) },
|
|
9
|
+
...f,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** A test that runs code and proves nothing. */
|
|
13
|
+
export const foreignVacuousTest = {
|
|
14
|
+
name: 'vacuous-test',
|
|
15
|
+
needs: ['syntax'],
|
|
16
|
+
supports: (file) => file.pack.tests !== undefined && TEST_FILE.test(file.path),
|
|
17
|
+
run(g) {
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const file of g.foreign) {
|
|
20
|
+
if (!file.pack.tests || !TEST_FILE.test(file.path))
|
|
21
|
+
continue;
|
|
22
|
+
for (const test of file.pack.tests(file.tree.rootNode)) {
|
|
23
|
+
const line = test.node.startPosition.row + 1;
|
|
24
|
+
if (!file.changed.added.has(line) || !test.provesNothing)
|
|
25
|
+
continue;
|
|
26
|
+
findings.push(at(file, test.node, {
|
|
27
|
+
check: 'vacuous-test',
|
|
28
|
+
severity: 'high',
|
|
29
|
+
confidence: 'proven',
|
|
30
|
+
title: test.name + ' runs code but asserts nothing',
|
|
31
|
+
evidence: {
|
|
32
|
+
oracle: file.pack.name + ' test AST',
|
|
33
|
+
detail: 'no assert, no assertion method, and no raises block anywhere in the body',
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** The enclosing test with the expected value blanked out. */
|
|
42
|
+
function contextOf(test, expectedNode) {
|
|
43
|
+
const text = test.text;
|
|
44
|
+
const offset = expectedNode.startPosition.row - test.startPosition.row;
|
|
45
|
+
const lines = text.split('\n');
|
|
46
|
+
const line = lines[offset];
|
|
47
|
+
if (line === undefined)
|
|
48
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
49
|
+
const column = expectedNode.startPosition.row === test.startPosition.row
|
|
50
|
+
? expectedNode.startPosition.column - test.startPosition.column
|
|
51
|
+
: expectedNode.startPosition.column;
|
|
52
|
+
lines[offset] = line.slice(0, column) + '\u0000' + line.slice(column + expectedNode.text.length);
|
|
53
|
+
return lines.join('\n').replace(/\s+/g, ' ').trim();
|
|
54
|
+
}
|
|
55
|
+
/** An expected value edited to match new output. */
|
|
56
|
+
export const foreignAssertionDrift = {
|
|
57
|
+
name: 'assertion-drift',
|
|
58
|
+
needs: ['syntax', 'base'],
|
|
59
|
+
supports: (file) => file.pack.tests !== undefined && TEST_FILE.test(file.path),
|
|
60
|
+
run(g) {
|
|
61
|
+
const changedModules = new Set(g.changed
|
|
62
|
+
.map((f) => f.path)
|
|
63
|
+
.filter((p) => !TEST_FILE.test(p))
|
|
64
|
+
.map((p) => (p.split('/').pop() ?? '').replace(/\.[a-z]+$/, '')));
|
|
65
|
+
const findings = [];
|
|
66
|
+
for (const file of g.foreign) {
|
|
67
|
+
if (!file.pack.tests || !file.beforeTree || !TEST_FILE.test(file.path))
|
|
68
|
+
continue;
|
|
69
|
+
const subject = (file.path.split('/').pop() ?? '')
|
|
70
|
+
.replace(/\.[a-z]+$/, '')
|
|
71
|
+
.replace(/^test_/, '')
|
|
72
|
+
.replace(/_test$/, '');
|
|
73
|
+
if (changedModules.has(subject))
|
|
74
|
+
continue;
|
|
75
|
+
const now = new Map();
|
|
76
|
+
for (const t of file.pack.tests(file.tree.rootNode)) {
|
|
77
|
+
for (const a of t.assertions) {
|
|
78
|
+
now.set(t.name + '|' + a.subject, { expected: a.expected, node: a.node, expectedNode: a.expectedNode, test: t.node });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const t of file.pack.tests(file.beforeTree.rootNode)) {
|
|
82
|
+
for (const a of t.assertions) {
|
|
83
|
+
const is = now.get(t.name + '|' + a.subject);
|
|
84
|
+
if (!is || is.expected === a.expected)
|
|
85
|
+
continue;
|
|
86
|
+
if (contextOf(is.test, is.expectedNode) !== contextOf(t.node, a.expectedNode))
|
|
87
|
+
continue;
|
|
88
|
+
findings.push(at(file, is.node, {
|
|
89
|
+
check: 'assertion-drift',
|
|
90
|
+
severity: 'high',
|
|
91
|
+
confidence: 'firm',
|
|
92
|
+
title: 'Expected value for `' + a.subject + '` changed from ' + a.expected + ' to ' + is.expected,
|
|
93
|
+
evidence: {
|
|
94
|
+
oracle: file.pack.name + ' pre/post test AST',
|
|
95
|
+
detail: 'the module this test covers did not change — the expectation moved with nothing it measures',
|
|
96
|
+
},
|
|
97
|
+
fix: 'Confirm the new value is correct behaviour, not the test being bent to fit the code',
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
/** Documentation that contradicts the signature it sits on. */
|
|
106
|
+
export const foreignLyingComment = {
|
|
107
|
+
name: 'lying-comment',
|
|
108
|
+
needs: ['syntax'],
|
|
109
|
+
supports: (file) => file.pack.documentedParams !== undefined,
|
|
110
|
+
run(g) {
|
|
111
|
+
const findings = [];
|
|
112
|
+
for (const file of g.foreign) {
|
|
113
|
+
if (!file.pack.documentedParams)
|
|
114
|
+
continue;
|
|
115
|
+
for (const doc of file.pack.documentedParams(file.tree.rootNode)) {
|
|
116
|
+
for (const documented of doc.documented) {
|
|
117
|
+
const line = documented.node.startPosition.row + 1;
|
|
118
|
+
if (!file.changed.added.has(line))
|
|
119
|
+
continue;
|
|
120
|
+
if (doc.declared.includes(documented.name))
|
|
121
|
+
continue;
|
|
122
|
+
findings.push(at(file, documented.node, {
|
|
123
|
+
check: 'lying-comment',
|
|
124
|
+
severity: 'medium',
|
|
125
|
+
confidence: 'proven',
|
|
126
|
+
title: 'The docstring documents `' +
|
|
127
|
+
documented.name +
|
|
128
|
+
'`, which ' +
|
|
129
|
+
doc.fn +
|
|
130
|
+
'() does not take (' +
|
|
131
|
+
(doc.declared.length > 0 ? 'it takes ' + doc.declared.join(', ') : 'it takes none') +
|
|
132
|
+
')',
|
|
133
|
+
evidence: { oracle: 'signature', detail: 'the documented name is not in the parameter list' },
|
|
134
|
+
fix: 'Update the docstring to the real parameters, or restore the argument it describes',
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return findings;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
export const FOREIGN_TEST_VERIFIERS = [foreignVacuousTest, foreignAssertionDrift, foreignLyingComment];
|
|
143
|
+
//# sourceMappingURL=foreign-tests.js.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { nodesOfType, walk } from '#app/lang/packs.js';
|
|
2
|
+
/**
|
|
3
|
+
* In tree-sitter-rust and -go a `string_literal` has exactly two children, the quotes,
|
|
4
|
+
* and the text between them belongs to no node — so walking to leaves loses it, and
|
|
5
|
+
* two files differing only in what their strings say tokenize identically. Atomic.
|
|
6
|
+
*/
|
|
7
|
+
const ATOMIC = /string|char|raw_|heredoc|interpolat/;
|
|
8
|
+
/** The token stream: what a compiler sees, minus layout and comments. */
|
|
9
|
+
export function tokensFor(root, pack) {
|
|
10
|
+
const out = [];
|
|
11
|
+
const visit = (n) => {
|
|
12
|
+
if (pack.nodes.comment.includes(n.type))
|
|
13
|
+
return;
|
|
14
|
+
if (ATOMIC.test(n.type)) {
|
|
15
|
+
out.push({ type: n.type, text: n.text });
|
|
16
|
+
return; // atomic: never descend, so the content is compared whole
|
|
17
|
+
}
|
|
18
|
+
if (n.childCount === 0) {
|
|
19
|
+
if (n.text.trim() !== '')
|
|
20
|
+
out.push({ type: n.type, text: n.text });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (let i = 0; i < n.childCount; i++) {
|
|
24
|
+
const child = n.child(i);
|
|
25
|
+
if (child)
|
|
26
|
+
visit(child);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
visit(root);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export function commentText(root, pack) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
walk(root, (n) => {
|
|
35
|
+
if (pack.nodes.comment.includes(n.type))
|
|
36
|
+
parts.push(n.text.trim());
|
|
37
|
+
});
|
|
38
|
+
return parts.join('\n');
|
|
39
|
+
}
|
|
40
|
+
/** Doc comments — `///`, `//!`, JSDoc-style blocks — are documentation, not an aside. */
|
|
41
|
+
export function documentsSomething(root, pack) {
|
|
42
|
+
let found = false;
|
|
43
|
+
walk(root, (n) => {
|
|
44
|
+
if (!pack.nodes.comment.includes(n.type))
|
|
45
|
+
return;
|
|
46
|
+
const t = n.text.trimStart();
|
|
47
|
+
if (t.startsWith('///') || t.startsWith('//!') || t.startsWith('/**') || t.startsWith('#:'))
|
|
48
|
+
found = true;
|
|
49
|
+
});
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
export function same(a, b) {
|
|
53
|
+
return a.length === b.length && a.every((t, i) => t.type === b[i].type && t.text === b[i].text);
|
|
54
|
+
}
|
|
55
|
+
export function finding(file, node, f) {
|
|
56
|
+
return {
|
|
57
|
+
id: '',
|
|
58
|
+
class: 'verified',
|
|
59
|
+
file: file.path,
|
|
60
|
+
line: node.startPosition.row + 1,
|
|
61
|
+
span: { column: node.startPosition.column + 1, length: Math.min(node.text.split('\n')[0]?.length ?? 1, 90) },
|
|
62
|
+
...f,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The grammar's name field is authoritative whatever node type it is — Rust names a
|
|
67
|
+
* struct `type_identifier`. Second-guessing it by node type once returned `static`.
|
|
68
|
+
*/
|
|
69
|
+
export function declaredName(decl, pack) {
|
|
70
|
+
const field = decl.childForFieldName(pack.nodes.declarationName);
|
|
71
|
+
if (!field)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (field.childCount === 0)
|
|
74
|
+
return field.text;
|
|
75
|
+
// C and C++ wrap the name in a declarator; look inside that, never wider
|
|
76
|
+
return nodesOfType(field, pack.nodes.identifier)[0]?.text;
|
|
77
|
+
}
|
|
78
|
+
/** Module level only: two classes sharing a method name is polymorphism. */
|
|
79
|
+
export function topLevelDeclarations(root, pack) {
|
|
80
|
+
const all = nodesOfType(root, pack.nodes.declaration);
|
|
81
|
+
const out = new Map();
|
|
82
|
+
for (const decl of all) {
|
|
83
|
+
const nested = all.some((other) => other !== decl &&
|
|
84
|
+
other.startPosition.row <= decl.startPosition.row &&
|
|
85
|
+
other.endPosition.row >= decl.endPosition.row);
|
|
86
|
+
if (nested)
|
|
87
|
+
continue;
|
|
88
|
+
const name = declaredName(decl, pack);
|
|
89
|
+
if (name)
|
|
90
|
+
out.set(name, decl);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=foreign-tokens.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { foreignScopeCreep } from './foreign-scope-creep.js';
|
|
2
|
+
import { foreignCopyPasteDrift } from './foreign-copy-paste-drift.js';
|
|
3
|
+
import { foreignDroppedGuard } from './foreign-dropped-guard.js';
|
|
4
|
+
import { foreignReinvented } from './foreign-reinvented.js';
|
|
5
|
+
import { foreignPhantomConfig } from './foreign-phantom-config.js';
|
|
6
|
+
export { foreignScopeCreep, foreignCopyPasteDrift, foreignDroppedGuard, foreignReinvented, foreignPhantomConfig };
|
|
7
|
+
export { tokensFor } from './foreign-tokens.js';
|
|
8
|
+
/** The checks that read a tree-sitter parse rather than a TypeScript program. */
|
|
9
|
+
export const FOREIGN_VERIFIERS = [
|
|
10
|
+
foreignScopeCreep,
|
|
11
|
+
foreignCopyPasteDrift,
|
|
12
|
+
foreignDroppedGuard,
|
|
13
|
+
foreignReinvented,
|
|
14
|
+
foreignPhantomConfig,
|
|
15
|
+
];
|
|
16
|
+
//# sourceMappingURL=foreign.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { phantomApi } from './phantom-api.js';
|
|
2
|
+
import { phantomDep } from './phantom-dep.js';
|
|
3
|
+
import { reinvented } from './reinvented.js';
|
|
4
|
+
import { droppedGuard } from './dropped-guard.js';
|
|
5
|
+
import { vacuousTest } from './vacuous-test.js';
|
|
6
|
+
import { swallowedError } from './swallowed-error.js';
|
|
7
|
+
import { assertionDrift } from './assertion-drift.js';
|
|
8
|
+
import { contractDrift } from './contract-drift.js';
|
|
9
|
+
import { scopeCreep } from './scope-creep.js';
|
|
10
|
+
import { phantomConfig } from './phantom-config.js';
|
|
11
|
+
import { copyPasteDrift } from './copy-paste-drift.js';
|
|
12
|
+
import { deadOnArrival } from './dead-on-arrival.js';
|
|
13
|
+
import { lyingComment } from './lying-comment.js';
|
|
14
|
+
import { foreignSwallowedError } from './foreign-swallowed-error.js';
|
|
15
|
+
import { FOREIGN_VERIFIERS } from './foreign.js';
|
|
16
|
+
import { foreignPhantomDep } from './foreign-phantom-dep.js';
|
|
17
|
+
import { foreignPhantomApi } from './foreign-phantom-api.js';
|
|
18
|
+
import { foreignContractDrift } from './foreign-contract-drift.js';
|
|
19
|
+
import { FOREIGN_TEST_VERIFIERS } from './foreign-tests.js';
|
|
20
|
+
/** The tree-sitter half shares its names with the TypeScript half; the ids differ. */
|
|
21
|
+
const tagged = (list) => list.map((v) => ({ ...v, id: 'foreign-' + v.name, domain: v.domain ?? 'foreign' }));
|
|
22
|
+
export const VERIFIERS = [
|
|
23
|
+
...[
|
|
24
|
+
phantomApi, phantomDep, reinvented, droppedGuard, swallowedError, vacuousTest,
|
|
25
|
+
assertionDrift, contractDrift, scopeCreep, phantomConfig, copyPasteDrift,
|
|
26
|
+
deadOnArrival, lyingComment,
|
|
27
|
+
].map((v) => ({ ...v, domain: 'typescript' })),
|
|
28
|
+
...tagged([
|
|
29
|
+
foreignSwallowedError,
|
|
30
|
+
...FOREIGN_VERIFIERS,
|
|
31
|
+
foreignPhantomDep,
|
|
32
|
+
{ ...foreignPhantomApi, domain: 'python' },
|
|
33
|
+
{ ...foreignContractDrift, domain: 'python' },
|
|
34
|
+
...FOREIGN_TEST_VERIFIERS,
|
|
35
|
+
]),
|
|
36
|
+
].map((v) => ({ ...v, id: v.id ?? v.name }));
|
|
37
|
+
export { phantomApi, phantomDep, reinvented, droppedGuard, swallowedError, vacuousTest, assertionDrift, contractDrift, scopeCreep, phantomConfig, copyPasteDrift, deadOnArrival, lyingComment, };
|
|
38
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Node } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
function documentedFunctions(sf) {
|
|
4
|
+
const out = [];
|
|
5
|
+
const collect = (node, name) => {
|
|
6
|
+
if (!Node.isJSDocable(node))
|
|
7
|
+
return;
|
|
8
|
+
if (node.getJsDocs().length === 0)
|
|
9
|
+
return;
|
|
10
|
+
if (!Node.isFunctionDeclaration(node) && !Node.isMethodDeclaration(node))
|
|
11
|
+
return;
|
|
12
|
+
const params = node.getParameters();
|
|
13
|
+
out.push({
|
|
14
|
+
node,
|
|
15
|
+
name,
|
|
16
|
+
params: params.map((p) => p.getName()),
|
|
17
|
+
paramsReliable: params.every((p) => Node.isIdentifier(p.getNameNode())),
|
|
18
|
+
returns: node.getReturnTypeNode()?.getText() ?? '',
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
for (const fn of sf.getFunctions())
|
|
22
|
+
collect(fn, fn.getName() ?? 'function');
|
|
23
|
+
for (const cls of sf.getClasses()) {
|
|
24
|
+
for (const m of cls.getMethods())
|
|
25
|
+
collect(m, (cls.getName() ?? '') + '.' + m.getName());
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
const VOID_RETURN = new Set(['void', 'Promise<void>', 'never']);
|
|
30
|
+
/** Documentation that contradicts the code it sits on. */
|
|
31
|
+
export const lyingComment = {
|
|
32
|
+
name: 'lying-comment',
|
|
33
|
+
needs: ['syntax'],
|
|
34
|
+
run(g) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
for (const { sf, changed } of g.files) {
|
|
37
|
+
const file = relPath(sf, g.root);
|
|
38
|
+
for (const fn of documentedFunctions(sf)) {
|
|
39
|
+
for (const doc of fn.node.getJsDocs()) {
|
|
40
|
+
for (const tag of doc.getTags()) {
|
|
41
|
+
const line = tag.getStartLineNumber();
|
|
42
|
+
if (!changed.added.has(line))
|
|
43
|
+
continue;
|
|
44
|
+
const tagName = tag.getTagName();
|
|
45
|
+
if (fn.paramsReliable && Node.isJSDocParameterTag(tag)) {
|
|
46
|
+
// `@param options.retries` documents a property of a real parameter
|
|
47
|
+
const root = tag.getName().split('.')[0] ?? '';
|
|
48
|
+
if (root !== '' && !fn.params.includes(root)) {
|
|
49
|
+
findings.push({
|
|
50
|
+
id: '',
|
|
51
|
+
class: 'verified',
|
|
52
|
+
check: 'lying-comment',
|
|
53
|
+
severity: 'medium',
|
|
54
|
+
confidence: 'proven',
|
|
55
|
+
file,
|
|
56
|
+
line,
|
|
57
|
+
span: locate(sf, tag.getStart(), tag.getWidth()).span,
|
|
58
|
+
title: '@param `' + root + '` documents an argument ' + fn.name + '() does not take (' +
|
|
59
|
+
(fn.params.length > 0 ? 'it takes ' + fn.params.join(', ') : 'it takes none') + ')',
|
|
60
|
+
evidence: { oracle: 'signature', detail: 'the documented name is not in the parameter list' },
|
|
61
|
+
fix: 'Update the doc to the real parameters, or restore the argument it describes',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if ((tagName === 'returns' || tagName === 'return') && VOID_RETURN.has(fn.returns)) {
|
|
66
|
+
const text = tag.getCommentText()?.trim() ?? '';
|
|
67
|
+
if (text !== '') {
|
|
68
|
+
findings.push({
|
|
69
|
+
id: '',
|
|
70
|
+
class: 'verified',
|
|
71
|
+
check: 'lying-comment',
|
|
72
|
+
severity: 'medium',
|
|
73
|
+
confidence: 'proven',
|
|
74
|
+
file,
|
|
75
|
+
line,
|
|
76
|
+
span: locate(sf, tag.getStart(), tag.getWidth()).span,
|
|
77
|
+
title: '@returns describes a value, but ' + fn.name + '() is declared to return ' + fn.returns,
|
|
78
|
+
evidence: { oracle: 'signature', detail: 'the declared return type yields nothing to return' },
|
|
79
|
+
fix: 'Drop the @returns, or return the value the doc promises',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=lying-comment.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { locate, relPath } from '#app/ground.js';
|
|
2
|
+
/**
|
|
3
|
+
* Compiler diagnostics that mean "the code refers to something that does not exist".
|
|
4
|
+
* Deliberately excludes 2307 (cannot find module) — phantom-dep owns that, so the
|
|
5
|
+
* same defect is never reported twice.
|
|
6
|
+
*/
|
|
7
|
+
const HALLUCINATION_CODES = new Map([
|
|
8
|
+
[2339, 'property does not exist on type'],
|
|
9
|
+
[2551, 'property does not exist on type (close match suggested)'],
|
|
10
|
+
[2554, 'wrong number of arguments'],
|
|
11
|
+
[2555, 'wrong number of arguments'],
|
|
12
|
+
[2724, 'module has no exported member'],
|
|
13
|
+
[2694, 'namespace has no exported member'],
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* The checker often knows the right answer: "did you mean 'toUpperCase'?". Where it
|
|
17
|
+
* does, the finding carries an exact replacement rather than advice, which a review
|
|
18
|
+
* host can render as a one-click suggestion.
|
|
19
|
+
*/
|
|
20
|
+
function suggestedName(message) {
|
|
21
|
+
return /Did you mean '([^']+)'\?/.exec(message)?.[1];
|
|
22
|
+
}
|
|
23
|
+
/*
|
|
24
|
+
* "Cannot find name" (TS2304/2552) is deliberately absent. Bench showed it firing on
|
|
25
|
+
* `chrome` in a browser-extension package and on test-runner globals — it reports an
|
|
26
|
+
* incompletely resolved project, not an invented API, and in a monorepo that is the
|
|
27
|
+
* normal state when one root project spans many packages. The codes that really do
|
|
28
|
+
* mean "this does not exist" are the property and arity ones kept above.
|
|
29
|
+
*/
|
|
30
|
+
/** A diagnostic message is either a string or a nested chain — flatten it to one line. */
|
|
31
|
+
function flatten(message) {
|
|
32
|
+
if (typeof message === 'string')
|
|
33
|
+
return message;
|
|
34
|
+
let text = message.getMessageText();
|
|
35
|
+
for (const next of message.getNext() ?? [])
|
|
36
|
+
text += ' ' + flatten(next);
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The flagship check: an API the generated code invented. The TS checker answers
|
|
41
|
+
* exactly, so a hit here is proven — but only report hits on changed lines,
|
|
42
|
+
* otherwise this is just `tsc` output rather than a review.
|
|
43
|
+
*/
|
|
44
|
+
export const phantomApi = {
|
|
45
|
+
name: 'phantom-api',
|
|
46
|
+
needs: ['types'],
|
|
47
|
+
run(g) {
|
|
48
|
+
// Without a tsconfig the checker has no lib or path resolution, and every
|
|
49
|
+
// global would look invented. Refusing to run beats reporting garbage.
|
|
50
|
+
if (!g.typed)
|
|
51
|
+
return [];
|
|
52
|
+
const findings = [];
|
|
53
|
+
for (const { sf, changed, typed } of g.files) {
|
|
54
|
+
// a file the tsconfig does not include has no bound program; the checker
|
|
55
|
+
// throws rather than answering, and the answer would be garbage anyway
|
|
56
|
+
if (!typed)
|
|
57
|
+
continue;
|
|
58
|
+
const file = relPath(sf, g.root);
|
|
59
|
+
for (const d of sf.getPreEmitDiagnostics()) {
|
|
60
|
+
const code = d.getCode();
|
|
61
|
+
const label = HALLUCINATION_CODES.get(code);
|
|
62
|
+
if (!label)
|
|
63
|
+
continue;
|
|
64
|
+
const start = d.getStart();
|
|
65
|
+
const line = d.getLineNumber();
|
|
66
|
+
if (line === undefined || !changed.added.has(line))
|
|
67
|
+
continue;
|
|
68
|
+
const span = start === undefined ? undefined : locate(sf, start, d.getLength() ?? 1).span;
|
|
69
|
+
const detail = flatten(d.getMessageText());
|
|
70
|
+
findings.push({
|
|
71
|
+
id: '',
|
|
72
|
+
class: 'verified',
|
|
73
|
+
check: 'phantom-api',
|
|
74
|
+
severity: 'high',
|
|
75
|
+
confidence: 'proven',
|
|
76
|
+
file,
|
|
77
|
+
line,
|
|
78
|
+
span,
|
|
79
|
+
title: detail,
|
|
80
|
+
replacement: suggestedName(detail),
|
|
81
|
+
evidence: { oracle: 'typescript', detail: 'TS' + code + ': ' + label },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=phantom-api.js.map
|