@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,15 @@
1
+ /**
2
+ * `src/a.ts:42:9: error: message [check]` — the shape compilers have emitted for
3
+ * decades, and the whole IDE integration. Problem matchers, errorformat and every
4
+ * "jump to next error" already parse it, so there is no extension to maintain.
5
+ */
6
+ export function compact(findings) {
7
+ return (findings
8
+ .map((f) => {
9
+ const severity = f.severity === 'critical' || f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'info';
10
+ const column = f.span?.column ?? 1;
11
+ return f.file + ':' + f.line + ':' + column + ': ' + severity + ': ' + f.title + ' [' + f.check + ']';
12
+ })
13
+ .join('\n') + (findings.length > 0 ? '\n' : ''));
14
+ }
15
+ //# sourceMappingURL=compact.js.map
@@ -0,0 +1,54 @@
1
+ import { ts } from 'ts-morph';
2
+ import { COLOR, gray, green, magenta, yellow } from './ansi.js';
3
+ /**
4
+ * Syntax highlighting through the TypeScript scanner rather than a regex or a new
5
+ * dependency. The compiler is already the project's oracle for analysis; it ships a
6
+ * real lexer, so template literals, regexes and nested comments tokenize correctly
7
+ * instead of approximately.
8
+ */
9
+ function colorFor(kind, text) {
10
+ const K = ts.SyntaxKind;
11
+ if (kind === K.SingleLineCommentTrivia || kind === K.MultiLineCommentTrivia)
12
+ return gray(text);
13
+ if (kind === K.StringLiteral ||
14
+ kind === K.NoSubstitutionTemplateLiteral ||
15
+ kind === K.TemplateHead ||
16
+ kind === K.TemplateMiddle ||
17
+ kind === K.TemplateTail ||
18
+ kind === K.RegularExpressionLiteral)
19
+ return green(text);
20
+ if (kind === K.NumericLiteral || kind === K.BigIntLiteral)
21
+ return yellow(text);
22
+ if (kind >= K.FirstKeyword && kind <= K.LastKeyword)
23
+ return magenta(text);
24
+ if (kind >= K.FirstPunctuation && kind <= K.LastPunctuation)
25
+ return gray(text);
26
+ return text; // identifiers and whitespace stay plain, so names read as the content
27
+ }
28
+ /**
29
+ * Highlighting is a display nicety, so it must never break a review: any scanner
30
+ * trouble falls back to the original text rather than throwing.
31
+ */
32
+ export function highlight(code, jsx = false) {
33
+ if (!COLOR || code === '')
34
+ return code;
35
+ try {
36
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest,
37
+ /* skipTrivia */ false, jsx ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard, code);
38
+ let out = '';
39
+ let token = scanner.scan();
40
+ // the guard bounds a pathological fragment; a code frame is only a few lines
41
+ for (let i = 0; token !== ts.SyntaxKind.EndOfFileToken && i < 4000; i++) {
42
+ out += colorFor(token, scanner.getTokenText());
43
+ token = scanner.scan();
44
+ }
45
+ return out === '' ? code : out;
46
+ }
47
+ catch {
48
+ return code;
49
+ }
50
+ }
51
+ export function isJsx(file) {
52
+ return file.endsWith('.tsx') || file.endsWith('.jsx');
53
+ }
54
+ //# sourceMappingURL=highlight.js.map
@@ -0,0 +1,113 @@
1
+ const MARK = { verified: '▣', judged: '▚' };
2
+ /** Untrusted prose encoded as literal CommonMark text. */
3
+ function text(s) {
4
+ return s
5
+ .replace(/\r?\n/g, ' ')
6
+ .replace(/[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/g, '\\$&');
7
+ }
8
+ /** A path inside a code span: only a backtick or a newline can end one. */
9
+ function path(s) {
10
+ return s.replace(/[`\r\n]/g, '');
11
+ }
12
+ /** A repository-relative URL whose untrusted path cannot become a scheme or fragment. */
13
+ function href(s) {
14
+ const segment = (part) => (part === '.' || part === '..' ? part.replace(/\./g, '%2E') : encodeURIComponent(part))
15
+ .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
16
+ return './' + path(s).split('/').map(segment).join('/');
17
+ }
18
+ /**
19
+ * A fence long enough that its content cannot close it.
20
+ *
21
+ * Indenting the content does not work: CommonMark lets a closing fence carry up to
22
+ * three spaces, so a frame line holding a fence ended the block anyway and everything
23
+ * below it rendered as markup. The rule the spec gives is to open with more backticks
24
+ * than the longest run inside.
25
+ */
26
+ function fenceFor(lines, info = '') {
27
+ const longest = Math.max(0, ...lines.flatMap((l) => [...l.matchAll(/`+/g)].map((m) => m[0].length)));
28
+ const bar = '`'.repeat(Math.max(3, longest + 1));
29
+ return { open: bar + info, close: bar };
30
+ }
31
+ function languageFor(file) {
32
+ const ext = /\.([^.\/]+)$/.exec(file.toLowerCase())?.[1];
33
+ return {
34
+ ts: 'typescript', tsx: 'tsx', mts: 'typescript', cts: 'typescript',
35
+ js: 'javascript', jsx: 'jsx', mjs: 'javascript', cjs: 'javascript',
36
+ py: 'python', pyi: 'python', rb: 'ruby', go: 'go', java: 'java',
37
+ rs: 'rust', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
38
+ c: 'c', h: 'c', cs: 'csharp', php: 'php', kt: 'kotlin', kts: 'kotlin',
39
+ swift: 'swift', sol: 'solidity',
40
+ }[ext ?? ''] ?? '';
41
+ }
42
+ function group(findings) {
43
+ const out = new Map();
44
+ for (const f of findings) {
45
+ const list = out.get(f.file) ?? [];
46
+ list.push(f);
47
+ out.set(f.file, list);
48
+ }
49
+ for (const list of out.values())
50
+ list.sort((a, b) => a.line - b.line);
51
+ return out;
52
+ }
53
+ export function markdown(findings, run) {
54
+ // "No findings" from a run that could not look is the one thing this must never
55
+ // say on its own — the reader takes a comment at face value, and a red job beside
56
+ // a green comment is read as a flaky job
57
+ const incomplete = run !== undefined && run.state !== 'complete';
58
+ const banner = incomplete
59
+ ? [
60
+ '> [!WARNING]',
61
+ '> **This review is ' + text(run.state) + ' — not a verdict.** Something was not looked at:',
62
+ ...run.notLookedAt.slice(0, 8).map((f) => '> - ' + text(f)),
63
+ '',
64
+ ]
65
+ : [];
66
+ if (findings.length === 0) {
67
+ return ['## PowerShot', '', ...banner, incomplete ? 'No findings *from what it managed to review*.' : 'No findings.', ''].join('\n');
68
+ }
69
+ const verified = findings.filter((f) => f.class === 'verified').length;
70
+ const judged = findings.length - verified;
71
+ const out = ['## PowerShot', '', ...banner];
72
+ out.push('**' + verified + ' verified** (deterministic, 0 tokens) · **' + judged + ' judged** (agent)', '');
73
+ for (const [file, list] of group(findings)) {
74
+ out.push('### `' + path(file) + '`', '');
75
+ for (const f of list) {
76
+ out.push(MARK[f.class] +
77
+ ' **' +
78
+ f.severity.toUpperCase() +
79
+ '** · `' +
80
+ f.check.replace(/`/g, '') +
81
+ '` · ' +
82
+ f.confidence +
83
+ ' — [' +
84
+ text(path(file)) +
85
+ ':' +
86
+ f.line +
87
+ '](' +
88
+ href(file) +
89
+ '#L' +
90
+ f.line +
91
+ ')');
92
+ out.push('', text(f.title), '');
93
+ if (f.frame) {
94
+ const fence = fenceFor(f.frame.lines, languageFor(file));
95
+ out.push(fence.open, ...f.frame.lines, fence.close, '');
96
+ }
97
+ if (f.evidence)
98
+ out.push('> _' + text(f.evidence.oracle) + '_: ' + text(f.evidence.detail), '');
99
+ if (f.suggestion !== undefined) {
100
+ // GitHub and GitLab both render this as a one-click "apply", so a finding the
101
+ // checker knows the answer to becomes a fix rather than an instruction
102
+ const fence = fenceFor([f.suggestion], 'suggestion');
103
+ out.push(fence.open, f.suggestion, fence.close, '');
104
+ }
105
+ else if (f.fix) {
106
+ const fence = fenceFor([f.fix]);
107
+ out.push(fence.open, f.fix, fence.close, '');
108
+ }
109
+ }
110
+ }
111
+ return out.join('\n');
112
+ }
113
+ //# sourceMappingURL=markdown.js.map
@@ -0,0 +1,66 @@
1
+ import { PACKAGE_HOMEPAGE } from '#app/package-meta.js';
2
+ /** SARIF has three levels; map ours onto them without inventing precision. */
3
+ function level(severity) {
4
+ if (severity === 'critical' || severity === 'high')
5
+ return 'error';
6
+ if (severity === 'medium')
7
+ return 'warning';
8
+ return 'note';
9
+ }
10
+ const DESCRIPTIONS = {
11
+ 'phantom-api': 'Calls an API that does not exist — hallucinated method, property, or arity',
12
+ 'phantom-dep': 'Imports a package that is not a declared dependency',
13
+ reinvented: 'Declares a helper that already exists in the repository',
14
+ 'dropped-guard': 'A guard present before the change is gone after it',
15
+ 'swallowed-error': 'Error handling that discards the failure',
16
+ 'vacuous-test': 'A test that asserts nothing, or mocks the unit under test',
17
+ 'assertion-drift': 'An expected value edited to match new output',
18
+ 'plausible-logic': 'Code that reads correctly but behaves incorrectly',
19
+ };
20
+ /**
21
+ * SARIF 2.1.0 — the format GitHub code scanning ingests, which turns findings into
22
+ * inline PR annotations. Only the fields consumers actually read are emitted.
23
+ */
24
+ export function sarif(findings) {
25
+ const checks = [...new Set(findings.map((f) => f.check))].sort();
26
+ const doc = {
27
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
28
+ version: '2.1.0',
29
+ runs: [
30
+ {
31
+ tool: {
32
+ driver: {
33
+ name: 'PowerShot',
34
+ informationUri: PACKAGE_HOMEPAGE,
35
+ rules: checks.map((check) => ({
36
+ id: check,
37
+ name: check,
38
+ shortDescription: { text: DESCRIPTIONS[check] ?? check },
39
+ properties: {
40
+ tags: [findings.find((f) => f.check === check)?.class ?? 'verified'],
41
+ },
42
+ })),
43
+ },
44
+ },
45
+ results: findings.map((f) => ({
46
+ ruleId: f.check,
47
+ level: level(f.severity),
48
+ message: {
49
+ text: f.evidence ? f.title + ' (' + f.evidence.oracle + ': ' + f.evidence.detail + ')' : f.title,
50
+ },
51
+ locations: [
52
+ {
53
+ physicalLocation: {
54
+ artifactLocation: { uri: f.file },
55
+ region: { startLine: f.line },
56
+ },
57
+ },
58
+ ],
59
+ properties: { class: f.class, confidence: f.confidence, severity: f.severity },
60
+ })),
61
+ },
62
+ ],
63
+ };
64
+ return JSON.stringify(doc, null, 2) + '\n';
65
+ }
66
+ //# sourceMappingURL=sarif.js.map
@@ -0,0 +1,170 @@
1
+ import { bold, brightRed, dim, gray, red, steel, yellow } from './ansi.js';
2
+ import { highlight, isJsx } from './highlight.js';
3
+ const SEVERITY_COLOR = {
4
+ critical: brightRed,
5
+ high: red,
6
+ medium: yellow,
7
+ low: gray,
8
+ info: gray,
9
+ };
10
+ function width() {
11
+ // stdout.columns is undefined when piped; COLUMNS is the conventional fallback
12
+ const columns = process.stdout.columns ?? (Number(process.env.COLUMNS) || 80);
13
+ return Math.max(60, Math.min(columns, 100));
14
+ }
15
+ function truncate(text, max) {
16
+ return text.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + '…';
17
+ }
18
+ /**
19
+ * The title is the finding, so it wraps instead of truncating — losing the end of
20
+ * "…resolves as success" would cost the reader the point of the sentence.
21
+ */
22
+ export function wrap(text, max) {
23
+ const words = text.split(/\s+/);
24
+ const lines = [];
25
+ let line = '';
26
+ for (const word of words) {
27
+ if (line === '')
28
+ line = word;
29
+ else if (line.length + 1 + word.length <= max)
30
+ line += ' ' + word;
31
+ else {
32
+ lines.push(line);
33
+ line = word;
34
+ }
35
+ }
36
+ if (line)
37
+ lines.push(line);
38
+ return lines.length > 0 ? lines : [''];
39
+ }
40
+ function groupByFile(findings) {
41
+ const out = new Map();
42
+ for (const f of findings) {
43
+ const list = out.get(f.file) ?? [];
44
+ list.push(f);
45
+ out.set(f.file, list);
46
+ }
47
+ for (const list of out.values())
48
+ list.sort((a, b) => a.line - b.line);
49
+ return out;
50
+ }
51
+ /**
52
+ * The code frame. Only the offending line is syntax-highlighted; its context is
53
+ * plain gray, so the eye lands on the line under review without needing a marker
54
+ * to compete with the code.
55
+ */
56
+ function emitFrame(out, f, w) {
57
+ if (!f.frame)
58
+ return;
59
+ const { firstLine, lines } = f.frame;
60
+ const gutterWidth = String(firstLine + lines.length - 1).length;
61
+ const room = w - gutterWidth - 9;
62
+ out.push('');
63
+ lines.forEach((text, i) => {
64
+ const lineNo = firstLine + i;
65
+ const isTarget = lineNo === f.line;
66
+ const gutter = String(lineNo).padStart(gutterWidth);
67
+ const arrow = isTarget ? SEVERITY_COLOR[f.severity]('❯') : ' ';
68
+ const shown = truncate(text, room);
69
+ const body = isTarget ? highlight(shown, isJsx(f.file)) : gray(shown);
70
+ out.push(' ' + arrow + ' ' + dim(gutter + ' │ ') + body);
71
+ // the caret sits directly under the token it marks, so "which part of this line"
72
+ // never has to be guessed from the message
73
+ if (isTarget && f.frame?.caret) {
74
+ const { offset, length } = f.frame.caret;
75
+ if (offset < shown.length) {
76
+ const run = Math.max(1, Math.min(length, shown.length - offset));
77
+ out.push(' ' + dim(' '.repeat(gutterWidth) + ' │ ') + ' '.repeat(offset) + SEVERITY_COLOR[f.severity]('^'.repeat(run)));
78
+ }
79
+ }
80
+ });
81
+ }
82
+ function emitEvidence(out, f, w) {
83
+ if (!f.evidence)
84
+ return;
85
+ const lead = ' ╰ ';
86
+ const indent = ' '.repeat(lead.length);
87
+ wrap(f.evidence.oracle + ': ' + f.evidence.detail, w - lead.length - 2).forEach((line, i) => out.push(dim((i === 0 ? lead : indent) + line)));
88
+ }
89
+ function severityCounts(findings) {
90
+ const order = ['critical', 'high', 'medium', 'low', 'info'];
91
+ return order
92
+ .map((s) => ({ s, n: findings.filter((f) => f.severity === s).length }))
93
+ .filter(({ n }) => n > 0)
94
+ .map(({ s, n }) => SEVERITY_COLOR[s](String(n) + ' ' + s))
95
+ .join(dim(' · '));
96
+ }
97
+ /**
98
+ * The review view: findings grouped under the file they belong to, each showing the
99
+ * code it is about. Seeing the line is what makes a finding actionable without
100
+ * opening an editor, so the frame is the centre of the layout, not a decoration.
101
+ */
102
+ export function terminal(findings, opts) {
103
+ const w = width();
104
+ const rule = dim(' ' + '─'.repeat(w - 2));
105
+ const out = [''];
106
+ out.push(' ' + bold('PowerShot') + dim(' · ' + opts.subtitle));
107
+ out.push(rule);
108
+ const incomplete = opts.state !== 'complete';
109
+ if (findings.length === 0) {
110
+ out.push('', incomplete
111
+ ? ' ' + yellow('!') + ' No findings — but this review is ' + opts.state + ', not a verdict.'
112
+ : ' ' + steel('✔') + ' No findings.');
113
+ for (const reason of opts.notLookedAt)
114
+ out.push(dim(' ' + reason));
115
+ out.push('');
116
+ return out.join('\n');
117
+ }
118
+ for (const [file, list] of groupByFile(findings)) {
119
+ out.push('', ' ' + bold(file));
120
+ for (const f of list) {
121
+ const isVerified = f.class === 'verified';
122
+ const mark = isVerified ? steel('▣') : red('▚');
123
+ const badge = SEVERITY_COLOR[f.severity](f.severity.toUpperCase().padEnd(8));
124
+ const origin = isVerified ? steel(f.check) : red(f.check);
125
+ out.push('');
126
+ out.push(' ' + mark + ' ' + badge + origin + dim(' · ' + f.confidence));
127
+ for (const line of wrap(f.title, w - 6))
128
+ out.push(' ' + line);
129
+ emitFrame(out, f, w);
130
+ emitEvidence(out, f, w);
131
+ if (f.fix) {
132
+ const [head, ...rest] = wrap(f.fix, w - 7);
133
+ out.push(' ' + yellow('→ ') + head);
134
+ for (const line of rest)
135
+ out.push(' ' + line);
136
+ }
137
+ }
138
+ }
139
+ const tally = findings.length + (findings.length === 1 ? ' finding' : ' findings') + dim(' ') + severityCounts(findings);
140
+ const split = steel(String(opts.verified) + ' verified') + dim(' · ') + red(String(opts.judged) + ' judged');
141
+ out.push('', rule);
142
+ out.push(' ' + tally + dim(' · ') + split);
143
+ if (incomplete) {
144
+ out.push('');
145
+ out.push(' ' + yellow('! this review is ' + opts.state + ' — findings may be missing'));
146
+ for (const reason of opts.notLookedAt)
147
+ out.push(dim(' ' + reason));
148
+ }
149
+ out.push('');
150
+ return out.join('\n');
151
+ }
152
+ export function progress(line) {
153
+ return dim(' ◇ ' + line);
154
+ }
155
+ export function stage(label) {
156
+ const tty = process.stderr.isTTY === true;
157
+ const started = Date.now();
158
+ if (tty)
159
+ process.stderr.write(dim(' ◇ ' + label.padEnd(9) + ' …'));
160
+ return (result) => {
161
+ const ms = Date.now() - started;
162
+ const took = ms >= 100 ? dim(' (' + (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms') + ')') : '';
163
+ if (tty) {
164
+ process.stderr.clearLine(0);
165
+ process.stderr.cursorTo(0);
166
+ }
167
+ process.stderr.write(progress(label.padEnd(9) + ' ' + result) + took + '\n');
168
+ };
169
+ }
170
+ //# sourceMappingURL=terminal.js.map
@@ -0,0 +1,148 @@
1
+ const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2
+ export function viewer(findings, meta) {
3
+ const verified = findings.filter((f) => f.class === 'verified').length;
4
+ const incomplete = meta.state !== 'complete';
5
+ const warning = incomplete
6
+ ? '<div class="warning"><strong>This review is ' + escape(meta.state) + ' — not a verdict.</strong>' +
7
+ (meta.notLookedAt.length > 0
8
+ ? '<ul>' + meta.notLookedAt.map((reason) => '<li>' + escape(reason) + '</li>').join('') + '</ul>'
9
+ : '') + '</div>'
10
+ : '';
11
+ const rows = findings
12
+ .map((f) => {
13
+ const frame = f.frame
14
+ ? '<pre class="frame">' +
15
+ f.frame.lines
16
+ .map((l, i) => {
17
+ const n = f.frame.firstLine + i;
18
+ const mark = n === f.line ? ' target' : '';
19
+ return '<span class="ln' + mark + '">' + String(n).padStart(4) + '</span> ' + escape(l);
20
+ })
21
+ .join('\n') +
22
+ '</pre>'
23
+ : '';
24
+ return `<article class="f ${f.class}" data-check="${escape(f.check)}" data-sev="${f.severity}">
25
+ <header><span class="sev ${f.severity}">${f.severity}</span>
26
+ <span class="check">${escape(f.check)}</span>
27
+ <span class="conf">${f.confidence}</span>
28
+ <span class="loc">${escape(f.file)}:${f.line}</span>
29
+ <span class="act"><button data-away="${escape(f.check)}|${escape(f.file)}|${f.line}">put away</button></span></header>
30
+ <p class="title">${escape(f.title)}</p>
31
+ ${frame}
32
+ ${f.evidence ? '<p class="ev">' + escape(f.evidence.oracle) + ': ' + escape(f.evidence.detail) + '</p>' : ''}
33
+ ${f.fix ? '<p class="fix">→ ' + escape(f.fix) + '</p>' : ''}
34
+ </article>`;
35
+ })
36
+ .join('\n');
37
+ return `<!doctype html>
38
+ <meta charset="utf-8">
39
+ <title>PowerShot ${escape(meta.id)}</title>
40
+ <style>
41
+ :root { color-scheme: light dark;
42
+ --bg:#0a0e13; --panel:#111823; --ink:#e8eef4; --muted:#8e9bab; --line:#1e2a37;
43
+ --red:#ff6a5e; --steel:#5ab0e6; --amber:#f0a94a; }
44
+ @media (prefers-color-scheme: light) {
45
+ :root { --bg:#e9edf1; --panel:#fff; --ink:#0d141c; --muted:#5a6675; --line:#d2dae2;
46
+ --red:#d4302a; --steel:#1f6fa6; --amber:#b06f12; } }
47
+ body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 ui-sans-serif,system-ui,sans-serif; }
48
+ .wrap { max-width:960px; margin:0 auto; padding:32px 24px 80px; }
49
+ h1 { font-size:1.4rem; margin:0 0 4px; }
50
+ .meta { color:var(--muted); font-family:ui-monospace,monospace; font-size:.8rem; margin-bottom:24px; }
51
+ .bar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:20px; }
52
+ button { font:inherit; font-size:.8rem; background:var(--panel); color:var(--ink);
53
+ border:1px solid var(--line); border-radius:6px; padding:5px 11px; cursor:pointer; }
54
+ button[aria-pressed=true] { border-color:var(--steel); color:var(--steel); }
55
+ .f { background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--steel);
56
+ border-radius:8px; padding:14px 18px; margin-bottom:14px; }
57
+ .f.judged { border-left-color:var(--red); }
58
+ header { display:flex; gap:10px; align-items:center; flex-wrap:wrap;
59
+ font-family:ui-monospace,monospace; font-size:.74rem; margin-bottom:8px; }
60
+ .sev { text-transform:uppercase; font-weight:700; }
61
+ .sev.critical,.sev.high { color:var(--red); } .sev.medium { color:var(--amber); }
62
+ .sev.low,.sev.info { color:var(--muted); }
63
+ .check { color:var(--steel); } .conf,.loc { color:var(--muted); } .loc { margin-left:auto; }
64
+ .title { margin:0 0 10px; font-weight:600; }
65
+ .frame { background:var(--bg); border:1px solid var(--line); border-radius:6px;
66
+ padding:10px 12px; overflow-x:auto; font:12px/1.65 ui-monospace,monospace; margin:0 0 10px; }
67
+ .ln { color:var(--muted); } .ln.target { color:var(--red); font-weight:700; }
68
+ .ev { color:var(--muted); font-size:.85rem; margin:0 0 6px; }
69
+ .fix { color:var(--amber); font-size:.85rem; margin:0; }
70
+ .none { color:var(--muted); }
71
+ .warning { border:1px solid var(--amber); border-radius:6px; padding:10px 14px; margin:0 0 18px; }
72
+ .warning ul { margin:6px 0 0; }
73
+ .f.put-away { opacity:.4; }
74
+ .f.put-away .title { text-decoration:line-through; }
75
+ .act { margin-left:8px; }
76
+ .act button { font-size:.7rem; font-family:ui-monospace,monospace; padding:2px 7px; color:var(--muted);
77
+ background:transparent; }
78
+ .act button:hover { color:var(--ink); border-color:var(--steel); }
79
+ </style>
80
+ <div class="wrap">
81
+ <h1>PowerShot</h1>
82
+ <p class="meta">${escape(meta.target)} · ${escape(meta.started.slice(0, 19).replace('T', ' '))} · session ${escape(meta.id)}<br>
83
+ ${findings.length} finding(s) — ${verified} verified, ${findings.length - verified} judged</p>
84
+ ${warning}
85
+ <div class="bar">
86
+ <button data-filter="all" aria-pressed="true">all</button>
87
+ <button data-filter="verified" aria-pressed="false">verified</button>
88
+ <button data-filter="judged" aria-pressed="false">judged</button>
89
+ <button id="show-away" aria-pressed="false">show put away</button>
90
+ </div>
91
+ ${findings.length === 0
92
+ ? '<p class="none">' + (incomplete ? 'No findings from what completed.' : 'No findings.') + '</p>'
93
+ : rows}
94
+ </div>
95
+ <script>
96
+ // Putting a finding away is per-reader and per-browser on purpose: this page is a
97
+ // file, often attached to a ticket, and one reader's triage is not a fact about the
98
+ // review that belongs in everyone else's copy of it.
99
+ const KEY = 'powershot:away:' + ${JSON.stringify(JSON.stringify(meta.id))}
100
+ const away = new Set(JSON.parse(localStorage.getItem(KEY) || '[]'))
101
+ let showAway = false
102
+
103
+ function paint() {
104
+ document.querySelectorAll('.f').forEach((el) => {
105
+ const id = el.querySelector('[data-away]').dataset.away
106
+ const isAway = away.has(id)
107
+ el.classList.toggle('put-away', isAway)
108
+ el.dataset.putAway = isAway ? '1' : '0'
109
+ if (isAway && !showAway) el.style.display = 'none'
110
+ if (!isAway) el.style.display = ''
111
+ el.querySelector('[data-away]').textContent = isAway ? 'restore' : 'put away'
112
+ })
113
+ const btn = document.getElementById('show-away')
114
+ btn.setAttribute('aria-pressed', String(showAway))
115
+ btn.textContent = away.size
116
+ ? (showAway ? 'hide put away' : 'show put away (' + away.size + ')')
117
+ : 'show put away'
118
+ }
119
+
120
+ document.querySelectorAll('[data-away]').forEach((b) => {
121
+ b.addEventListener('click', () => {
122
+ const id = b.dataset.away
123
+ away.has(id) ? away.delete(id) : away.add(id)
124
+ localStorage.setItem(KEY, JSON.stringify([...away]))
125
+ paint()
126
+ })
127
+ })
128
+ document.getElementById('show-away').addEventListener('click', () => {
129
+ showAway = !showAway
130
+ paint()
131
+ if (showAway) document.querySelectorAll('.f.put-away').forEach((el) => { el.style.display = '' })
132
+ })
133
+ paint()
134
+
135
+ document.querySelectorAll('button[data-filter]').forEach((b) => {
136
+ b.addEventListener('click', () => {
137
+ const want = b.dataset.filter
138
+ document.querySelectorAll('button[data-filter]').forEach((o) =>
139
+ o.setAttribute('aria-pressed', String(o === b)))
140
+ document.querySelectorAll('.f').forEach((f) => {
141
+ f.style.display = want === 'all' || f.classList.contains(want) ? '' : 'none'
142
+ })
143
+ })
144
+ })
145
+ </script>
146
+ `;
147
+ }
148
+ //# sourceMappingURL=viewer.js.map