@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,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