diff --git a/docs/workbench-history.md b/docs/workbench-history.md index 803353d..15b9375 100644 --- a/docs/workbench-history.md +++ b/docs/workbench-history.md @@ -153,3 +153,41 @@ Filtering uses the existing bounded metadata index and precedes twelve-row pagination. Corrupt indexes remain counted separately as unavailable; they are never treated as passed results. No report is opened, verification rerun, file pruned or apply capability created by a search. + + +## Compare two saved verification records + +History now has independent earlier/later record selectors. Pick two verification +records from their rows or enter their IDs, then compare. This reads their existing +metadata and digest-bound bodies; it never calls the verifier, changes the current +selection/baseline, consumes an approved plan or grants repair permission. Leaving +the history view, changing an ID or cancelling discards obsolete replies while +retaining the entered IDs and unrelated drafts. Comparisons use one bounded read +slot; a second concurrent comparison receives a recoverable busy response. + +Both records must describe the same saved directory identity and verification +mode. Skill reports require matching declared targets and complete, unsaturated +comparison fields, consistent summaries and finding coverage, unique issue IDs and +matching recorded exit codes. The same owner computes fresh-baseline and historical +issue differences, including pairing changed evidence at the same code/location. +The result distinguishes new, continuing, no-longer-observed and changed evidence; +no-longer-observed findings are not certified fixes. Old reports do not record +all tool/policy conditions, so identical selection is not a controlled regression. + +Artifact records retain the existing contract, declaration and receipt-bound +comparison rules. Their finding counts stay separate from per-check changes. +Different selections/contracts, incomplete or invalid evidence remain unavailable; +they do not produce invented zero counts. Corrupt or changed stored bodies fail +before comparison. Current source directories and contract files need not exist: +this is an offline comparison of saved evidence, not a fresh physical-file check. + +POST `/api/history/compare` accepts `beforeId` and `afterId` under the existing +loopback origin and session guards. History still caps state at 120 files/64 MiB, +metadata at 16 KiB and each body at 8 MiB. Early row/field caps precede deep schema +parsing; saturated 256-row/unknown evidence is unavailable. At most 32 code/count +or reconciliation-index details reach the browser, with full accepted counts and +explicit truncation. Raw finding messages, source paths, cells, recipes and backup +paths are not returned. No history or receipt is rewritten. + +The workflow borrows [Qodana's baseline distinctions](https://www.jetbrains.com/help/qodana/baseline.html) +while preserving this workbench's local, non-authenticated evidence boundary. diff --git a/src/workbench/history.ts b/src/workbench/history.ts index 19491f4..01c0811 100644 --- a/src/workbench/history.ts +++ b/src/workbench/history.ts @@ -4,6 +4,8 @@ import { isAbsolute, join } from "node:path"; import { z } from "zod"; import { parseStrictJson } from "../domain/strict-json.js"; import { readDirectoryEntries, readWorkspaceText } from "../sandbox/workspace-tree.js"; +import {parseComparisonReport,compareVerificationReports} from "./verification-comparison.js"; +import {compareArtifactReports,parseArtifactReport} from "../domain/artifact-report-comparison.js"; export const MAX_OUTPUT = 8 * 1024 * 1024; const MAX_META = 16 * 1024; @@ -94,9 +96,40 @@ export class WorkbenchHistory { async read(id:unknown) { await this.entries(); const record=await this.metadata(id); + return {record,output:await this.output(record)}; + } + private async output(record:HistoryRecord) { const output=record.dataFile?await readWorkspaceText(join(this.root,record.dataFile),MAX_OUTPUT):undefined; if(output!==undefined && sha(output)!==record.dataSha256)throw new Error("历史材料摘要不匹配。"); - return {record,output}; + return output; + } + async compare(beforeId:unknown,afterId:unknown) { + const first=uuid.parse(beforeId),second=uuid.parse(afterId); + if(first===second)throw new WorkbenchStateError("请选择两份不同的历史记录。"); + await this.entries(); + const before=await this.metadata(first),after=await this.metadata(second); + const identity=(record:HistoryRecord)=>({id:record.id,createdAt:record.createdAt,reportSha256:record.dataSha256,exitCode:record.exitCode}); + const base={schema:"skillsync.history-comparison/v1",before:identity(before),after:identity(after),kind:before.selection.kind, + readOnly:true,execution:"not-run",provenance:"not-authenticated"}; + const unavailable=(reason:string)=>({...base,status:"unavailable" as const,reason}); + if(before.kind!=="verify"||after.kind!=="verify")return unavailable("not-verification"); + if(JSON.stringify(before.selection)!==JSON.stringify(after.selection))return unavailable("selection-changed"); + // Scope predicates precede any body read. Do not reopen current source directories. + const left=await this.output(before),right=await this.output(after); + try { + if(before.selection.kind==="skill"){ + const a=parseComparisonReport(parseStrictJson(left!)),b=parseComparisonReport(parseStrictJson(right!)); + if(a.exitCode!==before.exitCode||b.exitCode!==after.exitCode)return unavailable("incomplete-evidence"); + if(JSON.stringify(a.targets.map(row=>[row.name,row.scope,row.profileId]))!==JSON.stringify(b.targets.map(row=>[row.name,row.scope,row.profileId])))return unavailable("declared-targets-changed"); + return {...base,...compareVerificationReports(a,b)}; + } + const a=parseArtifactReport(parseStrictJson(left!)),b=parseArtifactReport(parseStrictJson(right!)); + if(a.exitCode!==before.exitCode||b.exitCode!==after.exitCode)return unavailable("incomplete-evidence"); + const compared=compareArtifactReports(a,b,before.dataSha256!); + if(compared.status!=="compared")return unavailable(compared.reason??"incomplete-evidence"); + const details=(compared.checks??[]).filter(row=>row.change!=="unchanged").map(row=>({code:"reconciliation."+row.index,change:row.change,count:1})); + return {...base,status:"compared" as const,counts:compared.findings!,details:details.slice(0,32),detailsTotal:details.length,detailsTruncated:details.length>32}; + }catch{return unavailable("invalid-evidence");} } } diff --git a/src/workbench/page.ts b/src/workbench/page.ts index 8041f8b..dec0d25 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -4,13 +4,13 @@ export function workbenchPage(nonce: string): string {

SkillSync

本机核验台

打开 Skill 目录或交付包,查看核验结果、历史记录与修复计划。

- +

核验一个目录

选择目录与范围 → 核验 → 查看结果

选择要完成的任务

尚未核验

`; } -export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;background:#f3f6f6}*{box-sizing:border-box}main{max-width:1040px;margin:auto;padding:32px 20px}header{margin-bottom:28px}h1{margin:8px 0;font-size:28px;line-height:1.2}h3{font-size:17px}.product-name{font-weight:750;letter-spacing:-.02em;margin:0}.task-nav{display:flex;flex-wrap:wrap;gap:8px;padding-bottom:16px;border-bottom:1px solid #c8d8dc}.task-nav button[aria-pressed=true]{color:white;background:#235661;border-color:#235661}.primary{color:white;background:#235661;border-color:#235661}.task-steps{color:#496771;font-size:14px}.skip-link{position:absolute;top:-100px;padding:12px;background:white;color:#19343c}.skip-link:focus{top:8px;z-index:10}h2[tabindex],main{scroll-margin-top:16px}h2[tabindex]:focus{outline:3px solid #087a91;outline-offset:5px}hr{border:0;border-top:1px solid #c8d8dc;margin:28px 0}h2{font-size:20px}section{padding:20px;background:white;border:1px solid #c8d8dc;border-radius:8px;margin:20px 0}label{display:block;margin:12px 0 6px}input:not([type=checkbox]),select{font:inherit;min-height:44px;padding:8px;width:100%;border:1px solid #829fa7;border-radius:4px}input[type=checkbox]{width:24px;height:24px;vertical-align:middle;margin-right:8px}label:has(input[type=checkbox]){min-height:44px;display:flex;align-items:center}button{font:inherit;min-height:44px;padding:8px 18px;border:1px solid #6f939d;border-radius:4px;color:#19343c;background:#edf5f6;cursor:pointer}button:disabled{opacity:.55;cursor:default}.row{display:flex;gap:12px;align-items:center}.row input{flex:1;min-width:0}.row button{white-space:nowrap}.actions{flex-wrap:wrap;margin-top:18px}:focus-visible{outline:3px solid #087a91;outline-offset:3px}iframe{width:100%;height:580px;border:1px solid #c8d8dc;margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.7 ui-monospace,monospace}#history-list p,#batch-receipt,#batch-info{overflow-wrap:anywhere}footer{font-size:13px;color:#496771}footer p{margin:8px 0}summary{cursor:pointer;min-height:44px;padding:8px 0}.operation-status{display:flex;align-items:center;justify-content:space-between;gap:12px}.operation-status button{flex-shrink:0}.operation-status p{overflow-wrap:anywhere}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}.selection-options{border:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:12px}.selection-options label{display:flex;align-items:flex-start;gap:10px;border:1px solid #c8d8dc;border-radius:6px;padding:14px;margin:0}.selection-options label:has(input:checked){border-color:#235661;background:#edf5f6}.selection-options input[type=radio]{width:22px;min-height:22px;flex:0 0 22px}.selection-options small{display:block;color:#496771}.field-error{color:#9c2f35}.mode-help{background:#edf5f6;padding:12px}.result-summary{border-left:3px solid #318496;padding:4px 16px;margin:16px 0}.result-summary ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}.result-summary .actions{margin-bottom:16px}@media(max-width:580px){.selection-options{grid-template-columns:1fr}}@media(max-width:580px){.operation-status{align-items:flex-start;flex-direction:column}.row{flex-wrap:wrap}.row input{flex-basis:100%}main{padding:16px}section{padding:16px}}`; +export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;background:#f3f6f6}*{box-sizing:border-box}main{max-width:1040px;margin:auto;padding:32px 20px}header{margin-bottom:28px}h1{margin:8px 0;font-size:28px;line-height:1.2}h3{font-size:17px}.product-name{font-weight:750;letter-spacing:-.02em;margin:0}.task-nav{display:flex;flex-wrap:wrap;gap:8px;padding-bottom:16px;border-bottom:1px solid #c8d8dc}.task-nav button[aria-pressed=true]{color:white;background:#235661;border-color:#235661}.primary{color:white;background:#235661;border-color:#235661}.task-steps{color:#496771;font-size:14px}.skip-link{position:absolute;top:-100px;padding:12px;background:white;color:#19343c}.skip-link:focus{top:8px;z-index:10}h2[tabindex],main{scroll-margin-top:16px}h2[tabindex]:focus{outline:3px solid #087a91;outline-offset:5px}hr{border:0;border-top:1px solid #c8d8dc;margin:28px 0}h2{font-size:20px}section{padding:20px;background:white;border:1px solid #c8d8dc;border-radius:8px;margin:20px 0}label{display:block;margin:12px 0 6px}input:not([type=checkbox]),select{font:inherit;min-height:44px;padding:8px;width:100%;border:1px solid #829fa7;border-radius:4px}input[type=checkbox]{width:24px;height:24px;vertical-align:middle;margin-right:8px}label:has(input[type=checkbox]){min-height:44px;display:flex;align-items:center}button{font:inherit;min-height:44px;padding:8px 18px;border:1px solid #6f939d;border-radius:4px;color:#19343c;background:#edf5f6;cursor:pointer}button:disabled{opacity:.55;cursor:default}.row{display:flex;gap:12px;align-items:center}.row input{flex:1;min-width:0}.row button{white-space:nowrap}.actions{flex-wrap:wrap;margin-top:18px}:focus-visible{outline:3px solid #087a91;outline-offset:3px}iframe{width:100%;height:580px;border:1px solid #c8d8dc;margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.7 ui-monospace,monospace}#history-list p,#batch-receipt,#batch-info{overflow-wrap:anywhere}footer{font-size:13px;color:#496771}footer p{margin:8px 0}summary{cursor:pointer;min-height:44px;padding:8px 0}.operation-status{display:flex;align-items:center;justify-content:space-between;gap:12px}.operation-status button{flex-shrink:0}.operation-status p{overflow-wrap:anywhere}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}.selection-options{border:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:12px}.selection-options label{display:flex;align-items:flex-start;gap:10px;border:1px solid #c8d8dc;border-radius:6px;padding:14px;margin:0}.selection-options label:has(input:checked){border-color:#235661;background:#edf5f6}.selection-options input[type=radio]{width:22px;min-height:22px;flex:0 0 22px}.selection-options small{display:block;color:#496771}.field-error{color:#9c2f35}.mode-help{background:#edf5f6;padding:12px}.result-summary{border-left:3px solid #318496;padding:4px 16px;margin:16px 0}.result-summary ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}.result-summary .actions{margin-bottom:16px}@media(max-width:580px){.selection-options{grid-template-columns:1fr}}@media(max-width:580px){.operation-status{align-items:flex-start;flex-direction:column}.row{flex-wrap:wrap}.row input{flex-basis:100%}main{padding:16px}section{padding:16px}}.history-comparison{margin-top:24px;padding-top:20px;border-top:1px solid #c8d8dc}.history-comparison-counts{display:flex;gap:20px;flex-wrap:wrap}.history-comparison-counts div{min-width:100px}.history-comparison-counts dt{font-size:14px}.history-comparison-counts dd{font-size:24px;margin:4px 0}.history-comparison code{overflow-wrap:anywhere}#history-list button{margin:4px}.history-comparison ul{padding-left:20px}`; export const workbenchScript = `"use strict"; const $ = id => document.getElementById(id); @@ -18,19 +18,20 @@ const nonce = document.querySelector('meta[name="client"]').content; let busy = false, planId = null, selected = null, baselineId = null, historyId = null, historyOffset = 0, historyTotal = 0, historyLoading = false, historyRefreshQueued = false, renderedHistory = null, batchReady=false, summaryCanPlan=false, recoveryPath=null; // Switching tasks hides existing controls; it never replaces form nodes or submits them. let historyFilters={query:''}; +let historyCompareController=null,historyCompareSerial=0,historyCompareLoading=false; let activeTask='single', resultOwner='single', resultAvailable=false, fixAvailable=false; function syncWorkspace(){ for(const task of ['single','batch','history']){ $('task-'+task).hidden=task!==activeTask; $('nav-'+task).setAttribute('aria-pressed',String(task===activeTask)); } $('result').hidden=!resultAvailable||resultOwner!==activeTask; $('fix').hidden=!fixAvailable||activeTask!=='single'; } -function setTask(task){activeTask=task;syncWorkspace();if(!$('result').hidden&&reportHtml)mountReport();$(task==='single'?'selection':task==='batch'?'batch-title':'history-title').focus();} +function setTask(task){if(activeTask==='history'&&task!=='history')clearHistoryComparison();activeTask=task;syncWorkspace();if(!$('result').hidden&&reportHtml)mountReport();$(task==='single'?'selection':task==='batch'?'batch-title':'history-title').focus();} function revealResult(owner){resultOwner=owner;resultAvailable=true;syncWorkspace();if(activeTask===owner)$('result-title').focus();} function hideResult(){resultAvailable=false;syncWorkspace();} function setFix(visible){fixAvailable=visible;syncWorkspace();} for(const task of ['single','batch','history'])$('nav-'+task).onclick=()=>setTask(task); -async function api(action, body = {}) { - let response; try { response = await fetch('/api/' + action, {method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)}); } +async function api(action, body = {}, signal) { + let response; try { response = await fetch('/api/' + action, {method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body),signal}); } catch { throw new Error('无法连接本机服务,请确认核验台已启动后重试。'); } let data; try { data = await response.json(); } catch { throw new Error('服务响应不完整,请重新打开核验台后重试。'); } if(response.status===403) throw new Error('核验台会话已失效,请重新打开页面后重试。'); @@ -45,7 +46,7 @@ function mountReport(){ function showReport(html){reportHtml=html.replaceAll('href="#','href="about:srcdoc#');if(!$('result').hidden)mountReport();} function clearReport(){summaryCanPlan=false;if(!reportHtml)return;reportHtml='';$('report').srcdoc='';} function status(text){ $('status').textContent = text; } -function lock(value, applying = false){ busy=value; ['verify','plan','result-recheck','result-details','mode-skill','mode-artifacts','reverify','choose','choose-contract','root','contract','kind','delivery','history-refresh','batch-plan','batch-preview','batch-recover','batch-output','batch-previous','batch-retry','batch-package','batch-digest','batch-open','choose-batch-plan','choose-batch-package'].forEach(id => $(id).disabled=value); $('batch-run').disabled=value||!batchReady; document.querySelectorAll('.history-open').forEach(button=>button.disabled=value||historyLoading); ['history-query','history-kind','history-exit','history-filter','history-clear','history-refresh'].forEach(id=>$(id).disabled=value||historyLoading); $('history-prev').disabled=value||historyLoading||historyOffset===0; $('history-next').disabled=value||historyLoading||historyOffset+12>=historyTotal; $('export-summary').disabled=value||!historyId; $('cancel').disabled=!value || applying; $('confirm').disabled=value||!planId; $('apply').disabled=value || !$('confirm').checked || !planId; $('plan').disabled=value||!selected||!summaryCanPlan; } +function lock(value, applying = false){ busy=value; ['verify','plan','result-recheck','result-details','mode-skill','mode-artifacts','reverify','choose','choose-contract','root','contract','kind','delivery','history-refresh','batch-plan','batch-preview','batch-recover','batch-output','batch-previous','batch-retry','batch-package','batch-digest','batch-open','choose-batch-plan','choose-batch-package'].forEach(id => $(id).disabled=value); $('batch-run').disabled=value||!batchReady; document.querySelectorAll('.history-open').forEach(button=>button.disabled=value||historyLoading); ['history-query','history-kind','history-exit','history-filter','history-clear','history-refresh'].forEach(id=>$(id).disabled=value||historyLoading); $('history-prev').disabled=value||historyLoading||historyOffset===0; $('history-next').disabled=value||historyLoading||historyOffset+12>=historyTotal; $('export-summary').disabled=value||!historyId; $('cancel').disabled=!value || applying; $('confirm').disabled=value||!planId; $('apply').disabled=value || !$('confirm').checked || !planId; $('plan').disabled=value||!selected||!summaryCanPlan; syncHistoryCompareControls(); document.querySelectorAll('.history-compare-pick').forEach(button=>button.disabled=value||historyLoading); } function clearPlan(){$('fix-title').textContent='审阅修复计划';planId=null;$('confirm').checked=false;setFix(false);$('changes').textContent='';$('apply').disabled=true;} function invalidate(){ $('plan').disabled=true;$('plan').hidden=true;fieldError('root','');fieldError('contract','');selected=null; planId=null; baselineId=null; historyId=null; $('comparison').textContent=''; $('export-summary').disabled=true; hideResult(); setFix(false); $('confirm').checked=false; clearReport(); status('目录或模式已更改,请重新核验'); } ['root','contract'].forEach(id=>$(id).addEventListener('input',()=>{invalidate();syncMode();})); @@ -75,6 +76,42 @@ $('apply').onclick=async()=>{ catch(e){$('receipt').textContent=e.message;showTaskSummary({title:'应用结果待核对',description:'原计划已失效。请查看保存的回执或重新核验当前目录,不要重复应用旧计划。',counts:[],canPlan:false});status(e.message+';请重新核验当前目录');} finally{lock(false);} }; + +function syncHistoryCompareControls(){ + $('history-compare').disabled=busy||historyCompareLoading; + $('history-compare-before').disabled=busy;$('history-compare-after').disabled=busy; + $('history-compare-cancel').hidden=!historyCompareLoading; +} +function clearHistoryComparison(){ + historyCompareController?.abort();historyCompareController=null;historyCompareSerial++;historyCompareLoading=false; + $('history-compare-info').textContent='';$('history-compare-result').replaceChildren();$('history-compare-result').hidden=true;syncHistoryCompareControls(); +} +const historyReasons={'not-verification':'只比较已存核验报告,计划或应用回执不能替代核验结果。','selection-changed':'两份记录的目录或核验方式不同,不能按同一范围对照。','declared-targets-changed':'两份报告声明的核验目标不同。','incomplete-evidence':'证据不完整或达到容量上限,没有生成零变化结论。','invalid-evidence':'历史报告结构或记录关联无效,原材料保留。','contract-changed':'契约摘要缺失或不同,不能按相同声明对照。','check-declarations-changed':'两份交付报告声明的检查项不同。'}; +const historyChangeLabels={added:'新增',ongoing:'仍存在',resolved:'较晚记录未出现',changed:'同类证据变化',improved:'记录检查改善',regressed:'记录检查回退'}; +function renderHistoryComparison(data,before,after){ + if(!data||data.schema!=='skillsync.history-comparison/v1'||data.before?.id!==before||data.after?.id!==after||data.readOnly!==true||data.execution!=='not-run'||data.provenance!=='not-authenticated'||!['skill','artifacts'].includes(data.kind))throw new Error('历史对照响应不完整,请重新选择两份记录。'); + if(data.status==='unavailable'){$('history-compare-info').textContent='历史记录不可比较:'+(historyReasons[data.reason]||'材料无法完整比较,请检查两份原记录。');return;} + const keys=data.kind==='skill'?['added','ongoing','resolved','changed']:['added','ongoing','resolved']; + if(data.status!=='compared'||!data.counts||!keys.every(key=>Number.isSafeInteger(data.counts[key])&&data.counts[key]>=0&&data.counts[key]<=256)||!Array.isArray(data.details)||data.details.length>32||!Number.isSafeInteger(data.detailsTotal)||data.detailsTotal<0||data.detailsTotal>512||data.details.length!==Math.min(data.detailsTotal,32)||data.detailsTruncated!==(data.detailsTotal>data.details.length)||data.details.some(row=>!row||typeof row.code!=='string'||row.code.length>128||!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(row.code)||!Object.hasOwn(historyChangeLabels,row.change)||!Number.isSafeInteger(row.count)||row.count<1||row.count>256))throw new Error('历史对照响应不完整,请重新选择两份记录。'); + const result=$('history-compare-result'),summary=document.createElement('dl');summary.className='history-comparison-counts'; + for(const key of keys){const row=document.createElement('div'),name=document.createElement('dt'),value=document.createElement('dd');name.textContent=historyChangeLabels[key];value.textContent=String(data.counts[key]);row.append(name,value);summary.append(row);}result.append(summary); + if(data.details.length){const list=document.createElement('ul');for(const item of data.details){const row=document.createElement('li'),code=document.createElement('code'),text=document.createElement('span');code.textContent=item.code;text.textContent=' · '+historyChangeLabels[item.change]+' · '+item.count;row.append(code,text);list.append(row);}result.append(list);} + if(data.detailsTruncated){const note=document.createElement('p');note.textContent='显示 '+data.details.length+'/'+data.detailsTotal+' 类明细,汇总仍覆盖已接受的完整范围。';result.append(note);} + result.hidden=false;$('history-compare-info').textContent='历史证据对照已完成;没有重新核验当前目录。'; +} +$('history-compare').onclick=async()=>{ + if(busy||historyCompareLoading)return; + clearHistoryComparison();const before=$('history-compare-before').value.trim(),after=$('history-compare-after').value.trim(); + const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; + if(!uuid.test(before)||!uuid.test(after)||before===after){$('history-compare-info').textContent='请选择两份不同的有效历史记录编号。';return;} + const serial=historyCompareSerial,controller=new AbortController();historyCompareController=controller;historyCompareLoading=true;syncHistoryCompareControls();$('history-compare-info').textContent='正在只读核对两份历史材料…'; + try{const data=await api('history/compare',{beforeId:before,afterId:after},controller.signal);if(serial===historyCompareSerial&&!controller.signal.aborted)renderHistoryComparison(data,before,after);} + catch(error){if(serial===historyCompareSerial&&!controller.signal.aborted)$('history-compare-info').textContent=error.message;} + finally{if(serial===historyCompareSerial){historyCompareController=null;historyCompareLoading=false;syncHistoryCompareControls();}} +}; +$('history-compare-cancel').onclick=()=>{clearHistoryComparison();$('history-compare-info').textContent='已取消历史对照,原记录未改变。';}; +for(const name of ['before','after']){const field=$('history-compare-'+name);field.addEventListener('input',clearHistoryComparison);field.addEventListener('keydown',event=>{if(event.key==='Enter'){event.preventDefault();$('history-compare').click();}});} + function showComparison(value){ $('comparison').textContent=!value?'此次结果可作为后续复查基线。':value.status==='compared'?'与基线相比:新增 '+value.added+' · 仍然存在 '+value.ongoing+' · 证据变化 '+(value.changed||0)+' · 本次不再出现 '+value.resolved:'基线无法比较:范围已变化或证据不完整。'; } function showHistoryFilters(){ $('history-query').value=historyFilters.query; $('history-kind').value=historyFilters.kind||''; $('history-exit').value=historyFilters.exitCode===undefined?'':String(historyFilters.exitCode); } async function loadHistory(newest=false, filters=historyFilters, offset=newest?0:historyOffset){ @@ -85,7 +122,9 @@ async function loadHistory(newest=false, filters=historyFilters, offset=newest?0 for(const item of data.items){const row=document.createElement('p'), label=document.createElement('span'), button=document.createElement('button'); label.textContent=new Date(item.createdAt).toLocaleString()+' · '+({verify:'核验',plan:'修复计划','apply-intent':'应用请求',apply:'应用回执'}[item.kind])+' · '+item.label+' '; button.type='button';button.className='history-open';button.textContent=item.kind==='verify'?'查看并设为基线':'查看'; button.disabled=busy||historyLoading; button.onclick=async()=>{lock(true,true);try{const data=await api('history/open',{id:item.id}); selected=null;planId=null;baselineId=data.baselineId||null;historyId=data.id;$('root').value=data.selection.path;$('kind').value=data.selection.kind;$('contract').value=data.selection.contract||'';$('delivery').checked=data.selection.delivery;syncMode();$('confirm').checked=false;setFix(false);$('plan').hidden=true;revealResult('history');showReport(data.html);showComparison(data.comparison);showTaskSummary(data.summary);status('正在查看历史记录;点击“核验目录”检查当前文件。');}catch(e){status(e.message);}finally{lock(false);}}; - row.append(label,button);$('history-list').append(row);} + row.setAttribute('data-history-id',item.id);row.append(label,button); + if(item.kind==='verify')for(const [side,text]of [['before','设为较早'],['after','设为较晚']]){const pick=document.createElement('button');pick.type='button';pick.className='history-compare-pick';pick.textContent=text;pick.disabled=busy||historyLoading;pick.setAttribute('aria-label','将记录 '+item.id+' '+text);pick.onclick=()=>{$('history-compare-'+side).value=item.id;clearHistoryComparison();};row.append(pick);} + $('history-list').append(row);} renderedHistory=signature; } $('history-info').textContent=data.total+' 条匹配记录 · 保存文件 '+data.capacity.files+'/'+data.capacity.maxFiles+(data.unavailable?' · '+data.unavailable+' 条索引无法读取(不参与筛选)':''); $('history-prev').disabled=busy||historyOffset===0;$('history-next').disabled=busy||historyOffset+12>=historyTotal; diff --git a/src/workbench/server.ts b/src/workbench/server.ts index fe00700..95be5f4 100644 --- a/src/workbench/server.ts +++ b/src/workbench/server.ts @@ -17,7 +17,7 @@ import { journeySummary } from "./journey-summary.js"; import { workbenchPage, workbenchScript, workbenchStyle } from "./page.js"; import { exportHistorySummary, MAX_OUTPUT, WorkbenchHistory, WorkbenchStateError, type HistoryComparison, type Selection } from "./history.js"; import { parseStrictJson } from "../domain/strict-json.js"; -import { compareIssues } from "../domain/baseline.js"; +import {compareVerificationReports} from "./verification-comparison.js"; import { compareArtifactReports, parseArtifactReport } from "../domain/artifact-report-comparison.js"; import {previewAcceptancePlan,inspectAcceptancePackage,batchSummary} from "../cli/commands/batch.js"; import {renderBatchHtml} from "../reporters/batch-html.js"; @@ -38,6 +38,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; const nonce = randomBytes(32).toString("hex"); const cliPath = options.cliPath ?? fileURLToPath(new URL("../cli/index.js", import.meta.url)); let active: ChildProcess | undefined, busy = false, applying = false, cancelled = false; + let comparingHistory=false; let selection: Selection | undefined; let actionable: string[] = []; let plan: { id: string; path: string; value: ActionPlan; snapshot: string; historyId:string } | undefined; @@ -105,6 +106,13 @@ export async function startWorkbench(options: { port?: number; stateDir: string; if (req.url === "/api/history") return send(res,200,await history.list(input.offset===undefined?0:Number(input.offset),{ query:input.query??"",kind:input.kind,exitCode:input.exitCode, })); + if(req.url==="/api/history/compare"){ + if(comparingHistory)return send(res,409,{error:"已有历史对照读取中,请稍后重试。"}); + comparingHistory=true; + try{return send(res,200,await history.compare(input.beforeId,input.afterId));} + catch(error){return send(res,400,{error:error instanceof WorkbenchStateError?error.message:"历史材料无法稳定读取,请检查两份原记录;没有重新执行核验。"});} + finally{comparingHistory=false;} + } if (busy) return send(res, 409, {error: "已有操作进行中。"}); busy = true; cancelled = false; try { @@ -202,18 +210,9 @@ export async function startWorkbench(options: { port?: number; stateDir: string; if(selected.kind==="artifacts") { const compared=compareArtifactReports(baseline.report as ArtifactReport,parseArtifactReport(report),baseline.digest); comparison=compared.status==="compared"?{status:"compared",added:compared.findings!.added,ongoing:compared.findings!.ongoing,resolved:compared.findings!.resolved}:{status:"unavailable",reason:compared.reason}; - } else if([baseline.report.exitCode,report.exitCode].some(c=>c===2||c===4)||[(baseline.report as VerificationReport).issues,report.issues].some(v=>v.length>=256))comparison={status:"unavailable",reason:"incomplete-evidence"}; - else { - const pending=(value:VerificationReport)=>value.issues.filter(issue=>issue.finding.status==="fail"||issue.finding.status==="warn"); - const before=pending(baseline.report as VerificationReport),after=pending(report),c=compareIssues(before,after); - // A warning may bind the entire Skill digest. Pair changed evidence - // within the same code/location, rather than claiming it was fixed. - const group=(issue:VerificationReport["issues"][number])=>JSON.stringify([issue.finding.code,issue.finding.skill,issue.finding.target,issue.location]); - const removed=new Map(); - for(const issue of before.filter(i=>c.resolvedIds.includes(i.id)))removed.set(group(issue),(removed.get(group(issue))??0)+1); - let changed=0; - for(const issue of after.filter(i=>c.newIds.includes(i.id))) {const key=group(issue),count=removed.get(key)??0;if(count){changed++;removed.set(key,count-1);}} - comparison={status:"compared",added:c.newIds.length-changed,ongoing:c.ongoingIds.length,resolved:c.resolvedIds.length-changed,changed}; + } else { + const compared=compareVerificationReports(baseline.report as VerificationReport,report); + comparison=compared.status==='compared'?{status:'compared',...compared.counts}:{status:'unavailable',reason:compared.reason}; } } const saved=await history.append({kind:"verify",selection:selected,snapshot,baselineId:baseline?.id,parentId:latestId,exitCode:report.exitCode,comparison},output); diff --git a/src/workbench/verification-comparison.ts b/src/workbench/verification-comparison.ts new file mode 100644 index 0000000..613929c --- /dev/null +++ b/src/workbench/verification-comparison.ts @@ -0,0 +1,67 @@ +import {z} from "zod"; +import type {VerificationReport} from "../cli/commands/verify.js"; +import {compareIssues} from "../domain/baseline.js"; +import {issueIdForFinding} from "../domain/issue.js"; +import {FindingSchema} from "../domain/result.js"; + +export type IssueCounts={added:number;ongoing:number;resolved:number;changed:number}; +export type ComparisonDetail={code:string;change:string;count:number}; +type Report=Pick; +const text=z.string().min(1).max(4096); +const finding=FindingSchema.refine(row=>row.code.length<=128&&/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(row.code) + &&row.skill.length<=4096&&(row.target?.length??0)<=4096&&row.message.length<=4096&&(row.remediation?.length??0)<=4096 + &&row.evidence.length<=256&&row.evidence.every(item=>Object.keys(item).length<=32&&Object.values(item).every(value=>value.length<=4096))); +const count=z.number().int().min(0).max(256); +const schema=z.object({schema_version:z.literal(1),generated_at:z.string().datetime(), + targets:z.array(z.object({name:text,path:text,scope:z.enum(["explicit","project","user"]),profileId:text.optional()})).min(1).max(64), + findings:z.array(finding).max(256),issues:z.array(z.object({id:z.string().regex(/^iss_[a-f0-9]{64}$/), + state:z.enum(["open","acknowledged","resolved","ignored","regressed"]),finding, + location:z.object({path:text,line:z.number().int().nonnegative().safe().optional()}).passthrough().optional(), + })).max(256),summary:z.object({total:count,pass:count,warn:count,fail:count,unknown:count}).strict(), + exitCode:z.union([z.literal(0),z.literal(1),z.literal(2),z.literal(3),z.literal(4)]), +}); + +/** Validate comparison fields; unused explanations/repair recipes are never consumed. */ +export function parseComparisonReport(value:unknown) { + // Zod arrays collect child errors even after max() fails. Reject hostile widths + // before deep parsing, while the 8 MiB history reader remains the byte bound. + const object=(v:unknown):v is Record=>Boolean(v&&typeof v==="object"&&!Array.isArray(v)); + if(!object(value)||Object.keys(value).length>16||!Array.isArray(value.targets)||value.targets.length>64 + ||value.targets.some(row=>!object(row)||Object.keys(row).length>8) + ||!Array.isArray(value.findings)||value.findings.length>256||!Array.isArray(value.issues)||value.issues.length>256)throw new Error("History capacity"); + const candidates=[...value.findings,...value.issues.map(row=>object(row)?row.finding:undefined)]; + if(candidates.some(row=>!object(row)||Object.keys(row).length>16||!Array.isArray(row.evidence)||row.evidence.length>256 + ||row.evidence.some(item=>!object(item)||Object.keys(item).length>32||Object.values(item).some(v=>typeof v!=="string"||v.length>4096))))throw new Error("History evidence capacity"); + const parsed=schema.parse(value); + const actual={total:parsed.findings.length,pass:0,warn:0,fail:0,unknown:0}; + for(const item of parsed.findings)actual[item.status]++; + const expected=new Set(parsed.findings.map(item=>issueIdForFinding(item))),observed=new Set(parsed.issues.map(item=>issueIdForFinding(item.finding))); + if(Object.keys(actual).some(key=>actual[key as keyof typeof actual]!==parsed.summary[key as keyof typeof actual]) + ||new Set(parsed.issues.map(item=>item.id)).size!==parsed.issues.length||expected.size!==observed.size||[...expected].some(key=>!observed.has(key)))throw new Error("Incomplete history findings"); + return parsed; +} + +type Compared={status:"compared";counts:IssueCounts;details:ComparisonDetail[];detailsTotal:number;detailsTruncated:boolean}; +type Unavailable={status:"unavailable";reason:"incomplete-evidence"}; +type NeededReport=Pick&{issues:Array>}; + +/** Same owner for fresh/baseline and saved/saved comparisons. No raw messages or paths leave it. */ +export function compareVerificationReports(before:NeededReport,after:NeededReport):Compared|Unavailable { + if([before,after].some(report=>report.exitCode===2||report.exitCode===4||report.issues.length>=256||report.findings.length>=256||report.summary.unknown>0))return {status:"unavailable",reason:"incomplete-evidence"}; + const pending=(report:NeededReport)=>report.issues.filter(issue=>issue.finding.status==="fail"||issue.finding.status==="warn"); + const left=pending(before),right=pending(after),comparison=compareIssues(left,right); + const added=new Set(comparison.newIds),absent=new Set(comparison.resolvedIds),ongoing=new Set(comparison.ongoingIds); + const group=(issue:NeededReport["issues"][number])=>JSON.stringify([issue.finding.code,issue.finding.skill,issue.finding.target,issue.location]); + const removed=new Map>(); + for(const issue of left)if(absent.has(issue.id)){const key=group(issue),items=removed.get(key)??[];items.push(issue);removed.set(key,items);} + const details=new Map(); + const add=(code:string,change:string)=>{const key=JSON.stringify([code,change]);const row=details.get(key)??{code,change,count:0};row.count++;details.set(key,row);}; + let changed=0; + for(const issue of right){ + if(ongoing.has(issue.id))add(issue.finding.code,"ongoing"); + else if(added.has(issue.id)){const paired=removed.get(group(issue))?.pop();if(paired){changed++;add(issue.finding.code,"changed");}else add(issue.finding.code,"added");} + } + for(const items of removed.values())for(const issue of items)add(issue.finding.code,"resolved"); + const rows=[...details.values()].sort((a,b)=>a.code.localeCompare(b.code)||a.change.localeCompare(b.change)); + return {status:"compared",counts:{added:comparison.newIds.length-changed,ongoing:comparison.ongoingIds.length,resolved:comparison.resolvedIds.length-changed,changed},details:rows.slice(0,32),detailsTotal:rows.length,detailsTruncated:rows.length>32}; +} diff --git a/tests/workbench/page.test.ts b/tests/workbench/page.test.ts index c7e8a91..a560857 100644 --- a/tests/workbench/page.test.ts +++ b/tests/workbench/page.test.ts @@ -53,12 +53,42 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de return {}; }}; }; - runInNewContext(workbenchScript, {document: {getElementById: get, querySelector: () => get("meta"), querySelectorAll: () => [], createElement: () => new Element()}, fetch, URL, Blob, setTimeout}); + runInNewContext(workbenchScript, {document: {getElementById: get, querySelector: () => get("meta"), querySelectorAll: () => [], createElement: () => new Element()}, fetch, URL, Blob, setTimeout, AbortController}); await new Promise(resolve => setImmediate(resolve)); return {get, offsets, actions,inputs,respond(action:string,value:unknown){responses.set(action,value);}, fail(action: string, message: string) { rejected.set(action, message); }, async finishInitialHistory() { releaseHistory(); await new Promise(resolve => setImmediate(resolve)); }, async click(id: string) { await get(id).onclick?.(); await new Promise(resolve => setImmediate(resolve)); }}; } describe("workbench interactions", () => { + it("ignores delayed history replies after an input change",async()=>{ + const ui=await page(),before="11111111-1111-4111-8111-111111111111",after="22222222-2222-4222-8222-222222222222"; + ui.get("history-compare-before").value=before;ui.get("history-compare-after").value=after; + let release!:(value:unknown)=>void;ui.respond("history/compare",new Promise(resolve=>{release=resolve})); + const pending=ui.click("history-compare");await new Promise(resolve=>setImmediate(resolve)); + ui.get("history-compare-after").value=before;ui.get("history-compare-after").events.get("input")?.(); + release({schema:"skillsync.history-comparison/v1",kind:"skill",status:"compared",before:{id:before},after:{id:after},counts:{added:0,ongoing:0,resolved:0,changed:0},details:[],detailsTotal:0,detailsTruncated:false,readOnly:true,execution:"not-run",provenance:"not-authenticated"}); + await pending;expect(ui.get("history-compare-result").hidden).toBe(true);expect(ui.get("history-compare-info").textContent).toBe(""); + }); + + it("compares history independently of entered paths and reviewed apply consent", async () => { + const ui=await page();await ui.click("verify");await ui.click("plan"); + ui.get("confirm").checked=true;ui.get("confirm").onchange?.();await ui.click("nav-history"); + const before="11111111-1111-4111-8111-111111111111",after="22222222-2222-4222-8222-222222222222"; + ui.get("history-compare-before").value=before;ui.get("history-compare-after").value=after; + ui.respond("history/compare",{schema:"skillsync.history-comparison/v1",kind:"skill",status:"compared",before:{id:before},after:{id:after},counts:{added:1,ongoing:2,resolved:3,changed:1},details:[{code:"test.code",change:"added",count:1},{code:"test.code",change:"ongoing",count:2},{code:"test.code",change:"resolved",count:3},{code:"test.code",change:"changed",count:1}],detailsTotal:4,detailsTruncated:false,readOnly:true,execution:"not-run",provenance:"not-authenticated"}); + const initial=ui.actions.length;await ui.click("history-compare"); + expect(ui.actions.slice(initial)).toEqual(["history/compare"]); + expect(ui.inputs.at(-1)?.input).toEqual({beforeId:before,afterId:after}); + expect(ui.get("history-compare-info").textContent).toContain("历史"); + expect(ui.get("root").value).toBe("/synthetic/review");expect(ui.get("confirm").checked).toBe(true); + await ui.click("nav-single");expect(ui.get("apply").disabled).toBe(false); + }); + + it("keeps unavailable history comparison distinct from zero changes", async () => { + const ui=await page();const before="11111111-1111-4111-8111-111111111111",after="22222222-2222-4222-8222-222222222222"; + ui.get("history-compare-before").value=before;ui.get("history-compare-after").value=after; + ui.respond("history/compare",{schema:"skillsync.history-comparison/v1",kind:"skill",status:"unavailable",before:{id:before},after:{id:after},reason:"incomplete-evidence",readOnly:true,execution:"not-run",provenance:"not-authenticated"}); + await ui.click("history-compare");expect(ui.get("history-compare-info").textContent).toContain("不完整");expect(ui.get("history-compare-result").children).toHaveLength(0); + }); it("applies history filters explicitly, keeps them for paging and restores successful criteria on failure", async () => { const ui=await page(); ui.get("history-query").value="review";ui.get("history-kind").value="verify";ui.get("history-exit").value="0"; diff --git a/tests/workbench/server.test.ts b/tests/workbench/server.test.ts index 1849432..ca3be6d 100644 --- a/tests/workbench/server.test.ts +++ b/tests/workbench/server.test.ts @@ -22,6 +22,47 @@ async function setup(cliPath = resolve("dist/cli/index.js")) { return {base, root, script, app, api, token}; } describe("real CLI graphical workbench", () => { + it("compares saved verification evidence without commands, history writes or consuming the current plan", async () => { + const {api,base,root,script}=await setup(); + await api("select",{path:root,kind:"skill"}); + const first=await api("verify"), planned=await api("plan"); + const folder=join(base,"state"), info=await stat(folder), history=new WorkbenchHistory(folder,`${info.dev}:${info.ino}`); + const original=await history.read(first.data.historyId); + const after=JSON.parse(original.output!); + after.findings=after.findings.filter((row:{code:string})=>row.code!=="structure.invalid-script-mode"); + after.issues=after.issues.filter((row:{finding:{code:string}})=>row.finding.code!=="structure.invalid-script-mode"); + after.summary={total:after.findings.length,pass:0,warn:0,fail:0,unknown:0}; + for(const row of after.findings)after.summary[row.status]++; + after.exitCode=0; + const later=await history.append({kind:"verify",selection:original.record.selection,exitCode:0},JSON.stringify(after)); + const names=await readdir(folder), bytes=await Promise.all(names.map(name=>readFile(join(folder,name)))); + const compared=await api("history/compare",{beforeId:first.data.historyId,afterId:later.id}); + expect(compared.status).toBe(200);expect(compared.data.status).toBe("compared"); + expect(compared.data.counts.resolved).toBe(1);expect(compared.data.readOnly).toBe(true); + expect(compared.data.execution).toBe("not-run");expect(JSON.stringify(compared.data)).not.toContain(base); + expect(await readdir(folder)).toEqual(names); + for(let i=0;i { + const {api,base,root}=await setup();await api("select",{path:root,kind:"skill"}); + const first=await api("verify"), folder=join(base,"state"), info=await stat(folder), history=new WorkbenchHistory(folder,`${info.dev}:${info.ino}`); + const saved=await history.read(first.data.historyId), report=JSON.parse(saved.output!); + const foreign=await history.append({kind:"verify",selection:{...saved.record.selection,identity:"9:9"},exitCode:0},"PRIVATE_INVALID_BODY"); + const different=await api("history/compare",{beforeId:first.data.historyId,afterId:foreign.id}); + expect(different.status).toBe(200);expect(different.data.reason).toBe("selection-changed");expect(different.data.counts).toBeUndefined(); + report.exitCode=2; + const incomplete=await history.append({kind:"verify",selection:saved.record.selection,exitCode:2},JSON.stringify(report)); + const missing=await api("history/compare",{beforeId:first.data.historyId,afterId:incomplete.id}); + expect(missing.data.status).toBe("unavailable");expect(missing.data.counts).toBeUndefined(); + expect((await api("history/compare",{beforeId:"../private",afterId:incomplete.id})).status).toBe(400); + expect((await api("history/compare",{beforeId:first.data.historyId,afterId:first.data.historyId})).status).toBe(400); + await writeFile(join(folder,saved.record.dataFile!),"PRIVATE_TAMPERED_BODY"); + const tampered=await api("history/compare",{beforeId:first.data.historyId,afterId:incomplete.id}); + expect(tampered.status).toBe(400);expect(JSON.stringify(tampered.data)).not.toContain("PRIVATE_TAMPERED_BODY"); + }); it("filters history metadata before paging without opening bodies or leaking parent paths", async () => { const {api, base} = await setup(); const folder = join(base, "state"), info = await stat(folder); diff --git a/tests/workbench/verification-comparison.test.ts b/tests/workbench/verification-comparison.test.ts new file mode 100644 index 0000000..cc2f570 --- /dev/null +++ b/tests/workbench/verification-comparison.test.ts @@ -0,0 +1,54 @@ +import {describe,it,expect} from "vitest"; +import {mkdtemp,stat,realpath} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {toIssue} from "../../src/domain/issue.js"; +import type {Finding} from "../../src/domain/result.js"; +import {artifactSetHash} from "../../src/domain/artifact-report.js"; +import {WorkbenchHistory} from "../../src/workbench/history.js"; +import {parseComparisonReport,compareVerificationReports} from "../../src/workbench/verification-comparison.js"; + +function finding(code:string,evidence="old",status:Finding["status"]="warn"):Finding{return {level:0,severity:"warn",status,code,skill:"fixture",message:"PRIVATE_FINDING_MESSAGE",evidence:[{evidence}]};} +function report(findings:Finding[]){ + const summary={total:findings.length,pass:0,warn:0,fail:0,unknown:0};for(const row of findings)summary[row.status]++; + return {schema_version:1,generated_at:"2026-11-06T00:00:00.000Z",targets:[{name:"verify-1",path:"[local path]",scope:"explicit"}],findings,issues:findings.map(row=>toIssue(row,{})),summary,exitCode:0}; +} +describe("saved verification comparison boundaries",()=>{ + it("pairs evidence changes without calling an absent warning a verified fix",()=>{ + const before=parseComparisonReport(report([finding("test.changed"),finding("test.absent"),finding("test.ongoing")])); + const after=parseComparisonReport(report([finding("test.changed","new"),finding("test.added"),finding("test.ongoing")])); + const result=compareVerificationReports(before,after);expect(result.status).toBe("compared"); + if(result.status!=="compared")throw new Error("comparison unavailable"); + expect(result.counts).toEqual({added:1,ongoing:1,resolved:1,changed:1}); + expect(JSON.stringify(result)).not.toContain("PRIVATE_FINDING_MESSAGE");expect(result.details).toHaveLength(4); + }); + it("rejects duplicate identifiers, missing findings and inconsistent summaries",()=>{ + const value=report([finding("test.first"),finding("test.second")]); + value.issues[1].id=value.issues[0].id;expect(()=>parseComparisonReport(value)).toThrow(); + value.issues.pop();expect(()=>parseComparisonReport(value)).toThrow(); + const wrong=report([finding("test.first")]);wrong.summary.total=0;expect(()=>parseComparisonReport(wrong)).toThrow(); + expect(()=>parseComparisonReport({...wrong,issues:Array(10000).fill({})})).toThrow(); + }); + it("distinguishes saturated or unknown evidence from zero changes and caps code rows",()=>{ + const empty=parseComparisonReport(report([])); + const saturated=parseComparisonReport(report(Array.from({length:256},(_,i)=>finding("test.code"+i)))); + expect(compareVerificationReports(saturated,empty).status).toBe("unavailable"); + expect(compareVerificationReports(parseComparisonReport(report([finding("test.unknown","x","unknown")])),empty).status).toBe("unavailable"); + const detail=compareVerificationReports(parseComparisonReport(report(Array.from({length:40},(_,i)=>finding("test.code"+i)))),empty); + if(detail.status!=="compared")throw new Error("comparison unavailable"); + expect(detail.counts.resolved).toBe(40);expect(detail.details).toHaveLength(32);expect(detail.detailsTotal).toBe(40);expect(detail.detailsTruncated).toBe(true); + }); + it("compares offline artifact history through the existing contract-bound parser",async()=>{ + const root=await realpath(await mkdtemp(join(tmpdir(),"sync-saved-artifact-"))),info=await stat(root),history=new WorkbenchHistory(root,`${info.dev}:${info.ino}`); + const selection={path:"/synthetic/offline-artifacts",identity:"1:2",kind:"artifacts" as const,contract:"/synthetic/missing-contract.json",delivery:false}; + const files=[{path:"table.csv",bytes:10,sha256:"b".repeat(64)}]; + const before={schema:"skillsync.artifacts-report/v1",status:"passed",exitCode:0,evidence:"physical-files",execution:"not-run",provenance:"not-authenticated",receipt_binding:"not-provided",contract_sha256:"a".repeat(64),artifact_set_sha256:artifactSetHash(files),files,findings:[]}; + const after={...before,status:"failed",exitCode:1,findings:[{code:"csv.value-mismatch",path:"table.csv",field:"amount",message:"PRIVATE_CELL_SENTINEL"}]}; + const left=await history.append({kind:"verify",selection,exitCode:0},JSON.stringify(before)),right=await history.append({kind:"verify",selection,exitCode:1},JSON.stringify(after)); + const compared=await history.compare(left.id,right.id);expect(compared.status).toBe("compared"); + if(!("counts" in compared))throw new Error("counts unavailable"); + expect(compared.counts.added).toBe(1);expect(JSON.stringify(compared)).not.toContain("PRIVATE_CELL_SENTINEL");expect(JSON.stringify(compared)).not.toContain(selection.path); + const changed=await history.append({kind:"verify",selection,exitCode:1},JSON.stringify({...after,contract_sha256:"c".repeat(64)})); + const unavailable=await history.compare(left.id,changed.id);expect(unavailable.status).toBe("unavailable");expect(unavailable).not.toHaveProperty("counts"); + }); +});