@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.
Files changed (87) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +306 -0
  3. package/dist/agents.js +82 -0
  4. package/dist/bench.js +179 -0
  5. package/dist/budget.js +59 -0
  6. package/dist/bundle.js +173 -0
  7. package/dist/cache.js +155 -0
  8. package/dist/cli/agent-command.js +27 -0
  9. package/dist/cli/app.js +31 -0
  10. package/dist/cli/args.js +76 -0
  11. package/dist/cli/bench-command.js +89 -0
  12. package/dist/cli/dismiss-command.js +42 -0
  13. package/dist/cli/environment.js +32 -0
  14. package/dist/cli/reports.js +64 -0
  15. package/dist/cli/review-command.js +268 -0
  16. package/dist/cli/session-command.js +62 -0
  17. package/dist/cli.js +7 -0
  18. package/dist/config.js +130 -0
  19. package/dist/delegate.js +84 -0
  20. package/dist/dismissed.js +130 -0
  21. package/dist/fspolicy.js +62 -0
  22. package/dist/git.js +238 -0
  23. package/dist/ground.js +286 -0
  24. package/dist/judges/judge.js +85 -0
  25. package/dist/judges/llm.js +234 -0
  26. package/dist/judges/prompts.js +86 -0
  27. package/dist/judges/tools.js +125 -0
  28. package/dist/lang/packs.js +557 -0
  29. package/dist/lang/pyright.js +108 -0
  30. package/dist/lang/python-deps.js +174 -0
  31. package/dist/lang/ruby-deps.js +77 -0
  32. package/dist/langtest.js +248 -0
  33. package/dist/manifest.js +209 -0
  34. package/dist/otel.js +75 -0
  35. package/dist/package-meta.js +13 -0
  36. package/dist/package-smoke.js +110 -0
  37. package/dist/plan.js +134 -0
  38. package/dist/position.js +94 -0
  39. package/dist/report/ansi.js +18 -0
  40. package/dist/report/codequality.js +19 -0
  41. package/dist/report/compact.js +15 -0
  42. package/dist/report/highlight.js +54 -0
  43. package/dist/report/markdown.js +113 -0
  44. package/dist/report/sarif.js +66 -0
  45. package/dist/report/terminal.js +170 -0
  46. package/dist/report/viewer.js +148 -0
  47. package/dist/review.js +355 -0
  48. package/dist/scan.js +67 -0
  49. package/dist/selftest.js +1928 -0
  50. package/dist/session.js +140 -0
  51. package/dist/snapshot.js +101 -0
  52. package/dist/text.js +50 -0
  53. package/dist/types.js +2 -0
  54. package/dist/verifiers/assertion-drift.js +137 -0
  55. package/dist/verifiers/contract-drift.js +140 -0
  56. package/dist/verifiers/copy-paste-drift.js +106 -0
  57. package/dist/verifiers/dead-on-arrival.js +92 -0
  58. package/dist/verifiers/dropped-guard.js +144 -0
  59. package/dist/verifiers/foreign-contract-drift.js +114 -0
  60. package/dist/verifiers/foreign-copy-paste-drift.js +83 -0
  61. package/dist/verifiers/foreign-dropped-guard.js +78 -0
  62. package/dist/verifiers/foreign-phantom-api.js +36 -0
  63. package/dist/verifiers/foreign-phantom-config.js +40 -0
  64. package/dist/verifiers/foreign-phantom-dep.js +82 -0
  65. package/dist/verifiers/foreign-reinvented.js +65 -0
  66. package/dist/verifiers/foreign-scope-creep.js +42 -0
  67. package/dist/verifiers/foreign-swallowed-error.js +36 -0
  68. package/dist/verifiers/foreign-tests.js +143 -0
  69. package/dist/verifiers/foreign-tokens.js +94 -0
  70. package/dist/verifiers/foreign.js +16 -0
  71. package/dist/verifiers/index.js +38 -0
  72. package/dist/verifiers/lying-comment.js +90 -0
  73. package/dist/verifiers/phantom-api.js +88 -0
  74. package/dist/verifiers/phantom-config.js +93 -0
  75. package/dist/verifiers/phantom-dep.js +110 -0
  76. package/dist/verifiers/reinvented.js +74 -0
  77. package/dist/verifiers/scope-creep.js +77 -0
  78. package/dist/verifiers/swallowed-error.js +110 -0
  79. package/dist/verifiers/vacuous-test.js +138 -0
  80. package/docs/architecture.md +191 -0
  81. package/docs/assets/cli-preview.svg +68 -0
  82. package/docs/assets/powershot-logo.png +0 -0
  83. package/docs/ci.md +151 -0
  84. package/examples/github-actions/action.yml +23 -0
  85. package/examples/github-actions/cli.yml +43 -0
  86. package/examples/gitlab/.gitlab-ci.yml +21 -0
  87. package/package.json +65 -0
@@ -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
@@ -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,2 @@
1
+ export const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
2
+ //# sourceMappingURL=types.js.map
@@ -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