From fb145029cf8155ce7800dd866375cdec027d52ce Mon Sep 17 00:00:00 2001 From: Chumaniac <3408601995@qq.com> Date: Tue, 27 Oct 2026 10:52:01 +0800 Subject: [PATCH] Organize verification tasks and share bounded offline report content --- docs/batch-acceptance.md | 18 +++++++- src/reporters/artifacts-html.ts | 60 +++++++++++++++----------- src/reporters/batch-html.ts | 9 ++-- src/workbench/page.ts | 57 ++++++++++++++++-------- tests/reporters/artifacts-html.test.ts | 8 ++++ tests/workbench/batch.test.ts | 4 ++ tests/workbench/page.test.ts | 24 +++++++++++ 7 files changed, 131 insertions(+), 49 deletions(-) diff --git a/docs/batch-acceptance.md b/docs/batch-acceptance.md index 58a2d3e..b25688f 100644 --- a/docs/batch-acceptance.md +++ b/docs/batch-acceptance.md @@ -25,7 +25,7 @@ skillsync batch inspect --package /project/corrected-review.json --contract /pro The run exits 0 for all passed, 1 for failed, or 2 for incomplete/invalid evidence. Inspection exits 0 when the package is internally valid even if its acceptance result failed; use the embedded status/exitCode when making an acceptance decision. Output files must be new; existing packages and source files are never overwritten. A retry verifies passed input hashes before reusing evidence. Changed contract bytes, case identities or passed input content require a fresh run, without reusing the previous package. Current and prior reports remain available for comparison; unknown or truncated evidence cannot claim resolved findings. -In **gui**, expand the multi-batch acceptance and offline review section, choose a plan and preflight it, then explicitly run into a new file. After a correction, select the prior package and the failed-only option, preflight again and run into another new file. Open an existing package read-only with an optional expected SHA-256 obtained independently. Opening evidence clears any single-Skill repair capability. Cancellation may retain an already published package: inspect the chosen output before retrying. +In **gui**, choose the multi-batch acceptance task, select a plan and preflight it, then explicitly run into a new file. After a correction, select the prior package and the failed-only option, preflight again and run into another new file. Open an existing package read-only with an optional expected SHA-256 obtained independently. Opening evidence clears any single-Skill repair capability. Cancellation may retain an already published package: inspect the chosen output before retrying. The single JSON package includes the exact original plan SHA-256, the public declaration, per-batch file inventory, file/report hashes, complete bounded reports and comparisons. It omits source bodies, raw record values, contract expected scalar values and absolute source paths. Relative file names, case names and column metadata may still be sensitive. The scriptless HTML view can be reconstructed offline; oversized detail views are omitted with a visible notice, while the bounded JSON evidence remains. @@ -34,3 +34,19 @@ Shared admission limits: 16 batches, 10,000 files, 20,000 input entries, 16 MiB/ Package verification checks internal structure, counts, file/report digest bindings and comparison consistency. An expected package SHA-256 binds exact bytes; supplying the original contract also verifies its exact bytes and declaration scope. Optional `--plan` checks exact plan bytes without reading source data; a source-path mapping change also invalidates prior-plan reuse. These checks do not authenticate authorship, fetch cited sources, rerun original business data, or establish production acceptance. A self-consistent replacement package can be written by anyone; keep the digest through an independent trusted channel. The design independently applies declared validation batches and retained validation results described by [Great Expectations Checkpoints](https://docs.greatexpectations.io/docs/reference/api/checkpoint_class/) and [Frictionless validation](https://framework.frictionlessdata.io/docs/guides/validating-data). No upstream code or runtime dependency is copied. This source change does not republish existing v0.3.0 downloads or npm releases. + + +The workbench separates single-directory verification, batch acceptance and saved +history. Switching tasks keeps the same form nodes and entered paths, without +submitting a request or changing a repair approval. Reports remain associated +with the task that produced them. Cancellation stays available across tasks; +new verification hides the previous result until a fresh response arrives. + +Offline batch reports share the standalone artifact renderer and one style +sheet. Each batch has its own section IDs and links; its check details are plain +HTML under a disclosure, rather than another full document in a nested frame. +The display still bounds each report and the combined HTML independently of +JSON evidence. Scripts, source bodies and expected scalar values remain absent. +The workbench retains its outer sandboxed report frame. These changes adapt +Impeccable/Interface Design's task hierarchy and code-humanizer's reuse of proven +rendering logic; no upstream runtime or global configuration is installed. diff --git a/src/reporters/artifacts-html.ts b/src/reporters/artifacts-html.ts index 90dddb5..4be246c 100644 --- a/src/reporters/artifacts-html.ts +++ b/src/reporters/artifacts-html.ts @@ -72,11 +72,11 @@ function reconciliationCard(check: ReconciliationSummary, rowBudget: { remaining `; } -function comparisonSection(report: ArtifactReport): string { +function comparisonSection(report: ArtifactReport, scope: (id: string) => string): string { const value = report.comparison; if (!value) return ""; const checks = value.checks ?? []; - return `

Changes since baseline review

+ return `

Changes since baseline review

Caller-supplied history is not authenticated. The current directory was inspected again; previous messages and record positions are not copied into this comparison.

Previous result: ${text(value.baseline_status)} · Current inspection: ${text(value.current_status)}

${value.status === "unavailable" ? `

Comparison unavailable: ${text(value.reason)}. No improvement or resolution is established.

` : `

Finding categories: ${text(value.findings?.added)} new · ${text(value.findings?.ongoing)} ongoing · ${text(value.findings?.resolved)} resolved. Categories use code, file and column, not record identities.

@@ -87,10 +87,10 @@ function comparisonSection(report: ArtifactReport): string {
Baseline report identity${text(value.baseline_report_sha256)}

SHA-256 binds the supplied JSON bytes; it does not establish their author or truth.

`; } -function declarationSection(report: ArtifactReport): string { +function declarationSection(report: ArtifactReport, scope: (id: string) => string): string { const value = report.declarations; - if (!value) return '

Declared cross-file checks

Declaration metadata is unavailable in this snapshot. Consult its original contract; no expectations are inferred.

'; - return `

Declared cross-file checks

+ if (!value) return `

Declared cross-file checks

Declaration metadata is unavailable in this snapshot. Consult its original contract; no expectations are inferred.

`; + return `

Declared cross-file checks

These are declarations, not proof of a completed check. Per-file type, digest and scalar-value rules remain in the original contract; expected values and raw cells are omitted here.

Limits: ${text(value.limits.max_files)} files · ${text(value.limits.max_file_bytes)} bytes per file · ${text(value.limits.max_total_bytes)} total bytes.

Showing ${Math.min(128, value.checks.length)} of ${value.checks.length} declarations. Up to 8 mappings per declaration are shown; JSON retains the complete declared mappings.

@@ -106,12 +106,14 @@ function declarationSection(report: ArtifactReport): string { source = check.source.path; target = check.target.path; fields = `Identity: ${columns(check.source.key)} → ${columns(check.target.key)}

Integer value: ${text(check.source.value)} → ${text(check.target.value)}

`; } - return `

Declaration ${text(ordinal)} · ${text(check.type)}

Source: ${text(source)} → Target: ${text(target)}

${fields}
`; + return `

Declaration ${text(ordinal)} · ${text(check.type)}

Source: ${text(source)} → Target: ${text(target)}

${fields}
`; }).join("") || '

No cross-file check was declared. Consult the original contract for file-level requirements.

'}
`; } /** Use the same path policy as text/JSON, including when called directly. */ -export function renderArtifactsHtml(rawReport: ArtifactReport): string { +function renderArtifactsContent(rawReport: ArtifactReport, prefix: string, heading: "h1" | "h2"): string { + if (prefix && !/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(prefix)) throw new Error("Invalid report section prefix"); + const scope = (id: string) => prefix + id; const report = redactLocalPaths(rawReport); const checks = report.reconciliations ?? []; const incomplete = checks.filter(check => check.status === "unknown").length; @@ -121,35 +123,41 @@ export function renderArtifactsHtml(rawReport: ArtifactReport): string { finding.code === "artifact.reference-value-mismatch" || finding.code === "artifact.reference-value-unavailable"); const title = passed ? "Declared checks passed" : report.exitCode === 2 ? "Check setup required" : referenceOnly ? "Reference metadata needs review" : "Review required"; - const csp = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; const files = report.files.slice(0, FILE_DETAILS); // Failed and incomplete checks stay visible before successful checks. const ordered = [...checks.filter(check => check.status !== "passed"), ...checks.filter(check => check.status === "passed")]; const rowBudget = { remaining: 64 }; const referenceBudget = { remaining: REFERENCE_DETAILS }; const referenceCount = report.findings.filter(finding => finding.reference).length; - return ` - -SkillSync delivery review — ${title} - -
SkillSync
Offline delivery review
-
${passed ? "Passed" : "Not accepted"}

${title}

+ return `
${passed ? "Passed" : "Not accepted"}<${heading}${heading === "h2" ? ' class="artifact-outcome"' : ""}>${title}

${passed ? "Declared file checks passed. Business approval is separate." : "Review the findings below. Keep the original files."}

${report.files.length} inspected ${report.files.length === 1 ? "file" : "files"} · ${report.findings.length} reported ${report.findings.length === 1 ? "finding" : "findings"}${checks.length ? ` · ${failedChecks} failed and ${incomplete} incomplete allocation checks` : ""}

-${report.findings.length ? `Review ${report.findings.length} reported ${report.findings.length === 1 ? "finding" : "findings"}` : "Review inspected files"}
+${report.findings.length ? `Review ${report.findings.length} reported ${report.findings.length === 1 ? "finding" : "findings"}` : "Review inspected files"}
Receipt / file binding${text(report.receipt_binding)}Local hashes checked; author identity unverified.
-${comparisonSection(report)} -${declarationSection(report)} -

Allocation and identity checks

Showing ${Math.min(CHECK_DETAILS, checks.length)} of ${checks.length} reconciliation checks. Failed and incomplete checks appear first.

+${comparisonSection(report, scope)} +${declarationSection(report, scope)} +

Allocation and identity checks

Showing ${Math.min(CHECK_DETAILS, checks.length)} of ${checks.length} reconciliation checks. Failed and incomplete checks appear first.

${ordered.slice(0, CHECK_DETAILS).map(check => reconciliationCard(check, rowBudget)).join("") || "

No record reconciliation was declared. Other declared file checks are reflected in the result.

"}
-

Review actions

${referenceCount > REFERENCE_DETAILS ? `

Reference locations: first ${REFERENCE_DETAILS} of ${referenceCount} reported findings shown. The JSON report retains all location metadata.

` : ""}${report.findings.length ? `
${report.findings.slice(0, 256).map(finding => findingRow(finding, referenceBudget)).join("")}
FindingAffected metadataNext review step
` : "

No findings in the declared scope.

"}
-

Observed files

Showing ${files.length} of ${report.files.length} files.

+

Review actions

${referenceCount > REFERENCE_DETAILS ? `

Reference locations: first ${REFERENCE_DETAILS} of ${referenceCount} reported findings shown. The JSON report retains all location metadata.

` : ""}${report.findings.length ? `
${report.findings.slice(0, 256).map(finding => findingRow(finding, referenceBudget)).join("")}
FindingAffected metadataNext review step
` : "

No findings in the declared scope.

"}
+

Observed files

Showing ${files.length} of ${report.files.length} files.

${files.map(file => ``).join("") || ''}
Declared pathBytesRowsSHA-256
${text(file.path)}${text(file.bytes)}${text(file.rows)}${text(file.sha256)}
No accepted file observation is available.
Evidence boundaries and snapshot identity

Local inspection only: no Skill execution, author authentication or business approval. Metadata values longer than 160 characters are shortened; JSON retains full metadata.

-
Evidence
physical-files
Execution
execution: not-run
Provenance
provenance: not-authenticated
Contract SHA-256
${text(report.contract_sha256)}
Artifact set SHA-256
${text(report.artifact_set_sha256)}
-
SkillSync source ${text(VERSION)} · Standalone HTML, no scripts or external requests.${text(report.schema)}
\n`; +
Evidence
physical-files
Execution
execution: not-run
Provenance
provenance: not-authenticated
Contract SHA-256
${text(report.contract_sha256)}
Artifact set SHA-256
${text(report.artifact_set_sha256)}
`; +} + +export function renderArtifactsFragment(rawReport: ArtifactReport, prefix: string): string { + return renderArtifactsContent(rawReport, prefix, "h2"); +} + +export const artifactsReviewStyle = `:root{color-scheme:light;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#182c35;background:#f3f6f8;line-height:1.55}*{box-sizing:border-box}body{margin:0}a{color:#075f70}.skip{position:absolute;top:-60px;left:16px;background:white;padding:12px}.skip:focus{top:12px;z-index:2}:focus-visible{outline:3px solid #207d94;outline-offset:4px}header,main,footer{max-width:1120px;margin:auto;padding:28px 24px}header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;border-bottom:1px solid #d6e0e5}.brand{font-weight:800;letter-spacing:-.04em;font-size:24px}.kicker{font-size:12px;letter-spacing:.14em;color:#526976;text-transform:uppercase}nav{display:flex;gap:18px;align-items:center;flex-wrap:wrap}main{padding-top:36px}h1{font-size:30px;line-height:1.15;letter-spacing:-.04em;margin:10px 0 18px}h2{font-size:24px;margin:0 0 16px}h3{font-size:17px;margin:0}p{margin:10px 0;color:#49616e}.hero{padding:30px;border:1px solid #d8e3e8;border-radius:8px;background:white;border-top:5px solid #087566}.hero.fail{border-top-color:#b44838}.badge{display:inline-block;font-weight:700;font-size:12px;padding:5px 10px;border-radius:20px;background:#e9eff3;color:#334f60}.badge.pass{background:#dff3ec;color:#075f48}.badge.fail{background:#fbe8e3;color:#993c2d}.badge.unknown{background:#fff1d4;color:#745600}.binding{margin-top:18px;display:flex;gap:12px;flex-wrap:wrap;align-items:center}section{margin-top:36px}.check{background:white;padding:22px;border:1px solid #d7e2e7;border-radius:6px;margin:12px 0}.check-heading{display:flex;justify-content:space-between;gap:14px;align-items:center;flex-wrap:wrap}.sides{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:12px 0}.sides p{border-left:3px solid #c5d9e2;padding-left:14px}.sides span,.sides strong,.sides code,small{display:block}.sides span{font-size:12px;text-transform:uppercase;letter-spacing:.08em}code{font-family:ui-monospace,monospace;font-size:.9em;overflow-wrap:anywhere}.counts{display:flex;gap:20px;flex-wrap:wrap;margin:16px 0 0}.counts dt{color:#526976;font-size:12px}.counts dd{font-size:23px;font-weight:700;margin:3px 0;font-variant-numeric:tabular-nums}.notice{background:#fff7e5;padding:14px;border-radius:8px}.table-wrap{overflow:auto;border:1px solid #d7e2e7;border-radius:12px;background:white}table{border-collapse:collapse;width:100%;text-align:left}th,td{padding:14px 16px;border-bottom:1px solid #e3eaee;vertical-align:top;overflow-wrap:anywhere}th{background:#ecf2f5;font-size:12px;text-transform:uppercase;letter-spacing:.06em}td{max-width:340px}td p{margin:0 0 8px}small{color:#526976;font-size:12px;margin-top:5px}details{margin-top:16px;border:1px solid #d7e2e7;padding:16px;border-radius:10px;background:white}summary{cursor:pointer;font-weight:600;min-height:44px}dl.evidence{display:grid;grid-template-columns:180px 1fr;gap:8px}.evidence dd{margin:0;overflow-wrap:anywhere}footer{font-size:12px;color:#526976;border-top:1px solid #d7e2e7;margin-top:30px}footer code{display:block}@media(max-width:640px){header,main,footer{padding-left:16px;padding-right:16px}.hero{padding:20px}.sides{grid-template-columns:1fr}.check{padding:16px}dl.evidence{grid-template-columns:1fr;gap:4px}.evidence dd{margin-bottom:8px}th,td{padding:12px;min-width:110px}}@media print{body{background:white}header nav,.skip{display:none}main{padding-top:16px}.hero,.check,details{border-radius:0;break-inside:avoid}.table-wrap{overflow:visible}a{color:inherit}} + +.review-summary{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:18px;padding-top:18px;margin-top:18px;border-top:1px solid #d6e0e5}.review-summary p{margin:0;font-variant-numeric:tabular-nums}.review-link{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px;background:#075f70;color:white;border-radius:6px;text-decoration:none;font-weight:650}.review-link:hover{background:#064956}.reference-location{border-top:1px solid #d6e0e5;margin-top:14px;padding-top:12px;font-size:14px}.reference-location strong,.reference-location span,.reference-location small{display:block}.reference-location ul{list-style:none;padding:0;margin:8px 0}.reference-location li{margin:5px 0}.reference-location code{overflow-wrap:anywhere}h2[tabindex="-1"]{scroll-margin-top:24px}h2[tabindex="-1"]:focus{outline:3px solid #207d94;outline-offset:5px} + +.artifact-outcome{font-size:26px;line-height:1.2;letter-spacing:-.02em;margin:10px 0 18px} +`; + +export function renderArtifactsHtml(rawReport: ArtifactReport): string { + const body = renderArtifactsContent(rawReport, "", "h1"); + const csp = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; + return `SkillSync delivery review
SkillSync
Offline delivery review
${body}
SkillSync source ${text(VERSION)} · Standalone HTML, no scripts or external requests.${text(rawReport.schema)}
\n`; } diff --git a/src/reporters/batch-html.ts b/src/reporters/batch-html.ts index 7f12c54..ff9e790 100644 --- a/src/reporters/batch-html.ts +++ b/src/reporters/batch-html.ts @@ -1,5 +1,5 @@ import {type BatchReport,canonicalJson,validateBatchReport} from "../domain/batch-report.js"; -import {renderArtifactsHtml} from "./artifacts-html.js"; +import {artifactsReviewStyle,renderArtifactsFragment} from "./artifacts-html.js"; const escape=(value:unknown)=>String(value).replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]!)); const HTML_LIMIT=7*1024*1024; @@ -10,13 +10,14 @@ export function renderBatchHtml(input:BatchReport):string { const sections=report.batches.map(item=>{ let detail='

Full bounded evidence remains in the JSON review package. This report is above the offline detail display budget.

'; if(Buffer.byteLength(canonicalJson(item.report))<=256*1024){ - const rendered=renderArtifactsHtml(item.report),size=Buffer.byteLength(rendered); - if(size<=512*1024&&used+size*6<5*1024*1024){used+=size*6;detail=`
Inspect declared checks and affected records
`;} + const rendered=renderArtifactsFragment(item.report, 'batch-'+item.id+'-'),size=Buffer.byteLength(rendered); + if(size<=512*1024&&used+size<5*1024*1024){used+=size;detail=`
Inspect declared checks and affected records
${rendered}
`;} } const comparison=item.comparison?.status==='compared'?`

Compared with the retained prior report: ${item.comparison.findings!.added} new, ${item.comparison.findings!.ongoing} ongoing, ${item.comparison.findings!.resolved} resolved finding categories.

`:item.comparison?`

Comparison unavailable: ${escape(item.comparison.reason)}. No improvement is inferred from incomplete evidence.

`:''; return `

${escape(item.id)} · ${escape(item.status)}

${item.mode==='reused'?'Recorded reuse of unchanged passed inputs.':'Recorded inspection of the selected inputs.'} ${item.source_files} files · ${item.source_bytes} bytes.

${comparison}
Evidence bindings

Input snapshot: ${escape(item.source_sha256??'unavailable')}

Report: ${item.report_sha256}

${detail}
`; }).join(''); - const html=`SkillSync Batch acceptance

Batch acceptance

${escape(report.status)} · ${report.counts.passed} passed · ${report.counts.failed} failed · ${report.counts.incomplete} incomplete.

Review-package integrity is verified. Original data is not rechecked by opening this page; authors are not authenticated. Results describe the recorded contract and input snapshots.

${escape(report.createdAt)} · SkillSync ${escape(report.tool_version)} · read-only validation · no Skill execution.

Plan: ${report.declaration.plan_sha256}

Contract: ${report.declaration.contract_sha256}

${rows}
BatchOutcomeEvidence sourceFindings
${sections}

Raw data rows, original absolute paths and scalar expected values are omitted. User-defined batch IDs, relative file names, column/check declarations and record coordinates may still be sensitive. Hashes establish integrity bindings, not trusted authorship or scientific/production acceptance. Detail display has an independent capacity budget.

`; + const html=`SkillSync Batch acceptance

Batch acceptance

${escape(report.status)} · ${report.counts.passed} passed · ${report.counts.failed} failed · ${report.counts.incomplete} incomplete.

Review-package integrity is verified. Original data is not rechecked by opening this page; authors are not authenticated. Results describe the recorded contract and input snapshots.

${escape(report.createdAt)} · SkillSync ${escape(report.tool_version)} · read-only validation · no Skill execution.

Plan: ${report.declaration.plan_sha256}

Contract: ${report.declaration.contract_sha256}

${rows}
BatchOutcomeEvidence sourceFindings
${sections}

Raw data rows, original absolute paths and scalar expected values are omitted. User-defined batch IDs, relative file names, column/check declarations and record coordinates may still be sensitive. Hashes establish integrity bindings, not trusted authorship or scientific/production acceptance. Detail display has an independent capacity budget.

`; if(Buffer.byteLength(html)>HTML_LIMIT)throw new Error('Offline batch display exceeds its capacity.'); return html; } diff --git a/src/workbench/page.ts b/src/workbench/page.ts index f7b9b20..06d558d 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -1,20 +1,33 @@ /** Static local UI. All evidence is inserted as text or in a scriptless report frame. */ export function workbenchPage(nonce: string): string { - return `SkillSync 核验台
-

SkillSync 核验台

-
多批次验收与离线复核包

按计划只读核验多个交付;修正失败批次后保留已通过的证据。打开复核包只检查完整性,不重新核验原始数据或认证作者。

-
历史核验

选择历史记录作为基线,再核验当前文件。历史记录只读。

-

1. 选择目录

-

尚未核验

-

只检查本机文件,不运行 Skill。

报告保存位置

报告、计划和回执保存在本机私有目录;历史功能启用前的文件仍保留。

`; + return `SkillSync 核验台
+

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:36px}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}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}@media(max-width:580px){.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}@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 workbenchScript = `"use strict"; const $ = id => document.getElementById(id); 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; +// Switching tasks hides existing controls; it never replaces form nodes or submits them. +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 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)}); } catch { throw new Error('无法连接本机服务,请确认核验台已启动后重试。'); } @@ -22,20 +35,27 @@ async function api(action, body = {}) { if(response.status===403) throw new Error('核验台会话已失效,请重新打开页面后重试。'); if(!response.ok) throw new Error(data.error || '请求未完成,请重试。'); return data; } -function showReport(html){ $('report').srcdoc=html.replaceAll('href="#','href="about:srcdoc#'); } +// Keep one bounded report string and one fresh opaque browsing context. +let reportHtml=''; +function mountReport(){ + const frame=document.createElement('iframe');frame.id='report';frame.title='核验报告';frame.setAttribute('sandbox',''); + frame.srcdoc=reportHtml;$('report').replaceWith(frame); +} +function showReport(html){reportHtml=html.replaceAll('href="#','href="about:srcdoc#');if(!$('result').hidden)mountReport();} +function clearReport(){reportHtml='';$('report').srcdoc='';} function status(text){ $('status').textContent = text; } function lock(value, applying = false){ busy=value; ['verify','plan','reverify','choose','choose-contract','root','contract','kind','delivery','history-refresh','batch-plan','batch-preview','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); $('history-prev').disabled=value||historyOffset===0; $('history-next').disabled=value||historyOffset+12>=historyTotal; $('export-summary').disabled=value||!historyId; $('cancel').disabled=!value || applying; $('apply').disabled=value || !$('confirm').checked || !planId; } -function clearPlan(){planId=null;$('confirm').checked=false;$('fix').hidden=true;$('changes').textContent='';$('apply').disabled=true;} -function invalidate(){ selected=null; planId=null; baselineId=null; historyId=null; $('comparison').textContent=''; $('export-summary').disabled=true; $('result').hidden=true; $('fix').hidden=true; $('confirm').checked=false; $('report').srcdoc=''; status('目录或模式已更改,请重新核验'); } +function clearPlan(){planId=null;$('confirm').checked=false;setFix(false);$('changes').textContent='';$('apply').disabled=true;} +function invalidate(){ selected=null; planId=null; baselineId=null; historyId=null; $('comparison').textContent=''; $('export-summary').disabled=true; hideResult(); setFix(false); $('confirm').checked=false; clearReport(); status('目录或模式已更改,请重新核验'); } ['root','contract','kind','delivery'].forEach(id => $(id).addEventListener('change', () => {invalidate(); $('contract-fields').hidden=$('kind').value!=='artifacts';})); for(const [button,field] of [['choose','root'],['choose-contract','contract']]) $(button).onclick=async()=>{ lock(true,true); status('请在系统窗口中选择;取消后也可直接填写完整路径。'); try { const data=await api('choose',{file:field==='contract'}); $(field).value=data.path; invalidate(); } catch(e){status(e.message);} finally{lock(false);} }; -async function verify(recheck=false){ clearPlan();selected=null;$('plan').hidden=true;lock(true); status(recheck?'正在重新核验…':'正在核验…'); try { - if(!recheck){ await api('select',{path:$('root').value,kind:$('kind').value,contract:$('contract').value,delivery:$('delivery').checked,...(baselineId?{baselineId}:{})}); planId=null; $('fix').hidden=true; $('confirm').checked=false; } - const data=await api('verify'); selected=true; historyId=data.historyId; baselineId=data.baselineId; planId=null; $('confirm').checked=false; $('fix').hidden=true; showComparison(data.comparison); await loadHistory(true); showReport(data.html); $('result').hidden=false; $('plan').hidden=$('kind').value!=='skill'; status((recheck?'重新核验完成':'核验完成')+' · '+data.status); if(recheck) $('receipt').textContent='最新核验:'+data.status; +async function verify(recheck=false){ clearPlan();hideResult();clearReport();selected=null;$('plan').hidden=true;lock(true); status(recheck?'正在重新核验…':'正在核验…'); try { + if(!recheck){ await api('select',{path:$('root').value,kind:$('kind').value,contract:$('contract').value,delivery:$('delivery').checked,...(baselineId?{baselineId}:{})}); planId=null; setFix(false); $('confirm').checked=false; } + const data=await api('verify'); selected=true; historyId=data.historyId; baselineId=data.baselineId; planId=null; $('confirm').checked=false; setFix(false); showComparison(data.comparison); await loadHistory(true); revealResult('single'); showReport(data.html); $('plan').hidden=$('kind').value!=='skill'; status((recheck?'重新核验完成':'核验完成')+' · '+data.status); if(recheck) $('receipt').textContent='最新核验:'+data.status; }catch(e){status(e.message);}finally{lock(false);} } $('verify').onclick=()=>verify(); $('reverify').onclick=()=>verify(true); $('cancel').onclick=async()=>{try{await api('cancel');status('已请求取消,等待核验器退出…');}catch(e){status(e.message);}}; -$('plan').onclick=async()=>{if(!selected)return;clearPlan();lock(true);try{const data=await api('plan');planId=data.id; $('changes').textContent=data.description; $('fix').hidden=false; $('confirm').checked=false; $('receipt').textContent='确认应用后才会修改文件。'; status('计划已生成,请审阅'); await loadHistory(true);}catch(e){status(e.message);}finally{lock(false);}}; +$('plan').onclick=async()=>{if(!selected)return;clearPlan();lock(true);try{const data=await api('plan');planId=data.id; $('changes').textContent=data.description; setFix(true); $('confirm').checked=false; $('receipt').textContent='确认应用后才会修改文件。'; status('计划已生成,请审阅'); await loadHistory(true);}catch(e){status(e.message);}finally{lock(false);}}; $('confirm').onchange=()=>{$('apply').disabled=busy||!$('confirm').checked||!planId;}; $('apply').onclick=async()=>{lock(true,true);try{const data=await api('apply',{id:planId,confirm:$('confirm').checked}); planId=null; $('confirm').checked=false; $('receipt').textContent=data.message; status('已备份并应用,请重新核验'); await loadHistory(true);}catch(e){planId=null;$('confirm').checked=false;status(e.message+';请重新核验当前目录');}finally{lock(false);}}; function showComparison(value){ $('comparison').textContent=!value?'此次结果可作为后续复查基线。':value.status==='compared'?'与基线相比:新增 '+value.added+' · 仍然存在 '+value.ongoing+' · 证据变化 '+(value.changed||0)+' · 本次不再出现 '+value.resolved:'基线无法比较:范围已变化或证据不完整。'; } @@ -47,7 +67,7 @@ async function loadHistory(newest=false){ const signature=JSON.stringify(data.items); if(signature!==renderedHistory){ $('history-list').replaceChildren(); 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; - 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;$('contract-fields').hidden=data.selection.kind!=='artifacts';$('confirm').checked=false;$('fix').hidden=true;$('plan').hidden=true;showReport(data.html);$('result').hidden=false;showComparison(data.comparison);status('正在查看历史记录;点击“核验目录”检查当前文件。');}catch(e){status(e.message);}finally{lock(false);}}; + 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;$('contract-fields').hidden=data.selection.kind!=='artifacts';$('confirm').checked=false;setFix(false);$('plan').hidden=true;revealResult('history');showReport(data.html);showComparison(data.comparison);status('正在查看历史记录;点击“核验目录”检查当前文件。');}catch(e){status(e.message);}finally{lock(false);}}; row.append(label,button);$('history-list').append(row);} renderedHistory=signature; } $('history-info').textContent=data.total+' 条记录 · 保存文件 '+data.capacity.files+'/'+data.capacity.maxFiles+(data.unavailable?' · '+data.unavailable+' 条索引无法读取':''); @@ -58,12 +78,13 @@ $('history-refresh').onclick=()=>loadHistory(true); $('history-prev').onclick=()=>{if(historyLoading)return;historyOffset=Math.max(0,historyOffset-12);loadHistory();}; $('history-next').onclick=()=>{if(historyLoading)return;historyOffset+=12;loadHistory();}; $('export-summary').onclick=async()=>{lock(true,true);try{const data=await api('history/export',{id:historyId});const url=URL.createObjectURL(new Blob([data.html],{type:'text/html;charset=utf-8'})),link=document.createElement('a');link.href=url;link.download='skillsync-comparison.html';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){status(e.message);}finally{lock(false);}}; -function clearBatchContext(){clearPlan();selected=null;historyId=null;baselineId=null;$('plan').hidden=true;$('fix').hidden=true;$('export-summary').disabled=true;$('comparison').textContent='';$('result').hidden=true;$('report').srcdoc='';} +function clearBatchContext(){$('batch-receipt').textContent='';clearPlan();selected=null;historyId=null;baselineId=null;$('plan').hidden=true;setFix(false);$('export-summary').disabled=true;$('comparison').textContent='';hideResult();clearReport();} $('batch-plan').addEventListener('input',()=>{batchReady=false;$('batch-run').disabled=true;$('batch-info').textContent='计划已改变,请重新预检。';}); for(const [button,field]of [['choose-batch-plan','batch-plan'],['choose-batch-package','batch-package']])$(button).onclick=async()=>{lock(true,true);try{const data=await api('choose',{file:true});$(field).value=data.path;if(field==='batch-plan'){batchReady=false;$('batch-info').textContent='请选择预检。';}}catch(e){status(e.message);}finally{lock(false);}}; $('batch-preview').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在预检多批次计划…');try{const data=await api('batch/preview',{path:$('batch-plan').value});batchReady=true;$('batch-output').value=data.suggestedOutput;$('batch-info').textContent=data.batches.map(item=>item.id+' · '+item.files+' 文件 / '+item.bytes+' 字节'+(item.available?'':' · 来源不可用')).join(';');status('计划预检完成,尚未生成复核包。');}catch(e){status(e.message);}finally{lock(false);}}; -function showBatch(data){showReport(data.html);$('result').hidden=false;$('plan').hidden=true;$('fix').hidden=true;const c=data.summary.counts;$('batch-receipt').textContent='复核包完整性已验证 · 通过 '+c.passed+' · 失败 '+c.failed+' · 不完整 '+c.incomplete+' · SHA-256 '+data.package_sha256;$('batch-digest').value=data.package_sha256;status('已读取有界验收证据;原始数据未在打开时重新核验,作者未认证。');} +function showBatch(data){$('plan').hidden=true;setFix(false);const c=data.summary.counts;$('batch-receipt').textContent='复核包完整性已验证 · 通过 '+c.passed+' · 失败 '+c.failed+' · 不完整 '+c.incomplete+' · SHA-256 '+data.package_sha256;$('batch-digest').value=data.package_sha256;status('已读取有界验收证据;原始数据未在打开时重新核验,作者未认证。');revealResult('batch');showReport(data.html);} $('batch-run').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在逐批核验并生成新复核包…');try{const data=await api('batch/run',{path:$('batch-plan').value,output:$('batch-output').value,previous:$('batch-previous').value,retryFailed:$('batch-retry').checked});$('batch-package').value=data.packagePath;showBatch(data);}catch(e){status(e.message);}finally{lock(false);}}; $('batch-open').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在复核已保存的包…');try{showBatch(await api('batch/open',{path:$('batch-package').value,expectSha256:$('batch-digest').value}));}catch(e){status(e.message);}finally{lock(false);}}; +syncWorkspace(); loadHistory(); `; diff --git a/tests/reporters/artifacts-html.test.ts b/tests/reporters/artifacts-html.test.ts index 1249534..0c70e75 100644 --- a/tests/reporters/artifacts-html.test.ts +++ b/tests/reporters/artifacts-html.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { renderArtifacts, type ArtifactReport } from "../../src/cli/commands/artifacts.js"; +import { renderArtifactsFragment } from "../../src/reporters/artifacts-html.js"; import { VERSION } from "../../src/version.js"; function report(): ArtifactReport { @@ -13,6 +14,13 @@ function report(): ArtifactReport { } describe("offline artifact acceptance HTML", () => { + it("isolates batch anchor namespaces and rejects attribute injection in a prefix", () => { + const html=renderArtifactsFragment(report(),"batch-north-"); + expect(html).toContain('id="batch-north-issue-title"'); + expect(html).toContain('href="#batch-north-file-title"'); + expect(html).not.toMatch(/renderArtifactsFragment(report(),'bad" onclick="x')).toThrow("Invalid report section prefix"); + }); it("escapes and bounds declaration metadata with no scripts or external destinations", () => { const value = report(); value.declarations = { limits: { max_files: 3, max_file_bytes: 4096, max_total_bytes: 8192 }, checks: Array.from({ length: 128 }, (_, index) => ({ ordinal: index + 1, check: { type: "csv_summary" as const, csv: "", json: "/private/tmp/PRIVATE_DECLARATION", row_count_field: "rows", sums: Array.from({ length: 128 }, () => ({ column: "&".repeat(128), field: "&".repeat(128) })) } })) }; diff --git a/tests/workbench/batch.test.ts b/tests/workbench/batch.test.ts index 535fbad..6e428f5 100644 --- a/tests/workbench/batch.test.ts +++ b/tests/workbench/batch.test.ts @@ -17,6 +17,10 @@ it('previews then creates and reopens a batch package without enabling Skill rep const post=async(action:string,body={})=>{const response=await fetch(app.url+'/api/'+action,{method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)});return {status:response.status,data:await response.json()};}; const preview=await post('batch/preview',{path});expect(preview.status).toBe(200);expect(preview.data.batches).toHaveLength(2); const output=join(base,'first.json');const result=await post('batch/run',{path,output});expect(result.status).toBe(200);expect(result.data.summary.status).toBe('failed');expect(result.data.html).toContain('Batch acceptance'); + expect(result.data.html).not.toMatch(//g)||[]).length).toBe(1); + expect(result.data.html).toContain('id="batch-north-issues"'); + expect(result.data.html).toContain('id="batch-south-issues"'); const saved=await readFile(output); expect((await post('apply',{id:'old',confirm:true})).status).toBe(400); const reopened=await post('batch/open',{path:output,expectSha256:result.data.package_sha256});expect(reopened.status).toBe(200);expect(reopened.data.integrity).toBe('verified'); diff --git a/tests/workbench/page.test.ts b/tests/workbench/page.test.ts index 98c964e..400bf55 100644 --- a/tests/workbench/page.test.ts +++ b/tests/workbench/page.test.ts @@ -8,7 +8,12 @@ class Element { children: Element[] = []; onclick?: () => Promise | void; onchange?: () => void; + attributes = new Map(); + focused = false; + setAttribute(name: string, value: string) { this.attributes.set(name,value); } + focus() { this.focused = true; } addEventListener() {} + replaceWith(element: Element) { Object.assign(this, element); } replaceChildren() { this.children = []; } append(...elements: Element[]) { this.children.push(...elements); } } @@ -49,6 +54,18 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de } describe("workbench interactions", () => { + it("switches task surfaces without submitting or losing entered paths and approved-plan state", async () => { + const ui = await page(); await ui.click("verify"); await ui.click("plan"); + ui.get("root").value="/synthetic/kept"; ui.get("batch-plan").value="/synthetic/plan.json"; + ui.get("confirm").checked=true;ui.get("confirm").onchange?.(); + await ui.click("nav-batch"); + expect(ui.get("task-single").hidden).toBe(true);expect(ui.get("task-batch").hidden).toBe(false); + expect(ui.get("fix").hidden).toBe(true);expect(ui.get("result").hidden).toBe(true); + await ui.click("nav-single"); + expect(ui.get("root").value).toBe("/synthetic/kept");expect(ui.get("batch-plan").value).toBe("/synthetic/plan.json"); + expect(ui.get("confirm").checked).toBe(true);expect(ui.get("fix").hidden).toBe(false); + expect(ui.get("selection").focused).toBe(true);expect(ui.get("nav-single").attributes.get("aria-pressed")).toBe("true"); + }); it.each([["reverify", "verify", "Synthetic verification failed"], ["reverify", "verify", "核验已取消"], ["plan", "plan", "Synthetic plan failed"]])("revokes the old plan when %s does not complete: %s", async (button, action, message) => { const ui = await page(); await ui.click("verify"); await ui.click("plan"); ui.get("confirm").checked = true; ui.get("confirm").onchange?.(); @@ -57,6 +74,13 @@ describe("workbench interactions", () => { expect(ui.get("fix").hidden).toBe(true); expect(ui.get("confirm").checked).toBe(false); expect(ui.get("apply").disabled).toBe(true); }); + it("hides stale evidence when a later verification fails", async () => { + const ui=await page();await ui.click("verify"); + expect(ui.get("result").hidden).toBe(false); + ui.fail("verify","Synthetic verification failed");await ui.click("verify"); + expect(ui.get("result").hidden).toBe(true);expect(ui.get("report").srcdoc).toBe(""); + expect(ui.get("verify").disabled).toBe(false); + }); it("refreshes the latest result even when the initial history read arrives later", async () => { const ui = await page(undefined, true); await ui.click("verify"); await ui.finishInitialHistory();