argus-reviewer-e2e 0.1.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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/action/action.yml +147 -0
- package/action/sticky-comment.mjs +376 -0
- package/dist/api.d.ts +121 -0
- package/dist/api.js +256 -0
- package/dist/cache/fingerprint.d.ts +59 -0
- package/dist/cache/fingerprint.js +67 -0
- package/dist/cache/store.d.ts +17 -0
- package/dist/cache/store.js +41 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +1355 -0
- package/dist/config.d.ts +130 -0
- package/dist/config.js +163 -0
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +30 -0
- package/dist/detect.d.ts +50 -0
- package/dist/detect.js +105 -0
- package/dist/driver/browser.d.ts +64 -0
- package/dist/driver/browser.js +200 -0
- package/dist/driver/target.d.ts +23 -0
- package/dist/driver/target.js +97 -0
- package/dist/engine/actions.d.ts +26 -0
- package/dist/engine/actions.js +47 -0
- package/dist/engine/loop.d.ts +118 -0
- package/dist/engine/loop.js +649 -0
- package/dist/engine/prompts.d.ts +22 -0
- package/dist/engine/prompts.js +112 -0
- package/dist/evidence/ci.d.ts +18 -0
- package/dist/evidence/ci.js +61 -0
- package/dist/evidence/link.d.ts +35 -0
- package/dist/evidence/link.js +90 -0
- package/dist/executor/a0.d.ts +29 -0
- package/dist/executor/a0.js +38 -0
- package/dist/fsutil.d.ts +5 -0
- package/dist/fsutil.js +12 -0
- package/dist/index/context.d.ts +17 -0
- package/dist/index/context.js +88 -0
- package/dist/index/diff.d.ts +1 -0
- package/dist/index/diff.js +33 -0
- package/dist/index/invalidate.d.ts +28 -0
- package/dist/index/invalidate.js +56 -0
- package/dist/index/scan.d.ts +26 -0
- package/dist/index/scan.js +209 -0
- package/dist/journal/build.d.ts +15 -0
- package/dist/journal/build.js +47 -0
- package/dist/journal/schema.d.ts +59 -0
- package/dist/journal/schema.js +6 -0
- package/dist/journal/store.d.ts +10 -0
- package/dist/journal/store.js +26 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +46 -0
- package/dist/log.d.ts +17 -0
- package/dist/log.js +24 -0
- package/dist/report/comment.d.ts +13 -0
- package/dist/report/comment.js +135 -0
- package/dist/report/junit.d.ts +10 -0
- package/dist/report/junit.js +46 -0
- package/dist/report/run.d.ts +51 -0
- package/dist/report/run.js +36 -0
- package/dist/vision/cost.d.ts +36 -0
- package/dist/vision/cost.js +16 -0
- package/dist/vision/ledger.d.ts +29 -0
- package/dist/vision/ledger.js +65 -0
- package/dist/vision/openrouter.d.ts +70 -0
- package/dist/vision/openrouter.js +134 -0
- package/package.json +65 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { writeAtomicJson } from '../fsutil.js';
|
|
6
|
+
export const INDEX_SCHEMA_VERSION = 1;
|
|
7
|
+
const EXCLUDE_DIRS = new Set([
|
|
8
|
+
'node_modules', 'dist', 'dist-e2e-vision', '.git', 'coverage',
|
|
9
|
+
'.vision-e2e-cache', '.argus-reviewer-cache', 'argus-reviewer-report', 'vision-e2e-report', 'journal',
|
|
10
|
+
]);
|
|
11
|
+
const SOURCE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
12
|
+
const LOCKFILE_RE = /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock)$/;
|
|
13
|
+
const BINARY_RE = /\.(png|jpe?g|gif|webp|ico|woff2?|ttf|otf|eot|mp4|webm|pdf|zip|gz|tar|wasm|so|dylib|exe|bin)$/i;
|
|
14
|
+
const MAX_FILES = 50_000;
|
|
15
|
+
async function walk(root, dir, out) {
|
|
16
|
+
if (out.length >= MAX_FILES)
|
|
17
|
+
return;
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return; // unreadable/missing dirs are skipped, never fatal
|
|
24
|
+
}
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
if (out.length >= MAX_FILES)
|
|
27
|
+
return;
|
|
28
|
+
if (e.name.startsWith('.') && e.name !== '.storybook')
|
|
29
|
+
continue;
|
|
30
|
+
const p = join(dir, e.name);
|
|
31
|
+
if (e.isDirectory()) {
|
|
32
|
+
if (EXCLUDE_DIRS.has(e.name))
|
|
33
|
+
continue;
|
|
34
|
+
await walk(root, p, out);
|
|
35
|
+
}
|
|
36
|
+
else if (e.isFile()) {
|
|
37
|
+
if (LOCKFILE_RE.test(e.name) || BINARY_RE.test(e.name))
|
|
38
|
+
continue;
|
|
39
|
+
out.push(p);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function sha256(buf) {
|
|
44
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
45
|
+
}
|
|
46
|
+
/** Resolve an import specifier to a repo-relative path when it's local. */
|
|
47
|
+
function resolveImport(spec, fromFile, files) {
|
|
48
|
+
if (!spec.startsWith('.'))
|
|
49
|
+
return undefined;
|
|
50
|
+
const base = resolve(dirname(fromFile), spec);
|
|
51
|
+
const exts = ['ts', 'tsx', 'js', 'jsx', 'mts', 'cts', 'mjs', 'cjs'];
|
|
52
|
+
const candidates = [base, ...exts.map((e) => `${base}.${e}`), ...exts.map((e) => `${base}/index.${e}`)];
|
|
53
|
+
for (const cand of candidates) {
|
|
54
|
+
if (files.has(cand))
|
|
55
|
+
return cand;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Memoized per directory — sibling files share the same ancestor walk. */
|
|
60
|
+
async function nearestPackageVersion(file, root, cache) {
|
|
61
|
+
const start = dirname(file);
|
|
62
|
+
const cached = cache.get(start);
|
|
63
|
+
if (cached !== undefined)
|
|
64
|
+
return cached;
|
|
65
|
+
const promise = (async () => {
|
|
66
|
+
let dir = start;
|
|
67
|
+
// Path-boundary containment: `startsWith(root)` alone would accept a
|
|
68
|
+
// sibling like `/repo-extra`; require an exact match or a subpath.
|
|
69
|
+
while (dir === root || dir.startsWith(root + '/')) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await readFile(join(dir, 'package.json'), 'utf8');
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (parsed.version)
|
|
74
|
+
return parsed.version;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// no package.json at this level — keep walking up
|
|
78
|
+
}
|
|
79
|
+
if (dir === root)
|
|
80
|
+
break;
|
|
81
|
+
dir = dirname(dir);
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
})();
|
|
85
|
+
cache.set(start, promise);
|
|
86
|
+
return promise;
|
|
87
|
+
}
|
|
88
|
+
function inferPurpose(source, isSource) {
|
|
89
|
+
if (!isSource)
|
|
90
|
+
return undefined;
|
|
91
|
+
const doc = source.match(/^\s*\/\*\*([\s\S]*?)\*\//);
|
|
92
|
+
if (doc) {
|
|
93
|
+
const first = (doc[1] ?? '').split('\n').map((l) => l.replace(/^\s*\*\s?/, '').trim()).filter(Boolean)[0];
|
|
94
|
+
if (first !== undefined && first !== '')
|
|
95
|
+
return first.slice(0, 160);
|
|
96
|
+
}
|
|
97
|
+
const exports_ = [...source.matchAll(/export\s+(?:async\s+)?(?:function|class|const|interface|type)\s+(\w+)/g)]
|
|
98
|
+
.map((m) => m[1])
|
|
99
|
+
.slice(0, 4);
|
|
100
|
+
if (exports_.length > 0)
|
|
101
|
+
return `exports: ${exports_.join(', ')}`;
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
/** Scan a repo into a RepoIndex. Never throws on individual file failures. */
|
|
105
|
+
export async function scanRepo(root) {
|
|
106
|
+
const abs = resolve(root);
|
|
107
|
+
const files = [];
|
|
108
|
+
await walk(abs, abs, files);
|
|
109
|
+
const fileSet = new Set(files);
|
|
110
|
+
const pkgCache = new Map();
|
|
111
|
+
// Bounded concurrency — Promise.all over up to 50k files would exhaust
|
|
112
|
+
// file descriptors. Chunks of 64 keep I/O saturated without EMFILE risk.
|
|
113
|
+
const CONCURRENCY = 64;
|
|
114
|
+
const sorted = files.sort();
|
|
115
|
+
const entries = [];
|
|
116
|
+
for (let i = 0; i < sorted.length; i += CONCURRENCY) {
|
|
117
|
+
const chunk = sorted.slice(i, i + CONCURRENCY);
|
|
118
|
+
const results = await Promise.allSettled(chunk.map(async (file) => {
|
|
119
|
+
const rel = relative(abs, file);
|
|
120
|
+
let buf;
|
|
121
|
+
try {
|
|
122
|
+
buf = await readFile(file);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined; // unreadable files are skipped, never fatal
|
|
126
|
+
}
|
|
127
|
+
const isSource = SOURCE_RE.test(file);
|
|
128
|
+
const text = isSource ? buf.toString('utf8') : undefined;
|
|
129
|
+
const imports = [];
|
|
130
|
+
if (isSource && text !== undefined) {
|
|
131
|
+
try {
|
|
132
|
+
const info = ts.preProcessFile(text, true, true);
|
|
133
|
+
for (const spec of [...info.importedFiles.map((f) => f.fileName), ...info.referencedFiles.map((f) => f.fileName)]) {
|
|
134
|
+
const dep = resolveImport(spec, file, fileSet);
|
|
135
|
+
if (dep !== undefined)
|
|
136
|
+
imports.push(relative(abs, dep));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// unparseable file — entry still exists with empty imports
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const purpose = inferPurpose(text?.slice(0, 4000) ?? '', isSource);
|
|
144
|
+
const packageVersion = await nearestPackageVersion(file, abs, pkgCache);
|
|
145
|
+
const entry = { path: rel, imports, importedBy: [], contentHash: sha256(buf) };
|
|
146
|
+
if (purpose !== undefined)
|
|
147
|
+
entry.purpose = purpose;
|
|
148
|
+
if (packageVersion !== undefined)
|
|
149
|
+
entry.packageVersion = packageVersion;
|
|
150
|
+
return entry;
|
|
151
|
+
}));
|
|
152
|
+
for (const r of results) {
|
|
153
|
+
if (r.status === 'fulfilled' && r.value !== undefined)
|
|
154
|
+
entries.push(r.value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Build reverse edges in a second pass — no shared-map races during the
|
|
158
|
+
// parallel scan.
|
|
159
|
+
const importedBy = new Map();
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
for (const dep of e.imports) {
|
|
162
|
+
const list = importedBy.get(dep) ?? [];
|
|
163
|
+
list.push(e.path);
|
|
164
|
+
importedBy.set(dep, list);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
e.importedBy = (importedBy.get(e.path) ?? []).sort();
|
|
169
|
+
e.imports.sort();
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
173
|
+
generatedAt: new Date().toISOString(),
|
|
174
|
+
root: abs,
|
|
175
|
+
entries,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
export async function writeIndex(index, outPath) {
|
|
179
|
+
await writeAtomicJson(outPath, index);
|
|
180
|
+
}
|
|
181
|
+
const MAX_INDEX_BYTES = 32 * 1024 * 1024;
|
|
182
|
+
function isIndexEntry(v) {
|
|
183
|
+
const e = v;
|
|
184
|
+
return (typeof e === 'object' &&
|
|
185
|
+
e !== null &&
|
|
186
|
+
typeof e.path === 'string' &&
|
|
187
|
+
Array.isArray(e.imports) &&
|
|
188
|
+
Array.isArray(e.importedBy) &&
|
|
189
|
+
typeof e.contentHash === 'string');
|
|
190
|
+
}
|
|
191
|
+
/** Load a previously written index; undefined when absent, oversized, or malformed. */
|
|
192
|
+
export async function readIndex(path) {
|
|
193
|
+
try {
|
|
194
|
+
const stat = await import('node:fs/promises').then((fs) => fs.stat(path));
|
|
195
|
+
if (stat.size > MAX_INDEX_BYTES)
|
|
196
|
+
return undefined;
|
|
197
|
+
const raw = await readFile(path, 'utf8');
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
if (parsed.schemaVersion !== INDEX_SCHEMA_VERSION || !Array.isArray(parsed.entries)) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (!parsed.entries.every(isIndexEntry))
|
|
203
|
+
return undefined;
|
|
204
|
+
return parsed;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TestReport } from '../report/run.js';
|
|
2
|
+
import { ErrorRecord, JournalEntry } from './schema.js';
|
|
3
|
+
/** Assemble the immutable run record — lives here so the schema mapping is owned by the journal module. */
|
|
4
|
+
export declare function buildJournalEntry(opts: {
|
|
5
|
+
runId: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
commitSha: string | undefined;
|
|
8
|
+
branch: string | undefined;
|
|
9
|
+
startedAt: Date;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
reports: TestReport[];
|
|
12
|
+
runErrors: ErrorRecord[];
|
|
13
|
+
/** Overall run outcome — aborts before the first test must record ok:false. */
|
|
14
|
+
ok?: boolean;
|
|
15
|
+
}): JournalEntry;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { JOURNAL_SCHEMA_VERSION } from './schema.js';
|
|
2
|
+
/** Assemble the immutable run record — lives here so the schema mapping is owned by the journal module. */
|
|
3
|
+
export function buildJournalEntry(opts) {
|
|
4
|
+
const { reports } = opts;
|
|
5
|
+
const failed = reports.filter((r) => !r.ok).length;
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
8
|
+
runId: opts.runId,
|
|
9
|
+
repo: opts.repo,
|
|
10
|
+
commitSha: opts.commitSha,
|
|
11
|
+
branch: opts.branch,
|
|
12
|
+
startedAt: opts.startedAt.toISOString(),
|
|
13
|
+
durationMs: opts.durationMs,
|
|
14
|
+
ok: (opts.ok ?? true) && failed === 0,
|
|
15
|
+
totals: {
|
|
16
|
+
tests: reports.length,
|
|
17
|
+
passed: reports.length - failed,
|
|
18
|
+
failed,
|
|
19
|
+
visionCalls: reports.reduce((s, r) => s + r.visionCalls, 0),
|
|
20
|
+
visionCostUsd: reports.reduce((s, r) => s + r.visionCostUsd, 0),
|
|
21
|
+
budgetExceeded: reports.some((r) => r.budgetExceeded),
|
|
22
|
+
},
|
|
23
|
+
tests: reports.map((r) => ({
|
|
24
|
+
name: r.name,
|
|
25
|
+
file: r.file,
|
|
26
|
+
ok: r.ok,
|
|
27
|
+
durationMs: r.durationMs,
|
|
28
|
+
...(r.failureMessage !== undefined ? { failureMessage: r.failureMessage } : {}),
|
|
29
|
+
steps: r.steps.map((s) => ({
|
|
30
|
+
instruction: s.instruction,
|
|
31
|
+
action: s.action,
|
|
32
|
+
ok: s.ok,
|
|
33
|
+
...(s.reason !== undefined ? { reason: s.reason } : {}),
|
|
34
|
+
...(s.healed ? { healed: true } : {}),
|
|
35
|
+
...(s.model !== undefined ? { model: s.model } : {}),
|
|
36
|
+
})),
|
|
37
|
+
asserts: r.asserts.map((a) => ({
|
|
38
|
+
question: a.question,
|
|
39
|
+
verdict: a.verdict,
|
|
40
|
+
reasoning: a.reasoning,
|
|
41
|
+
...(a.cached ? { cached: true } : {}),
|
|
42
|
+
})),
|
|
43
|
+
visionCalls: r.visionCalls,
|
|
44
|
+
})),
|
|
45
|
+
errors: opts.runErrors,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal schema — one append-only record per run. Evidence store, not logs:
|
|
3
|
+
* every error and non-fatal recovery is a structured entry so failure modes
|
|
4
|
+
* can be analyzed across runs.
|
|
5
|
+
*/
|
|
6
|
+
export declare const JOURNAL_SCHEMA_VERSION = 1;
|
|
7
|
+
export interface ErrorRecord {
|
|
8
|
+
/** Pipeline stage: boot | target | locate | assert | heal | report | index */
|
|
9
|
+
stage: string;
|
|
10
|
+
message: string;
|
|
11
|
+
/** Minimal context: step instruction, model, page URL — never secrets. */
|
|
12
|
+
context?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface JournalStep {
|
|
15
|
+
instruction: string;
|
|
16
|
+
action: string;
|
|
17
|
+
ok: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
healed?: boolean;
|
|
20
|
+
model?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface JournalAssert {
|
|
23
|
+
question: string;
|
|
24
|
+
verdict: 'pass' | 'fail';
|
|
25
|
+
reasoning: string;
|
|
26
|
+
cached?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface JournalTest {
|
|
29
|
+
name: string;
|
|
30
|
+
file: string;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
durationMs: number;
|
|
33
|
+
failureMessage?: string;
|
|
34
|
+
steps: JournalStep[];
|
|
35
|
+
asserts: JournalAssert[];
|
|
36
|
+
visionCalls: number;
|
|
37
|
+
}
|
|
38
|
+
export interface JournalEntry {
|
|
39
|
+
schemaVersion: typeof JOURNAL_SCHEMA_VERSION;
|
|
40
|
+
runId: string;
|
|
41
|
+
/** Repo identifier: `owner/name` from git remote, else directory basename. */
|
|
42
|
+
repo: string;
|
|
43
|
+
commitSha: string | undefined;
|
|
44
|
+
branch: string | undefined;
|
|
45
|
+
startedAt: string;
|
|
46
|
+
durationMs: number;
|
|
47
|
+
ok: boolean;
|
|
48
|
+
totals: {
|
|
49
|
+
tests: number;
|
|
50
|
+
passed: number;
|
|
51
|
+
failed: number;
|
|
52
|
+
visionCalls: number;
|
|
53
|
+
visionCostUsd: number;
|
|
54
|
+
budgetExceeded: boolean;
|
|
55
|
+
};
|
|
56
|
+
tests: JournalTest[];
|
|
57
|
+
/** Non-fatal recoveries + fatal errors — the failure-mode dataset. */
|
|
58
|
+
errors: ErrorRecord[];
|
|
59
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { JournalEntry } from './schema.js';
|
|
2
|
+
export declare function journalDir(cacheDir: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Append one immutable run record. Atomic via tmp+rename (src/fsutil.ts).
|
|
5
|
+
* Returns the written path, or undefined on failure — a journal write must
|
|
6
|
+
* never abort a run.
|
|
7
|
+
*/
|
|
8
|
+
export declare function writeJournal(cacheDir: string, entry: JournalEntry): Promise<string | undefined>;
|
|
9
|
+
/** runId: timestamp + short random suffix — sortable and collision-safe. */
|
|
10
|
+
export declare function newRunId(): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { writeAtomicJson } from '../fsutil.js';
|
|
3
|
+
export function journalDir(cacheDir) {
|
|
4
|
+
return join(cacheDir, 'journal');
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Append one immutable run record. Atomic via tmp+rename (src/fsutil.ts).
|
|
8
|
+
* Returns the written path, or undefined on failure — a journal write must
|
|
9
|
+
* never abort a run.
|
|
10
|
+
*/
|
|
11
|
+
export async function writeJournal(cacheDir, entry) {
|
|
12
|
+
const path = join(journalDir(cacheDir), `${entry.runId}.json`);
|
|
13
|
+
try {
|
|
14
|
+
await writeAtomicJson(path, entry);
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** runId: timestamp + short random suffix — sortable and collision-safe. */
|
|
22
|
+
export function newRunId() {
|
|
23
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
24
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
25
|
+
return `${ts}-${rand}`;
|
|
26
|
+
}
|
package/dist/live.d.ts
ADDED
package/dist/live.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort live log: appends NDJSON lines to <dir>/live.ndjson (the
|
|
3
|
+
* run's cache dir, normally <cwd>/.argus-reviewer-cache) so the local
|
|
4
|
+
* dashboard (`npm run app`) can follow runs started from any terminal.
|
|
5
|
+
* Bounded to ~1MB; never throws — observability must not break runs.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
const MAX_BYTES = 1024 * 1024;
|
|
10
|
+
const KEEP_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_MSG = 500;
|
|
12
|
+
export function liveLogPath(dir) {
|
|
13
|
+
return resolve(join(dir, 'live.ndjson'));
|
|
14
|
+
}
|
|
15
|
+
export function liveLog(dir, source, level, msg) {
|
|
16
|
+
try {
|
|
17
|
+
const path = liveLogPath(dir);
|
|
18
|
+
// Non-recursive: callers pass the cache dir, which the flow store already
|
|
19
|
+
// creates. Recursive mkdir can spin forever on Node >= 26 when the path
|
|
20
|
+
// is a phantom procfs entry.
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(dirname(path));
|
|
23
|
+
}
|
|
24
|
+
catch { /* already exists or unwritable — appendFileSync decides */ }
|
|
25
|
+
const line = JSON.stringify({ ts: Date.now(), source, level, msg: msg.slice(0, MAX_MSG) }) + '\n';
|
|
26
|
+
try {
|
|
27
|
+
if (statSync(path).size > MAX_BYTES) {
|
|
28
|
+
const buf = readFileSync(path);
|
|
29
|
+
// Keep the tail but discard through the first newline so every
|
|
30
|
+
// retained line is a complete NDJSON record.
|
|
31
|
+
const start = Math.max(0, buf.length - KEEP_BYTES);
|
|
32
|
+
const nl = buf.indexOf(0x0a, start);
|
|
33
|
+
// Atomic replace gives live.ndjson a new inode, which is how
|
|
34
|
+
// tailers detect that rotation happened and re-seed. The tmp name is
|
|
35
|
+
// per-process so concurrent argus runs can't clobber each other's
|
|
36
|
+
// half-written rotation.
|
|
37
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
38
|
+
writeFileSync(tmp, nl === -1 ? buf.subarray(start) : buf.subarray(nl + 1));
|
|
39
|
+
renameSync(tmp, path);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { /* file doesn't exist yet */ }
|
|
43
|
+
appendFileSync(path, line);
|
|
44
|
+
}
|
|
45
|
+
catch { /* live log is best-effort */ }
|
|
46
|
+
}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny leveled logger. Level comes from ARGUS_DEBUG=1 or config.logLevel;
|
|
3
|
+
* default 'warn'. Debug emits model call excerpts and recovery paths —
|
|
4
|
+
* never secret values.
|
|
5
|
+
*/
|
|
6
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
7
|
+
export interface Logger {
|
|
8
|
+
level: LogLevel;
|
|
9
|
+
debug: (msg: string) => void;
|
|
10
|
+
info: (msg: string) => void;
|
|
11
|
+
warn: (msg: string) => void;
|
|
12
|
+
error: (msg: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function createLogger(level: LogLevel, sink: {
|
|
15
|
+
err: (line: string) => void;
|
|
16
|
+
}, live?: (level: LogLevel, msg: string) => void): Logger;
|
|
17
|
+
export declare function resolveLogLevel(env: Record<string, string | undefined>, configured?: string): LogLevel;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2
|
+
export function createLogger(level, sink, live) {
|
|
3
|
+
const emit = (l, msg) => {
|
|
4
|
+
if (ORDER[l] >= ORDER[level])
|
|
5
|
+
sink.err(`[${l}] ${msg}`);
|
|
6
|
+
live?.(l, msg);
|
|
7
|
+
};
|
|
8
|
+
// `live` receives every level regardless of `level` — the local dashboard
|
|
9
|
+
// wants full detail even when the console stays quiet.
|
|
10
|
+
return {
|
|
11
|
+
level,
|
|
12
|
+
debug: (m) => emit('debug', m),
|
|
13
|
+
info: (m) => emit('info', m),
|
|
14
|
+
warn: (m) => emit('warn', m),
|
|
15
|
+
error: (m) => emit('error', m),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function resolveLogLevel(env, configured) {
|
|
19
|
+
if (env.ARGUS_DEBUG === '1' || env.ARGUS_DEBUG === 'true')
|
|
20
|
+
return 'debug';
|
|
21
|
+
if (configured !== undefined && configured in ORDER)
|
|
22
|
+
return configured;
|
|
23
|
+
return 'warn';
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RunReport } from './run.js';
|
|
2
|
+
export declare const SENTINEL = "<!-- argus-reviewer -->";
|
|
3
|
+
export interface CommentOptions {
|
|
4
|
+
/** Link to the workflow run or artifact index. */
|
|
5
|
+
runUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Render the sticky PR comment markdown from a run report (or a missing-key state). */
|
|
8
|
+
export declare function renderComment(report: RunReport | undefined, opts?: CommentOptions & {
|
|
9
|
+
missingKey?: boolean;
|
|
10
|
+
}): string;
|
|
11
|
+
export type CheckConclusion = 'success' | 'failure' | 'neutral';
|
|
12
|
+
/** Map a run report (and optional missing-key flag) to a check-run conclusion. */
|
|
13
|
+
export declare function conclusionFromReport(report: RunReport | undefined, missingKey?: boolean): CheckConclusion;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export const SENTINEL = '<!-- argus-reviewer -->';
|
|
2
|
+
function formatUsd(n) {
|
|
3
|
+
return `$${(n ?? 0).toFixed(6)}`;
|
|
4
|
+
}
|
|
5
|
+
function statusLine(report, missingKey) {
|
|
6
|
+
if (missingKey)
|
|
7
|
+
return '## argus-reviewer ⚪ skipped — no OpenRouter key';
|
|
8
|
+
if (!report)
|
|
9
|
+
return '## argus-reviewer ⚪ no report';
|
|
10
|
+
const emoji = report.ok ? '✅' : '❌';
|
|
11
|
+
const status = report.ok ? 'PASS' : 'FAIL';
|
|
12
|
+
const budget = report.totals.budgetExceeded ? ' (budget cap exceeded)' : '';
|
|
13
|
+
return `## argus-reviewer ${emoji} ${status}${budget}`;
|
|
14
|
+
}
|
|
15
|
+
function testRows(tests) {
|
|
16
|
+
const lines = ['### Tests', ''];
|
|
17
|
+
lines.push('| Test | Result | Calls | Cost | Failure |');
|
|
18
|
+
lines.push('| --- | --- | --- | --- | --- |');
|
|
19
|
+
for (const t of tests) {
|
|
20
|
+
const result = t.ok ? '✅ pass' : '❌ fail';
|
|
21
|
+
const failure = t.failureMessage ? t.failureMessage.replace(/\|/g, '\\|') : '';
|
|
22
|
+
lines.push(`| ${t.name} | ${result} | ${t.visionCalls} | ${formatUsd(t.visionCostUsd)} | ${failure} |`);
|
|
23
|
+
}
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
function costRows(totals) {
|
|
27
|
+
const lines = ['### Cost ledger', ''];
|
|
28
|
+
lines.push('| Line item | Value |');
|
|
29
|
+
lines.push('| --- | --- |');
|
|
30
|
+
lines.push(`| Vision calls | ${totals.visionCalls} |`);
|
|
31
|
+
const perCall = totals.visionCalls > 0 ? formatUsd(totals.visionCostUsd / totals.visionCalls) : '$0.00';
|
|
32
|
+
lines.push(`| Per-call cost (avg) | ${perCall} |`);
|
|
33
|
+
for (const model of Object.keys(totals.callsByModel).sort()) {
|
|
34
|
+
lines.push(`| Calls (${model}) | ${totals.callsByModel[model]} |`);
|
|
35
|
+
lines.push(`| Spend (${model}) | ${formatUsd(totals.costByModel[model] ?? 0)} |`);
|
|
36
|
+
}
|
|
37
|
+
lines.push(`| Total vision spend | ${formatUsd(totals.visionCostUsd)} |`);
|
|
38
|
+
lines.push(`| Sandbox seconds | ${totals.sandboxSeconds.toFixed(1)}s |`);
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
function healRows(tests) {
|
|
42
|
+
const lines = ['### Heal events', ''];
|
|
43
|
+
const heals = tests.flatMap((t) => t.healEvents);
|
|
44
|
+
if (heals.length === 0) {
|
|
45
|
+
lines.push('No heals this run.');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
for (const h of heals) {
|
|
49
|
+
lines.push(`- \`${h.instruction}\` healed with ${h.model ?? 'unknown model'}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
function assertRows(tests) {
|
|
55
|
+
const lines = ['### Assertions', ''];
|
|
56
|
+
let any = false;
|
|
57
|
+
for (const t of tests) {
|
|
58
|
+
if (t.asserts.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
any = true;
|
|
61
|
+
lines.push(`**${t.name}**`);
|
|
62
|
+
for (const a of t.asserts) {
|
|
63
|
+
const icon = a.verdict === 'pass' ? '✅' : '❌';
|
|
64
|
+
lines.push(`- ${icon} *${a.question}* — ${a.reasoning}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push('');
|
|
67
|
+
}
|
|
68
|
+
if (!any) {
|
|
69
|
+
lines.push('No assertions recorded.');
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
function evidenceRows(videos, runUrl) {
|
|
74
|
+
const lines = ['### Evidence', ''];
|
|
75
|
+
for (const v of videos) {
|
|
76
|
+
lines.push(`- video: ${v}`);
|
|
77
|
+
}
|
|
78
|
+
if (runUrl) {
|
|
79
|
+
lines.push(`- [workflow run / artifacts](${runUrl})`);
|
|
80
|
+
}
|
|
81
|
+
if (videos.length === 0 && !runUrl) {
|
|
82
|
+
lines.push('No artifact links configured.');
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
function missingKeyBody() {
|
|
87
|
+
return [
|
|
88
|
+
'',
|
|
89
|
+
'`OPENROUTER_API_KEY` is not configured. Add it as a repository or workflow secret to run argus-reviewer.',
|
|
90
|
+
'',
|
|
91
|
+
'This status is intentionally neutral, not a failure.',
|
|
92
|
+
'',
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
function noReportBody() {
|
|
96
|
+
return [
|
|
97
|
+
'',
|
|
98
|
+
'No run report was produced. The run may have failed before writing reports.',
|
|
99
|
+
'',
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
/** Render the sticky PR comment markdown from a run report (or a missing-key state). */
|
|
103
|
+
export function renderComment(report, opts = {}) {
|
|
104
|
+
const lines = [SENTINEL, ''];
|
|
105
|
+
lines.push(statusLine(report, opts.missingKey ?? false));
|
|
106
|
+
lines.push('');
|
|
107
|
+
if (opts.missingKey) {
|
|
108
|
+
lines.push(...missingKeyBody());
|
|
109
|
+
}
|
|
110
|
+
else if (!report) {
|
|
111
|
+
lines.push(...noReportBody());
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
lines.push(`**Summary:** ${report.totals.passed}/${report.totals.tests} passed · ` +
|
|
115
|
+
`${report.totals.visionCalls} vision calls · ` +
|
|
116
|
+
`${formatUsd(report.totals.visionCostUsd)} spend · ` +
|
|
117
|
+
`${report.totals.sandboxSeconds.toFixed(1)}s sandbox · ` +
|
|
118
|
+
`${(report.durationMs / 1000).toFixed(1)}s wall`);
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(...testRows(report.tests));
|
|
121
|
+
lines.push(...costRows(report.totals));
|
|
122
|
+
lines.push(...healRows(report.tests));
|
|
123
|
+
lines.push(...assertRows(report.tests));
|
|
124
|
+
lines.push(...evidenceRows(report.artifacts.videos, opts.runUrl));
|
|
125
|
+
}
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
/** Map a run report (and optional missing-key flag) to a check-run conclusion. */
|
|
129
|
+
export function conclusionFromReport(report, missingKey = false) {
|
|
130
|
+
if (missingKey)
|
|
131
|
+
return 'neutral';
|
|
132
|
+
if (!report)
|
|
133
|
+
return 'failure';
|
|
134
|
+
return report.ok ? 'success' : 'failure';
|
|
135
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface JunitCase {
|
|
2
|
+
name: string;
|
|
3
|
+
className: string | undefined;
|
|
4
|
+
durationMs: number;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
failureMessage: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Render a JUnit XML report: one <testcase> per test with pass/fail + duration. */
|
|
9
|
+
export declare function renderJunitXml(suiteName: string, cases: JunitCase[]): string;
|
|
10
|
+
export declare function writeJunitXml(path: string, suiteName: string, cases: JunitCase[]): Promise<void>;
|