diff --git a/README.md b/README.md index d94c59d..2d7418d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,11 @@ separately shows approved or merging attempts. `GET /api/batches` supports `stat `attemptState` (comma-separated attempt states), `author`, `before` and `limit`. Filters apply before pagination. +Skip-claim reviews show the claim category and reason beside the original +instructions/pseudocode. They intentionally contain no submitted C++ or byte +comparison. Attempt details expose the original symbol identity independently of +compiled results so these reviews can load evidence without inventing a target. + The merge worker settles abandoned open code batches when they contain failed attempts and no attempt remains live. It records the reason and an audit event, preserving attempt feedback, task states, review votes and scores. This does not diff --git a/src/server/app.js b/src/server/app.js index 580c7cc..bf0bc0a 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -803,10 +803,16 @@ export function createApp(ctx) { const result = a.result_json ? JSON.parse(a.result_json) : null; const batch = a.batch_id ? store.get('SELECT result_json FROM batches WHERE id = ?', a.batch_id) : null; const batchResult = batch?.result_json ? JSON.parse(batch.result_json) : {}; + const symbol = store.get( + `SELECT s.id, s.name, ad.addr, ad.size FROM tasks t JOIN symbols s ON s.id = t.symbol_id + LEFT JOIN symbol_addresses ad ON ad.symbol_id = s.id AND ad.version_id = ? WHERE t.id = ?`, + store.currentVersion().id, a.task_id, + ); const isReviewer = hasRole(user, 'reviewer'); return { id: a.id, taskId: a.task_id, + symbol: symbol ? { ...symbol, addr: symbol.addr === null ? null : '0x' + hex(symbol.addr) } : null, author: a.author, state: a.state, score: a.score, @@ -822,7 +828,7 @@ export function createApp(ctx) { mergedFiles: isReviewer ? batchResult.mergedFiles ?? result?.mergedFiles ?? null : undefined, reviews: store.all('SELECT r.action, r.comment, r.created_at, u.name AS reviewer FROM reviews r JOIN users u ON u.id = r.reviewer_id WHERE r.attempt_id = ?', a.id), kind: a.kind, - claim: a.kind === 'skip' ? { category: a.skip_category, reason: a.notes } : undefined, + claim: a.kind === 'skip' ? { category: a.skip_category, reason: a.notes, noSourceExpected: true } : undefined, peerReviews: peers.verdicts(a.id), peerRequired: peers.needed(peers.counts(a.id).different), // "same" verdicts (by weight) that accept it as it stands targetSource: targetSource(result?.feedback?.target?.id, batchResult.mergedFiles ?? store.attemptFiles(a)), diff --git a/src/server/web/app.js b/src/server/web/app.js index 44cb83d..65bd80a 100644 --- a/src/server/web/app.js +++ b/src/server/web/app.js @@ -1241,8 +1241,11 @@ async function openAttempt(id) { if (!location.hash.startsWith(`#/review/${id}`)) history.replaceState(null, '', `#/review/${id}`); const f = a.feedback || {}; const t = f.target; - $('attempt-title').textContent = `${t ? t.demangled || t.id : `Attempt #${a.id}`}`; - $('attempt-meta').textContent = `#${a.id} by ${a.author} · ${t ? `${t.id} · ` : ''}${a.state.replace('_', ' ')} · ${((a.score || 0) * 100).toFixed(1)}% of bytes · size ${t?.size?.ours ?? '?'} / ${t?.size?.original ?? '?'} · base ${a.baseCommit.slice(0, 10)}`; + const identity = a.symbol ?? t; + const claimed = a.kind === 'skip'; + $('attempt-title').textContent = `${t?.demangled || identity?.name || identity?.id || `Attempt #${a.id}`}`; + const measurement = claimed ? 'no handwritten source submitted; byte matching does not apply' : `${a.score === null ? 'unmeasured' : `${(a.score * 100).toFixed(1)}% of bytes`} · size ${t?.size?.ours ?? '?'} / ${t?.size?.original ?? '?'}`; + $('attempt-meta').textContent = `#${a.id} by ${a.author} · ${identity?.id ? `${identity.id} · ` : ''}${a.state.replace('_', ' ')} · ${measurement} · base ${a.baseCommit.slice(0, 10)}`; const n = (v) => (a.peerReviews ?? []).filter((p) => p.verdict === v).length; const vm = $('attempt-verdicts'); vm.replaceChildren(el('span', 'same'), pips(n('same'), 0, a.peerRequired ?? 5), el('span', 'different'), pips(0, n('different'), 0)); @@ -1258,12 +1261,15 @@ async function openAttempt(id) { r.append(d); } - compare = { source: a.targetSource, ourAsm: t?.asm ?? null, original: null, error: null }; - showCompare(compareView); - if (t?.id) { - api(`/api/original/${t.id}`) - .then((o) => (compare.original = o)) - .catch((e) => (compare.error = e.message)) + compare = { source: a.targetSource, ourAsm: t?.asm ?? null, original: null, error: null, claim: a.claim ?? null }; + const selectedCompare = compare; + document.querySelector('#compare-tabs [data-view="code"]').textContent = claimed ? 'Claim and original pseudocode' : 'C++ vs pseudocode'; + document.querySelector('#compare-tabs [data-view="asm"]').textContent = claimed ? 'Original assembly' : 'Assembly vs assembly'; + showCompare(claimed ? 'asm' : compareView); + if (identity?.id) { + api(`/api/original/${identity.id}`) + .then((o) => { if (currentAttempt === a) selectedCompare.original = o; }) + .catch((e) => { if (currentAttempt === a) selectedCompare.error = e.message; }) .finally(() => currentAttempt === a && showCompare(compareView)); } @@ -1281,11 +1287,15 @@ async function openAttempt(id) { fold(`Other functions in the file (${f.otherFunctions.length}, ${exact} byte-identical)`, table(['Function', 'Status', 'Score'], f.otherFunctions.map((x) => [x.id, x.status, `${(x.score * 100).toFixed(1)}%`]))); } const files = Object.entries(a.mergedFiles || a.files || {}); - fold(`Submitted files (${files.length})`, ...files.map(([p, content]) => { - const d = el('details'); - d.append(el('summary', p), el('pre', content, 'code')); - return d; - })); + if (claimed) { + fold('No source files expected', el('p', 'This is a claim about how the original function was produced, not a C++ implementation. Review the stated evidence against the original instructions.')); + } else { + fold(`Submitted files (${files.length})`, ...files.map(([p, content]) => { + const d = el('details'); + d.append(el('summary', p), el('pre', content, 'code')); + return d; + })); + } fold(a.notes ? 'Notes from the author (untrusted)' : 'Notes from the author (none)', el('pre', a.notes || '(none)', 'notes')); // Deciding a function still in peer review is for maintainers. @@ -1309,6 +1319,14 @@ function showCompare(view) { if (!compare) return; const o = compare.original; const pending = !o && !compare.error ? 'Loading the original…' : `Original not available${compare.error ? `: ${compare.error}` : ''}`; + if (compare.claim) { + const claim = compare.claim; + const explanation = `No C++ implementation is expected for this claim (${claim.category}).\n\nReason (untrusted):\n${claim.reason}\n\nApprove only if the original instructions support the claim; otherwise request an implementation.`; + const original = view === 'asm' ? o?.asm : o?.pseudocode; + const title = view === 'asm' ? 'Original assembly' : 'Original pseudocode (reading aid)'; + body.append(twoPanes(['No handwritten source required — claim', explanation], [o ? `${title} · ${o.addr}` : title, original ?? (o ? 'No original text is available in this view.' : pending)])); + return; + } if (view === 'code') { body.append( twoPanes( diff --git a/src/server/web/index.html b/src/server/web/index.html index 5731720..ec14995 100644 --- a/src/server/web/index.html +++ b/src/server/web/index.html @@ -413,6 +413,6 @@

Clank Together · source is open: git clone https://clank.rodecomp.online/git/clank/game-decomp.git

- + diff --git a/test/server.test.js b/test/server.test.js index 93abcc1..60180b2 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1976,3 +1976,23 @@ test('the merger settles abandoned open batches without changing attempts, tasks } } }); + +test('skip attempt details expose original identity without inventing a compiled target or source', async () => { + const token = await activeUser('skipdetail'); + const user = ctx.accounts.byName('skipdetail'); + const task = ctx.store.get(`SELECT t.id, t.symbol_id, s.name, a.addr, a.size FROM tasks t JOIN symbols s ON s.id=t.symbol_id JOIN symbol_addresses a ON a.symbol_id=s.id WHERE a.version_id=? LIMIT 1`, ctx.store.currentVersion().id); + const reason = 'Calls the ordinary destructor, tests flags bit 1, conditionally deletes storage and returns this.'; + const id = Number(ctx.store.run(`INSERT INTO attempts (task_id,user_id,base_commit,files_json,state,kind,skip_category,notes,result_json,created_at) VALUES (?,?,?,'{}','peer_review','skip','compiler_generated',?,'{}',?)`, task.id,user.id,ctx.repo.head(),reason,now()).lastInsertRowid); + try { + const response = await call('GET', `/api/attempts/${id}`, null, token); + assert.equal(response.status, 200); + assert.deepEqual(response.body.symbol, { id: task.symbol_id, name: task.name, addr: '0x' + task.addr.toString(16).padStart(8, '0'), size: task.size }); + assert.deepEqual(response.body.claim, { category: 'compiler_generated', reason, noSourceExpected: true }); + assert.deepEqual(response.body.files, {}); + assert.equal(response.body.score, null); + assert.equal(response.body.targetSource, null); + assert.equal(response.body.feedback, null); + } finally { + ctx.store.run('DELETE FROM attempts WHERE id = ?', id); + } +});