@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,209 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export const SCHEMA = 'powershot.run/v1';
5
+ /** The single state machine behind manifests, benches, renderers and exit codes. */
6
+ export function completionOf(parts) {
7
+ const waivedUnits = parts.units.filter((unit) => unit.outcome === 'waived').length;
8
+ const failedUnits = parts.units.filter((unit) => unit.outcome === 'failed').length;
9
+ const failedFiles = parts.files.filter((file) => file.disposition === 'failed').length;
10
+ const limitedFiles = parts.files.filter((file) => file.disposition === 'selected' && file.missing?.length).length;
11
+ const state = parts.failures.length > 0 || failedUnits > 0 || failedFiles > 0
12
+ ? 'failed'
13
+ : parts.cancelled || parts.budgetStop || waivedUnits > 0 || limitedFiles > 0 || parts.skippedChecks.length > 0
14
+ ? 'partial'
15
+ : 'complete';
16
+ return {
17
+ state,
18
+ notLookedAt: reasons(parts.files, parts.units, parts.skippedChecks, parts.failures, parts.cancelled, parts.budgetStop),
19
+ };
20
+ }
21
+ /**
22
+ * The one authoritative record of a run.
23
+ *
24
+ * Every renderer, exit code, session and approval decision should read this rather
25
+ * than re-derive its own idea of what happened. The point is not bookkeeping: a run
26
+ * that turned files away, skipped checks it could not supply, or stopped on a budget
27
+ * still produces a findings list, and a findings list on its own cannot tell a reader
28
+ * which of those it is. `selected = completed ∪ reused ∪ failed ∪ waived` is the
29
+ * invariant that makes "no findings" mean something.
30
+ */
31
+ export class RunManifest {
32
+ id;
33
+ units = [];
34
+ ranChecks = [];
35
+ startedAt = new Date().toISOString();
36
+ constructor(id) {
37
+ this.id = id;
38
+ }
39
+ unit(record) {
40
+ this.units.push(record);
41
+ }
42
+ ran(check) {
43
+ this.ranChecks.push(check);
44
+ }
45
+ build(parts) {
46
+ const completion = completionOf({
47
+ files: parts.files,
48
+ units: this.units,
49
+ skippedChecks: parts.skippedChecks,
50
+ failures: parts.failures,
51
+ cancelled: parts.cancelled,
52
+ budgetStop: parts.budgetStop,
53
+ });
54
+ return {
55
+ schema: SCHEMA,
56
+ id: this.id,
57
+ operation: parts.operation,
58
+ started: this.startedAt,
59
+ ended: new Date().toISOString(),
60
+ repository: { head: parts.repositoryHead },
61
+ target: parts.target,
62
+ policy: parts.policy,
63
+ engine: parts.engine,
64
+ files: parts.files.map((file) => ({
65
+ ...file,
66
+ checks: [...file.checks],
67
+ missing: file.missing ? [...file.missing] : undefined,
68
+ })),
69
+ units: this.units.map((unit) => ({ ...unit })),
70
+ checks: {
71
+ ran: [...this.ranChecks],
72
+ skipped: parts.skippedChecks.map((check) => ({ ...check })),
73
+ },
74
+ findings: { ...parts.findings },
75
+ usage: { ...parts.usage },
76
+ state: completion.state,
77
+ failures: [...parts.failures],
78
+ notLookedAt: [...completion.notLookedAt],
79
+ };
80
+ }
81
+ }
82
+ /** Why this run is less than a full review, said once in words a reader can act on. */
83
+ function reasons(files, units, skipped, failures, cancelled, budgetStop) {
84
+ const out = [...failures];
85
+ if (cancelled)
86
+ out.push('cancelled before every unit was judged');
87
+ if (budgetStop)
88
+ out.push('stopped early: ' + budgetStop);
89
+ const group = (items, label) => {
90
+ if (items.length > 0)
91
+ out.push(label(items.length) + ': ' + items.slice(0, 5).join(', ') + (items.length > 5 ? ', …' : ''));
92
+ };
93
+ group(files.filter((f) => f.disposition === 'failed').map((f) => f.path), (n) => n + ' file(s) could not be read');
94
+ group(files.filter((f) => f.disposition === 'selected' && f.missing?.length).map((f) => f.path + ' (no ' + f.missing.join(', ') + ')'), (n) => n + ' file(s) reviewed with fewer checks than the rest');
95
+ group(units.filter((u) => u.outcome === 'failed' || u.outcome === 'waived').map((u) => u.judge + ' · ' + u.unit), (n) => n + ' judge unit(s) never answered');
96
+ group(skipped.map((s) => s.check + ' (no ' + s.missing + ')'), (n) => n + ' check(s) had no oracle to run against');
97
+ return [...new Set(out)];
98
+ }
99
+ /**
100
+ * The coverage contract, checked rather than asserted in prose.
101
+ *
102
+ * Returns what is wrong, empty when the manifest accounts for everything it selected.
103
+ * A manifest that fails this is a bug in PowerShot, not a finding about the code.
104
+ */
105
+ export function coverageProblems(m) {
106
+ const problems = [];
107
+ const dispositions = new Set(['selected', 'waived', 'failed']);
108
+ const checksByFile = new Set();
109
+ for (const f of m.files) {
110
+ if (!dispositions.has(f.disposition))
111
+ problems.push(f.path + ': unknown disposition ' + f.disposition);
112
+ if (f.disposition !== 'selected' && !f.reason)
113
+ problems.push(f.path + ': ' + f.disposition + ' without a reason');
114
+ if (!Array.isArray(f.checks)) {
115
+ problems.push(f.path + ': missing per-file checks');
116
+ continue;
117
+ }
118
+ if (f.disposition !== 'selected' && f.checks.length > 0) {
119
+ problems.push(f.path + ': ' + f.disposition + ' file received checks');
120
+ }
121
+ const local = new Set();
122
+ for (const check of f.checks) {
123
+ if (local.has(check))
124
+ problems.push(f.path + ': received check twice: ' + check);
125
+ local.add(check);
126
+ checksByFile.add(check);
127
+ }
128
+ }
129
+ const seen = new Set();
130
+ for (const u of m.units) {
131
+ const key = u.judge + '|' + u.unit;
132
+ if (seen.has(key))
133
+ problems.push('unit counted twice: ' + key);
134
+ seen.add(key);
135
+ if (u.outcome !== 'completed' && !u.reason)
136
+ problems.push(key + ': ' + u.outcome + ' without a reason');
137
+ }
138
+ const ran = new Set();
139
+ for (const check of m.checks.ran) {
140
+ if (ran.has(check))
141
+ problems.push('check counted twice as ran: ' + check);
142
+ ran.add(check);
143
+ }
144
+ for (const check of checksByFile) {
145
+ if (!ran.has(check))
146
+ problems.push('file received a check not recorded as ran: ' + check);
147
+ }
148
+ for (const check of ran) {
149
+ if (!checksByFile.has(check))
150
+ problems.push('ran check received no selected file: ' + check);
151
+ }
152
+ const skipped = new Set();
153
+ for (const check of m.checks.skipped) {
154
+ if (skipped.has(check.check))
155
+ problems.push('check counted twice as skipped: ' + check.check);
156
+ skipped.add(check.check);
157
+ if (ran.has(check.check))
158
+ problems.push('check counted as both ran and skipped: ' + check.check);
159
+ }
160
+ // a judged run that reports complete must have reached every unit it selected
161
+ const unreached = m.units.filter((u) => u.outcome === 'failed' || u.outcome === 'waived');
162
+ if (m.state === 'complete' && unreached.length > 0) {
163
+ problems.push('state is complete but ' + unreached.length + ' unit(s) were never judged');
164
+ }
165
+ if (m.state === 'complete' && m.files.some((f) => f.disposition === 'failed')) {
166
+ problems.push('state is complete but a file failed selection');
167
+ }
168
+ const limited = m.files.filter((f) => f.disposition === 'selected' && f.missing?.length);
169
+ if (m.state === 'complete' && limited.length > 0) {
170
+ problems.push('state is complete but ' + limited.length + ' file(s) were reviewed with fewer checks');
171
+ }
172
+ if (m.state === 'complete' && m.checks.skipped.length > 0) {
173
+ problems.push('state is complete but ' + m.checks.skipped.length + ' check(s) had no oracle');
174
+ }
175
+ if (m.state === 'complete' && m.notLookedAt.length > 0) {
176
+ problems.push('state is complete but notLookedAt is not empty');
177
+ }
178
+ if (m.state === 'complete' && m.failures.length > 0) {
179
+ problems.push('state is complete but failures are not empty');
180
+ }
181
+ if (m.state !== 'complete' && m.notLookedAt.length === 0) {
182
+ problems.push('state is ' + m.state + ' but notLookedAt is empty');
183
+ }
184
+ return problems;
185
+ }
186
+ /** Manifests hold paths and reasons from the reviewed tree; keep the last few. */
187
+ const KEEP = 100;
188
+ export function writeManifest(root, m) {
189
+ const dir = join(root, '.powershot', 'runs');
190
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
191
+ try {
192
+ const old = readdirSync(dir)
193
+ .filter((n) => n.endsWith('.json'))
194
+ .map((name) => ({ name, at: statSync(join(dir, name)).mtimeMs }))
195
+ .sort((a, b) => a.at - b.at);
196
+ for (const { name } of old.slice(0, Math.max(0, old.length - KEEP + 1)))
197
+ rmSync(join(dir, name), { force: true });
198
+ }
199
+ catch {
200
+ // housekeeping must never be the reason a finished review fails to record itself
201
+ }
202
+ const file = join(dir, m.id + '.json');
203
+ writeFileSync(file, JSON.stringify(m, null, 2), { mode: 0o600 });
204
+ return file;
205
+ }
206
+ export function hashOf(text) {
207
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
208
+ }
209
+ //# sourceMappingURL=manifest.js.map
package/dist/otel.js ADDED
@@ -0,0 +1,75 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { PACKAGE_VERSION } from './package-meta.js';
3
+ const HEX = (n) => randomBytes(n).toString('hex');
4
+ export class Trace {
5
+ spans = [];
6
+ traceId = HEX(16);
7
+ endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
8
+ get enabled() {
9
+ return Boolean(this.endpoint);
10
+ }
11
+ /** Returns a function that closes the span; a no-op when tracing is off. */
12
+ span(name, attrs = {}) {
13
+ if (!this.enabled)
14
+ return () => { };
15
+ const s = { name, start: Date.now(), attrs };
16
+ this.spans.push(s);
17
+ return (extra) => {
18
+ s.end = Date.now();
19
+ Object.assign(s.attrs, extra ?? {});
20
+ };
21
+ }
22
+ /**
23
+ * Never throws and never blocks a review: a collector that is down, slow, or absent
24
+ * must not turn a completed review into a failed command.
25
+ */
26
+ async flush() {
27
+ if (!this.enabled || this.spans.length === 0)
28
+ return;
29
+ const url = this.endpoint.replace(/\/+$/, '') + '/v1/traces';
30
+ const body = {
31
+ resourceSpans: [
32
+ {
33
+ resource: {
34
+ attributes: [
35
+ { key: 'service.name', value: { stringValue: 'powershot' } },
36
+ { key: 'service.version', value: { stringValue: PACKAGE_VERSION } },
37
+ ],
38
+ },
39
+ scopeSpans: [
40
+ {
41
+ scope: { name: 'powershot' },
42
+ spans: this.spans.map((s) => ({
43
+ traceId: this.traceId,
44
+ spanId: HEX(8),
45
+ name: s.name,
46
+ kind: 1,
47
+ startTimeUnixNano: String(s.start * 1_000_000),
48
+ endTimeUnixNano: String((s.end ?? Date.now()) * 1_000_000),
49
+ attributes: Object.entries(s.attrs).map(([key, v]) => ({
50
+ key,
51
+ value: typeof v === 'number' ? { intValue: String(Math.round(v)) } : { stringValue: String(v) },
52
+ })),
53
+ })),
54
+ },
55
+ ],
56
+ },
57
+ ],
58
+ };
59
+ try {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), 3000);
62
+ await fetch(url, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify(body),
66
+ signal: controller.signal,
67
+ });
68
+ clearTimeout(timer);
69
+ }
70
+ catch {
71
+ // telemetry is never worth failing a review over
72
+ }
73
+ }
74
+ }
75
+ //# sourceMappingURL=otel.js.map
@@ -0,0 +1,13 @@
1
+ import { createRequire } from 'node:module';
2
+ const metadata = (() => {
3
+ try {
4
+ return createRequire(import.meta.url)('../package.json');
5
+ }
6
+ catch {
7
+ return {};
8
+ }
9
+ })();
10
+ export const PACKAGE_NAME = metadata.name ?? '@0xcraft/powershot';
11
+ export const PACKAGE_VERSION = metadata.version ?? 'unknown';
12
+ export const PACKAGE_HOMEPAGE = metadata.homepage ?? 'https://github.com/xcrft/powershot';
13
+ //# sourceMappingURL=package-meta.js.map
@@ -0,0 +1,110 @@
1
+ // Install one tarball into an empty project and exercise the public binary. When a
2
+ // path is supplied, this script never packs again: release can test and publish the
3
+ // exact bytes whose checksum it recorded.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ const repo = process.cwd();
9
+ const dir = mkdtempSync(join(tmpdir(), 'powershot-smoke-'));
10
+ const supplied = process.argv[2];
11
+ const packageName = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).name;
12
+ const run = (command, args, cwd = dir) => execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
13
+ let failed = 0;
14
+ const check = (name, fn) => {
15
+ try {
16
+ fn();
17
+ console.log(' ok ' + name);
18
+ }
19
+ catch (error) {
20
+ failed++;
21
+ const failure = error;
22
+ console.log(' FAIL ' + name + '\n ' + String(failure.stderr ?? failure.message));
23
+ }
24
+ };
25
+ try {
26
+ let tarball;
27
+ if (supplied) {
28
+ tarball = resolve(repo, supplied);
29
+ if (!existsSync(tarball))
30
+ throw new Error('tarball does not exist: ' + tarball);
31
+ console.log('using ' + tarball);
32
+ }
33
+ else {
34
+ console.log('packing');
35
+ run('npm', ['pack', '--pack-destination', dir], repo);
36
+ const packed = readdirSync(dir).find((file) => file.endsWith('.tgz'));
37
+ if (!packed)
38
+ throw new Error('npm pack produced no tarball');
39
+ tarball = join(dir, packed);
40
+ }
41
+ console.log('installing into a clean tree');
42
+ writeFileSync(join(dir, 'package.json'), '{"name":"smoke","private":true}');
43
+ run('npm', ['install', '--silent', '--no-audit', '--no-fund', tarball]);
44
+ run('git', ['init', '-q', '.']);
45
+ run('git', ['config', 'user.email', 'smoke@test']);
46
+ run('git', ['config', 'user.name', 'smoke']);
47
+ writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"strict":true},"include":["*.ts"]}');
48
+ writeFileSync(join(dir, 'a.ts'), 'export function f() {\n try { JSON.parse("{}") } catch {}\n}\n');
49
+ run('git', ['add', '-A']);
50
+ run('git', ['commit', '-qm', 'seed']);
51
+ const bin = (name) => join(dir, 'node_modules', '.bin', process.platform === 'win32' ? name + '.cmd' : name);
52
+ const psh = bin('psh');
53
+ const powershot = bin('powershot');
54
+ console.log('\nsmoke');
55
+ check('both public commands are linked and print help', () => {
56
+ if (!run(psh, ['--help']).includes('psh review'))
57
+ throw new Error('unexpected help output');
58
+ if (!run(powershot, ['--help']).includes('psh review'))
59
+ throw new Error('unexpected long-command help output');
60
+ });
61
+ check('the package ships architecture and CI examples', () => {
62
+ const installed = join(dir, 'node_modules', ...packageName.split('/'));
63
+ if (!existsSync(join(installed, 'docs', 'architecture.md')))
64
+ throw new Error('architecture guide is missing');
65
+ if (!existsSync(join(installed, 'docs', 'ci.md')))
66
+ throw new Error('CI guide is missing');
67
+ if (!existsSync(join(installed, 'examples', 'github-actions', 'cli.yml')))
68
+ throw new Error('CI example is missing');
69
+ if (!existsSync(join(installed, 'examples', 'gitlab', '.gitlab-ci.yml')))
70
+ throw new Error('GitLab example is missing');
71
+ });
72
+ check('a scan from the installed package finds a real defect', () => {
73
+ try {
74
+ run(psh, ['scan', 'a.ts', '--verify-only', '--format', 'compact']);
75
+ throw new Error('expected exit 1 for a finding');
76
+ }
77
+ catch (error) {
78
+ const failure = error;
79
+ if (failure.status !== 1)
80
+ throw error;
81
+ if (!String(failure.stdout).includes('swallowed-error'))
82
+ throw new Error('no finding: ' + String(failure.stdout));
83
+ }
84
+ });
85
+ check('a clean tree exits 0', () => {
86
+ writeFileSync(join(dir, 'b.ts'), 'export const b = 1\n');
87
+ run(psh, ['scan', 'b.ts', '--verify-only']);
88
+ });
89
+ check('a bundled foreign-language pack loads', () => {
90
+ writeFileSync(join(dir, 'c.py'), 'def f():\n try:\n g()\n except Exception:\n pass\n');
91
+ try {
92
+ run(psh, ['scan', 'c.py', '--verify-only', '--checks', 'foreign-swallowed-error', '--format', 'compact']);
93
+ throw new Error('expected a finding from the python pack');
94
+ }
95
+ catch (error) {
96
+ if (error.status !== 1)
97
+ throw error;
98
+ }
99
+ });
100
+ }
101
+ finally {
102
+ rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ console.log('');
105
+ if (failed > 0) {
106
+ console.error(failed + ' smoke check(s) failed');
107
+ process.exit(1);
108
+ }
109
+ console.log('the package artifact works');
110
+ //# sourceMappingURL=package-smoke.js.map
package/dist/plan.js ADDED
@@ -0,0 +1,134 @@
1
+ import { statSync } from 'node:fs';
2
+ import { insideRepo, isSymlink } from './fspolicy.js';
3
+ import { matchesAny } from './config.js';
4
+ import { packFor } from './lang/packs.js';
5
+ import { pyrightAvailable } from './lang/pyright.js';
6
+ /**
7
+ * Past this a file is generated, minified or vendored rather than written. What is
8
+ * turned away is recorded as an outcome, not printed and forgotten.
9
+ */
10
+ const MAX_FILE_BYTES = 512 * 1024;
11
+ const TS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
12
+ /**
13
+ * One answer to "what is this run about", shared by everything that needs it.
14
+ *
15
+ * Every file the change touches ends in exactly one disposition: reviewed, waived by
16
+ * a policy someone wrote down, or failed because we could not look. Scattering those
17
+ * decisions across the pipeline is what let a run turn a file away in a progress line
18
+ * and still report the result as clean — the reader saw a summary that had no idea
19
+ * the file existed.
20
+ */
21
+ export class SelectionPlan {
22
+ rows;
23
+ constructor(rows) {
24
+ this.rows = rows;
25
+ }
26
+ static build(root, changed, config) {
27
+ const rows = new Map();
28
+ for (const c of changed) {
29
+ const abs = insideRepo(root, c.path);
30
+ const bytes = abs ? (statSync(abs, { throwIfNoEntry: false })?.size ?? 0) : 0;
31
+ const item = {
32
+ path: c.path,
33
+ disposition: 'selected',
34
+ bytes,
35
+ addedLines: c.added.size,
36
+ language: packFor(c.path)?.name ?? (TS.test(c.path) ? 'typescript' : 'other'),
37
+ checks: [],
38
+ };
39
+ if (!abs || isSymlink(abs)) {
40
+ item.disposition = 'failed';
41
+ item.reason = 'outside the repository, or a link that leaves it';
42
+ }
43
+ else if (matchesAny(c.path, config.ignore)) {
44
+ item.disposition = 'waived';
45
+ item.reason = 'ignored by config';
46
+ }
47
+ else if (bytes > MAX_FILE_BYTES) {
48
+ item.disposition = 'waived';
49
+ item.reason = 'over ' + Math.round(MAX_FILE_BYTES / 1024) + 'KB — generated or minified, not written';
50
+ }
51
+ rows.set(c.path, item);
52
+ }
53
+ return new SelectionPlan(rows);
54
+ }
55
+ /** A policy decision: it was not reviewed, and that is the intended outcome. */
56
+ waive(path, reason) {
57
+ const row = this.rows.get(path);
58
+ if (row && row.disposition === 'selected') {
59
+ row.disposition = 'waived';
60
+ row.reason = reason;
61
+ }
62
+ }
63
+ /** Reviewed, but with less than the run's full check set. */
64
+ limit(path, missing) {
65
+ const row = this.rows.get(path);
66
+ if (row && row.disposition === 'selected' && missing.length > 0) {
67
+ row.missing = [...new Set([...(row.missing ?? []), ...missing])];
68
+ }
69
+ }
70
+ /** Record coverage at the same file granularity used to decide applicability. */
71
+ checked(path, check) {
72
+ const row = this.rows.get(path);
73
+ if (row && row.disposition === 'selected') {
74
+ row.checks = [...new Set([...row.checks, check])];
75
+ }
76
+ }
77
+ /** We meant to review it and could not. This is what makes a run incomplete. */
78
+ fail(path, reason) {
79
+ const row = this.rows.get(path);
80
+ if (row) {
81
+ row.disposition = 'failed';
82
+ row.reason = reason;
83
+ }
84
+ }
85
+ keep(changed) {
86
+ return changed.filter((c) => this.rows.get(c.path)?.disposition === 'selected');
87
+ }
88
+ items() {
89
+ return [...this.rows.values()];
90
+ }
91
+ of(disposition) {
92
+ return this.items().filter((i) => i.disposition === disposition);
93
+ }
94
+ /** What a reader should be told about, grouped so one line covers many files. */
95
+ summary() {
96
+ const byReason = new Map();
97
+ for (const i of this.items()) {
98
+ if (i.disposition === 'selected')
99
+ continue;
100
+ const key = i.disposition + ': ' + (i.reason ?? 'unknown');
101
+ byReason.set(key, (byReason.get(key) ?? 0) + 1);
102
+ }
103
+ const limited = this.items().filter((i) => i.disposition === 'selected' && i.missing?.length);
104
+ const out = [...byReason].map(([reason, n]) => n + ' file(s) ' + reason);
105
+ if (limited.length > 0) {
106
+ out.push(limited.length + ' file(s) reviewed without ' + [...new Set(limited.flatMap((i) => i.missing))].join(', '));
107
+ }
108
+ return out;
109
+ }
110
+ }
111
+ /**
112
+ * What the ground can actually answer, which is not always what was asked for.
113
+ *
114
+ * A repository with no tsconfig has no member resolution and no reference graph; a
115
+ * scan has no base version to compare against. Working this out once, here, is what
116
+ * lets a check declare `needs` instead of each one rediscovering it.
117
+ */
118
+ export function capabilitiesOf(g) {
119
+ const caps = new Set(['syntax']);
120
+ if (g.typed) {
121
+ caps.add('types');
122
+ caps.add('references');
123
+ }
124
+ // Kept apart from `types` on purpose. A repository with a tsconfig and some Python
125
+ // in it would otherwise satisfy the Python check through the TypeScript checker,
126
+ // and the check would be recorded as run and satisfied with no oracle behind it.
127
+ if (g.foreign.some((f) => f.pack.name === 'python') && pyrightAvailable(g.root)) {
128
+ caps.add('python-types');
129
+ }
130
+ if (g.changed.some((c) => c.before !== undefined))
131
+ caps.add('base');
132
+ return caps;
133
+ }
134
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1,94 @@
1
+ import { reviewables, shownLines } from './bundle.js';
2
+ import { lines as splitLines } from './text.js';
3
+ const CONTEXT_LINES = 1;
4
+ /** undefined when the span cannot be pointed at: a caret in the wrong column accuses
5
+ * the wrong token. */
6
+ export function caretFor(span, lineText, dedent) {
7
+ if (!span || lineText === undefined)
8
+ return undefined;
9
+ const offset = span.column - 1 - dedent;
10
+ if (offset < 0 || offset >= lineText.length)
11
+ return undefined;
12
+ return { offset, length: Math.max(1, Math.min(span.length, lineText.length - offset)) };
13
+ }
14
+ /**
15
+ * A model writes its own file and line, so it can point anywhere.
16
+ *
17
+ * Being inside the file is not enough: a judge is only shown the changed lines and a
18
+ * little context, so a finding outside that window is about code the model never saw.
19
+ * Which of the two justified it is recorded, because "the change did this" and "the
20
+ * change sits next to this" are different claims for a reader to weigh.
21
+ */
22
+ export function positionable(findings, g) {
23
+ const files = reviewables(g);
24
+ const lineCount = new Map(files.map((r) => [r.path, splitLines(r.text).length]));
25
+ const added = new Map(files.map((r) => [r.path, r.added]));
26
+ const shown = shownLines(files);
27
+ const kept = [];
28
+ for (const f of findings) {
29
+ if (f.class === 'verified') {
30
+ kept.push(f);
31
+ continue;
32
+ }
33
+ const total = lineCount.get(f.file);
34
+ if (total === undefined || f.line < 1 || f.line > total)
35
+ continue;
36
+ if (added.get(f.file)?.has(f.line))
37
+ kept.push({ ...f, positioning: 'added' });
38
+ else if (shown.get(f.file)?.has(f.line))
39
+ kept.push({ ...f, positioning: 'context' });
40
+ }
41
+ return { kept, dropped: findings.length - kept.length };
42
+ }
43
+ /** From the source that was analysed, not from disk — an old commit differs. */
44
+ export function attachFrames(findings, g) {
45
+ const byFile = new Map();
46
+ for (const { sf, changed } of g.files)
47
+ byFile.set(changed.path, splitLines(sf.getFullText()));
48
+ for (const f of g.foreign)
49
+ byFile.set(f.path, splitLines(f.tree.rootNode.text));
50
+ return findings.map((f) => {
51
+ const lines = byFile.get(f.file);
52
+ if (!lines || f.line < 1 || f.line > lines.length)
53
+ return f;
54
+ const firstLine = Math.max(1, f.line - CONTEXT_LINES);
55
+ const lastLine = Math.min(lines.length, f.line + CONTEXT_LINES);
56
+ let start = firstLine;
57
+ let slice = lines.slice(firstLine - 1, lastLine).map((l) => l.replace(/\s+$/, ''));
58
+ while (slice.length > 1 && slice[0] === '' && start < f.line) {
59
+ slice = slice.slice(1);
60
+ start++;
61
+ }
62
+ while (slice.length > 1 && slice[slice.length - 1] === '')
63
+ slice = slice.slice(0, -1);
64
+ const indents = slice.filter((l) => l.trim() !== '').map((l) => l.length - l.trimStart().length);
65
+ const dedent = indents.length > 0 ? Math.min(...indents) : 0;
66
+ const rendered = slice.map((l) => l.slice(dedent));
67
+ // dedented coordinates, so the renderer only counts characters
68
+ const caret = caretFor(f.span, rendered[f.line - start], dedent);
69
+ // the whole line as it would be committed: untouched source, not the frame
70
+ let suggestion;
71
+ const original = lines[f.line - 1];
72
+ if (f.replacement !== undefined && f.span && original !== undefined) {
73
+ const from = f.span.column - 1;
74
+ if (from >= 0 && from + f.span.length <= original.length) {
75
+ suggestion = original.slice(0, from) + f.replacement + original.slice(from + f.span.length);
76
+ }
77
+ }
78
+ else if (f.suggestion !== undefined && original !== undefined) {
79
+ suggestion = validateSuggestion(f.suggestion, original);
80
+ }
81
+ return { ...f, suggestion, frame: { firstLine: start, lines: rendered, caret } };
82
+ });
83
+ }
84
+ /**
85
+ * Indentation always from the file, never the model: measured, a judge that got the
86
+ * fix exactly right returned it at four spaces where the file used two.
87
+ */
88
+ export function validateSuggestion(suggested, original) {
89
+ const body = suggested.trim();
90
+ if (body === '' || body === original.trim())
91
+ return undefined;
92
+ return (/^\s*/.exec(original)?.[0] ?? '') + body;
93
+ }
94
+ //# sourceMappingURL=position.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Colour is opt-out via NO_COLOR and off automatically when piped, so redirecting
3
+ * to a file or a PR comment never lands escape codes in the output.
4
+ */
5
+ export const COLOR = process.env.NO_COLOR === undefined &&
6
+ (process.env.FORCE_COLOR !== undefined || process.stdout.isTTY === true);
7
+ const ESC = '[';
8
+ export const paint = (code) => (s) => (COLOR ? ESC + code + 'm' + s + ESC + '0m' : s);
9
+ export const dim = paint('2');
10
+ export const bold = paint('1');
11
+ export const red = paint('31');
12
+ export const brightRed = paint('91');
13
+ export const green = paint('32');
14
+ export const steel = paint('36');
15
+ export const yellow = paint('33');
16
+ export const magenta = paint('35');
17
+ export const gray = paint('90');
18
+ //# sourceMappingURL=ansi.js.map
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto';
2
+ function level(s) {
3
+ return s === 'critical' ? 'blocker' : s === 'high' ? 'major' : s === 'medium' ? 'minor' : 'info';
4
+ }
5
+ /**
6
+ * GitLab Code Quality — the format that renders findings inside a merge request
7
+ * rather than in a job log. The fingerprint must be stable across runs, or GitLab
8
+ * reports every finding as new on each pipeline.
9
+ */
10
+ export function codeQuality(findings) {
11
+ return (JSON.stringify(findings.map((f) => ({
12
+ description: f.title,
13
+ check_name: f.check,
14
+ fingerprint: createHash('sha1').update(f.check + f.file + f.line + f.title).digest('hex'),
15
+ severity: level(f.severity),
16
+ location: { path: f.file, lines: { begin: f.line } },
17
+ })), null, 2) + '\n');
18
+ }
19
+ //# sourceMappingURL=codequality.js.map