@0xcraft/powershot 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +306 -0
- package/dist/agents.js +82 -0
- package/dist/bench.js +179 -0
- package/dist/budget.js +59 -0
- package/dist/bundle.js +173 -0
- package/dist/cache.js +155 -0
- package/dist/cli/agent-command.js +27 -0
- package/dist/cli/app.js +31 -0
- package/dist/cli/args.js +76 -0
- package/dist/cli/bench-command.js +89 -0
- package/dist/cli/dismiss-command.js +42 -0
- package/dist/cli/environment.js +32 -0
- package/dist/cli/reports.js +64 -0
- package/dist/cli/review-command.js +268 -0
- package/dist/cli/session-command.js +62 -0
- package/dist/cli.js +7 -0
- package/dist/config.js +130 -0
- package/dist/delegate.js +84 -0
- package/dist/dismissed.js +130 -0
- package/dist/fspolicy.js +62 -0
- package/dist/git.js +238 -0
- package/dist/ground.js +286 -0
- package/dist/judges/judge.js +85 -0
- package/dist/judges/llm.js +234 -0
- package/dist/judges/prompts.js +86 -0
- package/dist/judges/tools.js +125 -0
- package/dist/lang/packs.js +557 -0
- package/dist/lang/pyright.js +108 -0
- package/dist/lang/python-deps.js +174 -0
- package/dist/lang/ruby-deps.js +77 -0
- package/dist/langtest.js +248 -0
- package/dist/manifest.js +209 -0
- package/dist/otel.js +75 -0
- package/dist/package-meta.js +13 -0
- package/dist/package-smoke.js +110 -0
- package/dist/plan.js +134 -0
- package/dist/position.js +94 -0
- package/dist/report/ansi.js +18 -0
- package/dist/report/codequality.js +19 -0
- package/dist/report/compact.js +15 -0
- package/dist/report/highlight.js +54 -0
- package/dist/report/markdown.js +113 -0
- package/dist/report/sarif.js +66 -0
- package/dist/report/terminal.js +170 -0
- package/dist/report/viewer.js +148 -0
- package/dist/review.js +355 -0
- package/dist/scan.js +67 -0
- package/dist/selftest.js +1928 -0
- package/dist/session.js +140 -0
- package/dist/snapshot.js +101 -0
- package/dist/text.js +50 -0
- package/dist/types.js +2 -0
- package/dist/verifiers/assertion-drift.js +137 -0
- package/dist/verifiers/contract-drift.js +140 -0
- package/dist/verifiers/copy-paste-drift.js +106 -0
- package/dist/verifiers/dead-on-arrival.js +92 -0
- package/dist/verifiers/dropped-guard.js +144 -0
- package/dist/verifiers/foreign-contract-drift.js +114 -0
- package/dist/verifiers/foreign-copy-paste-drift.js +83 -0
- package/dist/verifiers/foreign-dropped-guard.js +78 -0
- package/dist/verifiers/foreign-phantom-api.js +36 -0
- package/dist/verifiers/foreign-phantom-config.js +40 -0
- package/dist/verifiers/foreign-phantom-dep.js +82 -0
- package/dist/verifiers/foreign-reinvented.js +65 -0
- package/dist/verifiers/foreign-scope-creep.js +42 -0
- package/dist/verifiers/foreign-swallowed-error.js +36 -0
- package/dist/verifiers/foreign-tests.js +143 -0
- package/dist/verifiers/foreign-tokens.js +94 -0
- package/dist/verifiers/foreign.js +16 -0
- package/dist/verifiers/index.js +38 -0
- package/dist/verifiers/lying-comment.js +90 -0
- package/dist/verifiers/phantom-api.js +88 -0
- package/dist/verifiers/phantom-config.js +93 -0
- package/dist/verifiers/phantom-dep.js +110 -0
- package/dist/verifiers/reinvented.js +74 -0
- package/dist/verifiers/scope-creep.js +77 -0
- package/dist/verifiers/swallowed-error.js +110 -0
- package/dist/verifiers/vacuous-test.js +138 -0
- package/docs/architecture.md +191 -0
- package/docs/assets/cli-preview.svg +68 -0
- package/docs/assets/powershot-logo.png +0 -0
- package/docs/ci.md +151 -0
- package/examples/github-actions/action.yml +23 -0
- package/examples/github-actions/cli.yml +43 -0
- package/examples/gitlab/.gitlab-ci.yml +21 -0
- package/package.json +65 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
/** `process.env.FOO` and `process.env['FOO']` */
|
|
4
|
+
function envReads(sf) {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
7
|
+
if (access.getExpression().getText() !== 'process.env')
|
|
8
|
+
continue;
|
|
9
|
+
const id = access.getNameNode();
|
|
10
|
+
out.push({ name: id.getText(), line: access.getStartLineNumber(), start: id.getStart(), width: id.getWidth() });
|
|
11
|
+
}
|
|
12
|
+
for (const access of sf.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)) {
|
|
13
|
+
if (access.getExpression().getText() !== 'process.env')
|
|
14
|
+
continue;
|
|
15
|
+
const arg = access.getArgumentExpression();
|
|
16
|
+
if (!arg || !Node.isStringLiteral(arg))
|
|
17
|
+
continue;
|
|
18
|
+
out.push({
|
|
19
|
+
name: arg.getLiteralValue(),
|
|
20
|
+
line: access.getStartLineNumber(),
|
|
21
|
+
start: arg.getStart(),
|
|
22
|
+
width: arg.getWidth(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Variables the platform sets, not the application.
|
|
29
|
+
*
|
|
30
|
+
* A terminal supplies COLUMNS, a CI runner supplies CI, the shell supplies HOME.
|
|
31
|
+
* None of them belong in an application's env manifest, and reporting them made the
|
|
32
|
+
* check accuse correct code of inventing configuration — measured on a whole-repo
|
|
33
|
+
* scan, every one of these was a false positive.
|
|
34
|
+
*/
|
|
35
|
+
const PLATFORM = new Set([
|
|
36
|
+
'CI', 'HOME', 'PATH', 'PWD', 'USER', 'SHELL', 'TERM', 'TMPDIR', 'TZ', 'LANG', 'LC_ALL',
|
|
37
|
+
'COLUMNS', 'LINES', 'FORCE_COLOR', 'NO_COLOR', 'CLICOLOR', 'CLICOLOR_FORCE', 'DEBUG',
|
|
38
|
+
'NODE_ENV', 'NODE_OPTIONS', 'NODE_DEBUG', 'NODE_EXTRA_CA_CERTS', 'npm_lifecycle_event',
|
|
39
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
40
|
+
'GITHUB_ACTIONS', 'GITHUB_TOKEN', 'GITHUB_OUTPUT', 'GITHUB_STEP_SUMMARY', 'RUNNER_OS',
|
|
41
|
+
]);
|
|
42
|
+
/** An environment variable the change reads that is declared nowhere. */
|
|
43
|
+
export const phantomConfig = {
|
|
44
|
+
name: 'phantom-config',
|
|
45
|
+
needs: ['syntax'],
|
|
46
|
+
run(g) {
|
|
47
|
+
const manifest = g.envManifest;
|
|
48
|
+
if (!manifest)
|
|
49
|
+
return [];
|
|
50
|
+
// a key used elsewhere in the codebase is a documentation gap, not an invention
|
|
51
|
+
const usedElsewhere = new Set();
|
|
52
|
+
const changedPaths = new Set(g.changed.map((c) => c.path));
|
|
53
|
+
for (const sf of g.project.getSourceFiles()) {
|
|
54
|
+
const path = relPath(sf, g.root);
|
|
55
|
+
if (changedPaths.has(path) || path.includes('node_modules'))
|
|
56
|
+
continue;
|
|
57
|
+
for (const read of envReads(sf))
|
|
58
|
+
usedElsewhere.add(read.name);
|
|
59
|
+
}
|
|
60
|
+
const findings = [];
|
|
61
|
+
for (const { sf, changed } of g.files) {
|
|
62
|
+
for (const read of envReads(sf)) {
|
|
63
|
+
if (!changed.added.has(read.line))
|
|
64
|
+
continue;
|
|
65
|
+
if (manifest.keys.has(read.name) || usedElsewhere.has(read.name))
|
|
66
|
+
continue;
|
|
67
|
+
if (PLATFORM.has(read.name))
|
|
68
|
+
continue;
|
|
69
|
+
findings.push({
|
|
70
|
+
id: '',
|
|
71
|
+
class: 'verified',
|
|
72
|
+
check: 'phantom-config',
|
|
73
|
+
severity: 'medium',
|
|
74
|
+
// The manifest is not the process environment. What the oracle settles is
|
|
75
|
+
// that nothing in the repository declares or uses this key — a deployment
|
|
76
|
+
// can still set it, so "will be undefined" is an inference, not a fact.
|
|
77
|
+
confidence: 'firm',
|
|
78
|
+
file: relPath(sf, g.root),
|
|
79
|
+
line: read.line,
|
|
80
|
+
span: locate(sf, read.start, read.width).span,
|
|
81
|
+
title: 'Reads process.env.' + read.name + ', which is not declared in ' + manifest.file + ' or used anywhere else',
|
|
82
|
+
evidence: {
|
|
83
|
+
oracle: manifest.file,
|
|
84
|
+
detail: 'the key appears in no manifest entry and in no other source file',
|
|
85
|
+
},
|
|
86
|
+
fix: 'Add ' + read.name + ' to ' + manifest.file + ', or drop the reference if it was invented',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
//# sourceMappingURL=phantom-config.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { builtinModules } from 'node:module';
|
|
2
|
+
import { SyntaxKind } from 'ts-morph';
|
|
3
|
+
import { locate, relPath } from '#app/ground.js';
|
|
4
|
+
const BUILTIN = new Set(builtinModules);
|
|
5
|
+
/** `@scope/name/sub` -> `@scope/name`, `name/sub` -> `name` */
|
|
6
|
+
function packageOf(specifier) {
|
|
7
|
+
const parts = specifier.split('/');
|
|
8
|
+
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] ?? specifier);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A URL-scheme specifier is not an npm manifest entry at all — Deno and Bun resolve
|
|
12
|
+
* `jsr:`, `npm:` and http imports themselves, so package.json can never declare them.
|
|
13
|
+
*/
|
|
14
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/;
|
|
15
|
+
/**
|
|
16
|
+
* `@/i18n` is not an npm package and never can be: a scoped name requires a scope,
|
|
17
|
+
* so an empty one (`@/`) is structurally invalid. Every repo that writes it means a
|
|
18
|
+
* local path alias, whichever tsconfig happens to declare it.
|
|
19
|
+
* A `#name` specifier is a private package import resolved through `package.json`.
|
|
20
|
+
*/
|
|
21
|
+
function isBare(specifier) {
|
|
22
|
+
if (specifier.startsWith('@/') || specifier.startsWith('#'))
|
|
23
|
+
return false;
|
|
24
|
+
return !specifier.startsWith('.') && !specifier.startsWith('/') && !SCHEME.test(specifier);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An import of a package that is not declared anywhere in the manifest.
|
|
28
|
+
* The manifest is the oracle, so this is proven — but only when we managed to
|
|
29
|
+
* read one, otherwise every import would look phantom.
|
|
30
|
+
*/
|
|
31
|
+
export const phantomDep = {
|
|
32
|
+
name: 'phantom-dep',
|
|
33
|
+
needs: ['syntax'],
|
|
34
|
+
run(g) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
for (const { sf, changed } of g.files) {
|
|
37
|
+
const deps = g.depsFor(sf.getFilePath());
|
|
38
|
+
if (deps.size === 0)
|
|
39
|
+
continue; // no manifest governs this file — nothing to claim
|
|
40
|
+
// a specifier the compiler resolves to repo source is internal, whatever it
|
|
41
|
+
// looks like: a tsconfig alias, a workspace package, a path mapping
|
|
42
|
+
const internal = new Set();
|
|
43
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
44
|
+
const resolved = imp.getModuleSpecifierSourceFile();
|
|
45
|
+
if (resolved && !resolved.getFilePath().includes('/node_modules/')) {
|
|
46
|
+
internal.add(imp.getModuleSpecifierValue());
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const specifiers = [];
|
|
50
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
51
|
+
const node = imp.getModuleSpecifier();
|
|
52
|
+
specifiers.push({
|
|
53
|
+
text: imp.getModuleSpecifierValue(),
|
|
54
|
+
line: imp.getStartLineNumber(),
|
|
55
|
+
span: locate(sf, node.getStart(), node.getWidth()).span,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
for (const exp of sf.getExportDeclarations()) {
|
|
59
|
+
const value = exp.getModuleSpecifierValue();
|
|
60
|
+
const node = exp.getModuleSpecifier();
|
|
61
|
+
if (value && node) {
|
|
62
|
+
specifiers.push({ text: value, line: exp.getStartLineNumber(), span: locate(sf, node.getStart(), node.getWidth()).span });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// require('x') and import('x')
|
|
66
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
67
|
+
const callee = call.getExpression().getText();
|
|
68
|
+
if (callee !== 'require' && callee !== 'import')
|
|
69
|
+
continue;
|
|
70
|
+
const arg = call.getArguments()[0];
|
|
71
|
+
if (arg?.isKind(SyntaxKind.StringLiteral)) {
|
|
72
|
+
specifiers.push({
|
|
73
|
+
text: arg.getLiteralValue(),
|
|
74
|
+
line: call.getStartLineNumber(),
|
|
75
|
+
span: locate(sf, arg.getStart(), arg.getWidth()).span,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const { text, line, span } of specifiers) {
|
|
80
|
+
if (!changed.added.has(line))
|
|
81
|
+
continue;
|
|
82
|
+
if (!isBare(text))
|
|
83
|
+
continue;
|
|
84
|
+
// a tsconfig path alias looks like a package and resolves to local source
|
|
85
|
+
if (g.internalPrefixes.some((prefix) => text.startsWith(prefix)))
|
|
86
|
+
continue;
|
|
87
|
+
if (internal.has(text))
|
|
88
|
+
continue;
|
|
89
|
+
const pkg = packageOf(text);
|
|
90
|
+
if (BUILTIN.has(pkg) || deps.has(pkg))
|
|
91
|
+
continue;
|
|
92
|
+
findings.push({
|
|
93
|
+
id: '',
|
|
94
|
+
class: 'verified',
|
|
95
|
+
check: 'phantom-dep',
|
|
96
|
+
severity: 'high',
|
|
97
|
+
confidence: 'proven',
|
|
98
|
+
file: relPath(sf, g.root),
|
|
99
|
+
line,
|
|
100
|
+
span,
|
|
101
|
+
title: 'Imports "' + pkg + '", which is not a declared dependency',
|
|
102
|
+
evidence: { oracle: 'package.json', detail: 'not found in dependencies, devDependencies, peerDependencies, or optionalDependencies' },
|
|
103
|
+
fix: 'npm install ' + pkg + ' — or remove the import if it was invented',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return findings;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
//# sourceMappingURL=phantom-dep.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { locate, normalizeName, relPath } from '#app/ground.js';
|
|
3
|
+
/**
|
|
4
|
+
* Names too generic to mean anything across files — two `render`s are usually
|
|
5
|
+
* two different things, not a duplication.
|
|
6
|
+
*/
|
|
7
|
+
const TEST_FILE = /(^|\/)(tests?|spec|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
8
|
+
const GENERIC = new Set([
|
|
9
|
+
'render', 'index', 'main', 'run', 'get', 'set', 'init', 'setup', 'start', 'stop',
|
|
10
|
+
'handler', 'handle', 'create', 'update', 'remove', 'delete', 'list', 'find',
|
|
11
|
+
'parse', 'format', 'load', 'save', 'toString', 'default', 'config', 'options',
|
|
12
|
+
]);
|
|
13
|
+
function declaredNames(sf) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const fn of sf.getFunctions()) {
|
|
16
|
+
const name = fn.getName();
|
|
17
|
+
const id = fn.getNameNode();
|
|
18
|
+
if (name && id)
|
|
19
|
+
out.push({ name, line: fn.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span });
|
|
20
|
+
}
|
|
21
|
+
for (const v of sf.getVariableDeclarations()) {
|
|
22
|
+
const init = v.getInitializer();
|
|
23
|
+
if (!init)
|
|
24
|
+
continue;
|
|
25
|
+
if (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression)) {
|
|
26
|
+
const id = v.getNameNode();
|
|
27
|
+
out.push({ name: v.getName(), line: v.getStartLineNumber(), span: locate(sf, id.getStart(), id.getWidth()).span });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** A helper the change introduces that already exists elsewhere in the repo. */
|
|
33
|
+
export const reinvented = {
|
|
34
|
+
name: 'reinvented',
|
|
35
|
+
needs: ['syntax'],
|
|
36
|
+
run(g) {
|
|
37
|
+
const findings = [];
|
|
38
|
+
for (const { sf, changed } of g.files) {
|
|
39
|
+
const file = relPath(sf, g.root);
|
|
40
|
+
// a fixture builder repeated across test files is a deliberate trade, and two
|
|
41
|
+
// tests describing the same scenario naturally share a name
|
|
42
|
+
if (TEST_FILE.test(file))
|
|
43
|
+
continue;
|
|
44
|
+
for (const { name, line, span } of declaredNames(sf)) {
|
|
45
|
+
if (!changed.added.has(line))
|
|
46
|
+
continue;
|
|
47
|
+
if (name.length < 6 || GENERIC.has(name) || GENERIC.has(name.toLowerCase()))
|
|
48
|
+
continue;
|
|
49
|
+
const existing = (g.symbolIndex.get(normalizeName(name)) ?? []).filter((s) => s.file !== file);
|
|
50
|
+
const match = existing[0];
|
|
51
|
+
if (!match)
|
|
52
|
+
continue;
|
|
53
|
+
findings.push({
|
|
54
|
+
id: '',
|
|
55
|
+
class: 'verified',
|
|
56
|
+
check: 'reinvented',
|
|
57
|
+
severity: 'medium',
|
|
58
|
+
confidence: 'firm',
|
|
59
|
+
file,
|
|
60
|
+
line,
|
|
61
|
+
span,
|
|
62
|
+
title: name + '() duplicates an existing export ' + match.file + ':' + match.name,
|
|
63
|
+
evidence: { oracle: 'repo symbol index', detail: 'already exported from ' + match.file + ':' + match.line },
|
|
64
|
+
// Deliberately not an import statement: the correct specifier depends on the
|
|
65
|
+
// repo's module resolution, and a wrong one would be exactly the kind of
|
|
66
|
+
// confidently-wrong output this tool exists to catch.
|
|
67
|
+
fix: 'Reuse ' + match.name + ' from ' + match.file + ' instead of redeclaring it',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
//# sourceMappingURL=reinvented.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ts } from 'ts-morph';
|
|
2
|
+
import { relPath } from '#app/ground.js';
|
|
3
|
+
/**
|
|
4
|
+
* The token stream a compiler would see: whitespace and comments dropped, everything
|
|
5
|
+
* that changes behaviour kept. Two files with the same stream are the same program.
|
|
6
|
+
*/
|
|
7
|
+
function tokens(code) {
|
|
8
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ true, ts.LanguageVariant.Standard, code);
|
|
9
|
+
const out = [];
|
|
10
|
+
let token = scanner.scan();
|
|
11
|
+
for (let i = 0; token !== ts.SyntaxKind.EndOfFileToken && i < 200_000; i++) {
|
|
12
|
+
out.push(token + ':' + scanner.getTokenText());
|
|
13
|
+
token = scanner.scan();
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
/** Comment text only, to tell a comment-only edit from pure reformatting. */
|
|
18
|
+
function comments(code) {
|
|
19
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ false, ts.LanguageVariant.Standard, code);
|
|
20
|
+
const out = [];
|
|
21
|
+
let token = scanner.scan();
|
|
22
|
+
for (let i = 0; token !== ts.SyntaxKind.EndOfFileToken && i < 200_000; i++) {
|
|
23
|
+
if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia) {
|
|
24
|
+
out.push(scanner.getTokenText().trim());
|
|
25
|
+
}
|
|
26
|
+
token = scanner.scan();
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function same(a, b) {
|
|
31
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
32
|
+
}
|
|
33
|
+
/** A file the change touches without changing the program. */
|
|
34
|
+
export const scopeCreep = {
|
|
35
|
+
name: 'scope-creep',
|
|
36
|
+
needs: ['syntax', 'base'],
|
|
37
|
+
run(g) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
for (const { sf, before } of g.files) {
|
|
40
|
+
if (!before)
|
|
41
|
+
continue; // a new file always adds something
|
|
42
|
+
const afterText = sf.getFullText();
|
|
43
|
+
const beforeText = before.getFullText();
|
|
44
|
+
if (afterText === beforeText)
|
|
45
|
+
continue; // not actually touched
|
|
46
|
+
if (!same(tokens(beforeText), tokens(afterText)))
|
|
47
|
+
continue; // the program did change
|
|
48
|
+
const commentsChanged = !same(comments(beforeText), comments(afterText));
|
|
49
|
+
// Documentation is content. A change to JSDoc is API documentation someone
|
|
50
|
+
// meant to write, and telling them to drop it from the change is wrong advice,
|
|
51
|
+
// so only incidental comments and pure layout churn are reported. The same rule
|
|
52
|
+
// applies in the tree-sitter implementation — one idea, one behaviour.
|
|
53
|
+
if (commentsChanged && comments(afterText).some((c) => c.startsWith('/**')))
|
|
54
|
+
continue;
|
|
55
|
+
const what = commentsChanged ? 'only comments' : 'only formatting';
|
|
56
|
+
findings.push({
|
|
57
|
+
id: '',
|
|
58
|
+
class: 'verified',
|
|
59
|
+
check: 'scope-creep',
|
|
60
|
+
severity: 'low',
|
|
61
|
+
confidence: 'proven',
|
|
62
|
+
file: relPath(sf, g.root),
|
|
63
|
+
line: 1,
|
|
64
|
+
title: 'This file changes ' + what + ' — the program it produces is identical',
|
|
65
|
+
evidence: {
|
|
66
|
+
oracle: 'token stream',
|
|
67
|
+
detail: 'before and after tokenize identically once whitespace and comments are dropped',
|
|
68
|
+
},
|
|
69
|
+
fix: commentsChanged
|
|
70
|
+
? 'Keep it if the comments are the point; otherwise drop the file from the change'
|
|
71
|
+
: 'Revert the reformatting so the diff shows the actual change',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=scope-creep.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { locate, relPath } from '#app/ground.js';
|
|
3
|
+
/** console.error / logger.warn / log.info — a call that records and returns */
|
|
4
|
+
const LOGGER = /^(console|logger|log)\s*\./;
|
|
5
|
+
/** A catch body that only logs has recorded the failure and carried on regardless. */
|
|
6
|
+
function isLogOnly(statements) {
|
|
7
|
+
if (statements.length === 0)
|
|
8
|
+
return false;
|
|
9
|
+
return statements.every((s) => {
|
|
10
|
+
if (!Node.isExpressionStatement(s))
|
|
11
|
+
return false;
|
|
12
|
+
let expr = s.getExpression();
|
|
13
|
+
if (Node.isAwaitExpression(expr))
|
|
14
|
+
expr = expr.getExpression();
|
|
15
|
+
if (!Node.isCallExpression(expr))
|
|
16
|
+
return false;
|
|
17
|
+
return LOGGER.test(expr.getExpression().getText());
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A comment inside an otherwise empty block is the author saying "I meant this".
|
|
22
|
+
* Deliberate silence is not a defect, so respect the signal.
|
|
23
|
+
*/
|
|
24
|
+
function hasComment(text) {
|
|
25
|
+
return /\/\/|\/\*/.test(text);
|
|
26
|
+
}
|
|
27
|
+
/** Error handling that looks defensive and defends nothing. */
|
|
28
|
+
export const swallowedError = {
|
|
29
|
+
name: 'swallowed-error',
|
|
30
|
+
needs: ['syntax'],
|
|
31
|
+
run(g) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
for (const { sf, changed } of g.files) {
|
|
34
|
+
const file = relPath(sf, g.root);
|
|
35
|
+
for (const clause of sf.getDescendantsOfKind(SyntaxKind.CatchClause)) {
|
|
36
|
+
const line = clause.getStartLineNumber();
|
|
37
|
+
if (!changed.added.has(line))
|
|
38
|
+
continue;
|
|
39
|
+
const block = clause.getBlock();
|
|
40
|
+
const statements = block.getStatements();
|
|
41
|
+
const span = locate(sf, block.getStart(), block.getWidth()).span;
|
|
42
|
+
if (statements.length === 0) {
|
|
43
|
+
if (hasComment(block.getFullText()))
|
|
44
|
+
continue;
|
|
45
|
+
findings.push({
|
|
46
|
+
id: '',
|
|
47
|
+
class: 'verified',
|
|
48
|
+
check: 'swallowed-error',
|
|
49
|
+
severity: 'high',
|
|
50
|
+
confidence: 'proven',
|
|
51
|
+
file,
|
|
52
|
+
line,
|
|
53
|
+
span,
|
|
54
|
+
title: 'Empty catch block — the failure is discarded and the caller is told it succeeded',
|
|
55
|
+
evidence: { oracle: 'AST', detail: 'catch body contains no statements' },
|
|
56
|
+
fix: 'Rethrow, return a typed failure, or add a comment saying why it is safe to ignore',
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (isLogOnly(statements)) {
|
|
61
|
+
findings.push({
|
|
62
|
+
id: '',
|
|
63
|
+
class: 'verified',
|
|
64
|
+
check: 'swallowed-error',
|
|
65
|
+
severity: 'medium',
|
|
66
|
+
confidence: 'firm',
|
|
67
|
+
file,
|
|
68
|
+
line,
|
|
69
|
+
span,
|
|
70
|
+
title: 'Catch only logs — execution continues as if the operation had succeeded',
|
|
71
|
+
evidence: { oracle: 'AST', detail: 'catch body contains logging calls and nothing else: no rethrow, no return, no recovery' },
|
|
72
|
+
fix: 'Rethrow after logging, or return a value that tells the caller it failed',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// .catch(() => {}) — the promise-chain spelling of the same defect
|
|
77
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
78
|
+
const callee = call.getExpression();
|
|
79
|
+
if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== 'catch')
|
|
80
|
+
continue;
|
|
81
|
+
const line = call.getStartLineNumber();
|
|
82
|
+
if (!changed.added.has(line))
|
|
83
|
+
continue;
|
|
84
|
+
const handler = call.getArguments()[0];
|
|
85
|
+
if (!handler || !(Node.isArrowFunction(handler) || Node.isFunctionExpression(handler)))
|
|
86
|
+
continue;
|
|
87
|
+
const body = handler.getBody();
|
|
88
|
+
if (!Node.isBlock(body) || body.getStatements().length > 0)
|
|
89
|
+
continue;
|
|
90
|
+
if (hasComment(body.getFullText()))
|
|
91
|
+
continue;
|
|
92
|
+
findings.push({
|
|
93
|
+
id: '',
|
|
94
|
+
class: 'verified',
|
|
95
|
+
check: 'swallowed-error',
|
|
96
|
+
severity: 'high',
|
|
97
|
+
confidence: 'proven',
|
|
98
|
+
file,
|
|
99
|
+
line,
|
|
100
|
+
span: locate(sf, handler.getStart(), handler.getWidth()).span,
|
|
101
|
+
title: 'Empty .catch() — the rejection is discarded and the chain resolves as success',
|
|
102
|
+
evidence: { oracle: 'AST', detail: 'catch handler body contains no statements' },
|
|
103
|
+
fix: 'Handle the rejection, or drop the .catch() so it propagates',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return findings;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
//# sourceMappingURL=swallowed-error.js.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { 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))?$/;
|
|
5
|
+
/** assertion styles across jest / vitest / node:test / chai / ava */
|
|
6
|
+
const ASSERTION = /^(expect|assert|assert\.\w+|t\.\w+|chai\.\w+|should)/;
|
|
7
|
+
/**
|
|
8
|
+
* End-to-end suites put their assertions in page objects, so the test body calls
|
|
9
|
+
* `page.verifySomething()` and contains no `expect` of its own. A method named for
|
|
10
|
+
* checking is an assertion by intent, and treating it otherwise reported four
|
|
11
|
+
* perfectly good Playwright tests on a real repository.
|
|
12
|
+
*/
|
|
13
|
+
const DELEGATED_ASSERTION = /(^|\.)(verify|assert|expect|should|check|confirm)[A-Z_]/;
|
|
14
|
+
function isTestFile(path) {
|
|
15
|
+
return TEST_FILE.test(path);
|
|
16
|
+
}
|
|
17
|
+
/** `src/a/useThing.test.ts` -> `useThing`: what this file claims to be testing. */
|
|
18
|
+
function subjectOf(testPath) {
|
|
19
|
+
const base = testPath.split('/').pop() ?? '';
|
|
20
|
+
const match = /^(.+?)\.(test|spec)\.[cm]?[jt]sx?$/.exec(base);
|
|
21
|
+
return match?.[1];
|
|
22
|
+
}
|
|
23
|
+
/** `../../services/itsmScraper` -> `itsmScraper` */
|
|
24
|
+
function moduleBase(specifier) {
|
|
25
|
+
return (specifier.split('/').pop() ?? specifier).replace(/\.[cm]?[jt]sx?$/, '');
|
|
26
|
+
}
|
|
27
|
+
function assertsSomething(body) {
|
|
28
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
29
|
+
const text = call.getExpression().getText();
|
|
30
|
+
if (ASSERTION.test(text) || DELEGATED_ASSERTION.test(text))
|
|
31
|
+
return true;
|
|
32
|
+
// fluent styles: expect(x).toBe(y) is caught above; value.should.equal(y) is not
|
|
33
|
+
if (/\.should\b/.test(text))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Module specifiers passed to jest.mock / vi.mock, with where each one sits so the
|
|
40
|
+
* caret can mark the string itself.
|
|
41
|
+
*/
|
|
42
|
+
function mockedModules(sf) {
|
|
43
|
+
const out = new Map();
|
|
44
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
45
|
+
const callee = call.getExpression().getText();
|
|
46
|
+
if (callee !== 'jest.mock' && callee !== 'vi.mock' && callee !== 'mock.module')
|
|
47
|
+
continue;
|
|
48
|
+
const arg = call.getArguments()[0];
|
|
49
|
+
if (arg?.isKind(SyntaxKind.StringLiteral)) {
|
|
50
|
+
out.set(arg.getLiteralValue(), {
|
|
51
|
+
line: call.getStartLineNumber(),
|
|
52
|
+
span: locate(sf, arg.getStart(), arg.getWidth()).span,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Two ways a test can look like proof and be none:
|
|
60
|
+
* 1. it runs code and asserts nothing
|
|
61
|
+
* 2. it mocks the very module it imports its subject from, so it asserts on the mock
|
|
62
|
+
*/
|
|
63
|
+
export const vacuousTest = {
|
|
64
|
+
name: 'vacuous-test',
|
|
65
|
+
needs: ['syntax'],
|
|
66
|
+
run(g) {
|
|
67
|
+
const findings = [];
|
|
68
|
+
for (const { sf, changed } of g.files) {
|
|
69
|
+
const file = relPath(sf, g.root);
|
|
70
|
+
if (!isTestFile(file))
|
|
71
|
+
continue;
|
|
72
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
73
|
+
if (!TEST_FN.test(call.getExpression().getText()))
|
|
74
|
+
continue;
|
|
75
|
+
const line = call.getStartLineNumber();
|
|
76
|
+
if (!changed.added.has(line))
|
|
77
|
+
continue;
|
|
78
|
+
const body = call.getArguments()[1];
|
|
79
|
+
if (!body)
|
|
80
|
+
continue; // it('todo') with no callback is a placeholder, not a lie
|
|
81
|
+
const block = body.getFirstDescendantByKind(SyntaxKind.Block) ?? body;
|
|
82
|
+
if (block.getDescendantsOfKind(SyntaxKind.CallExpression).length === 0)
|
|
83
|
+
continue; // empty body
|
|
84
|
+
if (!assertsSomething(block)) {
|
|
85
|
+
const title = call.getArguments()[0]?.getText() ?? 'test';
|
|
86
|
+
findings.push({
|
|
87
|
+
id: '',
|
|
88
|
+
class: 'verified',
|
|
89
|
+
check: 'vacuous-test',
|
|
90
|
+
severity: 'high',
|
|
91
|
+
confidence: 'proven',
|
|
92
|
+
file,
|
|
93
|
+
line,
|
|
94
|
+
span: locate(sf, call.getExpression().getStart(), call.getExpression().getWidth()).span,
|
|
95
|
+
title: 'Test ' + title + ' runs code but asserts nothing',
|
|
96
|
+
evidence: { oracle: 'test AST', detail: 'no expect/assert call anywhere in the test body' },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Mocking the unit under test — not merely mocking a dependency, which is
|
|
101
|
+
// ordinary practice. The subject is the module the test file is named after:
|
|
102
|
+
// useItsmScraping.test.ts tests useItsmScraping, so mocking @lakeside/ui-sdk
|
|
103
|
+
// there is a stubbed collaborator, not a self-mocking test.
|
|
104
|
+
const subjectName = subjectOf(file);
|
|
105
|
+
if (!subjectName)
|
|
106
|
+
continue;
|
|
107
|
+
const mocked = mockedModules(sf);
|
|
108
|
+
if (mocked.size === 0)
|
|
109
|
+
continue;
|
|
110
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
111
|
+
const spec = imp.getModuleSpecifierValue();
|
|
112
|
+
const mock = mocked.get(spec);
|
|
113
|
+
if (mock === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
if (moduleBase(spec) !== subjectName)
|
|
116
|
+
continue;
|
|
117
|
+
const names = imp.getNamedImports().map((n) => n.getName());
|
|
118
|
+
const subject = names[0] ?? imp.getDefaultImport()?.getText();
|
|
119
|
+
if (!subject)
|
|
120
|
+
continue;
|
|
121
|
+
findings.push({
|
|
122
|
+
id: '',
|
|
123
|
+
class: 'verified',
|
|
124
|
+
check: 'vacuous-test',
|
|
125
|
+
severity: 'high',
|
|
126
|
+
confidence: 'proven',
|
|
127
|
+
file,
|
|
128
|
+
line: mock.line,
|
|
129
|
+
span: mock.span,
|
|
130
|
+
title: 'Test mocks "' + spec + '", the module it imports ' + subject + ' from — it asserts on the mock',
|
|
131
|
+
evidence: { oracle: 'test AST', detail: 'the mocked specifier is also the import source of the unit under test' },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return findings;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=vacuous-test.js.map
|