@getopencrawl/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/browser.mjs ADDED
@@ -0,0 +1,166 @@
1
+ /** Explicitly scoped, read-only browser capture using an isolated OpenCrawl profile. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { configDir } from './src/client.mjs';
7
+
8
+ export function validateCaptureUrl(value) {
9
+ let url;
10
+ try { url = new URL(value); } catch { throw new Error('Browser capture needs an absolute URL.'); }
11
+ if (url.username || url.password) throw new Error('Browser URLs cannot contain credentials.');
12
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
13
+ throw new Error('Browser capture requires HTTPS, or HTTP on localhost.');
14
+ }
15
+ const mutationPath = /(?:^|\/)(?:logout|log-out|signout|sign-out|delete|remove|destroy|unsubscribe|deactivate|terminate)(?:\/|$)/i;
16
+ if (mutationPath.test(decodeURIComponent(url.pathname)) || [...url.searchParams.keys()].some(key => /^(action|delete|remove|logout|unsubscribe)$/i.test(key))) {
17
+ throw new Error('This URL appears to perform an action. Select a page that displays records.');
18
+ }
19
+ url.hash = '';
20
+ return url;
21
+ }
22
+
23
+ export function isReadOnlyRequest(method, postData) {
24
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) return true;
25
+ if (method.toUpperCase() !== 'POST' || !postData) return false;
26
+ try {
27
+ const payload = JSON.parse(postData);
28
+ const list = Array.isArray(payload) ? payload : [payload];
29
+ return list.length > 0 && list.every(item => typeof item.query === 'string' && /^\s*(?:#[^\n]*\n\s*)*(?:query\b|\{)/.test(item.query) && !/\bmutation\b/.test(item.query));
30
+ } catch { return false; }
31
+ }
32
+
33
+ async function playwrightModule() {
34
+ try { return await import('playwright'); }
35
+ catch { throw new Error('Browser capture needs the optional Playwright package. Install the CLI with optional dependencies, then run: opencrawl browser setup'); }
36
+ }
37
+ async function profile(name, origin) {
38
+ if (!/^[a-zA-Z0-9_-]{1,60}$/.test(name)) throw new Error('Choose a simple profile name using letters, digits, dashes, or underscores.');
39
+ const directory = path.join(configDir(), 'browser-profiles', name);
40
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
41
+ await fs.chmod(directory, 0o700);
42
+ const metadataFile = path.join(directory, 'profile.json');
43
+ let metadata;
44
+ try { metadata = JSON.parse(await fs.readFile(metadataFile, 'utf8')); }
45
+ catch (error) { if (error.code !== 'ENOENT') throw new Error('The OpenCrawl browser profile is invalid. Choose a new profile name.'); }
46
+ if (metadata && metadata.origin !== origin) throw new Error(`Profile ${name} is scoped to ${metadata.origin}. Choose a separate --profile for another origin.`);
47
+ if (!metadata) await fs.writeFile(metadataFile, JSON.stringify({ origin, created_at: new Date().toISOString() }), { mode: 0o600 });
48
+ return directory;
49
+ }
50
+
51
+ export async function browserCommand(action, target, options, client, overrides = {}) {
52
+ if (!['setup', 'login', 'capture', 'crawl'].includes(action)) throw new Error('Use browser setup, login URL, capture URL, or crawl URL.');
53
+ const playwright = overrides.playwright || await playwrightModule();
54
+ if (action === 'setup') {
55
+ // Invoke Playwright's own browser installer without a shell.
56
+ const { spawn } = await import('node:child_process');
57
+ const { createRequire } = await import('node:module');
58
+ const require = createRequire(import.meta.url);
59
+ const entry = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
60
+ await new Promise((resolve, reject) => {
61
+ const child = spawn(process.execPath, [entry, 'install', 'chromium'], { stdio: 'inherit' });
62
+ child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error('Browser installation failed.')));
63
+ });
64
+ return { installed: 'chromium' };
65
+ }
66
+ const selected = validateCaptureUrl(target);
67
+ const directory = await profile(options.profile || 'default', selected.origin);
68
+ const maxPages = action === 'crawl' ? Number(options['max-pages'] || 10) : 1;
69
+ if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100) throw new Error('--max-pages must be between 1 and 100.');
70
+ const prefix = options['path-prefix'] || (selected.pathname.endsWith('/') ? selected.pathname : selected.pathname.slice(0, selected.pathname.lastIndexOf('/') + 1));
71
+ if (!prefix.startsWith('/')) throw new Error('--path-prefix must start with /.');
72
+ const inScope = value => {
73
+ try { const url = validateCaptureUrl(value); return url.origin === selected.origin && (action !== 'crawl' || url.pathname.startsWith(prefix)); }
74
+ catch { return false; }
75
+ };
76
+ const queue = [selected.href];
77
+ if (options.urls) {
78
+ if (action !== 'capture') throw new Error('--urls can be used with browser capture only.');
79
+ const content = await fs.readFile(path.resolve(options.urls), 'utf8');
80
+ let urls;
81
+ try { urls = JSON.parse(content); } catch { urls = content.split('\n').map(line => line.trim()).filter(Boolean); }
82
+ if (!Array.isArray(urls) || urls.length > 100 || !urls.every(url => typeof url === 'string' && inScope(url))) {
83
+ throw new Error('--urls must contain at most 100 explicit page URLs on the selected origin.');
84
+ }
85
+ queue.push(...urls);
86
+ }
87
+ let context;
88
+ try {
89
+ context = await playwright.chromium.launchPersistentContext(path.join(directory, 'chromium'), {
90
+ headless: action === 'login' || options.interactive ? false : Boolean(options.headless), acceptDownloads: false,
91
+ permissions: [], viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block',
92
+ });
93
+ } catch { throw new Error('Could not launch the isolated OpenCrawl browser. Run opencrawl browser setup; close any other command using this profile.'); }
94
+ const results = [], warnings = [], seen = new Set();
95
+ let blocked = 0;
96
+ try {
97
+ const page = context.pages()[0] || await context.newPage();
98
+ if (action === 'login' || options.interactive) {
99
+ await page.goto(selected.href, { waitUntil: 'domcontentloaded', timeout: 45000 });
100
+ process.stderr.write(`Sign in manually in the isolated OpenCrawl browser. This profile is reserved for ${selected.origin}.\n`);
101
+ if (!process.stdin.isTTY && !overrides.waitForLogin) throw new Error('Browser login requires an interactive terminal.');
102
+ if (overrides.waitForLogin) await overrides.waitForLogin(page);
103
+ else { const input = createInterface({ input: process.stdin, output: process.stderr }); try { await input.question('After signing in, press Enter here to save the isolated session: '); } finally { input.close(); } }
104
+ if (action === 'login') return { profile: options.profile || 'default', origin: selected.origin, message: 'Session saved locally. Run browser capture with the page URL you want to index.' };
105
+ }
106
+ if (!client.configured) throw new Error('Run opencrawl login before capturing pages.');
107
+ await context.route('**/*', async route => {
108
+ const request = route.request();
109
+ if (!isReadOnlyRequest(request.method(), request.postData())) { blocked++; await route.abort('blockedbyclient'); return; }
110
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame() && !inScope(request.url())) { blocked++; await route.abort('blockedbyclient'); return; }
111
+ await route.continue();
112
+ });
113
+ // Browser popups are not part of the selected capture scope.
114
+ context.on('page', popup => { if (popup !== page) popup.close().catch(() => {}); });
115
+ while (queue.length && seen.size < (action === 'crawl' ? maxPages : 101)) {
116
+ const url = queue.shift();
117
+ if (seen.has(url) || !inScope(url)) continue;
118
+ seen.add(url);
119
+ try {
120
+ const response = options.interactive && page.url() === url ? null : await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
121
+ if (response && response.status() >= 400) throw new Error(`HTTP ${response.status()}`);
122
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
123
+ if (!inScope(page.url())) throw new Error('The page redirected outside the selected capture scope.');
124
+ const extracted = await page.evaluate(() => {
125
+ const root = (document.querySelector('main, [role="main"], article') || document.body).cloneNode(true);
126
+ for (const element of root.querySelectorAll('script,style,noscript,template,input,textarea,select,[contenteditable="true"],[aria-hidden="true"],[hidden]')) element.remove();
127
+ const blocks = new Set(['P','DIV','SECTION','ARTICLE','H1','H2','H3','H4','LI','TR','BR']);
128
+ const readable = node => node.nodeType === Node.TEXT_NODE ? node.textContent : [...node.childNodes].map(readable).join('') + (blocks.has(node.nodeName) ? '\n' : node.nodeName === 'TD' ? ' | ' : '');
129
+ const text = readable(root).replace(/\s+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
130
+ return { title: document.title, text, links: [...document.querySelectorAll('a[href]')].map(link => link.href),
131
+ headings: [...document.querySelectorAll('h1,h2,h3')].map(heading => heading.textContent.trim()).filter(Boolean) };
132
+ });
133
+ if (!extracted.text || extracted.text.length < 30) throw new Error('No readable page content. Sign in with browser login, then capture the record page.');
134
+ const document = { title: (extracted.title || url).slice(0, 500), content: extracted.text.slice(0, 2_000_000),
135
+ external_id: `browser:${url}`, kind: 'web', url, visibility: 'private', metadata: {
136
+ capture: 'isolated-browser', origin: selected.origin, captured_at: new Date().toISOString(), headings: extracted.headings,
137
+ content_complete: extracted.text.length <= 2_000_000, blocked_mutating_requests: blocked,
138
+ } };
139
+ const imported = await client.request('/api/v1/documents', { method: 'POST', body: document });
140
+ let screenshot, ocrJob;
141
+ if (options.screenshots || options.ocr) {
142
+ const output = options.screenshots ? path.resolve(options.screenshots) : path.join(directory, 'captures'); await fs.mkdir(output, { recursive: true, mode: 0o700 });
143
+ screenshot = path.join(output, crypto.createHash('sha256').update(url).digest('hex').slice(0, 16) + '.png');
144
+ await page.screenshot({ path: screenshot, fullPage: true, mask: [page.locator('input,textarea,[contenteditable="true"]')] });
145
+ await fs.chmod(screenshot, 0o600);
146
+ if (options.ocr) {
147
+ const form = new FormData();
148
+ form.append('file', new Blob([await fs.readFile(screenshot)], { type: 'image/png' }), path.basename(screenshot));
149
+ form.append('visibility', 'private');
150
+ const uploaded = await client.request('/api/v1/uploads', { method: 'POST', body: form });
151
+ ocrJob = uploaded.job?.id;
152
+ }
153
+ }
154
+ results.push({ url, title: document.title, document_id: imported.id || imported.document?.id, characters: document.content.length, screenshot, ocr_job_id: ocrJob });
155
+ if (action === 'crawl') {
156
+ for (const link of extracted.links) {
157
+ if (queue.length >= 1000) break;
158
+ if (inScope(link)) { const canonical = validateCaptureUrl(link).href; if (!seen.has(canonical)) queue.push(canonical); }
159
+ }
160
+ }
161
+ } catch (error) { warnings.push({ url, error: String(error.message).split('\n')[0].slice(0, 300) }); }
162
+ }
163
+ if (blocked) warnings.push({ message: `${blocked} requests outside read-only capture scope were blocked. Some applications require additional supported read endpoints to render fully.` });
164
+ return { imported: results.length, pages: results, warnings, origin: selected.origin, profile: options.profile || 'default' };
165
+ } finally { await context.close(); }
166
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@getopencrawl/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Search, import and bring OpenCrawl context to your agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "opencrawl": "bin/opencrawl.mjs"
8
+ },
9
+ "exports": "./src/client.mjs",
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "!src/**/*.test.mjs",
14
+ "README.md",
15
+ "browser.mjs"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test src/*.test.mjs",
19
+ "test:product-browser": "node --test tests/product-monitor-browser.test.mjs"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "license": "UNLICENSED",
25
+ "optionalDependencies": {
26
+ "playwright": "^1.58.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }
@@ -0,0 +1,112 @@
1
+ // Validation body compiled from sdk/src/answerContract.ts; parity is checked in tests.
2
+ // This optional reader never mutates a run or certifies whole-goal completion.
3
+ // Keep the validation/formatter body identical across the SDK, CLI and web.
4
+ const obj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
5
+ const keys = (v, names) => Object.keys(v).length === names.length && names.every(k => Object.hasOwn(v, k));
6
+ const int = (v, min = 0, max = Number.MAX_SAFE_INTEGER) => typeof v === 'number' && Number.isSafeInteger(v) && v >= min && v <= max;
7
+ const text = (v, max, min = 1) => typeof v === 'string' && Array.from(v).length >= min && Array.from(v).length <= max && !/[\uD800-\uDFFF]/u.test(v) && !v.includes('\0');
8
+ const digest = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
9
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
10
+ const requireValue = (ok) => { if (!ok)
11
+ throw new Error('Invalid optional answer assessment'); };
12
+ const unknownNotice = (c) => `Research assessment for “${c.text}”: the saved source text used for this run does not establish an answer or its contradiction. Scope: ${c.evidence_ids.join(', ')}.`;
13
+ const undeliveredNotice = (c) => `Research assessment for “${c.text}”: answer material was found in the saved source text, but a complete answer was not retained in this result. Scope: ${c.evidence_ids.join(', ')}.`;
14
+ const unassessedNotice = 'Requested-answer coverage was not assessed for this run. The findings and coverage notes remain available.';
15
+ const invalidNotice = 'Requested-answer assessment could not be validated for this run. The findings and coverage notes remain available.';
16
+ const reasons = [null, 'component_contract_invalid', 'component_review_empty', 'goal_representation_bound', 'optional_input_bound', 'optional_output_bound', 'input_binding_invalid', 'review_skipped', 'no_evidence', 'legacy_receipt'];
17
+ function notice(c) { return c.status === 'source_scoped_unknown_assessment' ? unknownNotice(c) : c.status === 'available_but_not_delivered' ? undeliveredNotice(c) : null; }
18
+ export function readAnswerContract(run, hash) {
19
+ if (!Object.hasOwn(run.usage, 'answer_contract'))
20
+ return { state: 'absent', contract: null, notices: [] };
21
+ try {
22
+ const value = run.usage.answer_contract;
23
+ requireValue(new TextEncoder().encode(JSON.stringify(run.usage)).length <= 16384);
24
+ requireValue(obj(value));
25
+ const v = value;
26
+ requireValue(v.version === 1 && v.inventory === 'nonexhaustive' && v.whole_request_completeness === 'not_certified');
27
+ if (keys(v, ['version', 'state', 'reason', 'inventory', 'whole_request_completeness', 'components_omitted'])) {
28
+ requireValue(v.state === 'unassessed' && v.reason === 'optional_output_bound' && int(v.components_omitted, 0, 8));
29
+ return { state: 'unassessed', contract: structuredClone(v), notices: [{ id: 'unassessed', text: unassessedNotice }] };
30
+ }
31
+ requireValue(keys(v, ['version', 'state', 'reason', 'scope', 'source_scope', 'inventory', 'whole_request_completeness', 'goal_sha256', 'components', 'notice_components_omitted', 'gap_notice']));
32
+ requireValue(['assessed_nonexhaustive', 'unassessed'].includes(v.state) && reasons.includes(v.reason) && v.scope === 'admitted_retained_windows_only' && digest(v.goal_sha256));
33
+ requireValue(Array.isArray(v.source_scope) && v.source_scope.length <= 20 && Array.isArray(v.components) && v.components.length <= 8);
34
+ const scopes = new Map();
35
+ for (const s of v.source_scope) {
36
+ requireValue(obj(s) && keys(s, ['evidence_id', 'source_sha256', 'window_text_sha256', 'window_chars', 'source_kind', ...(s.source_kind === 'web_page' ? ['page'] : [])]));
37
+ requireValue(text(s.evidence_id, 80) && s.evidence_id.trim() === s.evidence_id && !/[\u0000-\u0020\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(s.evidence_id) && !scopes.has(s.evidence_id) && digest(s.source_sha256) && digest(s.window_text_sha256) && int(s.window_chars, 1, 16000) && ['indexed', 'web_page'].includes(s.source_kind));
38
+ if (s.source_kind === 'web_page') {
39
+ const p = s.page;
40
+ requireValue(obj(p) && keys(p, ['body_sha256', 'start', 'end', 'total_chars']) && digest(p.body_sha256) && int(p.start, 0, 131071) && int(p.end, 1, 131072) && int(p.total_chars, 1, 131072) && p.start < p.end && p.end <= p.total_chars && p.end - p.start === s.window_chars);
41
+ }
42
+ scopes.set(s.evidence_id, s);
43
+ }
44
+ // Available citations must agree, but uncited scope is retained too. No new
45
+ // source fetch or mutable agent.goal is substituted for the original run.
46
+ for (const f of run.findings)
47
+ for (const cite of f.citations) {
48
+ const s = scopes.get(cite.id);
49
+ requireValue(s && s.window_text_sha256 === cite.body_sha256 && s.source_kind === cite.source_kind);
50
+ if (s?.source_kind === 'web_page')
51
+ requireValue(cite.page && ['body_sha256', 'start', 'end', 'total_chars'].every(k => s.page[k] === cite.page[k]));
52
+ }
53
+ requireValue(v.state === 'unassessed' ? (v.components.length === 0 && v.reason !== null) : (v.components.length > 0 && v.reason === null));
54
+ const ids = new Set(), spans = [], eligible = [];
55
+ const allIds = [...scopes.keys()];
56
+ for (const c of v.components) {
57
+ requireValue(obj(c) && keys(c, ['component_id', 'start', 'end', 'text', 'status', 'reason', 'fact_refs', 'evidence_ids', 'source_state']));
58
+ requireValue(typeof c.component_id === 'string' && /^[CM][1-8]$/.test(c.component_id) && !ids.has(c.component_id));
59
+ ids.add(c.component_id);
60
+ requireValue(int(c.start, 0, 3999) && int(c.end, 1, 4000) && text(c.text, 256) && c.text.trim() && c.end - c.start === Array.from(c.text).length);
61
+ requireValue(['literal_answer', 'literal_negative_answer', 'source_scoped_unknown_assessment', 'available_but_not_delivered', 'unassessed'].includes(c.status));
62
+ requireValue([null, 'linked_finding_rejected', 'request_anchor_not_meaningful', 'review_unassessed'].includes(c.reason) && ['supported', 'explicitly_contradicted', 'not_established', 'unassessed'].includes(c.source_state));
63
+ requireValue(Array.isArray(c.evidence_ids) && c.evidence_ids.length <= 20 && same(c.evidence_ids, allIds.filter(id => c.evidence_ids.includes(id))) && Array.isArray(c.fact_refs) && c.fact_refs.length <= 10);
64
+ const refIds = new Set(), ownIds = new Set();
65
+ for (const r of c.fact_refs) {
66
+ requireValue(obj(r) && keys(r, ['finding_id', 'field', 'qualifier_ordinal', 'literal_text_sha256']) && digest(r.literal_text_sha256));
67
+ const f = run.findings.find(f => f.id === r.finding_id), key = JSON.stringify([r.finding_id, r.field, r.qualifier_ordinal]);
68
+ requireValue(f && !refIds.has(key) && ['claim', 'qualifier'].includes(r.field) && int(r.qualifier_ordinal, r.field === 'claim' ? 0 : 1, r.field === 'claim' ? 0 : f?.qualifiers.length));
69
+ refIds.add(key);
70
+ const literal = r.field === 'claim' ? f.claim : f.qualifiers[r.qualifier_ordinal - 1];
71
+ requireValue(hash(literal) === r.literal_text_sha256);
72
+ f.citations.forEach(cite => ownIds.add(cite.id));
73
+ }
74
+ if (c.status === 'unassessed')
75
+ requireValue(c.source_state === 'unassessed' && ['request_anchor_not_meaningful', 'review_unassessed'].includes(c.reason) && !c.fact_refs.length && !c.evidence_ids.length);
76
+ else if (c.status === 'source_scoped_unknown_assessment')
77
+ requireValue(c.source_state === 'not_established' && c.reason === null && !c.fact_refs.length && allIds.length > 0 && same(c.evidence_ids, allIds));
78
+ else {
79
+ requireValue(['supported', 'explicitly_contradicted'].includes(c.source_state) && c.evidence_ids.length > 0);
80
+ if (c.status === 'available_but_not_delivered')
81
+ requireValue(c.reason === null || c.reason === 'linked_finding_rejected');
82
+ else
83
+ requireValue(c.reason === null && c.fact_refs.length > 0 && c.status === (c.source_state === 'supported' ? 'literal_answer' : 'literal_negative_answer') && same(c.evidence_ids, allIds.filter(id => ownIds.has(id))));
84
+ }
85
+ if (c.reason !== 'request_anchor_not_meaningful') {
86
+ requireValue(spans.every(p => p.text !== c.text && (p.end <= c.start || p.start >= c.end)));
87
+ spans.push(c);
88
+ }
89
+ if (notice(c))
90
+ eligible.push(c);
91
+ }
92
+ requireValue(int(v.notice_components_omitted, 0, eligible.length));
93
+ let delivered = [];
94
+ if (v.gap_notice !== null) {
95
+ const g = v.gap_notice;
96
+ requireValue(obj(g) && keys(g, ['index', 'sha256', 'component_ids']) && int(g.index, 0, run.gaps.length - 1) && g.index === run.gaps.length - 1 && digest(g.sha256) && hash(run.gaps[g.index]) === g.sha256);
97
+ requireValue(Array.isArray(g.component_ids) && g.component_ids.length > 0 && same(g.component_ids, eligible.filter(c => g.component_ids.includes(c.component_id)).map(c => c.component_id)));
98
+ const suffix = ' ' + eligible.filter(c => g.component_ids.includes(c.component_id)).map(c => notice(c)).join(' ');
99
+ requireValue(run.gaps[g.index].endsWith(suffix));
100
+ delivered = g.component_ids;
101
+ }
102
+ requireValue(v.notice_components_omitted === eligible.length - delivered.length);
103
+ const notices = v.state === 'unassessed' ? [{ id: 'unassessed', text: unassessedNotice }] : v.components.flatMap((c) => {
104
+ const n = notice(c);
105
+ return n && !delivered.includes(c.component_id) ? [{ id: c.component_id, text: n }] : c.status === 'unassessed' ? [{ id: c.component_id, text: `Research assessment for “${c.text}”: requested-answer coverage was not assessed.` }] : [];
106
+ });
107
+ return { state: v.state, contract: structuredClone(v), notices };
108
+ }
109
+ catch {
110
+ return { state: 'invalid', contract: null, notices: [{ id: 'invalid', text: invalidNotice }] };
111
+ }
112
+ }
@@ -0,0 +1,118 @@
1
+ // Validation body compiled from sdk/src/answerView.ts; parity is checked in tests.
2
+ const obj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
3
+ const keys = (v, names) => Object.keys(v).length === names.length && names.every(k => Object.hasOwn(v, k));
4
+ const need = (ok) => { if (!ok)
5
+ throw new Error('Invalid optional concise answer'); };
6
+ const digest = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
7
+ const refKey = (r) => JSON.stringify([r.finding_id, r.field, r.qualifier_ordinal, r.literal_text_sha256]);
8
+ const empty = (state) => ({ state, view: null, sections: [] });
9
+ const byteLength = (text) => new TextEncoder().encode(text).length;
10
+ /** Python's sorted-key, compact, ensure_ascii=False JSON for bounded public data.
11
+ * Literal strings are not normalized. Reject lossy JSON values and lone surrogates.
12
+ */
13
+ export function canonicalAnswerJson(value) {
14
+ let nodes = 0;
15
+ const scalar = (text) => { need(!/[\uD800-\uDFFF]/u.test(text)); return JSON.stringify(text); };
16
+ const compare = (a, b) => { const x = Array.from(a), y = Array.from(b); for (let i = 0; i < Math.min(x.length, y.length); i++) {
17
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
18
+ if (d)
19
+ return d;
20
+ } return x.length - y.length; };
21
+ const encode = (v, depth) => {
22
+ need(++nodes <= 8192 && depth <= 24);
23
+ if (v === null)
24
+ return 'null';
25
+ if (typeof v === 'string')
26
+ return scalar(v);
27
+ if (typeof v === 'boolean')
28
+ return v ? 'true' : 'false';
29
+ if (typeof v === 'number') {
30
+ need(Number.isSafeInteger(v) && !Object.is(v, -0));
31
+ return String(v);
32
+ }
33
+ if (Array.isArray(v))
34
+ return '[' + Array.from(v, item => encode(item, depth + 1)).join(',') + ']';
35
+ need(obj(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
36
+ return '{' + Object.keys(v).sort(compare).map(k => scalar(k) + ':' + encode(v[k], depth + 1)).join(',') + '}';
37
+ };
38
+ const result = encode(value, 0);
39
+ need(byteLength(result) <= 65536);
40
+ return result;
41
+ }
42
+ function omission(c, reason) {
43
+ if (reason === 'source_scoped_unknown')
44
+ return 'Not established in the saved sources for this part.';
45
+ if (reason === 'answer_not_delivered')
46
+ return 'Answer material was found in the saved source text, but a complete answer was not retained in this result.';
47
+ return c.status === 'unassessed' ? 'Review the exact evidence for this part. Requested-answer coverage was not assessed.' : 'Review the exact evidence for this part. Concise prose was not retained.';
48
+ }
49
+ /** The wrappers supply the existing validated contract. Optional failure never
50
+ * changes a run, its findings/gaps, or the original contract's coverage notices.
51
+ */
52
+ export function readAnswerView(run, contractView, hash) {
53
+ if (!Object.hasOwn(run.usage, 'answer_view'))
54
+ return empty('absent');
55
+ try {
56
+ need(contractView.state === 'assessed_nonexhaustive' && contractView.contract !== null);
57
+ const contract = contractView.contract, value = run.usage.answer_view;
58
+ need(obj(value));
59
+ const v = value;
60
+ need(keys(v, ['version', 'goal_sha256', 'answer_contract_sha256', 'findings_sha256', 'sections', 'omitted']) && v.version === 1);
61
+ need(byteLength(canonicalAnswerJson(v)) <= 8192 && byteLength(canonicalAnswerJson(run.usage)) <= 16384);
62
+ need(run.findings.length <= 5 && run.findings.every(f => f.qualifiers.length <= 5 && [f.claim, ...f.qualifiers].every(t => typeof t === 'string' && Array.from(t).length <= 1500)));
63
+ need(digest(v.goal_sha256) && v.goal_sha256 === contract.goal_sha256 && digest(v.answer_contract_sha256) && digest(v.findings_sha256));
64
+ need(hash(canonicalAnswerJson(run.usage.answer_contract)) === v.answer_contract_sha256 && hash(canonicalAnswerJson(run.findings)) === v.findings_sha256);
65
+ need(Array.isArray(v.sections) && v.sections.length <= 8 && Array.isArray(v.omitted) && v.omitted.length <= 8);
66
+ const ordered = [...contract.components].sort((a, b) => a.start - b.start || a.end - b.end), byId = new Map(ordered.map(c => [c.component_id, c]));
67
+ const sections = new Map(), omitted = new Map();
68
+ let total = 0;
69
+ for (const s of v.sections) {
70
+ need(obj(s) && keys(s, ['component_id', 'assertions']) && !sections.has(s.component_id));
71
+ const c = byId.get(s.component_id);
72
+ need(c && ['literal_answer', 'literal_negative_answer'].includes(c.status));
73
+ need(Array.isArray(s.assertions) && s.assertions.length >= 1 && s.assertions.length <= 2);
74
+ const expected = new Set(c.fact_refs.map(refKey)), actual = new Set();
75
+ for (const a of s.assertions) {
76
+ need(obj(a) && keys(a, ['text', 'fact_refs']) && typeof a.text === 'string' && a.text === a.text.trim() && a.text.length > 0 && !/\p{C}/u.test(a.text));
77
+ total += Array.from(a.text).length;
78
+ need(total <= 1800);
79
+ need(Array.isArray(a.fact_refs) && a.fact_refs.length >= 1 && a.fact_refs.length <= 10);
80
+ const own = new Set();
81
+ for (const r of a.fact_refs) {
82
+ need(obj(r) && keys(r, ['finding_id', 'field', 'qualifier_ordinal', 'literal_text_sha256']));
83
+ const finding = run.findings.find(f => f.id === r.finding_id);
84
+ need(finding && ['claim', 'qualifier'].includes(r.field) && Number.isSafeInteger(r.qualifier_ordinal) && digest(r.literal_text_sha256));
85
+ need(r.field === 'claim' ? r.qualifier_ordinal === 0 : r.qualifier_ordinal >= 1 && r.qualifier_ordinal <= finding.qualifiers.length);
86
+ const literal = r.field === 'claim' ? finding.claim : finding.qualifiers[r.qualifier_ordinal - 1];
87
+ need(typeof literal === 'string' && hash(literal) === r.literal_text_sha256);
88
+ const key = refKey(r);
89
+ need(!own.has(key));
90
+ own.add(key);
91
+ actual.add(key);
92
+ }
93
+ }
94
+ // Extra reviewed, retained fields may carry necessary context. They never
95
+ // substitute for a required component field or a missing qualification.
96
+ need([...expected].every(key => actual.has(key)));
97
+ sections.set(s.component_id, s.assertions);
98
+ }
99
+ for (const o of v.omitted) {
100
+ need(obj(o) && keys(o, ['component_id', 'reason']) && !sections.has(o.component_id) && !omitted.has(o.component_id));
101
+ const c = byId.get(o.component_id);
102
+ need(c);
103
+ const allowed = c.status === 'source_scoped_unknown_assessment' ? ['source_scoped_unknown'] : c.status === 'available_but_not_delivered' ? ['answer_not_delivered'] : c.status === 'unassessed' ? ['unassessed'] : ['prose_not_supplied', 'prose_review_failed', 'fact_binding_failed'];
104
+ need(allowed.includes(o.reason));
105
+ omitted.set(o.component_id, o.reason);
106
+ }
107
+ need(sections.size + omitted.size === ordered.length);
108
+ need(JSON.stringify([...sections.keys()]) === JSON.stringify(ordered.filter(c => sections.has(c.component_id)).map(c => c.component_id)));
109
+ need(JSON.stringify([...omitted.keys()]) === JSON.stringify(ordered.filter(c => omitted.has(c.component_id)).map(c => c.component_id)));
110
+ return { state: 'valid', view: structuredClone(v), sections: structuredClone(ordered.map(component => {
111
+ const reason = omitted.get(component.component_id) ?? null;
112
+ return { component, assertions: sections.get(component.component_id) ?? [], omission_reason: reason, notice: reason === null ? null : omission(component, reason) };
113
+ })) };
114
+ }
115
+ catch {
116
+ return empty('invalid');
117
+ }
118
+ }