@atmin.ai/review 0.1.0-alpha.2

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 (48) hide show
  1. package/DEPENDENCIES.md +15 -0
  2. package/LICENSE +55 -0
  3. package/NOTICE +6 -0
  4. package/README.md +144 -0
  5. package/dist/assessment.d.ts +26 -0
  6. package/dist/assessment.js +88 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +100 -0
  9. package/dist/contracts.d.ts +625 -0
  10. package/dist/contracts.js +86 -0
  11. package/dist/cost-report.d.ts +40 -0
  12. package/dist/cost-report.js +42 -0
  13. package/dist/github/api.d.ts +66 -0
  14. package/dist/github/api.js +190 -0
  15. package/dist/github/checks.d.ts +11 -0
  16. package/dist/github/checks.js +42 -0
  17. package/dist/github/cli.d.ts +2 -0
  18. package/dist/github/cli.js +94 -0
  19. package/dist/github/config.d.ts +16 -0
  20. package/dist/github/config.js +41 -0
  21. package/dist/github/runner.d.ts +7 -0
  22. package/dist/github/runner.js +67 -0
  23. package/dist/github/store.d.ts +40 -0
  24. package/dist/github/store.js +134 -0
  25. package/dist/github/task.d.ts +1 -0
  26. package/dist/github/task.js +29 -0
  27. package/dist/github/webhook.d.ts +6 -0
  28. package/dist/github/webhook.js +119 -0
  29. package/dist/github/worker.d.ts +19 -0
  30. package/dist/github/worker.js +209 -0
  31. package/dist/investigation.d.ts +198 -0
  32. package/dist/investigation.js +276 -0
  33. package/dist/openai-model.d.ts +2 -0
  34. package/dist/openai-model.js +41 -0
  35. package/dist/openrouter-model.d.ts +2 -0
  36. package/dist/openrouter-model.js +90 -0
  37. package/dist/provider-error.d.ts +10 -0
  38. package/dist/provider-error.js +21 -0
  39. package/dist/render.d.ts +4 -0
  40. package/dist/render.js +56 -0
  41. package/dist/run.d.ts +6 -0
  42. package/dist/run.js +39 -0
  43. package/dist/snapshot.d.ts +46 -0
  44. package/dist/snapshot.js +246 -0
  45. package/package.json +47 -0
  46. package/profiles/baseline-deepseek.json +10 -0
  47. package/profiles/smoke-openai.json +10 -0
  48. package/profiles/smoke-openrouter-free.json +10 -0
@@ -0,0 +1,21 @@
1
+ // Only controller-owned messages and allowlisted codes are safe to persist.
2
+ const funding = new Set(['credit_balance_exhausted', 'insufficient_quota', 'organization_spend_limit_exceeded', 'project_spend_limit_exceeded']);
3
+ export class ProviderRequestError extends Error {
4
+ failure;
5
+ constructor(stage, status, code) {
6
+ const kind = code && funding.has(code) ? 'funding'
7
+ : status === 401 || status === 403 ? 'authentication'
8
+ : status === 429 ? 'rate-limit'
9
+ : status !== undefined && status >= 400 && status < 500 ? 'request' : 'unavailable';
10
+ const messages = {
11
+ funding: 'Provider funding unavailable: add API credits or check the account spending limit.',
12
+ authentication: 'Provider authentication or access denied: check the configured API project and key.',
13
+ 'rate-limit': 'Provider rate limit reached: pause the experiment before another attempt.',
14
+ request: 'Provider rejected the request: inspect adapter compatibility before another attempt.',
15
+ unavailable: 'Provider unavailable: the request failed without confirmed usage.',
16
+ };
17
+ super(messages[kind]);
18
+ this.failure = { kind, stage, status: Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
19
+ code: code && funding.has(code) ? code : null };
20
+ }
21
+ }
@@ -0,0 +1,4 @@
1
+ import type { Packet, Result } from './contracts.js';
2
+ import type { Assessment } from './assessment.js';
3
+ export declare function escapeMarkdown(value: string): string;
4
+ export declare function renderMarkdown(packet: Packet, result: Result, assessment: Assessment): string;
package/dist/render.js ADDED
@@ -0,0 +1,56 @@
1
+ export function escapeMarkdown(value) {
2
+ return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')
3
+ .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
4
+ .replaceAll('@', '@\u200b').replace(/([\\`*_{}[\]()#+!|])/g, '\\$1');
5
+ }
6
+ function sourceLink(packet, anchor) {
7
+ const revision = anchor.side === 'head' ? packet.headSha : packet.mergeBaseSha;
8
+ const path = anchor.path.split('/').map(part => encodeURIComponent(part).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join('/');
9
+ const line = anchor.line === null ? '' : `#L${anchor.line}`;
10
+ const label = escapeMarkdown(anchor.path.replaceAll('\n', '\\n')) + (anchor.line === null ? '' : `:${anchor.line}`);
11
+ return `[${label}](https://github.com/${packet.repository}/blob/${revision}/${path}${line})`;
12
+ }
13
+ export function renderMarkdown(packet, result, assessment) {
14
+ const e = escapeMarkdown;
15
+ const headline = assessment.freshness.status === 'current' ? assessment.findingsVerdict : assessment.outcome;
16
+ const icon = headline === 'No issues found' ? '✅' : headline === 'Changes needed' ? '🔴' : '⚠️';
17
+ const count = (priorities) => assessment.findings.filter(f => priorities.includes(f.priority)).length;
18
+ const reviewed = result.coverage.filter(c => c.status === 'reviewed').length;
19
+ const validation = { passed: '✅ Passed', failed: '❌ Failed', missing: '⏳ Not verified', 'not-applicable': 'Not required' }[assessment.validation];
20
+ const lines = [
21
+ `## ${icon} ${headline}`, '',
22
+ `**atmin review** · ${assessment.scope === 'complete' ? 'Source review complete' : 'Source review incomplete'}`, '',
23
+ '| Changes needed · P0–P2 | Minor issues · P3 | Optional · P4 | Files reviewed |',
24
+ '| :---: | :---: | :---: | :---: |',
25
+ `| **${count(['P0', 'P1', 'P2'])}** | ${count(['P3'])} | ${count(['P4'])}${assessment.hiddenOptionalCount ? ` (${assessment.hiddenOptionalCount} hidden)` : ''} | ${reviewed} / ${packet.changedFiles.length} |`, '',
26
+ `**Required validation: ${validation}.**${assessment.validation === 'missing' ? ' Repository tests have not been verified.' : ''}`, '',
27
+ e(result.summary), '',
28
+ ];
29
+ if (assessment.freshness.status !== 'current')
30
+ lines.push(`**${assessment.freshness.status === 'superseded' ? 'Historical result' : 'Freshness unverified'}:** ${e(assessment.freshness.reason)}`, '');
31
+ if (assessment.scope !== 'complete')
32
+ lines.push('**Review incomplete:** unresolved work remains; zero findings is not a clean result.', '');
33
+ if (assessment.findings.length)
34
+ lines.push('### Findings', '');
35
+ for (const f of assessment.findings) {
36
+ lines.push(`#### ${f.priority} · ${e(f.title)}${f.priority === 'P4' ? ' (optional)' : ''}`, '', sourceLink(packet, f.anchor), '', `- Trigger: ${e(f.trigger)}`, `- Consequence: ${e(f.consequence)}`, `- Suggested change: ${e(f.suggestion)}`, '', '<details><summary>Reasoning and evidence</summary>', '', `- Priority rationale: ${e(f.priorityReason)}`, `- Counterevidence checked: ${e(f.counterEvidence)}`, `- Evidence: ${f.evidenceIds.map(e).join(', ')}`, '', '</details>', '');
37
+ }
38
+ if (assessment.hiddenOptionalCount)
39
+ lines.push(`${assessment.hiddenOptionalCount} optional P4 suggestion(s) hidden by target-branch policy.`, '');
40
+ lines.push('<details><summary>Validation and review evidence</summary>', '', '### Required validation', '');
41
+ for (const check of assessment.validationChecks) {
42
+ lines.push(`- ${e(check.name)}: **${check.status}** — ${e(check.reason)}${check.url ? ` [GitHub check](${check.url})` : ''}`);
43
+ }
44
+ lines.push('', '### Evidence', '');
45
+ if (!result.evidence.length)
46
+ lines.push('No investigation evidence recorded.');
47
+ for (const item of result.evidence)
48
+ lines.push(`- ${e(item.id)} · ${item.kind} · ${item.provenance}: ${e(item.summary)} ${item.anchors.map(a => sourceLink(packet, a)).join(', ')}`);
49
+ lines.push('', '### Coverage and limitations', '');
50
+ for (const file of packet.changedFiles) {
51
+ const coverage = result.coverage.find(c => c.path === file.path);
52
+ lines.push(`- ${e(file.path)} · ${file.change}/${file.kind} · ${coverage.status}`);
53
+ }
54
+ lines.push(...result.limitations.map(reason => `- Limitation: ${e(reason)}`), '', '</details>', '', '<details><summary>Run details</summary>', '', `Repository: ${e(packet.repository)} · PR ${packet.pr}`, `Head: \`${packet.headSha}\` · Target: \`${packet.baseSha}\` · Merge base: \`${packet.mergeBaseSha}\``, `Reviewer: ${e(result.reviewer.name)} · Model: ${e(result.reviewer.model)} · Context: ${result.reviewer.context}`, '', '**Advisory evidence.** Captured source ranges are checked against Git objects. Reasoning and declared test claims are not independently verified. This is not merge approval.', '', ...assessment.reasons.map(reason => `- ${e(reason)}`), '', `Rubric ${packet.policy.rubricVersion} · Policy digest \`${packet.policyHash}\``, `Captured: ${packet.createdAt} · Live check: ${assessment.freshness.checkedAt ?? 'not performed'}`, '', '</details>', '');
55
+ return lines.join('\n');
56
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type Model, type Profile } from './investigation.js';
2
+ export declare function readProfile(path: string): Profile;
3
+ export declare function runReview(directory: string, profile: Profile, injectedModel?: Model, signal?: AbortSignal): Promise<{
4
+ result: import("./contracts.js").Result;
5
+ receipt: import("./investigation.js").Receipt;
6
+ }>;
package/dist/run.js ADDED
@@ -0,0 +1,39 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { investigate, parseProfile } from './investigation.js';
4
+ import { openAIModel } from './openai-model.js';
5
+ import { openRouterModel } from './openrouter-model.js';
6
+ import { loadReview } from './snapshot.js';
7
+ export function readProfile(path) {
8
+ const bytes = readFileSync(path);
9
+ if (bytes.length > 8000)
10
+ throw new Error('Profile exceeds 8 KB');
11
+ return parseProfile(JSON.parse(bytes.toString('utf8')));
12
+ }
13
+ export async function runReview(directory, profile, injectedModel, signal) {
14
+ // No rerun/resume that can accidentally reset the same run's budget after an uncertain request.
15
+ const { packet, result } = loadReview(directory);
16
+ if (result.status !== 'not-started')
17
+ throw new Error('Investigation already started; prepare a new snapshot for another run');
18
+ const model = injectedModel ?? (profile.provider === 'openrouter' ? openRouterModel(profile) : openAIModel(profile));
19
+ const lock = join(directory, 'investigation.lock');
20
+ const fd = openSync(lock, 'wx', 0o600);
21
+ closeSync(fd);
22
+ const persist = (name, value) => {
23
+ const temporary = join(directory, `${name}.pending`);
24
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx', flush: true });
25
+ renameSync(temporary, join(directory, name));
26
+ };
27
+ try {
28
+ if (existsSync(join(directory, 'receipt.json')) || loadReview(directory).result.status !== 'not-started') {
29
+ throw new Error('Run has existing investigation or spending evidence; prepare a new snapshot');
30
+ }
31
+ return await investigate(directory, packet, profile, model, (result, receipt) => {
32
+ persist('receipt.json', receipt);
33
+ persist('result.json', result);
34
+ }, signal);
35
+ }
36
+ finally {
37
+ unlinkSync(lock);
38
+ }
39
+ }
@@ -0,0 +1,46 @@
1
+ import { type EvidenceAnchor, type Packet } from './contracts.js';
2
+ import { type Freshness } from './assessment.js';
3
+ export declare const withGitDeadline: <T>(deadlineMs: number, operation: () => T) => T;
4
+ export declare const hash: (value: string | Uint8Array) => string;
5
+ export interface PullState {
6
+ repository: string;
7
+ pr: number;
8
+ headSha: string;
9
+ baseSha: string;
10
+ baseRef: string;
11
+ state: 'open' | 'closed';
12
+ }
13
+ export declare function git(repository: string, args: string[]): Buffer;
14
+ export declare function parsePullUrl(input: string): {
15
+ repository: string;
16
+ pr: number;
17
+ };
18
+ type Request = (endpoint: string) => unknown;
19
+ export declare function readPull(repository: string, pr: number, request?: Request): PullState;
20
+ export declare function compareCurrent(packet: Packet, current: PullState): Freshness;
21
+ export declare function checkCurrent(packet: Packet): Freshness;
22
+ export declare function capture(repository: string, identity: PullState): {
23
+ packet: Packet;
24
+ diff: Buffer;
25
+ };
26
+ export declare function prepare(url: string, output?: string): {
27
+ directory: string;
28
+ packet: Packet;
29
+ };
30
+ export declare function validateAnchor(repository: string, packet: Packet, anchor: EvidenceAnchor): void;
31
+ export declare function sourceText(repository: string, revision: string, path: string): string | null;
32
+ export declare function sourcePaths(repository: string, revision: string): string[];
33
+ export declare function sourceSlice(repository: string, revision: string, path: string, startLine: number, count: number): {
34
+ text: string;
35
+ startLine: number;
36
+ endLine: number;
37
+ totalLines: number;
38
+ contentHash: string;
39
+ revision: string;
40
+ };
41
+ export declare function validateCapturedEvidence(repository: string, packet: Packet, item: import('./contracts.js').Evidence): void;
42
+ export declare function loadReview(directory: string): {
43
+ packet: Packet;
44
+ result: import("./contracts.js").Result;
45
+ };
46
+ export {};
@@ -0,0 +1,246 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
+ import { ReviewInputError, defaultPolicy, initialResult, parsePacket, parsePolicy, parseResult } from './contracts.js';
9
+ import { validateEvidence } from './assessment.js';
10
+ const MAX_BYTES = 16 * 1024 * 1024;
11
+ const commandDeadline = new AsyncLocalStorage();
12
+ export const withGitDeadline = (deadlineMs, operation) => commandDeadline.run(Date.now() + deadlineMs, operation);
13
+ const shaPattern = /^[a-f0-9]{40}$/;
14
+ const decode = (bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
15
+ export const hash = (value) => createHash('sha256').update(value).digest('hex');
16
+ function requireValue(condition, message) {
17
+ if (!condition)
18
+ throw new ReviewInputError(message);
19
+ }
20
+ // Do not inherit GIT_CONFIG_COUNT, GIT_DIR, replace objects, SSH overrides,
21
+ // NODE_OPTIONS, loaders or arbitrary Git config from the caller or checkout.
22
+ function commandEnv() {
23
+ const env = {};
24
+ for (const key of ['PATH', 'HOME', 'TMPDIR', 'SYSTEMROOT', 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_CONFIG_DIR']) {
25
+ if (process.env[key] !== undefined)
26
+ env[key] = process.env[key];
27
+ }
28
+ return { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
29
+ GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_ATTR_NOSYSTEM: '1',
30
+ GIT_LITERAL_PATHSPECS: '1', GH_PROMPT_DISABLED: '1', GH_HOST: 'github.com' };
31
+ }
32
+ function command(program, args, cwd, operation = args[0] ?? '') {
33
+ const timeout = Math.min(120000, (commandDeadline.getStore() ?? (Date.now() + 120000)) - Date.now());
34
+ requireValue(timeout > 0, 'Review deadline reached');
35
+ try {
36
+ return execFileSync(program, args, { ...(cwd ? { cwd } : {}), env: commandEnv(), maxBuffer: MAX_BYTES, timeout, stdio: ['ignore', 'pipe', 'pipe'] });
37
+ }
38
+ catch {
39
+ // Child-process exceptions include arguments/output. Do not echo private data.
40
+ throw new Error(`${program} ${operation} failed or exceeded its time/output limit. Check access and the requested revision.`);
41
+ }
42
+ }
43
+ export function git(repository, args) {
44
+ return command('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false',
45
+ '-c', 'core.attributesFile=/dev/null', '-c', 'credential.helper=',
46
+ '-c', 'credential.https://github.com.helper=!gh auth git-credential',
47
+ '-c', 'protocol.file.allow=never', '-c', 'protocol.ext.allow=never',
48
+ '-c', 'protocol.ssh.allow=never', '-C', repository, ...args], undefined, args[0]);
49
+ }
50
+ function gitText(repository, args) { return decode(git(repository, args)); }
51
+ export function parsePullUrl(input) {
52
+ const url = new URL(input);
53
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && !url.port && !url.username && !url.password && !url.search && !url.hash, 'Use an HTTPS github.com pull request URL without credentials, query or fragment');
54
+ const match = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)\/?$/.exec(url.pathname);
55
+ requireValue(match && !['.', '..'].includes(match[1]) && !['.', '..'].includes(match[2]), 'Invalid GitHub pull request URL');
56
+ const pr = Number(match[3]);
57
+ requireValue(Number.isSafeInteger(pr), 'Invalid PR number');
58
+ return { repository: `${match[1]}/${match[2]}`, pr };
59
+ }
60
+ const api = endpoint => JSON.parse(decode(command('gh', ['api', '--hostname', 'github.com', endpoint])));
61
+ function record(value) {
62
+ requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), 'Unexpected GitHub response');
63
+ return value;
64
+ }
65
+ export function readPull(repository, pr, request = api) {
66
+ // Validate identity before constructing API paths, including callers other than the CLI.
67
+ parsePullUrl(`https://github.com/${repository}/pull/${pr}`);
68
+ const pull = record(request(`repos/${repository}/pulls/${pr}`));
69
+ const base = record(pull.base);
70
+ const head = record(pull.head);
71
+ const baseRepo = record(base.repo);
72
+ requireValue(pull.number === pr && typeof baseRepo.full_name === 'string' && baseRepo.full_name.toLowerCase() === repository.toLowerCase(), 'GitHub PR identity changed');
73
+ requireValue(typeof base.ref === 'string' && base.ref.length > 0, 'Missing target branch');
74
+ // The PR object's base.sha may lag the actual target branch.
75
+ const ref = record(request(`repos/${repository}/git/ref/heads/${encodeURIComponent(base.ref)}`));
76
+ const object = record(ref.object);
77
+ requireValue(object.type === 'commit' && typeof object.sha === 'string' && shaPattern.test(object.sha), 'Cannot resolve current target commit');
78
+ requireValue(typeof head.sha === 'string' && shaPattern.test(head.sha), 'Cannot resolve PR head');
79
+ requireValue(pull.state === 'open' || pull.state === 'closed', 'Unknown PR state');
80
+ return { repository: baseRepo.full_name, pr, headSha: head.sha, baseSha: object.sha, baseRef: base.ref, state: pull.state };
81
+ }
82
+ export function compareCurrent(packet, current) {
83
+ const same = current.state === 'open' && current.repository.toLowerCase() === packet.repository.toLowerCase()
84
+ && current.pr === packet.pr && current.headSha === packet.headSha && current.baseSha === packet.baseSha && current.baseRef === packet.baseRef;
85
+ return { status: same ? 'current' : 'superseded', checkedAt: new Date().toISOString(),
86
+ reason: same ? 'Head and current target matched at the recorded check time; later pushes invalidate this result.'
87
+ : 'The PR closed, was retargeted, or its head/current target changed. This report applies only to the captured snapshot.' };
88
+ }
89
+ export function checkCurrent(packet) {
90
+ try {
91
+ return compareCurrent(packet, readPull(packet.repository, packet.pr));
92
+ }
93
+ catch {
94
+ return { status: 'unverified', checkedAt: new Date().toISOString(), reason: 'Live PR state could not be verified. No current-review conclusion is available.' };
95
+ }
96
+ }
97
+ function fileAt(repository, revision, path) {
98
+ requireValue(shaPattern.test(revision), 'Invalid immutable revision');
99
+ const entry = gitText(repository, ['ls-tree', '-z', '--full-tree', revision, '--', path]);
100
+ if (!entry)
101
+ return null;
102
+ const match = /^([0-7]{6}) (blob|commit) ([a-f0-9]{40})\t([\s\S]+)\0$/.exec(entry);
103
+ requireValue(match && match[4] === path, 'Expected one exact file object');
104
+ if (match[1] === '160000')
105
+ return { mode: match[1], bytes: Buffer.alloc(0) };
106
+ const size = Number(gitText(repository, ['cat-file', '-s', match[3]]).trim());
107
+ requireValue(Number.isSafeInteger(size) && size <= MAX_BYTES, 'File exceeds the 16 MiB capture limit; snapshot is unavailable');
108
+ return { mode: match[1], bytes: git(repository, ['cat-file', 'blob', match[3]]) };
109
+ }
110
+ function fileKind(file) {
111
+ if (file.mode === '120000')
112
+ return 'symlink';
113
+ if (file.mode === '160000')
114
+ return 'submodule';
115
+ if (file.bytes.includes(0))
116
+ return 'binary';
117
+ try {
118
+ decode(file.bytes);
119
+ }
120
+ catch {
121
+ return 'binary';
122
+ }
123
+ return 'text';
124
+ }
125
+ export function capture(repository, identity) {
126
+ requireValue(shaPattern.test(identity.headSha) && shaPattern.test(identity.baseSha), 'Invalid capture revisions');
127
+ const mergeBaseSha = gitText(repository, ['merge-base', identity.baseSha, identity.headSha]).trim();
128
+ requireValue(shaPattern.test(mergeBaseSha), 'No valid common ancestor; capture unavailable');
129
+ const inventory = gitText(repository, ['diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--name-status', '-z', mergeBaseSha, identity.headSha, '--']).split('\0');
130
+ requireValue(inventory.pop() === '', 'Incomplete diff inventory');
131
+ requireValue(inventory.length % 2 === 0 && inventory.length <= 2000, 'Invalid inventory or more than 1000 changed paths');
132
+ const changedFiles = [];
133
+ for (let i = 0; i < inventory.length; i += 2) {
134
+ const status = inventory[i];
135
+ const path = inventory[i + 1];
136
+ requireValue(['A', 'M', 'D', 'T'].includes(status), 'Unsupported change type');
137
+ const before = fileAt(repository, mergeBaseSha, path);
138
+ const after = fileAt(repository, identity.headSha, path);
139
+ requireValue(before || after, 'Changed path has no captured object');
140
+ const kinds = [before, after].filter((v) => v !== null).map(fileKind);
141
+ const kind = kinds.find(k => k !== 'text') ?? 'text';
142
+ changedFiles.push({ path, change: status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified', kind });
143
+ }
144
+ const policyObject = fileAt(repository, identity.baseSha, '.atmin/review.json');
145
+ requireValue(!policyObject || policyObject.mode === '100644' || policyObject.mode === '100755', 'Review policy must be a regular file');
146
+ const policy = policyObject ? parsePolicy(JSON.parse(decode(policyObject.bytes))) : defaultPolicy();
147
+ const diff = git(repository, ['diff', '--no-ext-diff', '--no-textconv', '--no-renames', mergeBaseSha, identity.headSha, '--']);
148
+ // Keep the actual diff bytes: non-UTF8 source can still be captured but never marked text-reviewed.
149
+ const packet = parsePacket({ schemaVersion: 1, repository: identity.repository, pr: identity.pr,
150
+ baseRef: identity.baseRef, headSha: identity.headSha, baseSha: identity.baseSha, mergeBaseSha,
151
+ createdAt: new Date().toISOString(), policy, policyHash: hash(JSON.stringify(policy)), diffHash: hash(diff), changedFiles });
152
+ return { packet, diff };
153
+ }
154
+ function saveJson(path, data) {
155
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
156
+ }
157
+ export function prepare(url, output) {
158
+ const parsed = parsePullUrl(url);
159
+ const current = readPull(parsed.repository, parsed.pr);
160
+ requireValue(current.state === 'open', 'Only open pull requests can be prepared');
161
+ const directory = output ? resolve(output) : mkdtempSync(join(tmpdir(), 'atmin-review-'));
162
+ if (output)
163
+ mkdirSync(directory, { mode: 0o700 }); // Never overwrite an existing packet/workspace.
164
+ const source = join(directory, 'source.git');
165
+ mkdirSync(source, { mode: 0o700 });
166
+ git(source, ['init', '--bare', '--template=']);
167
+ git(source, ['remote', 'add', 'origin', `https://github.com/${current.repository}.git`]);
168
+ // Investigation has no GitHub credential. Capture all objects now so reading
169
+ // unchanged guidance and callers cannot trigger an authenticated lazy fetch.
170
+ git(source, ['fetch', '--no-tags', 'origin', current.headSha, current.baseSha]);
171
+ const { packet, diff } = capture(source, current);
172
+ requireValue(compareCurrent(packet, readPull(current.repository, current.pr)).status === 'current', 'PR changed during capture; prepare a new snapshot');
173
+ writeFileSync(join(directory, 'change.diff'), diff, { mode: 0o600, flag: 'wx' });
174
+ saveJson(join(directory, 'packet.json'), packet);
175
+ saveJson(join(directory, 'result.json'), initialResult(packet));
176
+ return { directory, packet };
177
+ }
178
+ function jsonFile(path) {
179
+ const bytes = readFileSync(path);
180
+ requireValue(bytes.length <= MAX_BYTES, 'Review JSON exceeds the 16 MiB limit');
181
+ return JSON.parse(decode(bytes));
182
+ }
183
+ export function validateAnchor(repository, packet, anchor) {
184
+ const revision = anchor.side === 'head' ? packet.headSha : packet.mergeBaseSha;
185
+ const file = fileAt(repository, revision, anchor.path);
186
+ requireValue(file && fileKind(file) === 'text', 'Anchor does not reference a regular UTF-8 text file');
187
+ if (anchor.line === null)
188
+ return;
189
+ const content = decode(file.bytes);
190
+ const lineCount = content.length === 0 ? 0 : content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
191
+ requireValue(anchor.line <= lineCount, 'Anchor line is outside its immutable source file');
192
+ }
193
+ // Source tools expose only immutable Git text, never host files or repository commands.
194
+ export function sourceText(repository, revision, path) {
195
+ const file = fileAt(repository, revision, path);
196
+ if (!file)
197
+ return null;
198
+ requireValue(fileKind(file) === 'text', 'Only regular UTF-8 text can be read');
199
+ return decode(file.bytes);
200
+ }
201
+ export function sourcePaths(repository, revision) {
202
+ requireValue(shaPattern.test(revision), 'Invalid immutable revision');
203
+ const output = gitText(repository, ['ls-tree', '-r', '--name-only', '-z', revision]);
204
+ return output ? output.slice(0, -1).split('\0') : [];
205
+ }
206
+ export function sourceSlice(repository, revision, path, startLine, count) {
207
+ requireValue(Number.isSafeInteger(startLine) && startLine >= 1 && Number.isSafeInteger(count) && count >= 1 && count <= 200, 'Read requires a positive line and 1–200 lines');
208
+ const content = sourceText(repository, revision, path);
209
+ requireValue(content !== null, 'Path does not exist at this snapshot');
210
+ const lines = content === '' ? [] : content.split('\n');
211
+ if (content.endsWith('\n'))
212
+ lines.pop();
213
+ requireValue(startLine <= Math.max(1, lines.length), 'Start line is outside source');
214
+ const selected = lines.slice(startLine - 1, startLine - 1 + count);
215
+ const text = selected.join('\n');
216
+ requireValue(Buffer.byteLength(text) <= 24000, 'Source range exceeds 24 KB; request fewer lines');
217
+ return { text, startLine, endLine: startLine - 1 + selected.length, totalLines: lines.length, contentHash: hash(text), revision };
218
+ }
219
+ export function validateCapturedEvidence(repository, packet, item) {
220
+ if (item.provenance !== 'controller-captured')
221
+ return;
222
+ const anchor = item.anchors[0];
223
+ const revision = anchor.side === 'head' ? packet.headSha : packet.mergeBaseSha;
224
+ const actual = sourceSlice(repository, revision, anchor.path, item.capture.startLine, Math.max(1, item.capture.endLine - item.capture.startLine + 1));
225
+ const { text: _text, ...capture } = actual;
226
+ requireValue(isDeepStrictEqual(capture, item.capture) && anchor.line === (capture.totalLines ? capture.startLine : null), 'Captured source evidence does not match its immutable range and digest');
227
+ }
228
+ export function loadReview(directory) {
229
+ const packet = parsePacket(jsonFile(join(directory, 'packet.json')));
230
+ const result = parseResult(jsonFile(join(directory, 'result.json')));
231
+ const repository = join(directory, 'source.git');
232
+ const actual = capture(repository, { ...packet, state: 'open' }).packet;
233
+ const { createdAt: _recordedTime, ...recorded } = packet;
234
+ const { createdAt: _recomputedTime, ...recomputed } = actual;
235
+ requireValue(isDeepStrictEqual(recorded, recomputed), 'Packet differs from immutable Git inventory, diff or target-branch policy');
236
+ requireValue(hash(readFileSync(join(directory, 'change.diff'))) === packet.diffHash, 'Saved diff differs from its captured digest');
237
+ validateEvidence(packet, result);
238
+ for (const finding of result.findings)
239
+ validateAnchor(repository, packet, finding.anchor);
240
+ for (const item of result.evidence)
241
+ for (const anchor of item.anchors)
242
+ validateAnchor(repository, packet, anchor);
243
+ for (const item of result.evidence)
244
+ validateCapturedEvidence(repository, packet, item);
245
+ return { packet, result };
246
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@atmin.ai/review",
3
+ "version": "0.1.0-alpha.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "atmin review — immutable review evidence and deterministic verdicts",
7
+ "engines": {
8
+ "node": ">=24.0.0"
9
+ },
10
+ "bin": {
11
+ "atmin-review": "dist/cli.js",
12
+ "atmin-review-github": "dist/github/cli.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "npm run build && node --test test/*.test.mjs",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "ajv": "8.20.0",
21
+ "openai": "7.12.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "24.13.3",
25
+ "typescript": "5.9.3"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "profiles",
30
+ "LICENSE",
31
+ "NOTICE",
32
+ "DEPENDENCIES.md"
33
+ ],
34
+ "license": "Apache-2.0",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/atmin-ca/review.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/atmin-ca/review/issues"
41
+ },
42
+ "homepage": "https://github.com/atmin-ca/review#readme",
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "tag": "alpha"
46
+ }
47
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "provider": "openrouter",
3
+ "model": "deepseek/deepseek-v3.2",
4
+ "maxUsd": 2,
5
+ "maxTurns": 24,
6
+ "maxToolCalls": 80,
7
+ "maxInputTokens": 100000,
8
+ "maxOutputTokens": 8192,
9
+ "deadlineMs": 600000
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "provider": "openai",
3
+ "model": "gpt-5.4-2026-03-05",
4
+ "maxUsd": 2,
5
+ "maxTurns": 30,
6
+ "maxToolCalls": 60,
7
+ "maxInputTokens": 50000,
8
+ "maxOutputTokens": 4096,
9
+ "deadlineMs": 600000
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "provider": "openrouter",
3
+ "model": "cohere/north-mini-code:free",
4
+ "maxUsd": 0,
5
+ "maxTurns": 8,
6
+ "maxToolCalls": 40,
7
+ "maxInputTokens": 80000,
8
+ "maxOutputTokens": 4096,
9
+ "deadlineMs": 600000
10
+ }