@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
package/dist/session.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const DIR = '.powershot/sessions';
|
|
5
|
+
/**
|
|
6
|
+
* How many runs to keep. Sessions hold source frames, so an unbounded directory grows
|
|
7
|
+
* with the repository it is reviewing and nothing ever removes it.
|
|
8
|
+
*/
|
|
9
|
+
const KEEP = 50;
|
|
10
|
+
function prune(dir) {
|
|
11
|
+
try {
|
|
12
|
+
// ids are hashes, so the filename says nothing about age — the clock does
|
|
13
|
+
const files = readdirSync(dir)
|
|
14
|
+
.filter((n) => n.endsWith('.json'))
|
|
15
|
+
.map((name) => ({ name, at: statSync(join(dir, name)).mtimeMs }))
|
|
16
|
+
.sort((a, b) => a.at - b.at);
|
|
17
|
+
for (const { name } of files.slice(0, Math.max(0, files.length - KEEP + 1))) {
|
|
18
|
+
rmSync(join(dir, name), { force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// housekeeping must never be the reason a review does not start
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Only judge results are kept: the deterministic half is free and re-runs, which
|
|
26
|
+
* keeps a resumed review honest about the current state of the files. */
|
|
27
|
+
export class Session {
|
|
28
|
+
file;
|
|
29
|
+
data;
|
|
30
|
+
constructor(file, data) {
|
|
31
|
+
this.file = file;
|
|
32
|
+
this.data = data;
|
|
33
|
+
}
|
|
34
|
+
static create(root, target, asked) {
|
|
35
|
+
const id = createHash('sha1').update(target + ':' + Date.now()).digest('hex').slice(0, 8);
|
|
36
|
+
const dir = join(root, DIR);
|
|
37
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
prune(dir);
|
|
39
|
+
return new Session(join(dir, id + '.json'), {
|
|
40
|
+
id,
|
|
41
|
+
started: new Date().toISOString(),
|
|
42
|
+
target,
|
|
43
|
+
asked,
|
|
44
|
+
done: {},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Whether this session's answers came from who is being asked now. */
|
|
48
|
+
askedBy(provider, model) {
|
|
49
|
+
if (!this.data.asked)
|
|
50
|
+
return true; // recorded before this was stored; nothing to check
|
|
51
|
+
return this.data.asked.provider === provider && this.data.asked.model === model;
|
|
52
|
+
}
|
|
53
|
+
get asked() {
|
|
54
|
+
return this.data.asked;
|
|
55
|
+
}
|
|
56
|
+
static open(root, id) {
|
|
57
|
+
const file = join(root, DIR, id + '.json');
|
|
58
|
+
if (!existsSync(file))
|
|
59
|
+
return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return new Session(file, JSON.parse(readFileSync(file, 'utf8')));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined; // a corrupt session is not worth failing a review over
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
static list(root) {
|
|
68
|
+
const dir = join(root, DIR);
|
|
69
|
+
if (!existsSync(dir))
|
|
70
|
+
return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const name of readdirSync(dir)) {
|
|
73
|
+
if (!name.endsWith('.json'))
|
|
74
|
+
continue;
|
|
75
|
+
try {
|
|
76
|
+
const d = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
77
|
+
out.push({ id: d.id, started: d.started, target: d.target, done: Object.keys(d.done).length });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// skip anything unreadable rather than refusing to list the rest
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out.sort((a, b) => b.started.localeCompare(a.started));
|
|
84
|
+
}
|
|
85
|
+
get id() {
|
|
86
|
+
return this.data.id;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The content fingerprint distinguishes an edited bundle from the old question;
|
|
90
|
+
* hashing also keeps path-derived keys bounded and stable.
|
|
91
|
+
*/
|
|
92
|
+
static key(judge, unit, fingerprint) {
|
|
93
|
+
return judge + '|' + createHash('sha1').update(unit + '\0' + fingerprint).digest('hex').slice(0, 20);
|
|
94
|
+
}
|
|
95
|
+
get(judge, unit, fingerprint) {
|
|
96
|
+
return this.data.done[Session.key(judge, unit, fingerprint)];
|
|
97
|
+
}
|
|
98
|
+
record(judge, unit, fingerprint, findings) {
|
|
99
|
+
this.data.done[Session.key(judge, unit, fingerprint)] = findings;
|
|
100
|
+
this.save();
|
|
101
|
+
}
|
|
102
|
+
saveReport(findings, verdict) {
|
|
103
|
+
this.data.report = {
|
|
104
|
+
findings,
|
|
105
|
+
verified: findings.filter((f) => f.class === 'verified').length,
|
|
106
|
+
judged: findings.filter((f) => f.class === 'judged').length,
|
|
107
|
+
state: verdict.state,
|
|
108
|
+
notLookedAt: verdict.notLookedAt,
|
|
109
|
+
};
|
|
110
|
+
this.save();
|
|
111
|
+
}
|
|
112
|
+
get report() {
|
|
113
|
+
return this.data.report;
|
|
114
|
+
}
|
|
115
|
+
get target() {
|
|
116
|
+
return this.data.target;
|
|
117
|
+
}
|
|
118
|
+
get started() {
|
|
119
|
+
return this.data.started;
|
|
120
|
+
}
|
|
121
|
+
/** What moved between two reviews. */
|
|
122
|
+
static compare(before, after) {
|
|
123
|
+
if (before.report?.state !== 'complete' || after.report?.state !== 'complete') {
|
|
124
|
+
throw new Error('only complete review sessions can be compared');
|
|
125
|
+
}
|
|
126
|
+
const key = (f) => f.check + '|' + f.file + '|' + f.title;
|
|
127
|
+
const was = new Map((before.report?.findings ?? []).map((f) => [key(f), f]));
|
|
128
|
+
const is = new Map((after.report?.findings ?? []).map((f) => [key(f), f]));
|
|
129
|
+
return {
|
|
130
|
+
fixed: [...was].filter(([k]) => !is.has(k)).map(([, f]) => f),
|
|
131
|
+
introduced: [...is].filter(([k]) => !was.has(k)).map(([, f]) => f),
|
|
132
|
+
remaining: [...is].filter(([k]) => was.has(k)).map(([, f]) => f),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
save() {
|
|
136
|
+
// written after every unit, so an interrupted run keeps everything already paid for
|
|
137
|
+
writeFileSync(this.file, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=session.js.map
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { git } from './git.js';
|
|
6
|
+
export function targetRef(range) {
|
|
7
|
+
if (range.commit)
|
|
8
|
+
return range.commit;
|
|
9
|
+
if (range.from)
|
|
10
|
+
return range.to ?? 'HEAD';
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* True when the working tree already *is* the target, so there is nothing to check
|
|
15
|
+
* out. This is the ordinary CI case — a runner checks the head out and reviews it —
|
|
16
|
+
* and skipping the copy there keeps the common path as fast as it was.
|
|
17
|
+
*/
|
|
18
|
+
function treeIsTarget(root, ref) {
|
|
19
|
+
try {
|
|
20
|
+
if (git(root, ['rev-parse', ref]).trim() !== git(root, ['rev-parse', 'HEAD']).trim())
|
|
21
|
+
return false;
|
|
22
|
+
return git(root, ['status', '--porcelain']).trim() === '';
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** A worktree holds tracked files only, so a checkout has no node_modules and every
|
|
29
|
+
* dependency import becomes unresolvable — a type-aware review of imaginary findings. */
|
|
30
|
+
function linkDependencies(repo, tree) {
|
|
31
|
+
const manifests = git(repo, ['ls-files', '*package.json', 'package.json'])
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((p) => dirname(p));
|
|
35
|
+
for (const dir of new Set(['.', ...manifests])) {
|
|
36
|
+
const from = join(repo, dir, 'node_modules');
|
|
37
|
+
const to = join(tree, dir, 'node_modules');
|
|
38
|
+
if (!existsSync(from) || existsSync(to) || !existsSync(dirname(to)))
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
symlinkSync(from, to, 'junction');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// a link we cannot make degrades resolution for that package only
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A checkout of exactly the version under review.
|
|
50
|
+
*
|
|
51
|
+
* One thing it cannot restore: the dependencies of that version. `node_modules` is
|
|
52
|
+
* linked from the current install, because the old lockfile's tree is not on disk.
|
|
53
|
+
* Type resolution therefore reflects today's dependencies — which is why a replay is
|
|
54
|
+
* a precision signal about the checks and not a reproduction of the original review.
|
|
55
|
+
*/
|
|
56
|
+
export function openTargetTree(repo, ref) {
|
|
57
|
+
// a run killed mid-checkout leaves a registration behind; git's own answer is one
|
|
58
|
+
// command, and running it here is what keeps `git worktree list` honest over time
|
|
59
|
+
try {
|
|
60
|
+
git(repo, ['worktree', 'prune']);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// an older git, or a repository that has never had one — nothing to clean
|
|
64
|
+
}
|
|
65
|
+
const parent = mkdtempSync(join(tmpdir(), 'powershot-'));
|
|
66
|
+
const dir = join(parent, 'tree');
|
|
67
|
+
git(repo, ['worktree', 'add', '--detach', '--quiet', dir, ref]);
|
|
68
|
+
linkDependencies(repo, dir);
|
|
69
|
+
return {
|
|
70
|
+
dir,
|
|
71
|
+
checkout(next) {
|
|
72
|
+
execFileSync('git', ['checkout', '--detach', '--quiet', '--force', next], {
|
|
73
|
+
cwd: dir,
|
|
74
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
close() {
|
|
78
|
+
try {
|
|
79
|
+
git(repo, ['worktree', 'remove', '--force', dir]);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// the temp directory goes either way; a stale entry is pruned by git itself
|
|
83
|
+
}
|
|
84
|
+
rmSync(parent, { recursive: true, force: true });
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Run something against the exact target version, cleaning up whatever it took. */
|
|
89
|
+
export async function withTargetTree(repo, range, fn) {
|
|
90
|
+
const ref = targetRef(range);
|
|
91
|
+
if (!ref || treeIsTarget(repo, ref))
|
|
92
|
+
return fn(repo);
|
|
93
|
+
const tree = openTargetTree(repo, ref);
|
|
94
|
+
try {
|
|
95
|
+
return await fn(tree.dir);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
tree.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=snapshot.js.map
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Reading source that was not written on the reviewer's machine. */
|
|
2
|
+
/** Split on either line ending, so a CRLF checkout does not leave `\r` on every line. */
|
|
3
|
+
export function lines(text) {
|
|
4
|
+
return text.split(/\r?\n/);
|
|
5
|
+
}
|
|
6
|
+
/** Strip a single trailing carriage return, for text already split on `\n`. */
|
|
7
|
+
export function stripCR(line) {
|
|
8
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
9
|
+
}
|
|
10
|
+
const BOMS = [
|
|
11
|
+
[Buffer.from([0xef, 0xbb, 0xbf]), 'utf-8'],
|
|
12
|
+
[Buffer.from([0xff, 0xfe]), 'utf-16le'],
|
|
13
|
+
[Buffer.from([0xfe, 0xff]), 'utf-16be'],
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Decode bytes to text, honouring a byte-order mark and falling back rather than
|
|
17
|
+
* throwing. `latin1` is the last resort precisely because it cannot fail: every byte
|
|
18
|
+
* maps to a character, so a file in an encoding we cannot name still parses as code
|
|
19
|
+
* instead of taking the whole review down with it.
|
|
20
|
+
*/
|
|
21
|
+
export function decode(buf) {
|
|
22
|
+
for (const [bom, encoding] of BOMS) {
|
|
23
|
+
if (buf.length >= bom.length && buf.subarray(0, bom.length).equals(bom)) {
|
|
24
|
+
return new TextDecoder(encoding).decode(buf.subarray(bom.length));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return buf.toString('latin1');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A path, made safe for every renderer that interpolates one.
|
|
36
|
+
*
|
|
37
|
+
* `stripControl` keeps tabs and newlines because a code frame is layout. A path is
|
|
38
|
+
* not layout: one carrying a newline ended the markdown heading that held it and
|
|
39
|
+
* turned everything after into the attacker's own document.
|
|
40
|
+
*/
|
|
41
|
+
export function stripPath(text) {
|
|
42
|
+
return stripControl(text).replace(/[\t\r\n]/g, ' ').trim();
|
|
43
|
+
}
|
|
44
|
+
export function stripControl(text) {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC, terminated by BEL or ST
|
|
47
|
+
.replace(/\x1b[@-_][0-?]*[ -/]*[@-~]/g, '') // CSI and the other escape families
|
|
48
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=text.js.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(__tests__\/)/;
|
|
4
|
+
const TEST_FN = /^(it|test)(\.(only|each|concurrent|skip))?$/;
|
|
5
|
+
const NODE_ASSERT = /^(assert|t)\.(equal|strictEqual|deepEqual|deepStrictEqual|is|deepIs)$/;
|
|
6
|
+
/**
|
|
7
|
+
* Walk left through a matcher chain (`expect(x).not.toBe`) back to the
|
|
8
|
+
* `expect(...)` call, so the subject is found regardless of modifiers.
|
|
9
|
+
*/
|
|
10
|
+
function expectSubjectOf(callee) {
|
|
11
|
+
let node = callee;
|
|
12
|
+
while (node && Node.isPropertyAccessExpression(node))
|
|
13
|
+
node = node.getExpression();
|
|
14
|
+
if (!node || !Node.isCallExpression(node))
|
|
15
|
+
return undefined;
|
|
16
|
+
if (node.getExpression().getText() !== 'expect')
|
|
17
|
+
return undefined;
|
|
18
|
+
return node.getArguments()[0]?.getText() ?? '';
|
|
19
|
+
}
|
|
20
|
+
/** The enclosing it()/test() call, which is how an assertion is matched across versions. */
|
|
21
|
+
function enclosingTest(node) {
|
|
22
|
+
let current = node;
|
|
23
|
+
while (current) {
|
|
24
|
+
if (Node.isCallExpression(current) && TEST_FN.test(current.getExpression().getText()))
|
|
25
|
+
return current;
|
|
26
|
+
current = current.getParent();
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The test with its expected value blanked out and whitespace collapsed. */
|
|
31
|
+
function contextOf(test, expectedNode) {
|
|
32
|
+
// masked by position, never by text: `expect(withVat(1000)).toBe(1000)` would
|
|
33
|
+
// otherwise blank both occurrences and make every such test look rewritten
|
|
34
|
+
const text = test.getText();
|
|
35
|
+
const start = expectedNode.getStart() - test.getStart();
|
|
36
|
+
const end = start + expectedNode.getWidth();
|
|
37
|
+
if (start < 0 || end > text.length)
|
|
38
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
39
|
+
return (text.slice(0, start) + '\u0000' + text.slice(end)).replace(/\s+/g, ' ').trim();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Key an assertion by what it is asserting *about*, never by its expected value —
|
|
43
|
+
* the whole point is to detect the expected value moving underneath a stable subject.
|
|
44
|
+
*/
|
|
45
|
+
function assertionsIn(sf) {
|
|
46
|
+
const out = new Map();
|
|
47
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
48
|
+
const calleeText = call.getExpression().getText();
|
|
49
|
+
let subject;
|
|
50
|
+
let matcher;
|
|
51
|
+
let expectedNode;
|
|
52
|
+
if (Node.isPropertyAccessExpression(call.getExpression())) {
|
|
53
|
+
subject = expectSubjectOf(call.getExpression());
|
|
54
|
+
matcher = call.getExpression().getName();
|
|
55
|
+
expectedNode = call.getArguments()[0];
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
matcher = '';
|
|
59
|
+
expectedNode = undefined;
|
|
60
|
+
}
|
|
61
|
+
if (subject === undefined && NODE_ASSERT.test(calleeText)) {
|
|
62
|
+
subject = call.getArguments()[0]?.getText();
|
|
63
|
+
matcher = calleeText;
|
|
64
|
+
expectedNode = call.getArguments()[1];
|
|
65
|
+
}
|
|
66
|
+
if (subject === undefined || expectedNode === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
const expected = expectedNode.getText();
|
|
69
|
+
const test = enclosingTest(call);
|
|
70
|
+
const title = test?.getArguments()[0]?.getText();
|
|
71
|
+
if (test === undefined || title === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
out.set(title + '|' + subject + '|' + matcher, {
|
|
74
|
+
expected,
|
|
75
|
+
line: call.getStartLineNumber(),
|
|
76
|
+
subject,
|
|
77
|
+
span: locate(sf, call.getStart(), call.getWidth()).span,
|
|
78
|
+
context: contextOf(test, expectedNode),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** An expected value edited to match new output, rather than the code being fixed. */
|
|
84
|
+
export const assertionDrift = {
|
|
85
|
+
name: 'assertion-drift',
|
|
86
|
+
needs: ['syntax', 'base'],
|
|
87
|
+
run(g) {
|
|
88
|
+
const findings = [];
|
|
89
|
+
// The modules this change actually edited, by basename. Whether *some* source
|
|
90
|
+
// moved is the wrong question — a large change can edit one module and bend an
|
|
91
|
+
// unrelated test. What matters is whether the module a given test covers moved.
|
|
92
|
+
const changedModules = new Set(g.files
|
|
93
|
+
.map(({ sf }) => relPath(sf, g.root))
|
|
94
|
+
.filter((p) => !TEST_FILE.test(p))
|
|
95
|
+
.map((p) => (p.split('/').pop() ?? '').replace(/\.[cm]?[jt]sx?$/, '')));
|
|
96
|
+
for (const { sf, before } of g.files) {
|
|
97
|
+
if (!before)
|
|
98
|
+
continue;
|
|
99
|
+
const file = relPath(sf, g.root);
|
|
100
|
+
if (!TEST_FILE.test(file))
|
|
101
|
+
continue;
|
|
102
|
+
// An expectation moving alongside a change to the module it covers is what an
|
|
103
|
+
// intentional behaviour change looks like. Only the giveaway survives: the
|
|
104
|
+
// expectation moved and the thing it measures did not.
|
|
105
|
+
const subject = (file.split('/').pop() ?? '').replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '');
|
|
106
|
+
if (changedModules.has(subject))
|
|
107
|
+
continue;
|
|
108
|
+
const now = assertionsIn(sf);
|
|
109
|
+
for (const [key, was] of assertionsIn(before)) {
|
|
110
|
+
const is = now.get(key);
|
|
111
|
+
if (!is || is.expected === was.expected)
|
|
112
|
+
continue;
|
|
113
|
+
// everything but the expected value must be untouched, or this is a rewrite
|
|
114
|
+
if (is.context !== was.context)
|
|
115
|
+
continue;
|
|
116
|
+
findings.push({
|
|
117
|
+
id: '',
|
|
118
|
+
class: 'verified',
|
|
119
|
+
check: 'assertion-drift',
|
|
120
|
+
severity: 'high',
|
|
121
|
+
confidence: 'firm',
|
|
122
|
+
file,
|
|
123
|
+
line: is.line,
|
|
124
|
+
span: is.span,
|
|
125
|
+
title: 'Expected value for `' + is.subject + '` changed from ' + was.expected + ' to ' + is.expected,
|
|
126
|
+
evidence: {
|
|
127
|
+
oracle: 'pre/post test AST',
|
|
128
|
+
detail: 'the module this test covers did not change — the expectation moved with nothing it measures',
|
|
129
|
+
},
|
|
130
|
+
fix: 'Confirm the new value is correct behaviour, not the test being bent to fit the code',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return findings;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
//# sourceMappingURL=assertion-drift.js.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parameters read syntactically so the same extraction works on the base-ref project,
|
|
5
|
+
* which is parsed without type information.
|
|
6
|
+
*/
|
|
7
|
+
function signatureOf(name, node) {
|
|
8
|
+
const fn = Node.isFunctionDeclaration(node)
|
|
9
|
+
? node
|
|
10
|
+
: Node.isVariableDeclaration(node)
|
|
11
|
+
? node.getInitializerIfKind(SyntaxKind.ArrowFunction) ?? node.getInitializerIfKind(SyntaxKind.FunctionExpression)
|
|
12
|
+
: undefined;
|
|
13
|
+
if (!fn)
|
|
14
|
+
return undefined;
|
|
15
|
+
const params = fn.getParameters().map((p) => ({
|
|
16
|
+
name: p.getName(),
|
|
17
|
+
type: p.getTypeNode()?.getText() ?? '',
|
|
18
|
+
// a parameter with `?`, a default, or a rest spread is not required of the caller
|
|
19
|
+
optional: p.hasQuestionToken() || p.hasInitializer() || p.isRestParameter(),
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
params,
|
|
24
|
+
required: params.filter((p) => !p.optional).length,
|
|
25
|
+
returns: fn.getReturnTypeNode()?.getText() ?? '',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Top-level functions and exported arrow/function consts, by name. */
|
|
29
|
+
function signatures(sf) {
|
|
30
|
+
const out = new Map();
|
|
31
|
+
for (const fn of sf.getFunctions()) {
|
|
32
|
+
const name = fn.getName();
|
|
33
|
+
if (!name)
|
|
34
|
+
continue;
|
|
35
|
+
const sig = signatureOf(name, fn);
|
|
36
|
+
if (sig)
|
|
37
|
+
out.set(name, sig);
|
|
38
|
+
}
|
|
39
|
+
for (const v of sf.getVariableDeclarations()) {
|
|
40
|
+
const sig = signatureOf(v.getName(), v);
|
|
41
|
+
if (sig)
|
|
42
|
+
out.set(v.getName(), sig);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Only changes that actually cost a caller something are reported. Adding an optional
|
|
48
|
+
* parameter or widening a name is invisible from the outside, so it stays silent.
|
|
49
|
+
*/
|
|
50
|
+
function breakingChange(before, after) {
|
|
51
|
+
if (after.required > before.required) {
|
|
52
|
+
return {
|
|
53
|
+
detail: 'now requires ' + after.required + ' argument(s), was ' + before.required,
|
|
54
|
+
proven: true, // every existing call site is now short an argument
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (after.params.length < before.params.length) {
|
|
58
|
+
return {
|
|
59
|
+
detail: 'takes ' + after.params.length + ' parameter(s), was ' + before.params.length,
|
|
60
|
+
proven: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < Math.min(before.params.length, after.params.length); i++) {
|
|
64
|
+
const b = before.params[i];
|
|
65
|
+
const a = after.params[i];
|
|
66
|
+
if (b.type !== '' && a.type !== '' && b.type !== a.type) {
|
|
67
|
+
return { detail: 'parameter `' + a.name + '` changed from ' + b.type + ' to ' + a.type, proven: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (before.returns !== '' && after.returns !== '' && before.returns !== after.returns) {
|
|
71
|
+
return { detail: 'returns ' + after.returns + ', was ' + before.returns, proven: false };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A signature changed and callers outside the change were left behind.
|
|
77
|
+
*
|
|
78
|
+
* This is the blast radius `phantom-api` cannot see: that check only reports on lines
|
|
79
|
+
* the diff touched, so a call site broken in a file nobody edited is invisible to it.
|
|
80
|
+
* Here the reference graph is the oracle — the callers either exist or they do not.
|
|
81
|
+
*/
|
|
82
|
+
export const contractDrift = {
|
|
83
|
+
name: 'contract-drift',
|
|
84
|
+
needs: ['syntax', 'base', 'references'],
|
|
85
|
+
run(g) {
|
|
86
|
+
const findings = [];
|
|
87
|
+
const changedPaths = new Set(g.changed.map((c) => c.path));
|
|
88
|
+
for (const { sf, before } of g.files) {
|
|
89
|
+
if (!before)
|
|
90
|
+
continue;
|
|
91
|
+
const file = relPath(sf, g.root);
|
|
92
|
+
const now = signatures(sf);
|
|
93
|
+
for (const [name, was] of signatures(before)) {
|
|
94
|
+
const is = now.get(name);
|
|
95
|
+
if (!is)
|
|
96
|
+
continue; // removed entirely — a different finding
|
|
97
|
+
const broke = breakingChange(was, is);
|
|
98
|
+
if (!broke)
|
|
99
|
+
continue;
|
|
100
|
+
// the declaration node, both to position the finding and to walk references
|
|
101
|
+
const decl = sf.getFunction(name) ?? sf.getVariableDeclaration(name);
|
|
102
|
+
if (!decl)
|
|
103
|
+
continue;
|
|
104
|
+
let callers = [];
|
|
105
|
+
try {
|
|
106
|
+
callers = [
|
|
107
|
+
...new Set(decl
|
|
108
|
+
.findReferencesAsNodes()
|
|
109
|
+
.map((ref) => relPath(ref.getSourceFile(), g.root))
|
|
110
|
+
.filter((path) => path !== file && !changedPaths.has(path) && !path.includes('node_modules'))),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// the language service can decline on a malformed project; no references
|
|
115
|
+
// found is not the same as none existing, so stay silent rather than guess
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (callers.length === 0)
|
|
119
|
+
continue;
|
|
120
|
+
const shown = callers.slice(0, 3).join(', ') + (callers.length > 3 ? ' +' + (callers.length - 3) + ' more' : '');
|
|
121
|
+
const nameNode = Node.isFunctionDeclaration(decl) ? decl.getNameNode() : decl.getNameNode();
|
|
122
|
+
findings.push({
|
|
123
|
+
id: '',
|
|
124
|
+
class: 'verified',
|
|
125
|
+
check: 'contract-drift',
|
|
126
|
+
severity: broke.proven ? 'high' : 'medium',
|
|
127
|
+
confidence: broke.proven ? 'proven' : 'firm',
|
|
128
|
+
file,
|
|
129
|
+
line: decl.getStartLineNumber(),
|
|
130
|
+
span: nameNode ? locate(sf, nameNode.getStart(), nameNode.getWidth()).span : undefined,
|
|
131
|
+
title: name + '() ' + broke.detail + ', but ' + callers.length + ' call site(s) outside this change were not updated',
|
|
132
|
+
evidence: { oracle: 'reference graph', detail: 'callers in ' + shown },
|
|
133
|
+
fix: 'Update the call sites, or keep the old shape working (optional parameter, overload)',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return findings;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
//# sourceMappingURL=contract-drift.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ts, Node } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
/** Below this a coincidental shape match means nothing. */
|
|
4
|
+
const MIN_TOKENS = 8;
|
|
5
|
+
function tokenize(code) {
|
|
6
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, code);
|
|
7
|
+
const out = [];
|
|
8
|
+
let token = scanner.scan();
|
|
9
|
+
for (let i = 0; token !== ts.SyntaxKind.EndOfFileToken && i < 20_000; i++) {
|
|
10
|
+
out.push({ kind: token, text: scanner.getTokenText() });
|
|
11
|
+
token = scanner.scan();
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
function missedRename(a, b) {
|
|
16
|
+
if (a.length !== b.length || a.length < MIN_TOKENS)
|
|
17
|
+
return undefined;
|
|
18
|
+
const mapping = new Map();
|
|
19
|
+
let renamedSomething = false;
|
|
20
|
+
for (let i = 0; i < a.length; i++) {
|
|
21
|
+
const x = a[i];
|
|
22
|
+
const y = b[i];
|
|
23
|
+
if (x.kind !== y.kind)
|
|
24
|
+
return undefined; // different shape — not a clone
|
|
25
|
+
if (x.kind !== ts.SyntaxKind.Identifier) {
|
|
26
|
+
// literals and punctuation must match exactly, or the blocks simply differ
|
|
27
|
+
if (x.text !== y.text)
|
|
28
|
+
return undefined;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (x.text !== y.text)
|
|
32
|
+
renamedSomething = true;
|
|
33
|
+
const targets = mapping.get(x.text) ?? new Set();
|
|
34
|
+
targets.add(y.text);
|
|
35
|
+
mapping.set(x.text, targets);
|
|
36
|
+
}
|
|
37
|
+
if (!renamedSomething)
|
|
38
|
+
return undefined; // an exact duplicate is a different concern
|
|
39
|
+
for (const [name, targets] of mapping) {
|
|
40
|
+
// The signature of a missed rename is that one occurrence changed and another
|
|
41
|
+
// stayed behind, so the original name must be among the targets. Two different
|
|
42
|
+
// things that merely share a name — a local `tests` beside a member `.tests` —
|
|
43
|
+
// map to two *new* names, which is not a rename anyone forgot. Found by running
|
|
44
|
+
// this check over its own repository.
|
|
45
|
+
if (targets.size > 1 && targets.has(name))
|
|
46
|
+
return { name, became: [...targets] };
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function statementLists(sf) {
|
|
51
|
+
const lists = [sf.getStatements()];
|
|
52
|
+
for (const block of sf.getDescendantsOfKind(ts.SyntaxKind.Block))
|
|
53
|
+
lists.push(block.getStatements());
|
|
54
|
+
return lists;
|
|
55
|
+
}
|
|
56
|
+
/** A block copied from the one above it with one identifier left behind. */
|
|
57
|
+
export const copyPasteDrift = {
|
|
58
|
+
name: 'copy-paste-drift',
|
|
59
|
+
needs: ['syntax'],
|
|
60
|
+
run(g) {
|
|
61
|
+
const findings = [];
|
|
62
|
+
for (const { sf, changed } of g.files) {
|
|
63
|
+
const file = relPath(sf, g.root);
|
|
64
|
+
for (const statements of statementLists(sf)) {
|
|
65
|
+
for (let i = 1; i < statements.length; i++) {
|
|
66
|
+
const prev = statements[i - 1];
|
|
67
|
+
const curr = statements[i];
|
|
68
|
+
const line = curr.getStartLineNumber();
|
|
69
|
+
if (!changed.added.has(line))
|
|
70
|
+
continue;
|
|
71
|
+
if (Node.isEmptyStatement(prev) || Node.isEmptyStatement(curr))
|
|
72
|
+
continue;
|
|
73
|
+
// Consecutive imports from the same package share a shape by nature — a
|
|
74
|
+
// list of what a file uses is not a block anyone copied and half-renamed.
|
|
75
|
+
if (Node.isImportDeclaration(curr) || Node.isExportDeclaration(curr))
|
|
76
|
+
continue;
|
|
77
|
+
const miss = missedRename(tokenize(prev.getText()), tokenize(curr.getText()));
|
|
78
|
+
if (!miss)
|
|
79
|
+
continue;
|
|
80
|
+
findings.push({
|
|
81
|
+
id: '',
|
|
82
|
+
class: 'verified',
|
|
83
|
+
check: 'copy-paste-drift',
|
|
84
|
+
severity: 'high',
|
|
85
|
+
confidence: 'firm',
|
|
86
|
+
file,
|
|
87
|
+
line,
|
|
88
|
+
span: locate(sf, curr.getStart(), Math.min(curr.getWidth(), 120)).span,
|
|
89
|
+
title: 'Copied from the statement above with `' +
|
|
90
|
+
miss.name +
|
|
91
|
+
'` renamed inconsistently — it became ' +
|
|
92
|
+
miss.became.map((t) => '`' + t + '`').join(' in one place and ') +
|
|
93
|
+
' in another',
|
|
94
|
+
evidence: {
|
|
95
|
+
oracle: 'token stream',
|
|
96
|
+
detail: 'both statements have identical shape and literals, so one identifier was left un-renamed',
|
|
97
|
+
},
|
|
98
|
+
fix: 'Rename the remaining `' + miss.name + '`, or extract the shared shape into a function',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=copy-paste-drift.js.map
|