From 24d35cde73316c05b5599c4fd04be2202e61a1b9 Mon Sep 17 00:00:00 2001 From: abel Date: Sat, 1 Aug 2026 00:34:43 +0800 Subject: [PATCH 1/4] fix(reports): keep finding fix actions portable Derive finding-bound callbacks at copy time from a validated workspace-relative report route. Preserve raw reviewed prompts for context-free, moved, and HTTP reports, omit actions for empty prompts, and keep renderer-added absolute paths out of interaction payloads. Spec: docs/specs/2026-07-30-html-finding-fix-callback.md Test: focused HTML interaction/render suites, Playwright local and HTTP flows, npm test, and pack verification Risk: malformed declared bindings fail closed; local symlink fixtures remain permission-limited --- .../2026-07-30-html-finding-fix-callback.md | 167 ++++++++++++++++++ scripts/harness-analysis/render-report.mjs | 11 +- .../renderers/html-interactions.mjs | 141 +++++++++++++-- scripts/harness-analysis/renderers/html.mjs | 94 +++++++++- templates/reporting/html-visual.md | 21 ++- test/harness-report-render-cli.test.mjs | 137 +++++++++++++- test/html-report-interactions.test.mjs | 165 ++++++++++++++++- 7 files changed, 699 insertions(+), 37 deletions(-) create mode 100644 docs/specs/2026-07-30-html-finding-fix-callback.md diff --git a/docs/specs/2026-07-30-html-finding-fix-callback.md b/docs/specs/2026-07-30-html-finding-fix-callback.md new file mode 100644 index 0000000..324ee12 --- /dev/null +++ b/docs/specs/2026-07-30-html-finding-fix-callback.md @@ -0,0 +1,167 @@ +# Keep copied finding fixes bound without exposing local paths + +## Traceability + +- Spec ID: html-finding-fix-callback +- Review: QoderAI/better-harness#39 +- Status: Implemented + +## Intent + +Make `Copy AI Fix` from a durable Codex HTML report executable by the +activated Better Harness Skill when the report remains in its generated +workspace-relative location. Preserve callback-free report compatibility and +the report's portable reader surface without embedding renderer-added absolute +workspace or artifact paths. + +The callback is local action transport, not an analysis conclusion or +AI-authored reader field. Rendering must leave the persisted `findings.json`, +paired Markdown report, finding scores, and reviewed `aiFixPrompt` unchanged. + +## Acceptance Scenarios + +- AC-1: A report renders two Copy controls only for each finding whose reviewed + `aiFixPrompt` is a non-empty, non-whitespace string. A finding without a + usable prompt retains its details control but has no action row and no + callback metadata. +- AC-2: A bound report embeds only a normalized workspace-relative POSIX route + to the final `report.html`, plus finding ids and current revisions. It does + not embed renderer-added absolute `workspacePath`, `findingsPath`, + `dataPath`, or `target.path` values in either interaction payload. +- AC-3: When the current document is a `file:` URL whose decoded path ends in + the exact embedded report route, Copy derives the workspace root and sibling + final `findings.json` path locally. It appends exactly one + `` block with contract + `better-harness-fix-output/v1`, the derived exact paths, exact finding id, + and the finding's current `actualOutputRevision` or `0`. +- AC-4: When the renderer has no safe action context, or the report is opened + through HTTP or from a file path that does not match the expected route, all + clipboard and manual-copy paths transport the unchanged reviewed + `aiFixPrompt` without a callback or renderer-added local paths. +- AC-5: An intentionally empty action binding enables only the compatibility + fallback. A present but malformed, incomplete, stale, cross-bound, absolute, + or escaping binding fails deterministic HTML validation and never becomes a + compatibility fallback. +- AC-6: Action transport remains finding-scoped across multiple rows and + produces parseable callback JSON for Windows drive paths, Windows UNC paths, + macOS paths, Linux paths, spaces, Unicode, and punctuation. +- AC-7: The final route is derived from the published run directory, never the + temporary staging directory or source findings path. Existing output-location + allocation and replacement behavior remains unchanged. +- AC-8: The rendered `findings.json`, paired `report.md`, source + `aiFixPrompt`, Qoder Canvas behavior, report scoring, finding eligibility, + and visible details content remain unchanged. + +## Non-goals + +- Automatically submit, approve, or execute the copied fix. +- Add a ChatGPT Desktop host bridge, deep link, network request, sidecar action + file, new persisted finding field, or runtime file read. +- Guarantee a finding-bound callback after the HTML file is moved, downloaded + outside its generated route, shared to another machine, or served over HTTP. + Those states retain the reviewed manual prompt only. +- Change Qoder Canvas handoffs or broaden one finding-bound repair into a new + Harness review. +- Repair previously generated HTML files in place. + +## Plan and Tasks + +1. Project the full report into a minimal reader-safe interaction payload + containing only actionable finding ids and unchanged reviewed prompts. + Remove the full `reportData` object from embedded interaction JSON. + (AC-1, AC-2, AC-8) +2. Build deterministic machine action metadata from the target workspace and + final report location. Emit only a safe workspace-relative report route and + per-finding revision metadata; emit an intentionally empty binding when the + report is not safely beneath the workspace. (AC-1, AC-2, AC-5, AC-7) +3. Resolve file-local bindings inside the self-contained interaction + controller. Require exact route suffix matching, derive platform-native + workspace and sibling findings paths, and append the v1 callback only at + copy time. (AC-3, AC-6) +4. Preserve the raw-prompt compatibility route for intentionally empty + bindings, HTTP previews, and moved file reports. Keep malformed declared + bindings fail-closed. (AC-4, AC-5) +5. Render and validate Copy controls only for usable prompts. Validate exact + interaction and action payload projections plus per-finding action counts. + (AC-1, AC-5) +6. Add unit, renderer, CLI, and browser regression coverage for callback + generation, fallback states, empty prompts, binding tampering, final routes, + and cross-platform URL/path conversion. (AC-1..AC-8) + +## Test and Review Evidence + +- Interaction coverage: + `node --test test/html-report-interactions.test.mjs` must verify original + file binding, callback JSON parsing, Clipboard API/legacy/manual parity, + intentional-empty fallback, HTTP fallback, moved-file fallback, malformed + binding failure, and Windows/macOS/Linux paths. +- Renderer and CLI coverage: + `node --test test/harness-report-render-cli.test.mjs` must verify minimal + embedded interaction data, relative final report routes, actionable finding + counts, no renderer-added absolute paths, no staging routes, exact revisions, + and validator rejection of tampering. +- AC-8: compare rendered `findings.json` and Markdown semantics with the input, + and run the existing Canvas and report-render suites. +- Documentation integrity: + `node scripts/doc-link-graph/cli.mjs skills/better-harness`, then + `node --test test/doc-link-graph.test.mjs`. +- Complete validation: `npm test`, `npm run pack:verify`, and + `git diff --check`. +- Visual validation: serve a generated HTML report through the local HTML + preview, use Playwright to inspect console/page errors, exercise raw-prompt + HTTP fallback and the details dialog, and retain a screenshot. +- Repository preview smoke: run `npm run preview`, then request + `http://localhost:58575/health` and + `http://localhost:58575/canvas-module.js`. If the external Canvas SDK + runtime is unavailable, record the limitation separately rather than + claiming success. +- Risk: route suffix matching could bind a moved report to the wrong workspace. + Accept only safe relative segments and an exact decoded `file:` suffix; + otherwise transport only the reviewed prompt. +- Risk: Windows URL decoding and separator conversion could create invalid JSON + or paths. Generate the callback with `JSON.stringify` and parse it in + cross-platform fixtures. +- Risk: compatibility fallback could hide a corrupted binding. Distinguish an + explicit empty binding from any declared malformed or mismatched binding and + keep the latter fail-closed. + +## Revision Baseline + +- PR #39 review confirmed that callback-free renders expose Copy buttons whose + interaction controller can no longer find the original prompt. +- The same review confirmed that empty prompts currently receive callback-only + actions and that the hand-written Windows callback fixture is invalid JSON. +- The pre-change HTML already embeds absolute `dataPath` and `target.path`; + the first callback implementation additionally embeds `workspacePath` and + `findingsPath`. This revision removes renderer-added absolute paths from both + interaction payloads rather than documenting a local-path disclosure. + +## Implementation Evidence + +- The renderer now embeds only actionable finding ids/prompts and a separate + final workspace-relative report route with ids/revisions. Context-free or + unsafe output locations emit `{ reportRoute: null, findings: [] }`. +- The browser controller validates both minimal payloads, derives native + workspace and sibling `findings.json` paths only for an exact matching + `file:` route, and uses the same computed text for Clipboard API, legacy, and + manual-copy paths. HTTP and moved-file routes preserve the raw reviewed + prompt; malformed declared bindings fail closed. +- Copy controls and binding rows are omitted for empty or whitespace-only + prompts while details remain visible. The HTML validator enforces the minimal + payloads, relative route, revision, finding binding, and actionable counts. +- `node --test test/html-report-interactions.test.mjs` passed 9/9, including + Windows drive, Windows UNC, macOS, Linux Unicode, local-file, HTTP, moved-file, + empty-binding, and malformed-binding cases. All HTML-focused render tests + passed; the enclosing file has one unrelated local symlink-permission case. +- Playwright verified the original local file callback fields and the raw-prompt + HTTP fallback, opened the details dialog, retained a screenshot, and found no + report-script warning or error. The temporary HTTP server produced only a + missing `favicon.ico` 404. +- `npm run pack:verify` passed with 365 npm entries and 388 runtime ZIP entries. + The full `npm test` run passed 1036/1046 with 6 platform skips; the remaining + 4 failures are local Windows `EPERM` errors from tests that require symlink + creation and do not touch this change. +- `npm run preview` could not start because no Canvas SDK runtime or + `CANVAS_SDK_MEDIA_DIR` / `CANVAS_SDK_ROOT` is available, so `/health` and + `/canvas-module.js` were unavailable. The self-contained HTML browser route + has no Canvas runtime dependency. diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index cae9499..5d1fa80 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -178,7 +178,10 @@ async function writeArtifacts({ reportData, artifactDir, runDir }) { await writeJson(path.join(artifactDir, "findings.json"), findingsJson); artifacts.push(artifact("findings.json", runDir)); await writeFile(path.join(artifactDir, "report.md"), renderMarkdown(reportData)); - await writeFile(path.join(artifactDir, "report.html"), renderHtml(reportData)); + await writeFile( + path.join(artifactDir, "report.html"), + renderHtml(reportData, { findingsPath: path.join(runDir, "findings.json") }), + ); artifacts.push(artifact("report.md", runDir), artifact("report.html", runDir)); } @@ -240,7 +243,11 @@ async function validateArtifacts({ reportData, artifactDir, runDir, artifacts, o allowStandaloneTaskLoop: true, })); if (reportData.mode === "html") { - checks.push(evaluateHtmlReport(readFileSync(path.join(artifactDir, "report.html"), "utf8"), reportData)); + checks.push(evaluateHtmlReport( + readFileSync(path.join(artifactDir, "report.html"), "utf8"), + reportData, + { findingsPath: path.join(runDir, "findings.json") }, + )); } } diff --git a/scripts/harness-analysis/renderers/html-interactions.mjs b/scripts/harness-analysis/renderers/html-interactions.mjs index 625a13c..0faf02e 100644 --- a/scripts/harness-analysis/renderers/html-interactions.mjs +++ b/scripts/harness-analysis/renderers/html-interactions.mjs @@ -1,19 +1,140 @@ export function installHtmlReportInteractions({ document, navigator, + location, labels, schedule = (callback, delay) => globalThis.setTimeout(callback, delay), }) { document.documentElement.classList.remove("no-js"); - const reportDataElement = document.getElementById("harness-report-data"); - const reportData = reportDataElement - ? JSON.parse(reportDataElement.textContent || "{}") - : {}; - const findings = new Map( - (Array.isArray(reportData.findings) ? reportData.findings : []) - .map((finding) => [String(finding?.id ?? ""), finding]), - ); + function readJson(id) { + const element = document.getElementById(id); + if (!element) return { ok: false, value: null }; + try { + return { ok: true, value: JSON.parse(element.textContent || "{}") }; + } catch { + return { ok: false, value: null }; + } + } + + function hasOnlyKeys(value, keys) { + return value && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).every((key) => keys.includes(key)); + } + + function safeReportRoute(value) { + if (typeof value !== "string" || value.length === 0 || value.includes("\\")) return false; + if (value.startsWith("/") || /^[A-Za-z]:/u.test(value)) return false; + const segments = value.split("/"); + return segments.at(-1) === "report.html" + && segments.every((segment) => segment && segment !== "." && segment !== ".."); + } + + const reportDataState = readJson("harness-report-data"); + const reportActionsState = readJson("harness-report-actions"); + const prompts = new Map(); + const actions = new Map(); + let bindingMode = "invalid"; + let reportRoute = null; + + if (reportDataState.ok + && hasOnlyKeys(reportDataState.value, ["findings"]) + && Array.isArray(reportDataState.value.findings)) { + let validPrompts = true; + for (const finding of reportDataState.value.findings) { + const id = String(finding?.id ?? ""); + if (!hasOnlyKeys(finding, ["id", "aiFixPrompt"]) + || !id + || typeof finding.aiFixPrompt !== "string" + || finding.aiFixPrompt.trim().length === 0 + || prompts.has(id)) { + validPrompts = false; + break; + } + prompts.set(id, finding.aiFixPrompt); + } + + const payload = reportActionsState.value; + if (validPrompts + && reportActionsState.ok + && hasOnlyKeys(payload, ["reportRoute", "findings"]) + && Array.isArray(payload.findings)) { + let validActions = true; + for (const finding of payload.findings) { + const id = String(finding?.id ?? ""); + if (!hasOnlyKeys(finding, ["id", "expectedRevision"]) + || !id + || !Number.isInteger(finding.expectedRevision) + || finding.expectedRevision < 0 + || actions.has(id)) { + validActions = false; + break; + } + actions.set(id, finding); + } + const sameIds = actions.size === prompts.size + && [...prompts.keys()].every((id) => actions.has(id)); + if (validActions && payload.reportRoute === null && actions.size === 0) { + bindingMode = "empty"; + } else if (validActions && safeReportRoute(payload.reportRoute) && sameIds) { + bindingMode = "bound"; + reportRoute = payload.reportRoute; + } + } + } + + function resolveBindingPaths() { + try { + const url = new URL(String(location?.href ?? "")); + if (url.protocol !== "file:") return null; + const pathname = decodeURIComponent(url.pathname); + const windowsDrive = /^\/[A-Za-z]:\//u.test(pathname); + const windowsUnc = Boolean(url.hostname); + const suffix = `/${reportRoute}`; + const comparablePath = windowsDrive || windowsUnc ? pathname.toLowerCase() : pathname; + const comparableSuffix = windowsDrive || windowsUnc ? suffix.toLowerCase() : suffix; + if (!comparablePath.endsWith(comparableSuffix)) return null; + + const workspaceUrlPath = pathname.slice(0, pathname.length - suffix.length); + const reportDirectory = pathname.slice(0, pathname.lastIndexOf("/")); + const findingsUrlPath = `${reportDirectory}/findings.json`; + const toNativePath = (value) => { + if (windowsDrive) return value.slice(1).replace(/\//gu, "\\"); + if (windowsUnc) { + const host = decodeURIComponent(url.hostname); + return `\\\\${host}${value.replace(/\//gu, "\\")}`; + } + return value || "/"; + }; + return { + workspacePath: toNativePath(workspaceUrlPath), + findingsPath: toNativePath(findingsUrlPath), + }; + } catch { + return null; + } + } + + function promptForFinding(findingId) { + if (bindingMode === "invalid") return null; + const id = String(findingId); + const prompt = prompts.get(id); + if (typeof prompt !== "string" || prompt.trim().length === 0) return null; + if (bindingMode === "empty") return prompt; + const paths = resolveBindingPaths(); + if (!paths) return prompt; + const action = actions.get(id); + if (!action) return null; + const callback = { + contract: "better-harness-fix-output/v1", + workspacePath: paths.workspacePath, + findingsPath: paths.findingsPath, + findingId: id, + expectedRevision: action.expectedRevision, + }; + return `${prompt}\n\n\n${JSON.stringify(callback)}\n`; + } + const status = document.getElementById("copy-status"); const manualDialog = document.getElementById("manual-copy-dialog"); const manualText = document.getElementById("manual-copy-text"); @@ -55,7 +176,7 @@ export function installHtmlReportInteractions({ } async function copyFinding(findingId, trigger) { - const prompt = findings.get(String(findingId))?.aiFixPrompt; + const prompt = promptForFinding(findingId); if (typeof prompt !== "string" || prompt.length === 0) { report(labels.missingPrompt, "error"); return false; @@ -155,5 +276,5 @@ export function renderHtmlInteractionScript(language) { manualCopy: "Automatic copy was blocked. Copy the selected prompt manually.", missingPrompt: "No AI Fix prompt is available for this finding.", }; - return ``; + return ``; } diff --git a/scripts/harness-analysis/renderers/html.mjs b/scripts/harness-analysis/renderers/html.mjs index 482a3ee..c909ddd 100644 --- a/scripts/harness-analysis/renderers/html.mjs +++ b/scripts/harness-analysis/renderers/html.mjs @@ -1,3 +1,5 @@ +import path from "node:path"; + import { isAgentWorkLoopReport } from "../fluency-dimensions.mjs"; import { renderHtmlInteractionScript } from "./html-interactions.mjs"; import { renderMarkdown } from "./markdown.mjs"; @@ -67,6 +69,48 @@ function serializeForScript(value) { .replace(/\u2029/gu, "\\u2029"); } +function hasAiFixPrompt(finding) { + return typeof finding?.aiFixPrompt === "string" && finding.aiFixPrompt.trim().length > 0; +} + +export function buildHtmlInteractionData(reportData) { + return { + findings: list(reportData?.findings) + .flatMap((finding, index) => (hasAiFixPrompt(finding) + ? [{ id: String(finding?.id ?? index), aiFixPrompt: finding.aiFixPrompt }] + : [])), + }; +} + +function finalReportRoute(workspacePath, findingsPath) { + if (!path.isAbsolute(workspacePath) || !path.isAbsolute(findingsPath)) return null; + const reportPath = path.join(path.dirname(findingsPath), "report.html"); + const relative = path.relative(workspacePath, reportPath); + if (!relative || path.isAbsolute(relative)) return null; + const segments = relative.split(/[\\/]/u); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null; + return segments.join("/"); +} + +export function buildHtmlReportActions(reportData, { findingsPath } = {}) { + const workspacePath = String(reportData?.target?.path ?? ""); + const resolvedFindingsPath = String(findingsPath ?? ""); + const reportRoute = finalReportRoute(workspacePath, resolvedFindingsPath); + if (!reportRoute) return { reportRoute: null, findings: [] }; + return { + reportRoute, + findings: list(reportData?.findings) + .flatMap((finding, index) => (hasAiFixPrompt(finding) + ? [{ + id: String(finding?.id ?? index), + expectedRevision: Number.isInteger(finding?.actualOutputRevision) + ? finding.actualOutputRevision + : 0, + }] + : [])), + }; +} + function findingPromptParts(prompt, language) { const lines = String(prompt ?? "").split(/\r?\n/u).map((line) => line.trim()); const validationIndex = lines.findIndex((line) => /^##\s+Validation$/iu.test(line)); @@ -234,6 +278,7 @@ function renderFindings(reportData, language) { const findings = list(reportData.findings); if (findings.length === 0) return `

${renderVisibleText(copy(language, "No reviewed findings were retained.", "没有保留已复核 finding。"), language)}

`; return `
${findings.map((row, index) => { + const actionable = hasAiFixPrompt(row); const prompt = findingPromptParts(row.aiFixPrompt, language); const expected = textLines(row.expectedOutput ?? row.expectedOutcome); const refs = list(row.dimensionRefs).map((ref) => dimensions.get(ref) ?? ref); @@ -254,7 +299,7 @@ function renderFindings(reportData, language) {

${renderVisibleText(row.reason ?? "", language)}

- + ${actionable ? `` : ""}
@@ -266,7 +311,7 @@ function renderFindings(reportData, language) { ${expected.length ? `
${renderVisibleText(copy(language, "Expected Output", "预期结果"), language)}
    ${expected.map((item) => `
  1. ${renderVisibleText(item, language)}
  2. `).join("")}
` : ""} ${prompt.checks.length ? `
${renderVisibleText(copy(language, "Acceptance Checks", "验收检查"), language)}
    ${prompt.checks.map((item) => `
  • ${renderVisibleText(item, language)}
  • `).join("")}
` : ""}
- + ${actionable ? `` : ""}
@@ -375,9 +420,11 @@ function renderHtmlBody(reportData) { ${section("methodology", copy(language, "05 · Boundary", "05 · 边界"), copy(language, "Evidence and methodology", "证据与方法"), renderEvidence(summary, language), copy(language, "Reader-safe evidence only.", "仅使用读者安全证据。"), language)}`; } -export function renderHtml(reportData) { +export function renderHtml(reportData, actionContext) { const language = reportData.language === "zh-CN" ? "zh-CN" : "en"; const title = copy(reportData.language, "Harness Insights", "Harness 洞察"); + const reportActions = buildHtmlReportActions(reportData, actionContext); + const interactionData = buildHtmlInteractionData(reportData); return ` @@ -443,14 +490,15 @@ ${renderHtmlBody(reportData)} - + + ${renderHtmlInteractionScript(reportData.language)} `; } -export function evaluateHtmlReport(htmlText, reportData) { +export function evaluateHtmlReport(htmlText, reportData, actionContext) { const text = String(htmlText ?? ""); const errors = []; const warnings = []; @@ -459,6 +507,7 @@ export function evaluateHtmlReport(htmlText, reportData) { ["viewport", //iu], + ["embedded report actions", //iu], ["interaction controller", //iu], ["copy status", /id="copy-status"[^>]+role="status"[^>]+aria-live="polite"/iu], ["manual copy fallback", /([\s\S]*?)<\/script>/iu, + )?.[1]; + if (actionPayloadText !== undefined) { + try { + const actualActions = JSON.parse(actionPayloadText); + const expectedActions = buildHtmlReportActions(reportData, actionContext); + if (JSON.stringify(actualActions) !== JSON.stringify(expectedActions)) { + errors.push("report.html finding action payload does not match the exact relative binding metadata"); + } + } catch (error) { + errors.push(`report.html finding action payload is invalid: ${error.message}`); + } + } + const interactionPayloadText = text.match( + /([\s\S]*?)<\/script>/iu, + )?.[1]; + if (interactionPayloadText !== undefined) { + try { + const actualData = JSON.parse(interactionPayloadText); + const expectedData = buildHtmlInteractionData(reportData); + if (JSON.stringify(actualData) !== JSON.stringify(expectedData)) { + errors.push("report.html interaction data does not match the minimal reviewed prompt projection"); + } + } catch (error) { + errors.push(`report.html interaction data is invalid: ${error.message}`); + } + } const interactionController = text.match( /([\s\S]*?)<\/script>/iu, )?.[1] ?? ""; @@ -508,8 +585,9 @@ export function evaluateHtmlReport(htmlText, reportData) { errors.push(`report.html finding dialog count ${findingDialogs} does not match reviewed findings ${list(reportData?.findings).length}`); } const copyActions = (text.match(/data-copy-finding=/gu) ?? []).length; - if (copyActions !== list(reportData?.findings).length * 2) { - errors.push(`report.html copy action count ${copyActions} does not match expected ${list(reportData?.findings).length * 2}`); + const actionableFindings = list(reportData?.findings).filter(hasAiFixPrompt); + if (copyActions !== actionableFindings.length * 2) { + errors.push(`report.html copy action count ${copyActions} does not match expected ${actionableFindings.length * 2}`); } const detailActions = (text.match(/data-view-finding-dialog=/gu) ?? []).length; if (detailActions !== list(reportData?.findings).length) { @@ -521,7 +599,7 @@ export function evaluateHtmlReport(htmlText, reportData) { const bindings = [ ["card", `data-finding-id="${findingId}"`, 1], ["dialog", `data-finding-dialog-id="${findingId}"`, 1], - ["copy action", `data-copy-finding="${findingId}"`, 2], + ["copy action", `data-copy-finding="${findingId}"`, hasAiFixPrompt(finding) ? 2 : 0], ["detail action", `data-view-finding-dialog="${dialogId}"`, 1], ["close action", `data-close-finding-dialog="${dialogId}"`, 1], ]; diff --git a/templates/reporting/html-visual.md b/templates/reporting/html-visual.md index 233b7ff..d601ac3 100644 --- a/templates/reporting/html-visual.md +++ b/templates/reporting/html-visual.md @@ -63,11 +63,22 @@ raw evidence anchors in an evidence table or appendix. HTML cannot assume a host chat API, native prompt injection, or host-specific deep links. For each shared finding/action row, render compact `Copy AI Fix` -and `View details` controls when an AI fix exists. Copy the exact reviewed -prompt locally, report copy success truthfully, and fall back to selected -manual-copy text when browser clipboard paths fail. Keep the full cause, -expected output, and acceptance checks available in the scoped details dialog -and as readable no-JavaScript and print content. +and `View details` controls only when a non-empty reviewed AI fix exists; keep +`View details` when it does not. Keep `aiFixPrompt` unchanged in +`findings.json`. Embed only the actionable id/prompt projection plus a separate +machine binding containing the final `report.html` workspace-relative POSIX +route, finding id, and current output revision. Do not embed renderer-added +absolute workspace or artifact paths. + +When opened from its original matching `file:` location, derive the workspace +and sibling final `findings.json` paths at copy time and append one +renderer-owned `` callback. For HTTP previews, moved +files, or intentionally context-free renders, copy the unchanged reviewed +prompt without a callback. Keep malformed declared bindings fail-closed. Report +copy success truthfully and use the same computed text for clipboard, legacy, +and selected manual-copy fallbacks. Keep the full cause, expected output, and +acceptance checks in the scoped details dialog and readable no-JavaScript and +print content. ## Markdown Report Addendum diff --git a/test/harness-report-render-cli.test.mjs b/test/harness-report-render-cli.test.mjs index 4c76159..8135f19 100644 --- a/test/harness-report-render-cli.test.mjs +++ b/test/harness-report-render-cli.test.mjs @@ -117,6 +117,15 @@ function sampleFindings() { }; } +function embeddedJson(html, id) { + const payload = html.match(new RegExp( + `