@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,92 @@
|
|
|
1
|
+
import { Node } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
/**
|
|
4
|
+
* Only module-private declarations are considered. An exported symbol with no callers
|
|
5
|
+
* may simply be public API this repo does not consume itself, which is not a defect —
|
|
6
|
+
* an unexported one that nothing in its own module reaches provably is.
|
|
7
|
+
*/
|
|
8
|
+
function privateDeclarations(sf) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const fn of sf.getFunctions()) {
|
|
11
|
+
const name = fn.getName();
|
|
12
|
+
const id = fn.getNameNode();
|
|
13
|
+
if (!name || !id || fn.isExported() || fn.isDefaultExport())
|
|
14
|
+
continue;
|
|
15
|
+
out.push({ name, node: fn, nameStart: id.getStart(), nameWidth: id.getWidth(), line: fn.getStartLineNumber() });
|
|
16
|
+
}
|
|
17
|
+
for (const cls of sf.getClasses()) {
|
|
18
|
+
const name = cls.getName();
|
|
19
|
+
const id = cls.getNameNode();
|
|
20
|
+
if (!name || !id || cls.isExported() || cls.isDefaultExport())
|
|
21
|
+
continue;
|
|
22
|
+
out.push({ name, node: cls, nameStart: id.getStart(), nameWidth: id.getWidth(), line: cls.getStartLineNumber() });
|
|
23
|
+
}
|
|
24
|
+
for (const stmt of sf.getVariableStatements()) {
|
|
25
|
+
if (stmt.isExported())
|
|
26
|
+
continue;
|
|
27
|
+
for (const v of stmt.getDeclarations()) {
|
|
28
|
+
const id = v.getNameNode();
|
|
29
|
+
// destructuring binds several names at once; skip rather than guess which is dead
|
|
30
|
+
if (!Node.isIdentifier(id))
|
|
31
|
+
continue;
|
|
32
|
+
out.push({
|
|
33
|
+
name: v.getName(),
|
|
34
|
+
node: v,
|
|
35
|
+
nameStart: id.getStart(),
|
|
36
|
+
nameWidth: id.getWidth(),
|
|
37
|
+
line: v.getStartLineNumber(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** `_unused` is the conventional way to say "deliberately not referenced". */
|
|
44
|
+
function deliberatelyUnused(name) {
|
|
45
|
+
return name.startsWith('_');
|
|
46
|
+
}
|
|
47
|
+
/** Code the change adds that nothing reaches. */
|
|
48
|
+
export const deadOnArrival = {
|
|
49
|
+
name: 'dead-on-arrival',
|
|
50
|
+
needs: ['references'],
|
|
51
|
+
run(g) {
|
|
52
|
+
const findings = [];
|
|
53
|
+
for (const { sf, changed } of g.files) {
|
|
54
|
+
const file = relPath(sf, g.root);
|
|
55
|
+
for (const decl of privateDeclarations(sf)) {
|
|
56
|
+
if (!changed.added.has(decl.line))
|
|
57
|
+
continue;
|
|
58
|
+
if (deliberatelyUnused(decl.name))
|
|
59
|
+
continue;
|
|
60
|
+
let refs;
|
|
61
|
+
try {
|
|
62
|
+
refs = decl.node.findReferencesAsNodes();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue; // no answer from the language service is not the same as "no callers"
|
|
66
|
+
}
|
|
67
|
+
if (refs.length > 0)
|
|
68
|
+
continue;
|
|
69
|
+
// A decorator can register what it decorates without ever naming it again,
|
|
70
|
+
// so for those the reference graph answers a narrower question than the one
|
|
71
|
+
// the finding asks. Everywhere else nothing outside the module can reach a
|
|
72
|
+
// module-private name at all, which is what makes the graph's answer exact.
|
|
73
|
+
const decorated = Node.isDecoratable(decl.node) && decl.node.getDecorators().length > 0;
|
|
74
|
+
findings.push({
|
|
75
|
+
id: '',
|
|
76
|
+
class: 'verified',
|
|
77
|
+
check: 'dead-on-arrival',
|
|
78
|
+
severity: 'low',
|
|
79
|
+
confidence: decorated ? 'firm' : 'proven',
|
|
80
|
+
file,
|
|
81
|
+
line: decl.line,
|
|
82
|
+
span: locate(sf, decl.nameStart, decl.nameWidth).span,
|
|
83
|
+
title: decl.name + ' is added by this change and nothing references it',
|
|
84
|
+
evidence: { oracle: 'reference graph', detail: 'module-private and unreferenced anywhere in the project' },
|
|
85
|
+
fix: 'Delete it, or export it if something outside this module is meant to use it',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return findings;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
//# sourceMappingURL=dead-on-arrival.js.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
function functionsOf(sf) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const fn of sf.getFunctions()) {
|
|
6
|
+
const name = fn.getName();
|
|
7
|
+
if (name)
|
|
8
|
+
out.push({ name, node: fn });
|
|
9
|
+
}
|
|
10
|
+
for (const cls of sf.getClasses()) {
|
|
11
|
+
const prefix = (cls.getName() ?? 'anonymous') + '.';
|
|
12
|
+
for (const m of cls.getMethods())
|
|
13
|
+
out.push({ name: prefix + m.getName(), node: m });
|
|
14
|
+
}
|
|
15
|
+
for (const v of sf.getVariableDeclarations()) {
|
|
16
|
+
const init = v.getInitializer();
|
|
17
|
+
if (init?.isKind(SyntaxKind.ArrowFunction) || init?.isKind(SyntaxKind.FunctionExpression)) {
|
|
18
|
+
out.push({ name: v.getName(), node: v });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** collapse whitespace so reformatting alone never reads as a dropped guard */
|
|
24
|
+
function normalize(text) {
|
|
25
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
26
|
+
}
|
|
27
|
+
/** every identifier the new version of a function still mentions */
|
|
28
|
+
function identifiersIn(fn) {
|
|
29
|
+
const out = new Set();
|
|
30
|
+
for (const id of fn.getDescendantsOfKind(SyntaxKind.Identifier))
|
|
31
|
+
out.add(id.getText());
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The things a condition actually reads: the base of each reference, never the
|
|
36
|
+
* property names hanging off it. In `!inv.customer` the root is `inv` — `customer`
|
|
37
|
+
* is a field, and a rewritten body has no reason to mention it.
|
|
38
|
+
*/
|
|
39
|
+
function rootsOf(condition) {
|
|
40
|
+
const roots = [];
|
|
41
|
+
for (const id of condition.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
42
|
+
const parent = id.getParent();
|
|
43
|
+
// skip the `.name` half of a property access, and object-literal keys
|
|
44
|
+
if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === id)
|
|
45
|
+
continue;
|
|
46
|
+
if (Node.isPropertyAssignment(parent) && parent.getNameNode() === id)
|
|
47
|
+
continue;
|
|
48
|
+
roots.push(id.getText());
|
|
49
|
+
}
|
|
50
|
+
if (Node.isIdentifier(condition))
|
|
51
|
+
roots.push(condition.getText());
|
|
52
|
+
return roots;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A guard whose roots the rewritten function no longer has is not a guard someone
|
|
56
|
+
* dropped — it stopped existing along with the code it protected. `formatWeight`
|
|
57
|
+
* rewritten from ounces to grams cannot keep `if (pounds === 0)`, and reporting it
|
|
58
|
+
* would accuse a deliberate rewrite of losing a check it could not have kept.
|
|
59
|
+
*/
|
|
60
|
+
function stillApplicable(roots, available) {
|
|
61
|
+
return roots.every((n) => available.has(n) || GLOBALS.has(n));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The same check, respelled. When `pool` turns from an array into a number,
|
|
65
|
+
* `pool.length === 0` becomes `pool === 0`: different text, same guarantee. Matching
|
|
66
|
+
* on what a guard reads rather than how it is written keeps a rewrite from reading
|
|
67
|
+
* as a removal — at the cost of missing a guard narrowed on the same variable, which
|
|
68
|
+
* is the direction this tool chooses to err in.
|
|
69
|
+
*/
|
|
70
|
+
function guardedElsewhere(roots, after) {
|
|
71
|
+
if (roots.length === 0)
|
|
72
|
+
return false;
|
|
73
|
+
return [...after.values()].some((other) => roots.every((r) => other.includes(r)));
|
|
74
|
+
}
|
|
75
|
+
const GLOBALS = new Set(['undefined', 'NaN', 'Number', 'String', 'Array', 'Object', 'Boolean', 'Math', 'JSON', 'Date']);
|
|
76
|
+
/**
|
|
77
|
+
* A guard is an `if` whose branch bails out — throw, return, continue, or break.
|
|
78
|
+
* That shape is what protects the code below it, so losing one changes behaviour.
|
|
79
|
+
*/
|
|
80
|
+
function guardsOf(fn) {
|
|
81
|
+
const guards = new Map();
|
|
82
|
+
for (const ifStmt of fn.getDescendantsOfKind(SyntaxKind.IfStatement)) {
|
|
83
|
+
const branch = ifStmt.getThenStatement();
|
|
84
|
+
const bails = branch.getDescendantsOfKind(SyntaxKind.ThrowStatement).length > 0 ||
|
|
85
|
+
branch.getDescendantsOfKind(SyntaxKind.ReturnStatement).length > 0 ||
|
|
86
|
+
branch.getDescendantsOfKind(SyntaxKind.ContinueStatement).length > 0 ||
|
|
87
|
+
branch.getDescendantsOfKind(SyntaxKind.BreakStatement).length > 0 ||
|
|
88
|
+
Node.isThrowStatement(branch) ||
|
|
89
|
+
Node.isReturnStatement(branch);
|
|
90
|
+
if (bails)
|
|
91
|
+
guards.set(normalize(ifStmt.getExpression().getText()), rootsOf(ifStmt.getExpression()));
|
|
92
|
+
}
|
|
93
|
+
return guards;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A guard that existed before the change and is gone after it, in a function that
|
|
97
|
+
* still exists. The pre/post AST pair is the oracle, so this is proven: the
|
|
98
|
+
* condition text was there and now it is not.
|
|
99
|
+
*/
|
|
100
|
+
export const droppedGuard = {
|
|
101
|
+
name: 'dropped-guard',
|
|
102
|
+
needs: ['syntax', 'base'],
|
|
103
|
+
run(g) {
|
|
104
|
+
const findings = [];
|
|
105
|
+
for (const { sf, before } of g.files) {
|
|
106
|
+
if (!before)
|
|
107
|
+
continue; // a new file cannot have dropped anything
|
|
108
|
+
const after = new Map(functionsOf(sf).map((f) => [f.name, f]));
|
|
109
|
+
for (const prev of functionsOf(before)) {
|
|
110
|
+
const now = after.get(prev.name);
|
|
111
|
+
if (!now)
|
|
112
|
+
continue; // function removed entirely — that is a different finding
|
|
113
|
+
const had = guardsOf(prev.node);
|
|
114
|
+
if (had.size === 0)
|
|
115
|
+
continue;
|
|
116
|
+
const has = guardsOf(now.node);
|
|
117
|
+
const available = identifiersIn(now.node);
|
|
118
|
+
const dropped = [...had.entries()]
|
|
119
|
+
.filter(([guard, roots]) => !has.has(guard) && stillApplicable(roots, available) && !guardedElsewhere(roots, has))
|
|
120
|
+
.map(([guard]) => guard);
|
|
121
|
+
if (dropped.length === 0)
|
|
122
|
+
continue;
|
|
123
|
+
// one finding per function, not per guard: several guards lost in the same
|
|
124
|
+
// rewrite are one fact about one place, and repeating the line is noise
|
|
125
|
+
findings.push({
|
|
126
|
+
id: '',
|
|
127
|
+
class: 'verified',
|
|
128
|
+
check: 'dropped-guard',
|
|
129
|
+
severity: 'high',
|
|
130
|
+
confidence: 'proven',
|
|
131
|
+
file: relPath(sf, g.root),
|
|
132
|
+
line: now.node.getStartLineNumber(),
|
|
133
|
+
span: locate(sf, now.node.getStart(), Math.min(now.node.getWidth(), 80)).span,
|
|
134
|
+
title: dropped.map((d) => '`if (' + d + ')`').join(' and ') +
|
|
135
|
+
' ' + (dropped.length > 1 ? 'were' : 'was') +
|
|
136
|
+
' present in ' + prev.name + '() before this change and ' + (dropped.length > 1 ? 'are' : 'is') + ' gone',
|
|
137
|
+
evidence: { oracle: 'pre/post AST', detail: 'early-exit branch removed while the function still exists and still uses the same names' },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
//# sourceMappingURL=dropped-guard.js.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { pyrightDiagnostics } from '#app/lang/pyright.js';
|
|
4
|
+
import { decode, lines } from '#app/text.js';
|
|
5
|
+
import { repoPath } from '#app/fspolicy.js';
|
|
6
|
+
/**
|
|
7
|
+
* A Python signature changed and callers were left behind.
|
|
8
|
+
*
|
|
9
|
+
* The break is detected syntactically — a required parameter appeared, or one is
|
|
10
|
+
* gone — which is cheap and needs no checker. Finding who broke is where pyright
|
|
11
|
+
* earns its place: its errors land *at the call sites*, in files this change never
|
|
12
|
+
* touched, which is precisely the blast radius nothing else in a Python project is
|
|
13
|
+
* watching for.
|
|
14
|
+
*/
|
|
15
|
+
function callsFunction(line, name) {
|
|
16
|
+
let from = 0;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const at = line.indexOf(name, from);
|
|
19
|
+
if (at === -1)
|
|
20
|
+
return false;
|
|
21
|
+
const before = at === 0 ? '' : line[at - 1];
|
|
22
|
+
const after = line.slice(at + name.length).trimStart();
|
|
23
|
+
const isWordBoundary = before === '' || !/[A-Za-z0-9_.]/.test(before);
|
|
24
|
+
if (isWordBoundary && after.startsWith('('))
|
|
25
|
+
return true;
|
|
26
|
+
from = at + name.length;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const foreignContractDrift = {
|
|
30
|
+
name: 'contract-drift',
|
|
31
|
+
needs: ['syntax', 'base', 'python-types'],
|
|
32
|
+
run(g) {
|
|
33
|
+
const python = g.foreign.filter((f) => f.pack.name === 'python' && f.beforeTree);
|
|
34
|
+
if (python.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
// which callables lost compatibility, and in which file
|
|
37
|
+
const broken = new Map();
|
|
38
|
+
for (const file of python) {
|
|
39
|
+
const now = file.pack.signatures?.(file.tree.rootNode);
|
|
40
|
+
const was = file.pack.signatures?.(file.beforeTree.rootNode);
|
|
41
|
+
if (!now || !was)
|
|
42
|
+
continue;
|
|
43
|
+
for (const [name, before] of was) {
|
|
44
|
+
const after = now.get(name);
|
|
45
|
+
if (!after)
|
|
46
|
+
continue;
|
|
47
|
+
if (after.required > before.required) {
|
|
48
|
+
broken.set(name, {
|
|
49
|
+
file: file.path,
|
|
50
|
+
line: after.node.startPosition.row + 1,
|
|
51
|
+
detail: 'now requires ' + after.required + ' argument(s), was ' + before.required,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (after.params.length < before.params.length) {
|
|
55
|
+
broken.set(name, {
|
|
56
|
+
file: file.path,
|
|
57
|
+
line: after.node.startPosition.row + 1,
|
|
58
|
+
detail: 'takes ' + after.params.length + ' parameter(s), was ' + before.params.length,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (broken.size === 0)
|
|
64
|
+
return [];
|
|
65
|
+
const changed = new Set(python.map((f) => f.path));
|
|
66
|
+
const diagnostics = pyrightDiagnostics(g.root, [g.root]);
|
|
67
|
+
// group the broken callers by the function they were calling
|
|
68
|
+
const callers = new Map();
|
|
69
|
+
for (const d of diagnostics) {
|
|
70
|
+
if (d.rule !== 'reportCallIssue' && d.rule !== 'reportArgumentType')
|
|
71
|
+
continue;
|
|
72
|
+
const path = repoPath(g.root, d.file);
|
|
73
|
+
if (changed.has(path))
|
|
74
|
+
continue; // a caller inside the change is not left behind
|
|
75
|
+
let source = '';
|
|
76
|
+
try {
|
|
77
|
+
source = lines(decode(readFileSync(join(g.root, path))))[d.line - 1] ?? '';
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue; // cannot read it, cannot attribute it
|
|
81
|
+
}
|
|
82
|
+
for (const name of broken.keys()) {
|
|
83
|
+
// attribute the error only where the line actually calls the changed function
|
|
84
|
+
if (!callsFunction(source, name))
|
|
85
|
+
continue;
|
|
86
|
+
const list = callers.get(name) ?? [];
|
|
87
|
+
list.push({ file: path, line: d.line });
|
|
88
|
+
callers.set(name, list);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const findings = [];
|
|
92
|
+
for (const [name, sites] of callers) {
|
|
93
|
+
const where = broken.get(name);
|
|
94
|
+
const shown = sites.slice(0, 3).map((s) => s.file + ':' + s.line).join(', ');
|
|
95
|
+
findings.push({
|
|
96
|
+
id: '',
|
|
97
|
+
class: 'verified',
|
|
98
|
+
check: 'contract-drift',
|
|
99
|
+
severity: 'high',
|
|
100
|
+
confidence: 'proven',
|
|
101
|
+
file: where.file,
|
|
102
|
+
line: where.line,
|
|
103
|
+
title: name + '() ' + where.detail + ', and ' + sites.length + ' call site(s) outside this change now fail',
|
|
104
|
+
evidence: {
|
|
105
|
+
oracle: 'pyright',
|
|
106
|
+
detail: 'the checker reports a call error at ' + shown + (sites.length > 3 ? ' and others' : ''),
|
|
107
|
+
},
|
|
108
|
+
fix: 'Update the call sites, or keep the old shape working with a default',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return findings;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
//# sourceMappingURL=foreign-contract-drift.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { nodesOfType } from '#app/lang/packs.js';
|
|
2
|
+
import { finding, tokensFor } from './foreign-tokens.js';
|
|
3
|
+
const MIN_TOKENS = 8;
|
|
4
|
+
/** import / use / require statements, whatever each grammar calls them */
|
|
5
|
+
const IMPORTISH = /import|use_declaration|require|include|using_directive|package_/;
|
|
6
|
+
/** The defect is an inconsistent rename, not the duplication — see the TS version. */
|
|
7
|
+
function missedRename(a, b, pack) {
|
|
8
|
+
if (a.length !== b.length || a.length < MIN_TOKENS)
|
|
9
|
+
return undefined;
|
|
10
|
+
const mapping = new Map();
|
|
11
|
+
let renamed = false;
|
|
12
|
+
for (let i = 0; i < a.length; i++) {
|
|
13
|
+
const x = a[i];
|
|
14
|
+
const y = b[i];
|
|
15
|
+
if (x.type !== y.type)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (!pack.nodes.identifier.includes(x.type)) {
|
|
18
|
+
if (x.text !== y.text)
|
|
19
|
+
return undefined;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (x.text !== y.text)
|
|
23
|
+
renamed = true;
|
|
24
|
+
const targets = mapping.get(x.text) ?? new Set();
|
|
25
|
+
targets.add(y.text);
|
|
26
|
+
mapping.set(x.text, targets);
|
|
27
|
+
}
|
|
28
|
+
if (!renamed)
|
|
29
|
+
return undefined;
|
|
30
|
+
for (const [name, targets] of mapping) {
|
|
31
|
+
// The signature of a missed rename is that one occurrence changed and another
|
|
32
|
+
// stayed behind, so the original name must be among the targets. Two different
|
|
33
|
+
// things that merely share a name map to two *new* names, which is not a rename
|
|
34
|
+
// anyone forgot. Found by running this check over its own repository.
|
|
35
|
+
if (targets.size > 1 && targets.has(name))
|
|
36
|
+
return { name, became: [...targets] };
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
function statementLists(root, pack) {
|
|
41
|
+
const lists = [root.namedChildren];
|
|
42
|
+
for (const block of nodesOfType(root, pack.nodes.block))
|
|
43
|
+
lists.push(block.namedChildren);
|
|
44
|
+
return lists;
|
|
45
|
+
}
|
|
46
|
+
export const foreignCopyPasteDrift = {
|
|
47
|
+
name: 'copy-paste-drift',
|
|
48
|
+
needs: ['syntax'],
|
|
49
|
+
run(g) {
|
|
50
|
+
const findings = [];
|
|
51
|
+
for (const file of g.foreign) {
|
|
52
|
+
for (const statements of statementLists(file.tree.rootNode, file.pack)) {
|
|
53
|
+
for (let i = 1; i < statements.length; i++) {
|
|
54
|
+
const prev = statements[i - 1];
|
|
55
|
+
const curr = statements[i];
|
|
56
|
+
const line = curr.startPosition.row + 1;
|
|
57
|
+
if (!file.changed.added.has(line))
|
|
58
|
+
continue;
|
|
59
|
+
if (file.pack.nodes.comment.includes(curr.type) || file.pack.nodes.comment.includes(prev.type))
|
|
60
|
+
continue;
|
|
61
|
+
// Consecutive imports from the same package share a shape by nature — a
|
|
62
|
+
// list of what a file uses is not a block anyone copied and half-renamed.
|
|
63
|
+
if (IMPORTISH.test(curr.type) || IMPORTISH.test(prev.type))
|
|
64
|
+
continue;
|
|
65
|
+
const miss = missedRename(tokensFor(prev, file.pack), tokensFor(curr, file.pack), file.pack);
|
|
66
|
+
if (!miss)
|
|
67
|
+
continue;
|
|
68
|
+
findings.push(finding(file, curr, {
|
|
69
|
+
check: 'copy-paste-drift',
|
|
70
|
+
severity: 'high',
|
|
71
|
+
confidence: 'firm',
|
|
72
|
+
title: 'Copied from the statement above with `' + miss.name + '` renamed inconsistently — it became ' +
|
|
73
|
+
miss.became.map((t) => '`' + t + '`').join(' in one place and ') + ' in another',
|
|
74
|
+
evidence: { oracle: file.pack.name + ' token stream', detail: 'identical shape and literals, one identifier left un-renamed' },
|
|
75
|
+
fix: 'Rename the remaining `' + miss.name + '`, or extract the shared shape',
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return findings;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=foreign-copy-paste-drift.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { nodesOfType } from '#app/lang/packs.js';
|
|
2
|
+
import { declaredName, finding } from './foreign-tokens.js';
|
|
3
|
+
function guardsOf(fn, pack) {
|
|
4
|
+
const guards = new Map();
|
|
5
|
+
for (const stmt of nodesOfType(fn, pack.nodes.ifStatement)) {
|
|
6
|
+
const cond = stmt.childForFieldName(pack.nodes.ifCondition);
|
|
7
|
+
const body = stmt.childForFieldName(pack.nodes.ifBody);
|
|
8
|
+
if (!cond || !body)
|
|
9
|
+
continue;
|
|
10
|
+
const bails = nodesOfType(body, pack.nodes.bail).length > 0;
|
|
11
|
+
if (!bails)
|
|
12
|
+
continue;
|
|
13
|
+
const roots = nodesOfType(cond, pack.nodes.identifier).map((n) => n.text);
|
|
14
|
+
guards.set(cond.text.replace(/\s+/g, ' ').trim(), roots);
|
|
15
|
+
}
|
|
16
|
+
return guards;
|
|
17
|
+
}
|
|
18
|
+
/** Declarations at module level — the names another file could actually import. */
|
|
19
|
+
export function functionsByName(root, pack) {
|
|
20
|
+
const all = nodesOfType(root, pack.nodes.declaration);
|
|
21
|
+
const out = new Map();
|
|
22
|
+
for (const decl of all) {
|
|
23
|
+
const containsAnother = all.some((other) => other !== decl &&
|
|
24
|
+
other.startPosition.row >= decl.startPosition.row &&
|
|
25
|
+
other.endPosition.row <= decl.endPosition.row);
|
|
26
|
+
if (containsAnother)
|
|
27
|
+
continue;
|
|
28
|
+
const name = declaredName(decl, pack);
|
|
29
|
+
if (name)
|
|
30
|
+
out.set(name, decl);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** A guard the change removed from a function that still exists — see the TS version
|
|
35
|
+
* for why obsolete and respelled guards are excluded. */
|
|
36
|
+
export const foreignDroppedGuard = {
|
|
37
|
+
name: 'dropped-guard',
|
|
38
|
+
needs: ['syntax', 'base'],
|
|
39
|
+
run(g) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
for (const file of g.foreign) {
|
|
42
|
+
if (!file.beforeTree)
|
|
43
|
+
continue;
|
|
44
|
+
const after = functionsByName(file.tree.rootNode, file.pack);
|
|
45
|
+
for (const [name, prev] of functionsByName(file.beforeTree.rootNode, file.pack)) {
|
|
46
|
+
const now = after.get(name);
|
|
47
|
+
if (!now)
|
|
48
|
+
continue;
|
|
49
|
+
const had = guardsOf(prev, file.pack);
|
|
50
|
+
if (had.size === 0)
|
|
51
|
+
continue;
|
|
52
|
+
const has = guardsOf(now, file.pack);
|
|
53
|
+
const available = new Set(nodesOfType(now, file.pack.nodes.identifier).map((n) => n.text));
|
|
54
|
+
const dropped = [...had.entries()]
|
|
55
|
+
.filter(([text, roots]) => {
|
|
56
|
+
if (has.has(text))
|
|
57
|
+
return false;
|
|
58
|
+
if (!roots.every((r) => available.has(r)))
|
|
59
|
+
return false; // obsolete with the code it protected
|
|
60
|
+
return ![...has.values()].some((other) => roots.every((r) => other.includes(r))); // respelled
|
|
61
|
+
})
|
|
62
|
+
.map(([text]) => text);
|
|
63
|
+
if (dropped.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
findings.push(finding(file, now, {
|
|
66
|
+
check: 'dropped-guard',
|
|
67
|
+
severity: 'high',
|
|
68
|
+
confidence: 'proven',
|
|
69
|
+
title: dropped.map((d) => '`' + d + '`').join(' and ') + ' guarded ' + name + '() before this change and ' +
|
|
70
|
+
(dropped.length > 1 ? 'are' : 'is') + ' gone',
|
|
71
|
+
evidence: { oracle: file.pack.name + ' pre/post AST', detail: 'early-exit branch removed while the function still uses the same names' },
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
//# sourceMappingURL=foreign-dropped-guard.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pyrightAvailable, pyrightDiagnostics } from '#app/lang/pyright.js';
|
|
3
|
+
/** An API the generated code invented, in Python. */
|
|
4
|
+
export const foreignPhantomApi = {
|
|
5
|
+
name: 'phantom-api',
|
|
6
|
+
needs: ['python-types'],
|
|
7
|
+
run(g) {
|
|
8
|
+
const python = g.foreign.filter((f) => f.pack.name === 'python');
|
|
9
|
+
if (python.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
if (!pyrightAvailable(g.root))
|
|
12
|
+
return []; // no oracle, no claim
|
|
13
|
+
const byAbsolute = new Map(python.map((f) => [join(g.root, f.path), f]));
|
|
14
|
+
const diagnostics = pyrightDiagnostics(g.root, [...byAbsolute.keys()]);
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const d of diagnostics) {
|
|
17
|
+
const file = byAbsolute.get(d.file);
|
|
18
|
+
if (!file || !file.changed.added.has(d.line))
|
|
19
|
+
continue;
|
|
20
|
+
findings.push({
|
|
21
|
+
id: '',
|
|
22
|
+
class: 'verified',
|
|
23
|
+
check: 'phantom-api',
|
|
24
|
+
severity: 'high',
|
|
25
|
+
confidence: 'proven',
|
|
26
|
+
file: file.path,
|
|
27
|
+
line: d.line,
|
|
28
|
+
span: { column: d.column, length: 1 },
|
|
29
|
+
title: d.message,
|
|
30
|
+
evidence: { oracle: 'pyright', detail: d.rule + ': ' + d.meaning },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return findings;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=foreign-phantom-api.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { finding } from './foreign-tokens.js';
|
|
2
|
+
export const foreignPhantomConfig = {
|
|
3
|
+
name: 'phantom-config',
|
|
4
|
+
needs: ['syntax'],
|
|
5
|
+
supports: (file) => file.pack.envReads !== undefined,
|
|
6
|
+
run(g) {
|
|
7
|
+
if (g.foreign.length === 0)
|
|
8
|
+
return [];
|
|
9
|
+
// a key read anywhere else in the change is a documentation gap, not an invention
|
|
10
|
+
const elsewhere = new Set();
|
|
11
|
+
for (const file of g.foreign) {
|
|
12
|
+
for (const read of file.pack.envReads?.(file.tree.rootNode) ?? []) {
|
|
13
|
+
if (!file.changed.added.has(read.node.startPosition.row + 1))
|
|
14
|
+
elsewhere.add(read.name);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const findings = [];
|
|
18
|
+
for (const file of g.foreign) {
|
|
19
|
+
for (const read of file.pack.envReads?.(file.tree.rootNode) ?? []) {
|
|
20
|
+
const line = read.node.startPosition.row + 1;
|
|
21
|
+
if (!file.changed.added.has(line))
|
|
22
|
+
continue;
|
|
23
|
+
if (g.envManifest?.keys.has(read.name) || elsewhere.has(read.name))
|
|
24
|
+
continue;
|
|
25
|
+
if (!g.envManifest)
|
|
26
|
+
continue; // no manifest to be wrong about
|
|
27
|
+
findings.push(finding(file, read.node, {
|
|
28
|
+
check: 'phantom-config',
|
|
29
|
+
severity: 'medium',
|
|
30
|
+
confidence: 'proven',
|
|
31
|
+
title: 'Reads ' + read.name + ', which is not declared in ' + g.envManifest.file,
|
|
32
|
+
evidence: { oracle: g.envManifest.file, detail: 'the key appears in no manifest entry and nowhere else in the change' },
|
|
33
|
+
fix: 'Add ' + read.name + ' to ' + g.envManifest.file + ', or drop the reference',
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return findings;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=foreign-phantom-config.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { isPhantom, localModules, pythonManifest } from '#app/lang/python-deps.js';
|
|
3
|
+
import { isPhantomGem, rubyLocal, rubyManifest } from '#app/lang/ruby-deps.js';
|
|
4
|
+
/** An import nothing installs, in a language with no build step to catch it. */
|
|
5
|
+
export const foreignPhantomDep = {
|
|
6
|
+
name: 'phantom-dep',
|
|
7
|
+
needs: ['syntax'],
|
|
8
|
+
supports: (file) => file.pack.name === 'python' || file.pack.name === 'ruby',
|
|
9
|
+
run(g) {
|
|
10
|
+
return [...pythonFindings(g), ...rubyFindings(g)];
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
function rubyFindings(g) {
|
|
14
|
+
const ruby = g.foreign.filter((f) => f.pack.name === 'ruby');
|
|
15
|
+
if (ruby.length === 0)
|
|
16
|
+
return [];
|
|
17
|
+
const manifest = rubyManifest(g.root);
|
|
18
|
+
if (!manifest)
|
|
19
|
+
return [];
|
|
20
|
+
const local = rubyLocal(g.root);
|
|
21
|
+
const findings = [];
|
|
22
|
+
for (const file of ruby) {
|
|
23
|
+
for (const required of file.pack.imports?.(file.tree.rootNode) ?? []) {
|
|
24
|
+
const line = required.node.startPosition.row + 1;
|
|
25
|
+
if (!file.changed.added.has(line))
|
|
26
|
+
continue;
|
|
27
|
+
if (!isPhantomGem(required.name, manifest, local))
|
|
28
|
+
continue;
|
|
29
|
+
findings.push({
|
|
30
|
+
id: '',
|
|
31
|
+
class: 'verified',
|
|
32
|
+
check: 'phantom-dep',
|
|
33
|
+
severity: 'high',
|
|
34
|
+
confidence: 'firm',
|
|
35
|
+
file: file.path,
|
|
36
|
+
line,
|
|
37
|
+
span: { column: required.node.startPosition.column + 1, length: Math.min(required.node.text.length, 60) },
|
|
38
|
+
title: 'Requires "' + required.name + '", which is neither stdlib, local, nor a declared gem',
|
|
39
|
+
evidence: { oracle: manifest.file, detail: 'not found in the manifest, the standard library, or this repository' },
|
|
40
|
+
fix: 'Add it to ' + manifest.file.split(', ')[0] + ', or drop the require if it was invented',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
46
|
+
function pythonFindings(g) {
|
|
47
|
+
const python = g.foreign.filter((f) => f.pack.name === 'python');
|
|
48
|
+
if (python.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
const local = localModules(g.root);
|
|
51
|
+
const findings = [];
|
|
52
|
+
for (const file of python) {
|
|
53
|
+
// the manifests governing this file, not just the repository's own
|
|
54
|
+
const manifest = pythonManifest(g.root, dirname(join(g.root, file.path)));
|
|
55
|
+
if (!manifest)
|
|
56
|
+
continue;
|
|
57
|
+
for (const imported of file.pack.imports?.(file.tree.rootNode) ?? []) {
|
|
58
|
+
const line = imported.node.startPosition.row + 1;
|
|
59
|
+
if (!file.changed.added.has(line))
|
|
60
|
+
continue;
|
|
61
|
+
if (!isPhantom(imported.name, manifest, local))
|
|
62
|
+
continue;
|
|
63
|
+
findings.push({
|
|
64
|
+
id: '',
|
|
65
|
+
class: 'verified',
|
|
66
|
+
check: 'phantom-dep',
|
|
67
|
+
severity: 'high',
|
|
68
|
+
// firm, not proven: a package can be installed by means this cannot see —
|
|
69
|
+
// vendored, system-wide, or an editable install outside the manifest
|
|
70
|
+
confidence: 'firm',
|
|
71
|
+
file: file.path,
|
|
72
|
+
line,
|
|
73
|
+
span: { column: imported.node.startPosition.column + 1, length: Math.min(imported.node.text.length, 60) },
|
|
74
|
+
title: 'Imports "' + imported.name + '", which is neither stdlib, local, nor a declared dependency',
|
|
75
|
+
evidence: { oracle: manifest.file, detail: 'not found in the manifest, the standard library, or this repository' },
|
|
76
|
+
fix: 'Add it to ' + manifest.file.split(', ')[0] + ', or drop the import if it was invented',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=foreign-phantom-dep.js.map
|