@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
package/dist/config.js ADDED
@@ -0,0 +1,130 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { git } from './git.js';
4
+ import { SEVERITIES } from './types.js';
5
+ const DEFAULTS = {
6
+ provider: 'anthropic',
7
+ model: 'claude-sonnet-5',
8
+ verifiers: ['*'],
9
+ // security may duplicate SAST; convention needs repository idioms in the diff
10
+ judges: ['plausible-logic', 'test-adequacy', 'intent'],
11
+ minSeverity: 'low',
12
+ // findings in vendored trees are not decisions made by this repository
13
+ ignore: [
14
+ '**/node_modules/**', '**/dist/**', '**/build/**', '**/*.generated.*', '**/*.min.js',
15
+ '**/vendor/**', '**/vendored/**', '**/third_party/**', '**/Pods/**',
16
+ '**/.venv/**', '**/venv/**', '**/site-packages/**', '**/target/debug/**', '**/target/release/**',
17
+ ],
18
+ promptCache: true,
19
+ };
20
+ const FILE = 'powershot.config.json';
21
+ function atRef(root, ref) {
22
+ try {
23
+ return git(root, ['show', ref + ':' + FILE], true);
24
+ }
25
+ catch {
26
+ return undefined; // no policy at that ref is the same as the default one
27
+ }
28
+ }
29
+ export function policyChanged(root, baseRef) {
30
+ const path = join(root, FILE);
31
+ const head = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
32
+ return (atRef(root, baseRef) ?? '') !== (head ?? '');
33
+ }
34
+ const KEYS = new Set([...Object.keys(DEFAULTS), 'checks']);
35
+ const PROVIDERS = new Set(['anthropic', 'openai', 'gemini']);
36
+ /** A misspelled name in the config is the quietest way to get a clean review. */
37
+ export function validateConfig(raw, known) {
38
+ const problems = [];
39
+ const suggest = (name, from) => {
40
+ const near = from.find((k) => k.startsWith(name.slice(0, 4)) || name.startsWith(k.slice(0, 4)));
41
+ return near ? ' — did you mean ' + near + '?' : ' — known: ' + from.join(', ');
42
+ };
43
+ for (const key of Object.keys(raw)) {
44
+ if (!KEYS.has(key))
45
+ problems.push('unknown setting "' + key + '"' + suggest(key, [...KEYS]));
46
+ }
47
+ if (raw.provider !== undefined && !PROVIDERS.has(String(raw.provider))) {
48
+ problems.push('provider "' + String(raw.provider) + '" is not one of: ' + [...PROVIDERS].join(', '));
49
+ }
50
+ if (raw.minSeverity !== undefined && !SEVERITIES.includes(raw.minSeverity)) {
51
+ problems.push('minSeverity "' + String(raw.minSeverity) + '" is not one of: ' + SEVERITIES.join(', '));
52
+ }
53
+ for (const [field, names] of [['verifiers', known.verifiers], ['judges', known.judges]]) {
54
+ const value = raw[field];
55
+ if (value === undefined)
56
+ continue;
57
+ // a bare string reaches `enabled()` as a string, whose `includes` matches
58
+ // substrings — so "security" would quietly enable a judge named "secur"
59
+ const list = Array.isArray(value) ? value : value?.enable;
60
+ if (!Array.isArray(list)) {
61
+ problems.push(field + ' must be a list of names, or { "enable": [...] }');
62
+ continue;
63
+ }
64
+ for (const name of list) {
65
+ if (name !== '*' && !names.includes(String(name))) {
66
+ problems.push('no ' + field.slice(0, -1) + ' named "' + String(name) + '"' + suggest(String(name), names));
67
+ }
68
+ }
69
+ }
70
+ return problems;
71
+ }
72
+ /**
73
+ * The policy a review is judged under.
74
+ *
75
+ * `baseRef` is what stops a change from relaxing the gate that is judging it. The
76
+ * config is a file in the repository, so a pull request can set `ignore: ["**"]`,
77
+ * empty the check list, or raise `minSeverity`, and obtain a clean required check
78
+ * from its own edit. Read from the base instead, a policy change takes effect once
79
+ * it has been reviewed — like every other change. Workspace runs pass no base.
80
+ */
81
+ export function loadConfig(root, known, baseRef) {
82
+ const path = join(root, FILE);
83
+ const text = baseRef === undefined ? (existsSync(path) ? readFileSync(path, 'utf8') : undefined) : atRef(root, baseRef);
84
+ if (text === undefined)
85
+ return DEFAULTS;
86
+ let raw;
87
+ try {
88
+ raw = JSON.parse(text);
89
+ }
90
+ catch (e) {
91
+ throw new Error('powershot.config.json is not valid JSON: ' + e.message);
92
+ }
93
+ if (known) {
94
+ const problems = validateConfig(raw, known);
95
+ if (problems.length > 0) {
96
+ throw new Error('powershot.config.json:\n ' + problems.join('\n '));
97
+ }
98
+ }
99
+ return {
100
+ ...DEFAULTS,
101
+ ...raw,
102
+ verifiers: raw.verifiers?.enable ?? raw.verifiers ?? DEFAULTS.verifiers,
103
+ judges: raw.judges?.enable ?? raw.judges ?? DEFAULTS.judges,
104
+ };
105
+ }
106
+ export function enabled(list, name) {
107
+ return list.includes('*') || list.includes(name);
108
+ }
109
+ /**
110
+ * Minimal glob: `**` and `*` only, which is all an ignore pattern needs.
111
+ *
112
+ * A globstar before a slash matches zero directories as well as many, the way every
113
+ * other glob reads it. Without that, a globstar-slash-vendor pattern covered a nested
114
+ * vendor tree and missed one at the repository root, which is where most of them are.
115
+ */
116
+ export function matchesAny(path, patterns) {
117
+ return patterns.some((p) => {
118
+ const source = '^' +
119
+ p
120
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
121
+ .replace(/\*\*\//g, '<<dirs>>')
122
+ .replace(/\*\*/g, '<<any>>')
123
+ .replace(/\*/g, '[^/]*')
124
+ .replace(/<<dirs>>/g, '(?:[^/]*\\/)*')
125
+ .replace(/<<any>>/g, '.*') +
126
+ '$';
127
+ return new RegExp(source).test(path);
128
+ });
129
+ }
130
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,84 @@
1
+ import { bundle, bundleName } from './bundle.js';
2
+ import { renderChanges } from './judges/judge.js';
3
+ import { COMMON, JUDGES } from './judges/prompts.js';
4
+ import { enabled } from './config.js';
5
+ export function delegateBrief(g, cfg, opts) {
6
+ const units = bundle(g, opts.maxBundleLines ?? 1200);
7
+ const judges = JUDGES.filter((j) => opts.checks ? opts.checks.includes(j.name) : enabled(cfg.judges, j.name));
8
+ const out = [
9
+ '# PowerShot review brief',
10
+ '',
11
+ 'The deterministic checks have already run; what follows is the judgement work.',
12
+ 'Act as each judge below over each review unit, then reply with a single JSON array',
13
+ 'combining every finding. Add nothing outside the array.',
14
+ '',
15
+ '## Output contract',
16
+ '',
17
+ '```json',
18
+ '[{"file":"src/a.ts","line":12,"severity":"high","confidence":"firm",',
19
+ ' "check":"plausible-logic","title":"…","why":"…","fix":"…"}]',
20
+ '```',
21
+ '',
22
+ 'Return `[]` when a judge finds nothing. An empty array is a success, not a failure.',
23
+ '',
24
+ '## Ground rules',
25
+ '',
26
+ COMMON,
27
+ '',
28
+ ];
29
+ for (const judge of judges) {
30
+ out.push('## Judge: ' + judge.name, '', judge.brief, '');
31
+ if (judge.needsIntent) {
32
+ out.push(opts.intent
33
+ ? 'This change states that it does the following:\n\n> ' + opts.intent.split('\n').join('\n> ')
34
+ : '_No stated intent available (no commit in range) — skip this judge._', '');
35
+ }
36
+ }
37
+ out.push('## Review units', '');
38
+ units.forEach((unit, i) => {
39
+ out.push('### Unit ' + (i + 1) + ' — ' + bundleName(unit, g.root), '', '```', renderChanges(unit.files), '```', '');
40
+ });
41
+ return out.join('\n');
42
+ }
43
+ export function absorbDelegated(json) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(json);
47
+ }
48
+ catch (error) {
49
+ throw new Error('delegated findings are not valid JSON: ' + error.message);
50
+ }
51
+ if (!Array.isArray(parsed))
52
+ throw new Error('delegated findings must be a JSON array');
53
+ const out = [];
54
+ for (const [index, value] of parsed.entries()) {
55
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
56
+ throw new Error('delegated finding ' + (index + 1) + ' must be an object');
57
+ }
58
+ const item = value;
59
+ if (typeof item.file !== 'string' || item.file.trim() === '' ||
60
+ typeof item.line !== 'number' || !Number.isInteger(item.line) || item.line < 1 ||
61
+ typeof item.title !== 'string' || item.title.trim() === '') {
62
+ throw new Error('delegated finding ' + (index + 1) + ' needs a file, positive integer line, and title');
63
+ }
64
+ out.push({
65
+ id: '',
66
+ class: 'judged',
67
+ check: typeof item.check === 'string' ? item.check : 'delegated',
68
+ severity: ['critical', 'high', 'medium', 'low', 'info'].includes(item.severity)
69
+ ? item.severity
70
+ : 'medium',
71
+ confidence: item.confidence === 'firm' ? 'firm' : 'tentative',
72
+ file: item.file,
73
+ line: item.line,
74
+ title: item.title,
75
+ evidence: typeof item.why === 'string' ? { oracle: 'delegated agent', detail: item.why } : undefined,
76
+ fix: typeof item.fix === 'string' ? item.fix : undefined,
77
+ suggestion: typeof item.suggestion === 'string' && item.suggestion.trim() !== '' && !item.suggestion.includes('\n')
78
+ ? item.suggestion
79
+ : undefined,
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+ //# sourceMappingURL=delegate.js.map
@@ -0,0 +1,130 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { git } from './git.js';
5
+ const FILE = '.powershot/dismissed.json';
6
+ /**
7
+ * Committed, unlike the judge cache: a dismissal is a team decision, not an
8
+ * optimisation. Keyed on the line's code rather than its position, so it survives
9
+ * code growing above it and lapses when that line itself changes.
10
+ */
11
+ export class Dismissals {
12
+ file;
13
+ entries;
14
+ constructor(file, entries) {
15
+ this.file = file;
16
+ this.entries = entries;
17
+ }
18
+ /** `baseRef` is what stops a change from waving through its own findings. */
19
+ static open(root, baseRef) {
20
+ const file = join(root, FILE);
21
+ return new Dismissals(file, parse(baseRef === undefined ? fromTree(file) : fromRef(root, baseRef)));
22
+ }
23
+ /**
24
+ * Fingerprints this change adds to the list, which are deliberately not in force
25
+ * yet. Reported rather than applied: a diff that suppresses findings is a thing a
26
+ * reviewer should be told about, not a thing that quietly succeeds.
27
+ */
28
+ static pendingIn(root, baseRef) {
29
+ const inBase = new Set(parse(fromRef(root, baseRef)).map((d) => d.fingerprint));
30
+ return parse(fromTree(join(root, FILE))).filter((d) => !inBase.has(d.fingerprint)).length;
31
+ }
32
+ /**
33
+ * What a dismissal is about: this check, on this file, against this line of code.
34
+ * The line number is not part of it — code above a defect grows all the time, and a
35
+ * decision about a line should not expire because something unrelated moved it.
36
+ */
37
+ static fingerprint(f) {
38
+ const code = codeOf(f);
39
+ return createHash('sha256')
40
+ .update(f.check)
41
+ .update(' ')
42
+ .update(f.file)
43
+ .update(' ')
44
+ .update(code)
45
+ .digest('hex')
46
+ .slice(0, 24);
47
+ }
48
+ has(f) {
49
+ const fp = Dismissals.fingerprint(f);
50
+ return this.entries.some((d) => d.fingerprint === fp);
51
+ }
52
+ add(f, reason, at) {
53
+ const fingerprint = Dismissals.fingerprint(f);
54
+ if (this.entries.some((d) => d.fingerprint === fingerprint))
55
+ return false;
56
+ this.entries.push({ fingerprint, check: f.check, file: f.file, code: codeOf(f), title: f.title, reason, at });
57
+ this.save();
58
+ return true;
59
+ }
60
+ remove(fingerprint) {
61
+ const before = this.entries.length;
62
+ this.entries = this.entries.filter((d) => !d.fingerprint.startsWith(fingerprint));
63
+ if (this.entries.length === before)
64
+ return false;
65
+ this.save();
66
+ return true;
67
+ }
68
+ list() {
69
+ return this.entries;
70
+ }
71
+ save() {
72
+ mkdirSync(join(this.file, '..'), { recursive: true });
73
+ // written readably, because this file is committed and people read diffs of it
74
+ writeFileSync(this.file, JSON.stringify(this.entries, null, 2) + '\n');
75
+ }
76
+ }
77
+ function fromTree(file) {
78
+ try {
79
+ return existsSync(file) ? readFileSync(file, 'utf8') : undefined;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ function fromRef(root, ref) {
86
+ try {
87
+ return git(root, ['show', ref + ':' + FILE], true);
88
+ }
89
+ catch {
90
+ return undefined; // no list at that ref, which is the same as an empty one
91
+ }
92
+ }
93
+ function parse(text) {
94
+ if (text === undefined)
95
+ return [];
96
+ try {
97
+ const value = JSON.parse(text);
98
+ return Array.isArray(value) ? value : [];
99
+ }
100
+ catch {
101
+ return []; // an unreadable list must hide nothing rather than everything
102
+ }
103
+ }
104
+ const LAST = '.powershot/last-report.json';
105
+ /** The findings the most recent review reported, so `dismiss F2` knows what F2 was. */
106
+ export function rememberReport(root, findings) {
107
+ try {
108
+ mkdirSync(join(root, '.powershot'), { recursive: true, mode: 0o700 });
109
+ // holds source frames from the reviewed tree, so not world-readable
110
+ writeFileSync(join(root, LAST), JSON.stringify(findings), { mode: 0o600 });
111
+ }
112
+ catch {
113
+ // being unable to remember the last report must not fail the review itself
114
+ }
115
+ }
116
+ export function lastReport(root) {
117
+ try {
118
+ const parsed = JSON.parse(readFileSync(join(root, LAST), 'utf8'));
119
+ return Array.isArray(parsed) ? parsed : [];
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ /** The line the finding is about, as it read at the time. */
126
+ function codeOf(f) {
127
+ const line = f.frame ? f.frame.lines[f.line - f.frame.firstLine] : undefined;
128
+ return (line ?? f.title).trim();
129
+ }
130
+ //# sourceMappingURL=dismissed.js.map
@@ -0,0 +1,62 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ const DENIED_SEGMENT = /^(\.env(\..+)?|\.git|\.ssh|id_rsa|id_ed25519|\.npmrc|\.netrc|\.pypirc)$/i;
4
+ const DENIED_FILE = /\.(pem|key|p12|pfx|jks|keystore|kdbx)$/i;
5
+ const ENV_TEMPLATE = /^\.env\.(example|sample|template|defaults|dist)$/i;
6
+ export function deniedPath(path) {
7
+ const segments = path.split(/[\\/]/);
8
+ return segments.some((s) => !ENV_TEMPLATE.test(s) && DENIED_SEGMENT.test(s)) ||
9
+ DENIED_FILE.test(segments[segments.length - 1] ?? '');
10
+ }
11
+ function contained(root, path) {
12
+ const rel = relative(root, path);
13
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
14
+ }
15
+ /** Stable repository path for manifests, findings, filters, and machine output. */
16
+ export function repoPath(root, path) {
17
+ const slash = (value) => value.replace(/\\/g, '/');
18
+ const base = slash(root).replace(/\/+$/, '') || '/';
19
+ const file = slash(path);
20
+ const fold = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
21
+ if (fold(file) === fold(base))
22
+ return '';
23
+ if (fold(file).startsWith(fold(base) + '/'))
24
+ return file.slice(base.length + 1);
25
+ if (!isAbsolute(path) && !/^[A-Za-z]:\//.test(file))
26
+ return file.replace(/^\.\/+/, '');
27
+ return slash(relative(root, path));
28
+ }
29
+ /** The absolute path to read, or undefined when reading it would leave the repository. */
30
+ export function insideRepo(root, path) {
31
+ const base = resolve(root);
32
+ const abs = resolve(base, path);
33
+ if (!contained(base, abs))
34
+ return undefined;
35
+ if (deniedPath(abs))
36
+ return undefined;
37
+ let real;
38
+ try {
39
+ real = realpathSync(abs);
40
+ }
41
+ catch {
42
+ return abs; // does not exist yet; nothing to follow, and the read will fail plainly
43
+ }
44
+ const realRoot = (() => {
45
+ try {
46
+ return realpathSync(base);
47
+ }
48
+ catch {
49
+ return base;
50
+ }
51
+ })();
52
+ if (!contained(realRoot, real))
53
+ return undefined;
54
+ if (deniedPath(real))
55
+ return undefined;
56
+ return abs;
57
+ }
58
+ /** True for a link, whether or not it points anywhere legitimate. */
59
+ export function isSymlink(abs) {
60
+ return lstatSync(abs, { throwIfNoEntry: false })?.isSymbolicLink() ?? false;
61
+ }
62
+ //# sourceMappingURL=fspolicy.js.map
package/dist/git.js ADDED
@@ -0,0 +1,238 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { decode, lines, stripCR } from './text.js';
4
+ import { insideRepo, isSymlink } from './fspolicy.js';
5
+ export function git(root, args, quiet = false) {
6
+ try {
7
+ const out = execFileSync('git', args, {
8
+ cwd: root,
9
+ maxBuffer: 64 * 1024 * 1024,
10
+ stdio: ['ignore', 'pipe', quiet ? 'ignore' : 'pipe'],
11
+ });
12
+ return decode(out);
13
+ }
14
+ catch (e) {
15
+ const err = e;
16
+ const detail = err.stderr ? decode(err.stderr).trim() : '';
17
+ throw new Error(detail || err.message || 'git ' + args[0] + ' failed');
18
+ }
19
+ }
20
+ export function repoRoot(cwd) {
21
+ return git(cwd, ['rev-parse', '--show-toplevel']).trim();
22
+ }
23
+ function fileAtRef(root, ref, path) {
24
+ try {
25
+ const record = git(root, ['ls-tree', '-z', ref, '--', ':(literal)' + path], true).split('\0')[0];
26
+ const header = record?.slice(0, record.indexOf('\t')).split(' ');
27
+ const oid = header?.[2];
28
+ if (!oid || header?.[1] !== 'blob')
29
+ return undefined;
30
+ return git(root, ['cat-file', 'blob', oid], true);
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ /**
37
+ * Added/modified line numbers per file, parsed from a zero-context diff.
38
+ * Only new-side ranges matter: we review what the change introduced.
39
+ */
40
+ export function parseAddedLines(diff) {
41
+ const out = new Map();
42
+ let current;
43
+ // a CRLF diff would otherwise put `\r` inside every captured path, and nothing
44
+ // would ever match a real file
45
+ for (const raw of diff.split('\n')) {
46
+ const line = stripCR(raw);
47
+ const file = /^\+\+\+ b\/(.+)$/.exec(line);
48
+ if (file && file[1]) {
49
+ current = new Set();
50
+ out.set(file[1], current);
51
+ continue;
52
+ }
53
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
54
+ if (hunk && current) {
55
+ const start = Number(hunk[1]);
56
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
57
+ for (let i = 0; i < count; i++)
58
+ current.add(start + i);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ function addedLinesInPatch(diff) {
64
+ const added = new Set();
65
+ for (const raw of diff.split('\n')) {
66
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(stripCR(raw));
67
+ if (!hunk)
68
+ continue;
69
+ const start = Number(hunk[1]);
70
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
71
+ for (let i = 0; i < count; i++)
72
+ added.add(start + i);
73
+ }
74
+ return added;
75
+ }
76
+ function changedPaths(raw) {
77
+ const fields = raw.split('\0');
78
+ const out = [];
79
+ for (let i = 0; i < fields.length;) {
80
+ const status = fields[i++];
81
+ if (!status)
82
+ continue;
83
+ if (status.startsWith('R') || status.startsWith('C')) {
84
+ const beforePath = fields[i++];
85
+ const path = fields[i++];
86
+ if (beforePath && path)
87
+ out.push({ path, beforePath });
88
+ continue;
89
+ }
90
+ const path = fields[i++];
91
+ if (path)
92
+ out.push({ path, beforePath: status === 'A' ? undefined : path });
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * A ref is an argument only once it is proven to be one.
98
+ *
99
+ * `execFileSync` stops the shell from interpreting a ref, but not git: an argument
100
+ * beginning with `-` is an option, so `--from=--output=/tmp/x` makes `git diff` write
101
+ * a file and report nothing — an arbitrary write behind a clean exit code. Resolving
102
+ * the ref also turns a name that does not exist into an error rather than an empty
103
+ * diff, which is the same clean-looking nothing by a different route.
104
+ */
105
+ export function resolveRef(root, value, flag) {
106
+ if (value.startsWith('-'))
107
+ throw new Error(flag + ' looks like an option, not a ref: ' + value);
108
+ if (/[\0-\x1f\x7f]/.test(value))
109
+ throw new Error(flag + ' contains a control character');
110
+ try {
111
+ return git(root, ['rev-parse', '--verify', '--quiet', value + '^{commit}'], true).trim();
112
+ }
113
+ catch {
114
+ throw new Error(flag + ' is not a commit in this repository: ' + value);
115
+ }
116
+ }
117
+ export function checkRange(root, range) {
118
+ if (range.commit !== undefined)
119
+ resolveRef(root, range.commit, '--commit');
120
+ if (range.from !== undefined)
121
+ resolveRef(root, range.from, '--from');
122
+ if (range.to !== undefined)
123
+ resolveRef(root, range.to, '--to');
124
+ }
125
+ export function mergeBase(root, from, to) {
126
+ try {
127
+ return git(root, ['merge-base', from, to]).trim() || from;
128
+ }
129
+ catch {
130
+ return from; // unrelated histories: the tip is the only base there is
131
+ }
132
+ }
133
+ export function baseRefOf(root, range) {
134
+ if (range.commit)
135
+ return range.commit + '^';
136
+ if (range.from && range.to)
137
+ return mergeBase(root, range.from, range.to);
138
+ if (range.from)
139
+ return range.from;
140
+ return 'HEAD';
141
+ }
142
+ /**
143
+ * What the change says it does, taken from commit subjects. Workspace mode has no
144
+ * commit yet, so it has no stated intent — the intent judge simply does not run.
145
+ */
146
+ export function statedIntent(root, range) {
147
+ try {
148
+ if (range.commit)
149
+ return git(root, ['log', '-1', '--pretty=%B', range.commit]).trim() || undefined;
150
+ if (range.from && range.to) {
151
+ return git(root, ['log', '--pretty=- %s', range.from + '..' + range.to]).trim() || undefined;
152
+ }
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ return undefined;
158
+ }
159
+ export function collectChanges(root, range) {
160
+ checkRange(root, range);
161
+ // An unborn repository has no HEAD to diff against. Every present file is new, so
162
+ // reading the index and working tree directly is both simpler and more accurate
163
+ // than inventing a base commit (including for staged files edited again afterwards).
164
+ if (!range.commit && !range.from && !shaOf(root, 'HEAD')) {
165
+ const paths = new Set([
166
+ ...git(root, ['ls-files', '-z', '--cached']).split('\0').filter(Boolean),
167
+ ...git(root, ['ls-files', '-z', '--others', '--exclude-standard']).split('\0').filter(Boolean),
168
+ ]);
169
+ const files = [];
170
+ for (const path of paths) {
171
+ const abs = insideRepo(root, path);
172
+ if (!abs || isSymlink(abs) || !existsSync(abs))
173
+ continue;
174
+ const count = lines(decode(readFileSync(abs))).length;
175
+ files.push({ path, added: new Set(Array.from({ length: count }, (_, i) => i + 1)), before: undefined });
176
+ }
177
+ return files;
178
+ }
179
+ let diffArgs;
180
+ let baseRef;
181
+ if (range.commit) {
182
+ baseRef = range.commit + '^';
183
+ diffArgs = [range.commit + '^', range.commit];
184
+ }
185
+ else if (range.from && range.to) {
186
+ // `diff A...B` is measured from the merge base, so the base content has to come
187
+ // from there too. Reading it from the branch tip instead lines up new-side line
188
+ // numbers with a version of the file the diff never looked at.
189
+ baseRef = mergeBase(root, range.from, range.to);
190
+ diffArgs = [range.from + '...' + range.to];
191
+ }
192
+ else {
193
+ baseRef = 'HEAD';
194
+ diffArgs = ['HEAD'];
195
+ }
196
+ const files = [];
197
+ const tracked = changedPaths(git(root, [
198
+ 'diff', '--name-status', '-z', '--diff-filter=ACMRT', '--find-renames', ...diffArgs,
199
+ ]));
200
+ for (const { path, beforePath } of tracked) {
201
+ const pathspecs = beforePath && beforePath !== path
202
+ ? [':(literal)' + beforePath, ':(literal)' + path]
203
+ : [':(literal)' + path];
204
+ const patch = git(root, [
205
+ 'diff', '--unified=0', '--no-color', '--no-ext-diff', '--find-renames',
206
+ ...diffArgs, '--', ...pathspecs,
207
+ ]);
208
+ files.push({
209
+ path,
210
+ added: addedLinesInPatch(patch),
211
+ before: beforePath === undefined ? undefined : fileAtRef(root, baseRef, beforePath),
212
+ });
213
+ }
214
+ if (!range.commit && !range.from) {
215
+ const untracked = git(root, ['ls-files', '-z', '--others', '--exclude-standard']).split('\0').filter(Boolean);
216
+ for (const path of untracked) {
217
+ // an untracked symlink can point anywhere, and what a review reads it can send
218
+ const abs = insideRepo(root, path);
219
+ if (!abs || isSymlink(abs) || !existsSync(abs))
220
+ continue;
221
+ const count = lines(decode(readFileSync(abs))).length;
222
+ files.push({ path, added: new Set(Array.from({ length: count }, (_, i) => i + 1)), before: undefined });
223
+ }
224
+ }
225
+ return files;
226
+ }
227
+ export function shaOf(root, ref) {
228
+ try {
229
+ return git(root, ['rev-parse', '--verify', '--quiet', ref + '^{commit}'], true).trim() || undefined;
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ export function headSha(root) {
236
+ return shaOf(root, 'HEAD');
237
+ }
238
+ //# sourceMappingURL=git.js.map