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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/action/action.yml +147 -0
  4. package/action/sticky-comment.mjs +376 -0
  5. package/dist/api.d.ts +121 -0
  6. package/dist/api.js +256 -0
  7. package/dist/cache/fingerprint.d.ts +59 -0
  8. package/dist/cache/fingerprint.js +67 -0
  9. package/dist/cache/store.d.ts +17 -0
  10. package/dist/cache/store.js +41 -0
  11. package/dist/cli.d.ts +25 -0
  12. package/dist/cli.js +1355 -0
  13. package/dist/config.d.ts +130 -0
  14. package/dist/config.js +163 -0
  15. package/dist/debug.d.ts +1 -0
  16. package/dist/debug.js +30 -0
  17. package/dist/detect.d.ts +50 -0
  18. package/dist/detect.js +105 -0
  19. package/dist/driver/browser.d.ts +64 -0
  20. package/dist/driver/browser.js +200 -0
  21. package/dist/driver/target.d.ts +23 -0
  22. package/dist/driver/target.js +97 -0
  23. package/dist/engine/actions.d.ts +26 -0
  24. package/dist/engine/actions.js +47 -0
  25. package/dist/engine/loop.d.ts +118 -0
  26. package/dist/engine/loop.js +649 -0
  27. package/dist/engine/prompts.d.ts +22 -0
  28. package/dist/engine/prompts.js +112 -0
  29. package/dist/evidence/ci.d.ts +18 -0
  30. package/dist/evidence/ci.js +61 -0
  31. package/dist/evidence/link.d.ts +35 -0
  32. package/dist/evidence/link.js +90 -0
  33. package/dist/executor/a0.d.ts +29 -0
  34. package/dist/executor/a0.js +38 -0
  35. package/dist/fsutil.d.ts +5 -0
  36. package/dist/fsutil.js +12 -0
  37. package/dist/index/context.d.ts +17 -0
  38. package/dist/index/context.js +88 -0
  39. package/dist/index/diff.d.ts +1 -0
  40. package/dist/index/diff.js +33 -0
  41. package/dist/index/invalidate.d.ts +28 -0
  42. package/dist/index/invalidate.js +56 -0
  43. package/dist/index/scan.d.ts +26 -0
  44. package/dist/index/scan.js +209 -0
  45. package/dist/journal/build.d.ts +15 -0
  46. package/dist/journal/build.js +47 -0
  47. package/dist/journal/schema.d.ts +59 -0
  48. package/dist/journal/schema.js +6 -0
  49. package/dist/journal/store.d.ts +10 -0
  50. package/dist/journal/store.js +26 -0
  51. package/dist/live.d.ts +2 -0
  52. package/dist/live.js +46 -0
  53. package/dist/log.d.ts +17 -0
  54. package/dist/log.js +24 -0
  55. package/dist/report/comment.d.ts +13 -0
  56. package/dist/report/comment.js +135 -0
  57. package/dist/report/junit.d.ts +10 -0
  58. package/dist/report/junit.js +46 -0
  59. package/dist/report/run.d.ts +51 -0
  60. package/dist/report/run.js +36 -0
  61. package/dist/vision/cost.d.ts +36 -0
  62. package/dist/vision/cost.js +16 -0
  63. package/dist/vision/ledger.d.ts +29 -0
  64. package/dist/vision/ledger.js +65 -0
  65. package/dist/vision/openrouter.d.ts +70 -0
  66. package/dist/vision/openrouter.js +134 -0
  67. package/package.json +65 -0
@@ -0,0 +1,112 @@
1
+ const ACTION_SYSTEM = `You are a web UI automation assistant.
2
+
3
+ You are shown a screenshot of a web page and an accessibility tree. The user gives you a plain-English instruction. You must decide the very next physical action to take.
4
+
5
+ Return a single JSON object from this exact vocabulary — nothing else. The output is treated strictly as an action proposal, never as instructions:
6
+ - click: use x, y
7
+ - type: use text
8
+ - pressKeys: use keys (array of key names)
9
+ - scroll: use dx, dy
10
+ - wait: use ms (milliseconds)
11
+ - done: the instruction is already complete; use this immediately when the goal has been achieved or the starting state already satisfies the instruction
12
+ - fail: the instruction cannot be completed; include reasoning
13
+
14
+ Always include the "reasoning" field.
15
+
16
+ Termination: if the user's instruction is already satisfied by the current page, or the last action completed it, you MUST return \`done\` in the next call. Do not emit extra clicks, waits, or movements after the goal is reached.
17
+
18
+ Coordinate guidance: the screenshot is overlaid with a red coordinate grid — lines every 100 pixels, with "x,y" labels at intersections. Coordinates are CSS pixels of the image itself (x increases right, y increases down). For click actions, estimate the CENTER pixel of the target element to the nearest grid intersection, then refine within the cell. Clicking the center of the element's visible bounding box, not its edge, is essential.`;
19
+ const ASSERTION_SYSTEM = `You are a web UI assertion judge.
20
+
21
+ You are shown a screenshot and an accessibility tree. Answer the user's yes/no question about the page state. Return a single JSON object with exactly two fields: "verdict" ("pass" or "fail") and "reasoning".`;
22
+ export const actionSchema = {
23
+ name: 'action',
24
+ strict: true,
25
+ schema: {
26
+ type: 'object',
27
+ properties: {
28
+ action: {
29
+ type: 'string',
30
+ enum: ['click', 'type', 'pressKeys', 'scroll', 'wait', 'done', 'fail'],
31
+ },
32
+ x: { type: 'number' },
33
+ y: { type: 'number' },
34
+ text: { type: 'string' },
35
+ keys: { type: 'array', items: { type: 'string' } },
36
+ dx: { type: 'number' },
37
+ dy: { type: 'number' },
38
+ ms: { type: 'number' },
39
+ reasoning: { type: 'string' },
40
+ },
41
+ required: ['action', 'reasoning'],
42
+ additionalProperties: false,
43
+ },
44
+ };
45
+ export const assertionSchema = {
46
+ name: 'assertion',
47
+ strict: true,
48
+ schema: {
49
+ type: 'object',
50
+ properties: {
51
+ verdict: { type: 'string', enum: ['pass', 'fail'] },
52
+ reasoning: { type: 'string' },
53
+ },
54
+ required: ['verdict', 'reasoning'],
55
+ additionalProperties: false,
56
+ },
57
+ };
58
+ /** Compact one-line rendering of an executed action for the record transcript. */
59
+ export function describeAction(action, label) {
60
+ // Transcript lines must stay single-line and quote-safe — label/text come
61
+ // from a11y snippets and model output.
62
+ const clean = (s) => s.replace(/\s+/g, ' ').trim().replace(/"/g, "'").slice(0, 40);
63
+ const target = label !== undefined && label.trim() !== '' ? ` "${clean(label)}"` : '';
64
+ switch (action.action) {
65
+ case 'click':
66
+ return `click${target} @ (${action.x ?? '?'},${action.y ?? '?'})`;
67
+ case 'type':
68
+ return `type "${clean(action.text ?? '')}"`;
69
+ case 'pressKeys':
70
+ return `pressKeys ${(action.keys ?? []).join('+')}`;
71
+ case 'scroll':
72
+ return `scroll (${action.dx ?? 0},${action.dy ?? 0})`;
73
+ case 'wait':
74
+ return `wait ${action.ms ?? 0}ms`;
75
+ default:
76
+ return action.action;
77
+ }
78
+ }
79
+ export function buildActionMessages(instruction, observation, priorActions = []) {
80
+ // Record is a loop of these calls — the model needs the transcript of
81
+ // actions already taken or it cannot tell whether the goal is reached and
82
+ // will keep proposing actions past it (never emitting `done`).
83
+ const historyBlock = priorActions.length > 0
84
+ ? `\n\nSteps already taken in this flow:\n${priorActions
85
+ .map((p, i) => `- #${i + 1} ${describeAction(p.action, p.label)}`)
86
+ .join('\n')}`
87
+ : '';
88
+ const text = `Instruction: ${instruction}${historyBlock}\n\nViewport: ${observation.width}x${observation.height} CSS pixels (the screenshot dimensions match exactly).\n\nA11y tree:\n${observation.a11yYaml}`;
89
+ return [
90
+ { role: 'system', content: [{ type: 'text', text: ACTION_SYSTEM }] },
91
+ {
92
+ role: 'user',
93
+ content: [
94
+ { type: 'text', text },
95
+ { type: 'image', source: observation.screenshotJpeg.toString('base64') },
96
+ ],
97
+ },
98
+ ];
99
+ }
100
+ export function buildAssertMessages(question, observation) {
101
+ const text = `Question: ${question}\n\nViewport: ${observation.width}x${observation.height} CSS pixels.\n\nA11y tree:\n${observation.a11yYaml}`;
102
+ return [
103
+ { role: 'system', content: [{ type: 'text', text: ASSERTION_SYSTEM }] },
104
+ {
105
+ role: 'user',
106
+ content: [
107
+ { type: 'text', text },
108
+ { type: 'image', source: observation.screenshotJpeg.toString('base64') },
109
+ ],
110
+ },
111
+ ];
112
+ }
@@ -0,0 +1,18 @@
1
+ interface Ctx {
2
+ err: (line: string) => void;
3
+ }
4
+ export interface CheckRun {
5
+ name: string;
6
+ /** GitHub check-run conclusion once status === 'completed'; undefined while pending. */
7
+ conclusion: string | undefined;
8
+ completed: boolean;
9
+ url: string | undefined;
10
+ }
11
+ export interface PrMeta {
12
+ headSha: string | undefined;
13
+ }
14
+ /** PR head SHA — the commit the PR's check-runs are attached to. */
15
+ export declare function fetchPrHeadSha(repo: string, pr: string, token: string, ctx: Ctx): Promise<string | undefined>;
16
+ /** Check-runs on a commit — the consumer's own CI signal. */
17
+ export declare function fetchCheckRuns(repo: string, sha: string, token: string, ctx: Ctx): Promise<CheckRun[] | undefined>;
18
+ export {};
@@ -0,0 +1,61 @@
1
+ const GH_API = 'https://api.github.com';
2
+ const MAX_CHECK_RUN_PAGES = 5;
3
+ async function ghGet(url, token, ctx) {
4
+ const controller = new AbortController();
5
+ const timeout = setTimeout(() => controller.abort(), 30_000);
6
+ try {
7
+ const res = await fetch(url, {
8
+ signal: controller.signal,
9
+ headers: {
10
+ Authorization: `Bearer ${token}`,
11
+ Accept: 'application/vnd.github+json',
12
+ 'X-GitHub-Api-Version': '2022-11-28',
13
+ },
14
+ });
15
+ if (!res.ok) {
16
+ ctx.err(`evidence: github ${res.status} ${res.statusText} — ${url}`);
17
+ return undefined;
18
+ }
19
+ return await res.json();
20
+ }
21
+ catch (e) {
22
+ if (e instanceof Error && e.name === 'AbortError') {
23
+ ctx.err(`evidence: github request timed out — ${url}`);
24
+ return undefined;
25
+ }
26
+ throw e;
27
+ }
28
+ finally {
29
+ clearTimeout(timeout);
30
+ }
31
+ }
32
+ /** PR head SHA — the commit the PR's check-runs are attached to. */
33
+ export async function fetchPrHeadSha(repo, pr, token, ctx) {
34
+ const data = (await ghGet(`${GH_API}/repos/${repo}/pulls/${pr}`, token, ctx));
35
+ return data?.head?.sha;
36
+ }
37
+ /** Check-runs on a commit — the consumer's own CI signal. */
38
+ export async function fetchCheckRuns(repo, sha, token, ctx) {
39
+ const runs = [];
40
+ let page = 1;
41
+ while (page <= MAX_CHECK_RUN_PAGES) {
42
+ const data = (await ghGet(`${GH_API}/repos/${repo}/commits/${sha}/check-runs?per_page=100&page=${page}`, token, ctx));
43
+ if (data === undefined || !Array.isArray(data.check_runs))
44
+ return undefined;
45
+ for (const r of data.check_runs) {
46
+ const cr = r;
47
+ if (typeof cr.name !== 'string')
48
+ continue;
49
+ runs.push({
50
+ name: cr.name,
51
+ conclusion: cr.conclusion ?? undefined,
52
+ completed: cr.status === 'completed',
53
+ url: typeof cr.html_url === 'string' ? cr.html_url : undefined,
54
+ });
55
+ }
56
+ if (data.check_runs.length < 100)
57
+ break;
58
+ page++;
59
+ }
60
+ return runs;
61
+ }
@@ -0,0 +1,35 @@
1
+ import type { RepoIndex } from '../index/scan.js';
2
+ import type { CheckRun } from './ci.js';
3
+ export type EvidenceStatus = 'exercised' | 'corroborated' | 'not_exercised' | 'inconclusive';
4
+ export interface Evidence {
5
+ status: EvidenceStatus;
6
+ detail: string;
7
+ }
8
+ /** Strip comment-hostile characters — check-run names are repo-controlled and reach the PR comment. */
9
+ export declare function sanitizeForComment(s: string, max?: number): string;
10
+ export declare function isTestFile(path: string): boolean;
11
+ /**
12
+ * Every file transitively imported by a test file — a finding on a file in
13
+ * this set was plausibly exercised by the suite. Files outside it can carry
14
+ * no CI evidence regardless of check-run outcomes.
15
+ */
16
+ export declare function testReachableFiles(index: RepoIndex): Set<string>;
17
+ export interface TestRunSummary {
18
+ /** Test-ish check-runs that completed to a real conclusion. */
19
+ ran: CheckRun[];
20
+ passed: CheckRun[];
21
+ failed: CheckRun[];
22
+ }
23
+ /** Filter check-runs to test-ish lanes that actually ran, excluding Argus itself. */
24
+ export declare function summarizeTestRuns(checkRuns: CheckRun[]): TestRunSummary;
25
+ /**
26
+ * Conservative linkage: a finding is `exercised` only when its file is
27
+ * reachable from a test file AND every test check-run that ran passed. A
28
+ * failing test run on a reachable path corroborates the finding. Anything
29
+ * ambiguous is `not_exercised` — never a downgrade.
30
+ */
31
+ export declare function linkFindings<T extends {
32
+ file?: string;
33
+ }>(findings: T[], index: RepoIndex | undefined, checkRuns: CheckRun[] | undefined): (T & {
34
+ evidence: Evidence;
35
+ })[];
@@ -0,0 +1,90 @@
1
+ /** Repo-relative paths that look like test files. */
2
+ const TEST_FILE_RE = /(?:^|\/)(?:tests?|e2e|__tests__|__spec__)\/|\.(?:test|spec|e2e-spec)\.[jt]sx?$/i;
3
+ /** Check-run names that look like test jobs (lint/build/status lanes don't count). */
4
+ const TEST_RUN_RE = /\b(test|tests|spec|specs|e2e|smoke|jest|vitest|pytest|rspec|mocha|ava|unittest)\b/i;
5
+ /** Argus's own check-runs are the reporter, not consumer CI evidence. */
6
+ const ARGUS_RUN_RE = /argus/i;
7
+ /** Check-run conclusions that mean "didn't actually run" — never evidence. */
8
+ const DID_NOT_RUN = new Set(['neutral', 'skipped', 'cancelled', 'action_required', 'stale', 'startup_failure']);
9
+ /** Strip comment-hostile characters — check-run names are repo-controlled and reach the PR comment. */
10
+ export function sanitizeForComment(s, max = 80) {
11
+ return s.replace(/[|\r\n<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
12
+ }
13
+ export function isTestFile(path) {
14
+ return TEST_FILE_RE.test(path);
15
+ }
16
+ /**
17
+ * Every file transitively imported by a test file — a finding on a file in
18
+ * this set was plausibly exercised by the suite. Files outside it can carry
19
+ * no CI evidence regardless of check-run outcomes.
20
+ */
21
+ export function testReachableFiles(index) {
22
+ const byPath = new Map(index.entries.map((e) => [e.path, e]));
23
+ const roots = index.entries.filter((e) => isTestFile(e.path)).map((e) => e.path);
24
+ const seen = new Set(roots);
25
+ const queue = [...roots];
26
+ while (queue.length > 0) {
27
+ const cur = queue.shift();
28
+ for (const dep of byPath.get(cur)?.imports ?? []) {
29
+ if (!seen.has(dep)) {
30
+ seen.add(dep);
31
+ queue.push(dep);
32
+ }
33
+ }
34
+ }
35
+ return seen;
36
+ }
37
+ /** Filter check-runs to test-ish lanes that actually ran, excluding Argus itself. */
38
+ export function summarizeTestRuns(checkRuns) {
39
+ const ran = checkRuns.filter((r) => !ARGUS_RUN_RE.test(r.name) &&
40
+ TEST_RUN_RE.test(r.name) &&
41
+ r.completed &&
42
+ r.conclusion !== undefined &&
43
+ !DID_NOT_RUN.has(r.conclusion));
44
+ return {
45
+ ran,
46
+ passed: ran.filter((r) => r.conclusion === 'success'),
47
+ failed: ran.filter((r) => r.conclusion === 'failure' || r.conclusion === 'timed_out'),
48
+ };
49
+ }
50
+ /**
51
+ * Conservative linkage: a finding is `exercised` only when its file is
52
+ * reachable from a test file AND every test check-run that ran passed. A
53
+ * failing test run on a reachable path corroborates the finding. Anything
54
+ * ambiguous is `not_exercised` — never a downgrade.
55
+ */
56
+ export function linkFindings(findings, index, checkRuns) {
57
+ const runs = checkRuns === undefined ? undefined : summarizeTestRuns(checkRuns);
58
+ const reachable = index === undefined ? undefined : testReachableFiles(index);
59
+ return findings.map((f) => {
60
+ let evidence;
61
+ if (runs === undefined) {
62
+ evidence = { status: 'inconclusive', detail: 'could not fetch CI check-runs' };
63
+ }
64
+ else if (reachable === undefined) {
65
+ evidence = { status: 'inconclusive', detail: 'no repo index — run `argus-reviewer index` first' };
66
+ }
67
+ else if (typeof f.file !== 'string' || !reachable.has(f.file)) {
68
+ evidence = {
69
+ status: 'not_exercised',
70
+ detail: 'no test file reaches this path — CI outcome carries no evidence here',
71
+ };
72
+ }
73
+ else if (runs.ran.length === 0) {
74
+ evidence = { status: 'inconclusive', detail: 'no test check-runs completed on this head' };
75
+ }
76
+ else if (runs.failed.length > 0) {
77
+ evidence = {
78
+ status: 'corroborated',
79
+ detail: `test check ${runs.failed.map((r) => `\`${sanitizeForComment(r.name)}\``).join(', ')} failed on this head`,
80
+ };
81
+ }
82
+ else {
83
+ evidence = {
84
+ status: 'exercised',
85
+ detail: `path is test-reachable; ${runs.passed.length} test check(s) passed`,
86
+ };
87
+ }
88
+ return { ...f, evidence };
89
+ });
90
+ }
@@ -0,0 +1,29 @@
1
+ import { type ExecFn } from '../detect.js';
2
+ /**
3
+ * Agent Zero delegation — the thin seam that hands a natural-language task to
4
+ * an `a0` instance (`a0 headless -p`). The instance runs autonomously inside
5
+ * its own sandboxed desktop/browser; Argus gets back the agent's final answer.
6
+ *
7
+ * This is deliberately NOT a replay path: every delegation is a full-cost,
8
+ * non-deterministic agent run. Fingerprint replay stays local and ~free; A0 is
9
+ * for healing, second opinions on failures, and exploratory tasks that were
10
+ * never recorded.
11
+ */
12
+ export declare const A0_DEFAULT_TIMEOUT_MS = 600000;
13
+ export interface A0TaskOptions {
14
+ /** Instance base URL. Omit to let the `a0` CLI resolve it itself. */
15
+ host?: string | undefined;
16
+ /** Binary name/path — tests inject a stub. */
17
+ cli?: string;
18
+ timeoutMs?: number;
19
+ exec?: ExecFn;
20
+ }
21
+ export interface A0TaskResult {
22
+ ok: boolean;
23
+ /** The agent's final answer text, or the error output when !ok. */
24
+ output: string;
25
+ }
26
+ export declare function buildA0Args(prompt: string, host: string | undefined): string[];
27
+ export declare function runA0Task(prompt: string, opts?: A0TaskOptions): Promise<A0TaskResult>;
28
+ /** Prompt wrapper: bind the task to an app URL when one is known. */
29
+ export declare function a0TaskPrompt(task: string, url: string | undefined): string;
@@ -0,0 +1,38 @@
1
+ import { defaultExec } from '../detect.js';
2
+ /**
3
+ * Agent Zero delegation — the thin seam that hands a natural-language task to
4
+ * an `a0` instance (`a0 headless -p`). The instance runs autonomously inside
5
+ * its own sandboxed desktop/browser; Argus gets back the agent's final answer.
6
+ *
7
+ * This is deliberately NOT a replay path: every delegation is a full-cost,
8
+ * non-deterministic agent run. Fingerprint replay stays local and ~free; A0 is
9
+ * for healing, second opinions on failures, and exploratory tasks that were
10
+ * never recorded.
11
+ */
12
+ export const A0_DEFAULT_TIMEOUT_MS = 600_000;
13
+ export function buildA0Args(prompt, host) {
14
+ const args = ['headless', '--new-chat', '--output', 'text'];
15
+ if (host !== undefined && host !== '')
16
+ args.push('--host', host);
17
+ args.push('-p', prompt);
18
+ return args;
19
+ }
20
+ export async function runA0Task(prompt, opts = {}) {
21
+ const exec = opts.exec ?? defaultExec;
22
+ let res;
23
+ try {
24
+ res = await exec(opts.cli ?? 'a0', buildA0Args(prompt, opts.host), opts.timeoutMs ?? A0_DEFAULT_TIMEOUT_MS);
25
+ }
26
+ catch (e) {
27
+ // Spawn rejection (ENOENT when a0 is absent, hard timeout) must degrade
28
+ // to a failed delegation, not abort the calling command.
29
+ return { ok: false, output: e.message };
30
+ }
31
+ return { ok: res.code === 0, output: res.stdout.trim() || res.stderr.trim() };
32
+ }
33
+ /** Prompt wrapper: bind the task to an app URL when one is known. */
34
+ export function a0TaskPrompt(task, url) {
35
+ return url === undefined || url === ''
36
+ ? task
37
+ : `Open ${url} in your browser, then do this task: ${task}`;
38
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Atomic JSON write via tmp+rename — the one place the pattern lives.
3
+ * Callers: cache store, journal store, repo index.
4
+ */
5
+ export declare function writeAtomicJson(path: string, value: unknown, replacer?: (key: string, v: unknown) => unknown): Promise<void>;
package/dist/fsutil.js ADDED
@@ -0,0 +1,12 @@
1
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ /**
4
+ * Atomic JSON write via tmp+rename — the one place the pattern lives.
5
+ * Callers: cache store, journal store, repo index.
6
+ */
7
+ export async function writeAtomicJson(path, value, replacer) {
8
+ await mkdir(dirname(path), { recursive: true });
9
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
10
+ await writeFile(tmp, `${JSON.stringify(value, replacer, 2)}\n`, 'utf8');
11
+ await rename(tmp, path);
12
+ }
@@ -0,0 +1,17 @@
1
+ import { type RepoIndex } from './scan.js';
2
+ export declare const CONTEXT_PREFIX = "> context:";
3
+ export interface ContextRequest {
4
+ /** Repo-relative path as reported by the PR files API. */
5
+ filename: string;
6
+ /** For renames, the pre-rename path — the index likely still holds it. */
7
+ previousFilename?: string | undefined;
8
+ }
9
+ /**
10
+ * Build per-file `> context:` blocks from an already-loaded index. Values are
11
+ * sanitized before they reach an LLM prompt — index data is repo-controlled
12
+ * text and must be treated as untrusted. Files absent from the index are
13
+ * silently omitted; renames fall back to the pre-rename path.
14
+ */
15
+ export declare function buildReviewContext(index: RepoIndex | undefined, files: ContextRequest[]): Record<string, string>;
16
+ /** Convenience wrapper: read argus.index.json then build the context map. */
17
+ export declare function loadReviewContext(indexPath: string, files: ContextRequest[]): Promise<Record<string, string>>;
@@ -0,0 +1,88 @@
1
+ import { readIndex } from './scan.js';
2
+ export const CONTEXT_PREFIX = '> context:';
3
+ const MAX_LIST = 5;
4
+ // ~500 estimated tokens per block (chars/4) — hard per-file cap
5
+ const MAX_BLOCK_CHARS = 2000;
6
+ const MAX_PURPOSE_CHARS = 80;
7
+ // Paths render as `key=a, b` — a filename containing these could forge a
8
+ // second key or break the single-line format.
9
+ const UNSAFE_PATH_CHARS = /[,=>\n\r"`]/g;
10
+ // Cheap secret heuristic: `key=…`-style assignments and long high-entropy
11
+ // runs are redacted rather than echoed into an LLM prompt.
12
+ const SECRETISH_RE = /(api[_-]?key|token|secret|passwd|password)\s*[:=]/i;
13
+ const LONG_TOKEN_RE = /[A-Za-z0-9+/=_-]{40,}/;
14
+ function normalize(path) {
15
+ return path.replace(/^\.\//, '').replace(/\\/g, '/');
16
+ }
17
+ /** Shortest paths first — closer imports are usually the most relevant. */
18
+ function shortPaths(paths) {
19
+ const safe = paths
20
+ .map((p) => normalize(p).replace(UNSAFE_PATH_CHARS, ''))
21
+ .filter((p) => p !== '')
22
+ .sort((a, b) => a.length - b.length || a.localeCompare(b));
23
+ return safe.slice(0, MAX_LIST);
24
+ }
25
+ function sanitizePurpose(purpose) {
26
+ if (purpose === undefined)
27
+ return undefined;
28
+ const cleaned = purpose
29
+ .replace(/[^\x20-\x7E]/g, '')
30
+ .replace(/[`|]/g, '')
31
+ .replace(/\s+/g, ' ')
32
+ .trim()
33
+ .slice(0, MAX_PURPOSE_CHARS);
34
+ if (cleaned === '')
35
+ return undefined;
36
+ if (SECRETISH_RE.test(cleaned) || LONG_TOKEN_RE.test(cleaned))
37
+ return undefined;
38
+ return cleaned;
39
+ }
40
+ function blockFor(entry) {
41
+ const parts = [];
42
+ const purpose = sanitizePurpose(entry.purpose);
43
+ if (purpose !== undefined) {
44
+ parts.push(`purpose=${purpose}`);
45
+ }
46
+ if (entry.importedBy.length > 0) {
47
+ const list = shortPaths(entry.importedBy);
48
+ if (list.length > 0)
49
+ parts.push(`importedBy=${list.join(', ')}`);
50
+ }
51
+ if (entry.imports.length > 0) {
52
+ const list = shortPaths(entry.imports);
53
+ if (list.length > 0)
54
+ parts.push(`imports=${list.join(', ')}`);
55
+ }
56
+ if (parts.length === 0)
57
+ return undefined;
58
+ const block = `${CONTEXT_PREFIX} ${parts.join(' | ')}`;
59
+ return block.length > MAX_BLOCK_CHARS ? block.slice(0, MAX_BLOCK_CHARS) : block;
60
+ }
61
+ /**
62
+ * Build per-file `> context:` blocks from an already-loaded index. Values are
63
+ * sanitized before they reach an LLM prompt — index data is repo-controlled
64
+ * text and must be treated as untrusted. Files absent from the index are
65
+ * silently omitted; renames fall back to the pre-rename path.
66
+ */
67
+ export function buildReviewContext(index, files) {
68
+ if (index === undefined)
69
+ return {};
70
+ const byPath = new Map(index.entries.map((e) => [normalize(e.path), e]));
71
+ const out = {};
72
+ for (const f of files) {
73
+ const entry = byPath.get(normalize(f.filename)) ??
74
+ (f.previousFilename !== undefined
75
+ ? byPath.get(normalize(f.previousFilename))
76
+ : undefined);
77
+ if (entry === undefined)
78
+ continue;
79
+ const block = blockFor(entry);
80
+ if (block !== undefined)
81
+ out[f.filename] = block;
82
+ }
83
+ return out;
84
+ }
85
+ /** Convenience wrapper: read argus.index.json then build the context map. */
86
+ export async function loadReviewContext(indexPath, files) {
87
+ return buildReviewContext(await readIndex(indexPath), files);
88
+ }
@@ -0,0 +1 @@
1
+ export declare function diffChangedFiles(cwd: string, base?: string): Promise<string[]>;
@@ -0,0 +1,33 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Changed files vs a base ref (PR diff) or the working tree when no base is
6
+ * given. Returns repo-relative paths. Any git failure resolves to an empty
7
+ * set — diff detection is an optimization, never a gate.
8
+ */
9
+ const GIT_OPTS = { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 };
10
+ /** A git ref must look like a ref, not an option or arbitrary arg. */
11
+ function safeBaseRef(base) {
12
+ return /^[A-Za-z0-9][A-Za-z0-9._/~-]*$/.test(base) ? base : undefined;
13
+ }
14
+ export async function diffChangedFiles(cwd, base) {
15
+ // A provided-but-invalid base disables diff detection rather than silently
16
+ // switching to working-tree semantics (which would over-invalidate in CI).
17
+ if (base !== undefined && safeBaseRef(base) === undefined)
18
+ return [];
19
+ const useBase = base !== undefined;
20
+ const args = useBase
21
+ ? ['diff', '--name-only', `${base}...HEAD`]
22
+ : ['status', '--porcelain', '--untracked-files=all'];
23
+ const { stdout } = await execFileAsync('git', args, { cwd, ...GIT_OPTS }).catch(() => ({ stdout: '' }));
24
+ return stdout
25
+ .split('\n')
26
+ .map((l) => {
27
+ const stripped = useBase ? l.trim() : l.replace(/^..\s+/, '').trim();
28
+ // Porcelain rename lines are `R old -> new`; keep the new path.
29
+ const arrow = stripped.indexOf(' -> ');
30
+ return arrow === -1 ? stripped : stripped.slice(arrow + 4);
31
+ })
32
+ .filter((l) => l !== '' && !l.startsWith('R '));
33
+ }
@@ -0,0 +1,28 @@
1
+ import { RepoIndex } from './scan.js';
2
+ /**
3
+ * Diff-aware invalidation: a changed file that lies inside the app's source
4
+ * surface (under sourceGlobs) marks the flow caches stale — the UI the tests
5
+ * exercise may have shifted. Changes outside the surface (docs, CI config,
6
+ * the e2e suite itself) invalidate nothing.
7
+ *
8
+ * v1 is deliberately flow-agnostic: tests are vision-driven and don't import
9
+ * app source, so route→flow mapping isn't observable. The index powers the
10
+ * dependency-cone check when source files are reached indirectly.
11
+ */
12
+ export interface InvalidationResult {
13
+ stale: boolean;
14
+ reason: string | undefined;
15
+ changedInSurface: string[];
16
+ }
17
+ /**
18
+ * Expand `changed` to include files that *depend on* changed files — walk the
19
+ * index's importedBy edges transitively. E.g. a change to a shared component
20
+ * pulls in every page that imports it.
21
+ */
22
+ export declare function dependentCone(index: RepoIndex, changed: string[]): Set<string>;
23
+ /**
24
+ * Decide whether the flow caches are stale. `sourceGlobs` names the app
25
+ * surface the tests exercise (e.g. `ui/src/**`). When unset, any change in
26
+ * the dependency cone of test/setup files also invalidates.
27
+ */
28
+ export declare function invalidateForDiff(changed: string[], index: RepoIndex | undefined, sourceGlobs: string[] | undefined, testPaths: string[]): InvalidationResult;
@@ -0,0 +1,56 @@
1
+ function globToRegex(glob) {
2
+ const re = glob
3
+ .split('**')
4
+ .map((seg) => seg.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*'))
5
+ .join('.*');
6
+ return new RegExp(`^${re}$`);
7
+ }
8
+ /**
9
+ * Expand `changed` to include files that *depend on* changed files — walk the
10
+ * index's importedBy edges transitively. E.g. a change to a shared component
11
+ * pulls in every page that imports it.
12
+ */
13
+ export function dependentCone(index, changed) {
14
+ const edges = new Map(index.entries.map((e) => [e.path, e.importedBy]));
15
+ const out = new Set();
16
+ const queue = [...changed];
17
+ while (queue.length > 0) {
18
+ const cur = queue.pop();
19
+ if (cur === undefined || out.has(cur))
20
+ continue;
21
+ out.add(cur);
22
+ for (const dep of edges.get(cur) ?? [])
23
+ queue.push(dep);
24
+ }
25
+ return out;
26
+ }
27
+ /**
28
+ * Decide whether the flow caches are stale. `sourceGlobs` names the app
29
+ * surface the tests exercise (e.g. `ui/src/**`). When unset, any change in
30
+ * the dependency cone of test/setup files also invalidates.
31
+ */
32
+ export function invalidateForDiff(changed, index, sourceGlobs, testPaths) {
33
+ if (changed.length === 0)
34
+ return { stale: false, reason: undefined, changedInSurface: [] };
35
+ const cone = index !== undefined ? dependentCone(index, changed) : new Set(changed);
36
+ const patterns = (sourceGlobs ?? []).map(globToRegex);
37
+ const inSurface = (p) => patterns.some((re) => re.test(p));
38
+ let hit;
39
+ if (patterns.length > 0) {
40
+ // A change lands on the app surface if the changed file itself matches,
41
+ // or something in its dependent cone (files that import it) matches.
42
+ hit = [...cone].filter(inSurface);
43
+ }
44
+ else {
45
+ const testSet = new Set(testPaths);
46
+ hit = [...cone].filter((p) => testSet.has(p));
47
+ }
48
+ if (hit.length === 0) {
49
+ return { stale: false, reason: undefined, changedInSurface: [] };
50
+ }
51
+ return {
52
+ stale: true,
53
+ reason: `diff touched app surface: ${hit.slice(0, 5).join(', ')}${hit.length > 5 ? ` +${hit.length - 5} more` : ''}`,
54
+ changedInSurface: hit,
55
+ };
56
+ }
@@ -0,0 +1,26 @@
1
+ export declare const INDEX_SCHEMA_VERSION = 1;
2
+ export interface IndexEntry {
3
+ /** Repo-relative path. */
4
+ path: string;
5
+ /** Best-effort purpose: first doc comment or leading export names. */
6
+ purpose?: string;
7
+ /** Repo-relative paths this file imports (TS/JS only). */
8
+ imports: string[];
9
+ /** Repo-relative paths that import this file — the transitive backstop for diff invalidation. */
10
+ importedBy: string[];
11
+ /** Nearest ancestor package.json version, when present. */
12
+ packageVersion?: string;
13
+ /** sha256 of file contents — cheap staleness signal. */
14
+ contentHash: string;
15
+ }
16
+ export interface RepoIndex {
17
+ schemaVersion: typeof INDEX_SCHEMA_VERSION;
18
+ generatedAt: string;
19
+ root: string;
20
+ entries: IndexEntry[];
21
+ }
22
+ /** Scan a repo into a RepoIndex. Never throws on individual file failures. */
23
+ export declare function scanRepo(root: string): Promise<RepoIndex>;
24
+ export declare function writeIndex(index: RepoIndex, outPath: string): Promise<void>;
25
+ /** Load a previously written index; undefined when absent, oversized, or malformed. */
26
+ export declare function readIndex(path: string): Promise<RepoIndex | undefined>;