From b784ecaef8f96e61e636584d69dd98c26c5c52fe Mon Sep 17 00:00:00 2001 From: chendongdong Date: Thu, 30 Jul 2026 14:48:04 +0800 Subject: [PATCH 1/5] fix(core-change-watch): keep project evidence current Require framework-specific detection signals, expose bounded root Just recipes as static unverified argv entrypoints, and filter current-work projections without removing raw history. Implements docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md. Validated with focused red/green core tests, real database-caching projection assertions, doc-link tests, and npm run check (884/884). Co-authored-by: Codex (GPT 5.6 Sol) --- CHANGELOG.md | 4 + ...-change-watch-profile-and-current-paths.md | 95 +++++++++++++++++++ .../project-harness/core-change-watch.md | 19 ++++ scripts/core-change-watch/core-candidates.mjs | 3 +- scripts/core-change-watch/evidence-pack.mjs | 65 ++++++++----- scripts/core-change-watch/project-profile.mjs | 66 ++++++++++++- test/core-change-watch.test.mjs | 93 ++++++++++++++++++ 7 files changed, 320 insertions(+), 25 deletions(-) create mode 100644 docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6a1ac..5f62c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ observable behavior and compatibility, not every internal refactor. (`qoder, codex, claude, cursor, qwen, copilot`) when it rejects an unsupported `--platform`, matching the session-analysis and asset-baseline gates. The existing error prefix and exit behavior are unchanged. +- Core Change Watch now requires framework-specific evidence before labeling + Rails or FastAPI, exposes bounded root Just recipes as statically discovered + unverified argv entrypoints, and keeps historical-only files out of current + recommended reads and action targets. ## 0.3.0 - 2026-07-27 diff --git a/docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md b/docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md new file mode 100644 index 0000000..40fc9a2 --- /dev/null +++ b/docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md @@ -0,0 +1,95 @@ +# Core Change Watch Profile And Current Paths + +## Traceability + +- Spec ID: 2026-07-30-core-change-watch-profile-and-current-paths +- Status: Implemented +- Issue: None; focused evidence-correctness maintenance discovered by a validated Better Harness report. + +## Intent + +Keep Core Change Watch's static project profile and current-work guidance +truthful. Framework labels must require framework-specific evidence, supported +root Just recipes must be visible without being executed, and current reads or +actions must not point to files that exist only in bounded Git history. + +## Acceptance Scenarios + +- AC-1: A Python project with FastAPI declared in a supported Python manifest + and generic `app/api` or `app/services` paths is identified as FastAPI, is not + identified as Rails, and receives no TypeScript-only path reason. A real + Rails fixture remains identified from Rails-specific evidence. +- AC-2: Public recipes in a root `justfile`, `Justfile`, or `.justfile` are + represented additively as entry candidates with `kind: "just-recipe"`, an + argv-style `command`, the source path, and `executionStatus: "unverified"`. + Parsing is static, bounded, handles LF and CRLF, ignores private recipes, and + never runs Just or a shell. +- AC-3: `recommendedReads` and every `followUpActions[].files` entry resolve to + a currently present tracked file or a currently present changed/untracked + file. Deleted historical paths remain available in raw history evidence but + are not projected as current work targets. +- AC-4: Focused Core Change Watch tests, repository documentation checks, and + the full package gate pass without a new dependency. + +## Non-Goals + +- Session lane/lead population binding or source-fingerprint changes. +- Renderer progress-track ARIA changes. +- Agent Work Loop score changes or synthetic Task Episodes. +- Executing Just recipes, shell-string dispatch, or proving command success. +- Adding dependencies or a general TOML/Just parser. +- Editing generated reports, installed plugin caches, Session content, Memory, + or user-home data. +- Rewriting or deleting valid raw Git-history evidence. + +## Plan And Tasks + +Allowed files, copied verbatim from the approved Worker package: + +1. `docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md` +2. `scripts/core-change-watch/project-profile.mjs` +3. `scripts/core-change-watch/core-candidates.mjs` +4. `scripts/core-change-watch/evidence-pack.mjs` +5. `test/core-change-watch.test.mjs` +6. `references/project-harness/core-change-watch.md` +7. `CHANGELOG.md` +8. `docs/better-harness-doc-links.mmd` + +Tasks: + +1. Add focused regression fixtures for AC-1 through AC-3 and preserve the + defect-specific failing run before production edits. +2. Tighten framework/path evidence and add bounded static Just recipe + projection without changing existing entry-candidate meanings. +3. Filter history-derived current-work projections through present repository + inventory while preserving the history profile. +4. Update the canonical Core Change Watch guidance and changelog, regenerate + the Markdown routing graph, and mark this spec Implemented only after all + acceptance evidence passes. +5. Run the change-traceability Review Readiness Check before commit or review. + +## Test And Review Evidence + +- The focused red run failed all three new regressions for the intended causes: + missing FastAPI evidence, missing Just recipe projection, and a deleted + historical path projected as current work. The same run passed 3/3 after the + implementation. +- The real `database-caching` projection reports only `fastapi`, exposes + `just sync`, `just api-dev`, `just health`, and `just check` with unverified + status, uses no TypeScript reason for `app/api`, and emits only readable + recommended/action paths. +- AC-1 to AC-3 red/green: + `node --test --test-name-pattern='framework-specific evidence|Just recipes|non-current historical paths' test/core-change-watch.test.mjs` +- Focused suite: `node --test test/core-change-watch.test.mjs` +- Real target projection: run `analyzeProjectProfile`, + `analyzeCoreCandidates`, and `buildEvidencePack` against `database-caching`; + assert FastAPI/no Rails, argv Just entries with unverified status, neutral + `app/api` reasons, and readable current-work paths. +- Documentation: regenerate the routing graph and run + `node --test test/doc-link-graph.test.mjs`. +- Package gate: `TMPDIR= npm run check` passed with 884/884 + tests plus npm-package and runtime-zip verification. The clean temp root + avoids unrelated `/tmp/CLAUDE.md` ancestor-instruction contamination. +- Risk review: verify additive output fields, cross-platform path handling, + preserved Rails and raw-history fixtures, no shell execution, no dependency + changes, and an explicit AI co-author marker at commit time. diff --git a/references/project-harness/core-change-watch.md b/references/project-harness/core-change-watch.md index fb08aa0..3f52fbb 100644 --- a/references/project-harness/core-change-watch.md +++ b/references/project-harness/core-change-watch.md @@ -12,6 +12,25 @@ the resulting boundary evidence-qualified in visible output: say "inferred core boundary" or "affected core files", and name concrete source paths when they support the claim. +## Project Profile And Current-Work Paths + +Framework labels require framework-specific manifest or convention evidence. +Generic directories such as `app/api` and `app/services` remain useful +language-neutral path signals, but they do not establish Rails, TypeScript, or +another framework by themselves. + +A root `justfile`, `Justfile`, or `.justfile` contributes bounded public recipe +entry candidates. These rows expose an argv-style command and retain +`executionStatus: "unverified"`; collection parses the file statically and does +not run Just or a shell. Private recipes remain outside the command surface. + +Raw bounded history may retain files that no longer exist because those paths +remain valid historical facts. Before `recommendedReads` and follow-up action +file lists are emitted, Core Change Watch intersects their candidates with the +currently present tracked inventory plus currently present changed/untracked +files. Do not interpret a historical hot path as a current edit target unless +it passes that projection boundary. + ## High-Impact Expansion Expand before synthesis for Core, platform, IDE plugin, LSP, auth, tool diff --git a/scripts/core-change-watch/core-candidates.mjs b/scripts/core-change-watch/core-candidates.mjs index 7d20758..200bc6c 100644 --- a/scripts/core-change-watch/core-candidates.mjs +++ b/scripts/core-change-watch/core-candidates.mjs @@ -37,7 +37,7 @@ const CORE_PATH_PATTERNS = [ { pattern: /^cmd\/[^/]+/i, score: 14, reason: "go command entrypoint" }, { pattern: /^src\/main\/java\//i, score: 24, reason: "java production source" }, { pattern: /^packages\/[^/]+/i, score: 16, reason: "workspace package" }, - { pattern: /^app\/api(\/|$)/i, score: 20, reason: "typescript api route" }, + { pattern: /^app\/api(\/|$)/i, score: 20, reason: "api route path" }, { pattern: /^src\/(controller|controllers|service|services|entity|repository|security)(\/|$)/i, score: 18, reason: "framework source path" }, { pattern: /^app\/(http|models|services|providers|policies|jobs|console|controllers|models)(\/|$)/i, score: 18, reason: "framework app path" }, { pattern: /^routes(\/|$)/i, score: 12, reason: "framework route path" }, @@ -45,6 +45,7 @@ const CORE_PATH_PATTERNS = [ ]; const FRAMEWORK_PATH_SIGNALS = [ + { framework: "fastapi", pattern: /^app\/api(\/|$)/, score: 18, reason: "fastapi route path" }, { framework: "laravel", pattern: /^app\/(Http|Models|Services|Providers|Policies|Jobs|Console)(\/|$)/, score: 22, reason: "laravel application path" }, { framework: "laravel", pattern: /^routes(\/|$)/, score: 14, reason: "laravel route path" }, { framework: "symfony", pattern: /^src\/(Controller|Entity|Service|Security|Repository|MessageHandler)(\/|$)/, score: 22, reason: "symfony application path" }, diff --git a/scripts/core-change-watch/evidence-pack.mjs b/scripts/core-change-watch/evidence-pack.mjs index cdfdbba..3515587 100644 --- a/scripts/core-change-watch/evidence-pack.mjs +++ b/scripts/core-change-watch/evidence-pack.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node +import { existsSync } from "node:fs"; +import path from "node:path"; + import { DEFAULT_MAX_COMMITS, SCHEMA_VERSION, @@ -8,6 +11,7 @@ import { isCli, isSupportingFile, languageFor, + listTrackedFiles, normalizeHistoryWindows, normalizeLanguages, option, @@ -22,8 +26,8 @@ import { analyzeDiffImpact } from "./diff-impact.mjs"; import { analyzeGitHistoryProfile } from "./git-history-profile.mjs"; import { analyzeProjectProfile } from "./project-profile.mjs"; -function addRead(reads, path, reason, priority = 50) { - if (!path) { +function addRead(reads, path, reason, priority = 50, currentPaths = null) { + if (!path || (currentPaths && !currentPaths.has(path))) { return; } const role = fileRoleFor(path); @@ -44,32 +48,32 @@ function addRead(reads, path, reason, priority = 50) { }); } -function recommendedReads(core, diff, profile, maxReads) { +function recommendedReads(core, diff, profile, maxReads, currentPaths) { const reads = new Map(); for (const file of diff.changedFiles) { - addRead(reads, file.path, "changed file", 100); + addRead(reads, file.path, "changed file", 100, currentPaths); } for (const hit of diff.coreHits) { - addRead(reads, hit.filePath, `changed core candidate ${hit.path}`, 95); + addRead(reads, hit.filePath, `changed core candidate ${hit.path}`, 95, currentPaths); } for (const hit of diff.hotHits) { - addRead(reads, hit.path, "changed hot file", 90); + addRead(reads, hit.path, "changed hot file", 90, currentPaths); } for (const candidate of core.candidates.slice(0, 8)) { for (const file of candidate.evidence.hotFiles.slice(0, 3)) { - addRead(reads, file.path, `hot file under ${candidate.path}`, 75); + addRead(reads, file.path, `hot file under ${candidate.path}`, 75, currentPaths); } for (const file of candidate.evidence.sourceFiles.slice(0, 2)) { - addRead(reads, file, `representative source under ${candidate.path}`, 55); + addRead(reads, file, `representative source under ${candidate.path}`, 55, currentPaths); } } for (const entry of profile.entryCandidates.slice(0, 5)) { - addRead(reads, entry.path, "entry candidate", 45); + addRead(reads, entry.path, "entry candidate", 45, currentPaths); } return [...reads.values()] @@ -77,12 +81,12 @@ function recommendedReads(core, diff, profile, maxReads) { .slice(0, maxReads); } -function actionFiles(files, limit = 8) { +function actionFiles(files, limit = 8, currentPaths = null) { const seen = new Set(); const result = []; for (const file of files) { const path = typeof file === "string" ? file : file?.path; - if (!path || seen.has(path)) { + if (!path || seen.has(path) || (currentPaths && !currentPaths.has(path))) { continue; } seen.add(path); @@ -98,29 +102,30 @@ function actionFiles(files, limit = 8) { return result; } -function supportingFilesByRole(history, diff, role) { +function supportingFilesByRole(history, diff, role, currentPaths) { const fromDiff = diff.companionHits?.filter((item) => item.role === role) ?? []; const fromHistory = history.supportingHotFiles?.filter((item) => item.role === role) ?? []; - return actionFiles([...fromDiff, ...fromHistory], 8); + return actionFiles([...fromDiff, ...fromHistory], 8, currentPaths); } -function buildFollowUpActions(core, diff, history, changeDrift, maxActions) { +function buildFollowUpActions(core, diff, history, changeDrift, maxActions, currentPaths) { const actions = []; const topCoreFiles = actionFiles(core.candidates.slice(0, 4).flatMap((candidate) => [ ...candidate.evidence.hotFiles, ...candidate.evidence.sourceFiles, - ]), 10); + ]), 10, currentPaths); const changedPrimaryFiles = actionFiles( diff.changedFiles.filter((file) => !file.supporting), 10, + currentPaths, ); - const affectedCoreFiles = actionFiles(diff.coreHits.map((hit) => hit.filePath), 10); - const localizationFiles = supportingFilesByRole(history, diff, "localization"); - const documentationFiles = supportingFilesByRole(history, diff, "documentation"); + const affectedCoreFiles = actionFiles(diff.coreHits.map((hit) => hit.filePath), 10, currentPaths); + const localizationFiles = supportingFilesByRole(history, diff, "localization", currentPaths); + const documentationFiles = supportingFilesByRole(history, diff, "documentation", currentPaths); const testFiles = actionFiles([ ...(history.supportingHotFiles?.filter((item) => item.role === "test" || item.role === "fixture") ?? []), ...diff.changedFiles.filter((item) => item.role === "test" || item.role === "fixture"), - ], 8); + ], 8, currentPaths); if (topCoreFiles.length > 0) { actions.push({ @@ -189,7 +194,7 @@ function buildFollowUpActions(core, diff, history, changeDrift, maxActions) { priority: finding.severity === "high" ? "high" : "medium", title: companionActionTitle(finding), reason: finding.evidence, - files: actionFiles([...finding.triggerFiles, ...finding.candidateCompanionFiles], 8), + files: actionFiles([...finding.triggerFiles, ...finding.candidateCompanionFiles], 8, currentPaths), passCheck: finding.action, }); } @@ -213,6 +218,14 @@ function buildFollowUpActions(core, diff, history, changeDrift, maxActions) { return actions.slice(0, maxActions); } +function currentWorkPaths(repoRoot, diff) { + const candidates = new Set([ + ...listTrackedFiles(repoRoot), + ...diff.changedFiles.map((file) => file.path), + ]); + return new Set([...candidates].filter((filePath) => existsSync(path.resolve(repoRoot, filePath)))); +} + function stageFilters(projectProfile, historyProfile, coreAnalysis, diffImpact) { return { projectProfile: projectProfile.filters, @@ -503,7 +516,15 @@ export async function buildEvidencePack(options = {}) { ignore: options.ignore, changedFiles: diffImpact.changedFiles, }); - const followUpActions = buildFollowUpActions(coreAnalysis, diffImpact, historyProfile, changeDrift, maxFollowUpActions); + const currentPaths = currentWorkPaths(repoRoot, diffImpact); + const followUpActions = buildFollowUpActions( + coreAnalysis, + diffImpact, + historyProfile, + changeDrift, + maxFollowUpActions, + currentPaths, + ); const filters = stageFilters(projectProfile, historyProfile, coreAnalysis, diffImpact); const pack = { schemaVersion: SCHEMA_VERSION, @@ -546,7 +567,7 @@ export async function buildEvidencePack(options = {}) { coreAnalysis, diffImpact, changeDrift, - recommendedReads: recommendedReads(coreAnalysis, diffImpact, projectProfile, maxRecommendedReads), + recommendedReads: recommendedReads(coreAnalysis, diffImpact, projectProfile, maxRecommendedReads, currentPaths), followUpActions, agentGuidance: buildAgentGuidance(historyProfile, coreAnalysis, filters), reviewMatrix: buildReviewMatrix(projectProfile, historyProfile, coreAnalysis, diffImpact, changeDrift, followUpActions), diff --git a/scripts/core-change-watch/project-profile.mjs b/scripts/core-change-watch/project-profile.mjs index 819ac0e..62225c0 100644 --- a/scripts/core-change-watch/project-profile.mjs +++ b/scripts/core-change-watch/project-profile.mjs @@ -53,6 +53,8 @@ const SOURCE_FILES_PER_AGENT_INSTRUCTION = 250; const SIGNIFICANT_SOURCE_ROOT_FILES = 100; const SOURCE_LINE_METHOD = "tracked readable primary source files only; excludes tests, non-source assets, generated/dependency paths, and untracked files"; const SOURCE_LINE_SKIPPED_METHOD = "not measured by default; use --measure-source-lines to count tracked readable primary source files only"; +const ROOT_JUSTFILES = new Set(["justfile", "Justfile", ".justfile"]); +const MAX_JUST_RECIPES = 100; function scriptNames(manifest, filePath) { if (!manifest?.scripts || typeof manifest.scripts !== "object") { @@ -133,6 +135,41 @@ function entryScore(filePath) { return 0; } +function justRecipeScore(name) { + const conventional = new Map([ + ["default", 100], + ["sync", 99], + ["api-dev", 98], + ["health", 97], + ["check", 96], + ]); + return conventional.get(name) ?? 70; +} + +function justRecipeNames(text) { + const recipes = []; + let privateRecipe = false; + for (const line of String(text ?? "").split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (!/^\s/u.test(line) && /^\[[^\]]+\]$/u.test(trimmed)) { + privateRecipe = /(?:^|[\s,])private(?:[\s,]|$)/u.test(trimmed.slice(1, -1)); + continue; + } + const match = !/^\s/u.test(line) + ? /^@?([A-Za-z_][A-Za-z0-9_-]*)\b(?!\s*:=)[^:\r\n]*:(?:\s|$)/u.exec(line) + : null; + if (match) { + if (!privateRecipe && !match[1].startsWith("_")) recipes.push(match[1]); + privateRecipe = false; + if (recipes.length >= MAX_JUST_RECIPES) break; + continue; + } + if (!/^\s/u.test(line)) privateRecipe = false; + } + return [...new Set(recipes)]; +} + function corePathHint(filePath) { const directory = analysisDirectoryFor(filePath); if (/(^|\/)(core|auth|security|permission|permissions|crypto|session|payment|billing|domain|service|api)(\/|$)/i.test(directory)) { @@ -305,6 +342,7 @@ function detectFrameworks(repoRoot, trackedFiles) { || firstManifestWithDependency(composerManifests, "symfony/http-kernel"); const codeIgniterManifest = firstManifestWithDependency(composerManifests, "codeigniter4/framework"); const railsGemfile = firstTextMatch(repoRoot, fileSet, /(^|\/)Gemfile$/, /\bgem\s+["']rails["']/); + const railsRoutes = manifestFiles(fileSet, /(^|\/)config\/routes\.rb$/)[0] ?? ""; const pythonManifestText = joinedManifestText(repoRoot, fileSet, /(^|\/)(requirements\.txt|pyproject\.toml|Pipfile)$/); const javaBuildText = joinedManifestText(repoRoot, fileSet, /(^|\/)(pom\.xml|build\.gradle|build\.gradle\.kts)$/); @@ -323,8 +361,11 @@ function detectFrameworks(repoRoot, trackedFiles) { if (codeIgniterManifest || fileSet.has("app/Config/Routes.php")) { addFrameworkSignal(signals, "codeigniter", codeIgniterManifest ? 70 : 30, codeIgniterManifest ? `${codeIgniterManifest}:codeigniter4/framework` : "app/Config/Routes.php"); } - if (railsGemfile || fileSet.has("config/routes.rb") || hasFileMatching(fileSet, /^app\/(models|controllers|services)\//)) { - addFrameworkSignal(signals, "rails", railsGemfile ? 75 : 35, railsGemfile ? `${railsGemfile}:rails` : "rails app paths"); + if (railsGemfile || railsRoutes) { + addFrameworkSignal(signals, "rails", railsGemfile ? 75 : 45, railsGemfile ? `${railsGemfile}:rails` : railsRoutes); + } + if (/\bfastapi\b/iu.test(pythonManifestText)) { + addFrameworkSignal(signals, "fastapi", 75, "python manifest:fastapi"); } if (/\bdjango\b/i.test(pythonManifestText) || fileSet.has("manage.py") || hasFileMatching(fileSet, /(^|\/)settings\.py$/)) { addFrameworkSignal(signals, "django", /\bdjango\b/i.test(pythonManifestText) ? 70 : 35, /\bdjango\b/i.test(pythonManifestText) ? "python manifest:django" : "manage.py/settings.py"); @@ -513,6 +554,10 @@ function buildProjectInfo({ path: item.path, language: item.language, score: item.score, + ...(item.kind ? { kind: item.kind } : {}), + ...(item.command ? { command: item.command } : {}), + ...(item.sourcePath ? { sourcePath: item.sourcePath } : {}), + ...(item.executionStatus ? { executionStatus: item.executionStatus } : {}), })), manifests: manifests.slice(0, 8), }; @@ -551,6 +596,23 @@ export async function analyzeProjectProfile(options = {}) { continue; } + if (!normalized.includes("/") && ROOT_JUSTFILES.has(normalized)) { + const recipes = justRecipeNames(safeReadText(repoRoot, normalized)); + manifests.push({ path: normalized, kind: "just", scripts: [...recipes].sort() }); + for (const recipe of recipes) { + entryCandidates.push({ + path: normalized, + language: "just", + score: justRecipeScore(recipe), + reasons: compactReasonList(["statically discovered root Just recipe"]), + kind: "just-recipe", + command: ["just", recipe], + sourcePath: normalized, + executionStatus: "unverified", + }); + } + } + if (isManifestFile(normalized)) { const manifest = readJsonFile(repoRoot, normalized); manifests.push({ diff --git a/test/core-change-watch.test.mjs b/test/core-change-watch.test.mjs index 6cba292..0f4e8fd 100644 --- a/test/core-change-watch.test.mjs +++ b/test/core-change-watch.test.mjs @@ -89,6 +89,99 @@ test("project profile identifies language, manifest, source root, and entry sign } }); +test("framework-specific evidence distinguishes FastAPI from generic Rails and TypeScript paths", async () => { + const repo = await makeRepo({ + "pyproject.toml": "[project]\nname = \"fastapi-service\"\ndependencies = [\"fastapi>=0.116\"]\n", + "app/api/routes.py": "def route(): return True\n", + "app/services/search.py": "def search(): return []\n", + }); + + try { + const profile = await analyzeProjectProfile({ cwd: repo }); + const core = await analyzeCoreCandidates({ cwd: repo, profile, maxCommits: 20 }); + + assert.ok(profile.frameworks.some((item) => item.name === "fastapi" && item.confidence === "high")); + assert.equal(profile.frameworks.some((item) => item.name === "rails"), false); + assert.equal( + core.candidates + .filter((item) => item.path === "app/api" || item.path.startsWith("app/api/")) + .some((item) => item.reasons.some((reason) => /typescript/iu.test(reason))), + false, + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("Just recipes are projected statically as unverified argv entry candidates", async () => { + const repo = await makeRepo({ + "Justfile": [ + "default:", + " @just --list", + "sync:", + " uv sync", + "api-dev *args=\"\":", + " uv run uvicorn app.main:app {{args}}", + "health:", + " curl http://localhost:7102/health", + "[private]", + "secret-maintenance:", + " echo hidden", + "check: sync", + " just test", + "", + ].join("\r\n"), + "app/main.py": "def main(): return True\n", + }); + + try { + const profile = await analyzeProjectProfile({ cwd: repo }); + const entries = profile.projectInfo.entryCandidates.filter((item) => item.kind === "just-recipe"); + + for (const recipe of ["default", "sync", "api-dev", "health", "check"]) { + const entry = entries.find((item) => item.command?.join(" ") === `just ${recipe}`); + assert.ok(entry, recipe); + assert.equal(entry.sourcePath, "Justfile"); + assert.equal(entry.executionStatus, "unverified"); + } + assert.equal(entries.some((item) => item.command?.includes("secret-maintenance")), false); + assert.deepEqual( + profile.manifests.find((item) => item.path === "Justfile")?.scripts, + ["api-dev", "check", "default", "health", "sync"], + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("non-current historical paths stay in history but leave current reads and actions", async () => { + const repo = await makeRepo({ + "src/core/current.ts": "export const current = true;\n", + "src/core/legacy.ts": "export const legacy = 0;\n", + }); + + try { + for (let index = 1; index <= 4; index += 1) { + await writeFixtureFile(repo, "src/core/legacy.ts", `export const legacy = ${index};\n`); + git(repo, ["add", "."]); + git(repo, ["commit", "-q", "-m", `touch legacy ${index}`]); + } + git(repo, ["rm", "-q", "src/core/legacy.ts"]); + git(repo, ["commit", "-q", "-m", "remove legacy source"]); + await writeFixtureFile(repo, "src/core/new-service.ts", "export const added = true;\n"); + + const pack = await buildEvidencePack({ cwd: repo, baseRef: "HEAD", maxCommits: 30 }); + const actionPaths = pack.followUpActions.flatMap((action) => action.files ?? []).map((item) => item.path); + + assert.ok(pack.historyProfile.hotFiles.some((item) => item.path === "src/core/legacy.ts")); + assert.equal(pack.recommendedReads.some((item) => item.path === "src/core/legacy.ts"), false); + assert.equal(actionPaths.includes("src/core/legacy.ts"), false); + assert.ok(pack.recommendedReads.some((item) => item.path === "src/core/new-service.ts")); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + test("project profile summarizes project identity, scale, and AGENTS instruction density", async () => { const repo = await makeRepo({ "package.json": JSON.stringify({ From d60eb79729939500a56357cbd8b661987e6d3abf Mon Sep 17 00:00:00 2001 From: chendongdong Date: Thu, 30 Jul 2026 15:18:28 +0800 Subject: [PATCH 2/5] fix(evidence): bind session population across lanes Discover and privacy-filter one frozen Session population before facts or lead hydration, then expose versioned digest/count bindings that fail closed on population, selection, and admission contradictions. Implements docs/specs/2026-07-30-session-population-binding.md. Validated with focused red/green binding tests, a bound normal-depth database-caching bundle, documentation checks, and npm run check (892/892). Co-authored-by: Codex (GPT 5.6 Sol) --- CHANGELOG.md | 4 + .../2026-07-30-session-population-binding.md | 112 ++++++++ .../evidence-bundle/index.mjs | 90 ++++++- .../evidence-bundle/session-evidence.mjs | 74 +++++- scripts/harness-analysis/report-run.mjs | 11 +- scripts/harness-analysis/task-loop-source.mjs | 27 +- .../session-analysis/session-population.mjs | 244 ++++++++++++++++++ test/better-harness-evidence-bundle.test.mjs | 206 ++++++++++++++- test/session-population.test.mjs | 90 +++++++ test/task-loop-source.test.mjs | 21 +- 10 files changed, 855 insertions(+), 24 deletions(-) create mode 100644 docs/specs/2026-07-30-session-population-binding.md create mode 100644 scripts/session-analysis/session-population.mjs create mode 100644 test/session-population.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f62c87..cc48eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ observable behavior and compatibility, not every internal refactor. Rails or FastAPI, exposes bounded root Just recipes as statically discovered unverified argv entrypoints, and keeps historical-only files out of current recommended reads and action targets. +- Evidence bundles now discover and privacy-filter one frozen Session population + before either Session facts or lead analysis hydrates it. Versioned redacted + bindings fail closed on population, selection, or admission contradictions + while preserving bounded lead selection and explicit zero-signal filtering. ## 0.3.0 - 2026-07-27 diff --git a/docs/specs/2026-07-30-session-population-binding.md b/docs/specs/2026-07-30-session-population-binding.md new file mode 100644 index 0000000..1e9cbfd --- /dev/null +++ b/docs/specs/2026-07-30-session-population-binding.md @@ -0,0 +1,112 @@ +# Session Population Binding + +## Traceability + +- Spec ID: 2026-07-30-session-population-binding +- Status: Implemented +- Issue: None; focused evidence-integrity maintenance discovered by a validated Better Harness report. + +## Intent + +Make one evidence-bundle run derive Session facts and lead analysis from one +frozen, privacy-filtered Session population. The binding must prevent an active +Session from entering the lead after it was omitted from facts, without +persisting or exposing the private inventory or weakening bounded lead +selection. + +## Acceptance Scenarios + +- AC-1: The bundle discovers the frozen Session population once, removes Qoder + home-only sources and an exact explicit/provider active Session identity + before either lane hydrates it, and supplies the same eligible snapshot to + both collectors. With an explicit frozen `until`, exact identity exclusion + remains enabled while recency-window inference remains disabled. +- AC-2: Public output contains only bounded, versioned bindings: frozen scope + and omission policy/counts, eligible population count/fingerprint, Session + all-eligible selection count/fingerprint, and lead bounded selection + count/fingerprint with a parent-population assertion. It contains no raw + Session IDs, source paths, prompts, commands, content, Memories, user-home + payloads, reversible locators, or serialized snapshot. +- AC-3: Bundle validation fails closed with stable redacted error codes when a + binding is missing or mismatched, a selected fingerprint is not a subset of + its population, or admission counters do not reconcile. Session admission + satisfies `taskEpisodes = candidateEpisodes + noRequest + selfAnalysis + + lowSignal`, `candidateEpisodes = distinctRequests + duplicateRequests`, and + `distinctRequests = emittedCandidates + candidateBudget`. +- AC-4: Lead admission satisfies `projectedEpisodes = admittedEpisodes + + zeroSignalDiscardedEpisodes` and `admittedEpisodes = retained taskEpisodes`. + Selected fingerprints remain lane-specific for bounded strategies; cross-lane + Episode totals are compared only when selected and projection-policy + fingerprints match. Legitimate zero-signal filtering remains valid. +- AC-5: Existing scoring, lead stratification and limits, raw-data exclusions, + provider adapters, and standalone analysis commands remain unchanged. + +## Non-Goals + +- Reading or migrating raw Sessions, Memories, user-home content, private + plugin data, or unsanitized generated reports. +- Persisting the shared population snapshot or exposing private identity data. +- All-eligible lead hydration, larger limits, weaker stratification, synthetic + Task Episodes, or score/weight changes. +- Renderer accessibility work, Core Change Watch work, report edits, installed + plugin-cache changes, releases, merges, or production publication. +- Provider-adapter changes or a new dependency. + +## Plan And Tasks + +Allowed files, copied verbatim from the approved Worker package: + +1. `docs/specs/2026-07-30-session-population-binding.md` +2. `scripts/session-analysis/session-population.mjs` +3. `scripts/harness-analysis/evidence-bundle/contract.mjs` +4. `scripts/harness-analysis/evidence-bundle/index.mjs` +5. `scripts/harness-analysis/evidence-bundle/session-evidence.mjs` +6. `scripts/harness-analysis/report-run.mjs` +7. `scripts/harness-analysis/task-loop-source.mjs` +8. `test/session-population.test.mjs` +9. `test/better-harness-evidence-bundle.test.mjs` +10. `test/task-loop-source.test.mjs` +11. `CHANGELOG.md` +12. `docs/better-harness-doc-links.mmd` + +Tasks: + +1. Add sanitized fixtures for exact active-session exclusion, shared snapshot + reuse, binding mismatch, admission reconciliation, and zero-signal filtering. +2. Preserve a test-only red run that demonstrates active-session divergence and + missing fail-closed binding before production edits. +3. Implement the private population owner, public binding contract, shared + orchestration, and bounded lead projection without persistence or new data + authority. +4. Regenerate the Markdown routing graph, update the changelog, run focused and + full gates, and mark this spec Implemented only after evidence passes. +5. Perform Review Readiness over the no-issue rationale, Spec, Tests, Risk, AI, + Refs, generated files, and staged/unstaged boundaries before commit. + +## Test And Review Evidence + +- Red run: six focused tests failed for the intended missing owner, unshared + population, non-failing mismatch, and unavailable zero-signal accounting + before production edits. +- Focused binding tests: nine population, binding, active-session, count + contradiction, frozen-inventory, and zero-signal tests pass. +- Full Session/bundle/source compatibility: 61/61 tests pass across + `test/session-population.test.mjs`, `test/better-harness-evidence-bundle.test.mjs`, + `test/task-loop-source.test.mjs`, and `test/harness-report-run.test.mjs`. +- Real target: a normal-depth Codex bundle for `database-caching` completed with + both lanes available and the binding `bound`. It recorded exact active + identity availability, omitted one active Session, bound four eligible + Sessions to the same parent fingerprint, and reconciled four projected + Episodes as four explicit zero-signal discards and zero retained Episodes. +- Privacy serialization audit: the focused bundle fixture proves neither the + private inventory nor its Session IDs appear in serialized output; the real + target inspection printed only versioned bindings and aggregate counters. +- Documentation: the canonical routing graph regenerated without drift and the + six documentation checks pass. +- Package gate: `TMPDIR= npm run check` passed 892/892 tests + plus npm-package (321 entries) and runtime-zip (345 entries) verification. +- Review Readiness: the diff is confined to the approved owner/test/spec/ + changelog files; the optional contract and generated graph files were not + changed. No dependency, provider adapter, scoring owner, generated report, + installed cache, raw identity, or private payload changed. The commit must + carry exactly one required Codex co-author marker. diff --git a/scripts/harness-analysis/evidence-bundle/index.mjs b/scripts/harness-analysis/evidence-bundle/index.mjs index d175642..cd37a9e 100644 --- a/scripts/harness-analysis/evidence-bundle/index.mjs +++ b/scripts/harness-analysis/evidence-bundle/index.mjs @@ -1,4 +1,5 @@ import { analyzeHarnessEvidence } from "../report-run.mjs"; +import { validateSessionPopulationBundle } from "../../session-analysis/session-population.mjs"; import { collectAgentCustomize } from "./agent-customize.mjs"; import { EVIDENCE_BUNDLE_KIND, @@ -10,7 +11,10 @@ import { unavailableLane, } from "./contract.mjs"; import { collectProjectHarness } from "./project-harness.mjs"; -import { collectSessionEvidence } from "./session-evidence.mjs"; +import { + collectSessionEvidence, + collectSessionPopulation, +} from "./session-evidence.mjs"; async function capture(owner, operation) { try { @@ -26,7 +30,7 @@ async function capture(owner, operation) { } } -async function collectLead(context, options, analyze) { +async function collectLead(context, options, analyze, sessionPopulation) { try { const data = await analyze({ workspace: context.workspace, @@ -35,6 +39,7 @@ async function collectLead(context, options, analyze) { since: context.window.since, until: context.window.until, format: "json", + sessionPopulation, "include-global-capabilities": context.authority.includeUserHome, ...(options["canvas-out"] ? { "canvas-out": options["canvas-out"] } : {}), ...(options["replace-canvas"] ? { "replace-canvas": options["replace-canvas"] } : {}), @@ -51,18 +56,88 @@ async function collectLead(context, options, analyze) { } } +function populationDiagnostics(population, sessionEvidence, lead) { + if (!population?.binding) { + return { status: "unavailable" }; + } + const session = sessionEvidence?.data + ? { + population: sessionEvidence.data.populationBinding, + selection: sessionEvidence.data.selectionBinding, + admission: sessionEvidence.data.admissionBinding, + } + : null; + const leadBinding = lead?.data?.sessionBinding ?? null; + const errors = validateSessionPopulationBundle({ + population: population.binding, + session, + lead: leadBinding, + }); + const sessionEligibleCount = Number(sessionEvidence?.data?.scope?.eligibleSessions ?? -1); + const sessionSelectedCount = Number(sessionEvidence?.data?.scope?.selectedSessions ?? -1); + const leadSelection = lead?.data?.summaryFacts?.evidenceBoundary?.manifest?.selection ?? {}; + if (sessionEligibleCount !== population.binding.eligible.count + || sessionSelectedCount !== session?.selection?.selected?.count) { + errors.push("Session public counts do not match its population binding"); + } + if (Number(leadSelection.eligibleCount ?? -1) !== population.binding.eligible.count + || Number(leadSelection.analyzedCount ?? -1) !== leadBinding?.selection?.selected?.count) { + errors.push("lead public counts do not match its population binding"); + } + const sessionAdmission = session?.admission ?? {}; + const leadAdmission = leadBinding?.admission ?? {}; + const comparable = session?.selection?.selected?.fingerprint === leadBinding?.selection?.selected?.fingerprint + && session?.selection?.projectionPolicyFingerprint === leadBinding?.selection?.projectionPolicyFingerprint; + return { + status: errors.length > 0 ? "conflict" : "bound", + population: population.binding, + sessionSelection: session?.selection ?? null, + leadSelection: leadBinding?.selection ?? null, + episodes: { + comparison: comparable ? "comparable" : "not-comparable-selection-or-policy", + sessionTaskEpisodes: Number(sessionAdmission.taskEpisodes ?? 0), + leadProjectedEpisodes: Number(leadAdmission.projectedEpisodes ?? 0), + leadRetainedEpisodes: Number(leadAdmission.retainedTaskEpisodes ?? 0), + leadZeroSignalDiscardedEpisodes: Number(leadAdmission.zeroSignalDiscardedEpisodes ?? 0), + }, + ...(errors.length > 0 ? { errorCodes: ["SESSION_POPULATION_BINDING_MISMATCH"] } : {}), + }; +} + export async function collectEvidenceBundle(options = {}, dependencies = {}) { const context = freezeEvidenceBundleContext(options, dependencies.now?.() ?? new Date()); const sessionCollector = dependencies.collectSessionEvidence ?? collectSessionEvidence; const projectCollector = dependencies.collectProjectHarness ?? collectProjectHarness; const customizeCollector = dependencies.collectAgentCustomize ?? collectAgentCustomize; const leadAnalyzer = dependencies.analyzeHarnessEvidence ?? analyzeHarnessEvidence; - const [sessionEvidence, projectHarness, agentCustomize, lead] = await Promise.all([ - capture("session-evidence", () => sessionCollector(context, options, dependencies)), - capture("project-harness", () => projectCollector(context, options, dependencies)), - capture("agent-customize", () => customizeCollector(context, options, dependencies)), - collectLead(context, options, leadAnalyzer), + const populationCollector = dependencies.collectSessionPopulation ?? collectSessionPopulation; + const projectPromise = capture("project-harness", () => projectCollector(context, options, dependencies)); + const customizePromise = capture("agent-customize", () => customizeCollector(context, options, dependencies)); + const populationLane = await capture("session-population", async () => + availableLane(await populationCollector(context, options, dependencies))); + const sessionPopulation = laneIsAvailable(populationLane) ? populationLane.data : null; + const sessionPromise = sessionPopulation + ? capture("session-evidence", () => sessionCollector(context, options, { + ...dependencies, + sessionPopulation, + })) + : Promise.resolve(unavailableLane("session-evidence", populationLane.error)); + const leadPromise = sessionPopulation + ? collectLead(context, options, leadAnalyzer, sessionPopulation) + : Promise.resolve(unavailableLane("lead-analyzer", populationLane.error)); + let [sessionEvidence, projectHarness, agentCustomize, lead] = await Promise.all([ + sessionPromise, + projectPromise, + customizePromise, + leadPromise, ]); + const sessionPopulationBinding = populationDiagnostics(sessionPopulation, sessionEvidence, lead); + if (sessionPopulationBinding.status === "conflict") { + lead = unavailableLane("lead-analyzer", Object.assign( + new Error("Session population binding mismatch"), + { code: "SESSION_POPULATION_BINDING_MISMATCH" }, + )); + } const lanes = { sessionEvidence, projectHarness, agentCustomize }; const incompleteLanes = EVIDENCE_LANE_NAMES.filter((name) => !laneIsAvailable(lanes[name])); const unavailableLanes = EVIDENCE_LANE_NAMES.filter((name) => lanes[name]?.status === "unavailable"); @@ -88,6 +163,7 @@ export async function collectEvidenceBundle(options = {}, dependencies = {}) { partialLanes, leadRequired: true, individualCommandsRemainDiagnostic: true, + sessionPopulationBinding, }, }; } diff --git a/scripts/harness-analysis/evidence-bundle/session-evidence.mjs b/scripts/harness-analysis/evidence-bundle/session-evidence.mjs index 23e5ade..9b72227 100644 --- a/scripts/harness-analysis/evidence-bundle/session-evidence.mjs +++ b/scripts/harness-analysis/evidence-bundle/session-evidence.mjs @@ -1,4 +1,9 @@ import { createAnalyzer } from "../../session-analysis.mjs"; +import { + bindSessionSelection, + freezeSessionPopulation, + sessionAdmissionBinding, +} from "../../session-analysis/session-population.mjs"; import { availableLane } from "./contract.mjs"; const SOURCE_COVERAGE_STATES = new Set([ @@ -9,19 +14,52 @@ const SOURCE_COVERAGE_STATES = new Set([ "observed", ]); +function analyzerOptions(context, options = {}) { + return { + workspace: context.workspace, + platform: context.provider, + since: context.window.since, + until: context.window.until, + ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), + }; +} + +export async function collectSessionPopulation(context, options = {}, dependencies = {}) { + const create = dependencies.createAnalyzer ?? createAnalyzer; + const analyzer = await create(context.provider); + const runOptions = analyzerOptions(context, options); + const discovery = await analyzer.analyze({ ...runOptions, command: "sources" }); + if (!Array.isArray(discovery?.sessions)) { + throw Object.assign(new Error("Session population discovery returned an invalid contract"), { + code: "INVALID_SESSION_POPULATION", + }); + } + return freezeSessionPopulation({ + scope: { + ...(discovery.scope ?? {}), + platform: context.provider, + workspace: context.workspace, + since: context.window.since, + until: context.window.until, + }, + sessions: discovery.sessions, + excludedSessionId: options["exclude-session-id"] ?? options.excludeSessionId, + providerSessionId: typeof analyzer.currentSessionId === "function" ? analyzer.currentSessionId() : null, + suppliedUntil: true, + }); +} + export async function collectSessionEvidence(context, options = {}, dependencies = {}) { const create = dependencies.createAnalyzer ?? createAnalyzer; const analyzer = await create(context.provider); + const population = dependencies.sessionPopulation ?? null; const data = await analyzer.analyze({ command: "facts", - workspace: context.workspace, - platform: context.provider, + ...analyzerOptions(context, options), selection: "all-eligible", - since: context.window.since, - until: context.window.until, limit: context.evidenceLimit, "episode-limit": context.evidenceLimit, - ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), + ...(population ? { sessionInventory: population.sessions } : {}), }); if (data?.kind !== "session-core-facts" || !Array.isArray(data.candidates)) { throw Object.assign(new Error("session facts returned an invalid contract"), { @@ -37,5 +75,29 @@ export async function collectSessionEvidence(context, options = {}, dependencies const laneStatus = ["unobserved", "partial"].includes(sourceCoverageStatus) ? "partial" : "available"; - return availableLane(data, laneStatus); + if (!population) return availableLane(data, laneStatus); + const eligibleCount = Number(data.scope?.eligibleSessions ?? -1); + const selectedCount = Number(data.scope?.selectedSessions ?? -1); + if (eligibleCount !== population.binding.eligible.count + || selectedCount !== population.binding.eligible.count) { + throw Object.assign(new Error("Session facts do not match the frozen all-eligible population"), { + code: "SESSION_POPULATION_BINDING_MISMATCH", + }); + } + const selectionBinding = bindSessionSelection(population, population.sessions, { + strategy: "all-eligible", + projectionPolicy: "session-fact-candidates-v2", + }); + const boundData = { + ...data, + omitted: { + ...(data.omitted ?? {}), + activeSessions: population.binding.omission.activeSessions, + homeSessionOnly: population.binding.omission.homeSessionOnly, + }, + populationBinding: population.binding, + selectionBinding, + }; + boundData.admissionBinding = sessionAdmissionBinding(boundData, selectionBinding); + return availableLane(boundData, laneStatus); } diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 6156f93..d2bb37b 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -152,9 +152,9 @@ export async function analyzeHarnessEvidence(options = {}) { code: "CANVAS_REPLACE_REQUIRES_OUTPUT", }); } - const source = options.sourceInput - ? clone(options.sourceInput) - : (await createTaskLoopSourceFromSessions({ + const sourceResult = options.sourceInput + ? { source: clone(options.sourceInput), sessionBinding: options.sessionBinding ?? null } + : await createTaskLoopSourceFromSessions({ workspace, platform, language, @@ -168,7 +168,9 @@ export async function analyzeHarnessEvidence(options = {}) { claudeHome: options["claude-home"], cursorHome: options["cursor-home"], qwenHome: options["qwen-home"], - })).source; + sessionPopulation: options.sessionPopulation, + }); + const source = sourceResult.source; const sourceErrors = validateHarnessReportSource(source); if (sourceErrors.length > 0) { throw Object.assign(new Error(sourceErrors.join("; ")), { @@ -193,6 +195,7 @@ export async function analyzeHarnessEvidence(options = {}) { schemaVersion: HARNESS_ANALYSIS_EVIDENCE_SCHEMA_VERSION, evidence, summaryFacts, + ...(sourceResult.sessionBinding ? { sessionBinding: sourceResult.sessionBinding } : {}), ...(canvasPath ? { canvasPath, canvasReplaced } : {}), }; } diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 20c935a..8108f66 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -18,6 +18,10 @@ import { buildTaskEpisodes, stableFingerprint } from "../session-analysis/episod import { buildObservationManifest } from "../session-analysis/observation-manifest.mjs"; import { sanitizePrivateReviewText } from "../session-analysis/privacy-safe-text.mjs"; import { sessionAnalysisRef } from "../session-analysis/session-ref.mjs"; +import { + bindSessionSelection, + leadAdmissionBinding, +} from "../session-analysis/session-population.mjs"; import { selectSessions } from "../session-analysis/selection.mjs"; import { assertSessionSelectionBinding, @@ -1036,7 +1040,9 @@ export async function createTaskLoopSourceFromSessions(options = {}) { ?? false, }; const discovery = await analyzer.analyze({ ...analyzerOptions, command: "sources" }); - const sessionInventory = Object.freeze(discovery.sessions.map((session) => Object.freeze(structuredClone(session)))); + const population = options.sessionPopulation ?? null; + const inventorySource = population?.sessions ?? discovery.sessions; + const sessionInventory = Object.freeze(inventorySource.map((session) => Object.freeze(structuredClone(session)))); if (selectionProfile) { assertSessionSelectionBinding(selectionProfile, selectionPlan, { eligibleCount: sessionInventory.length }); } @@ -1166,7 +1172,24 @@ export async function createTaskLoopSourceFromSessions(options = {}) { memoryInventory: practiceInventory?.memories ?? { included: false, categories: [] }, }); assertStandardUsageComplete(source, selected, includeUsage); - return { source, selection: selected }; + if (!population) return { source, selection: selected }; + const selectionBinding = bindSessionSelection(population, selected.sessions, { + strategy: selected.strategy, + projectionPolicy: "lead-report-signal-v1", + }); + const admittedEpisodes = Number(source.sessionEvents?.candidateEpisodeCount ?? 0); + const zeroSignalDiscardedEpisodes = Number(source.sessionEvents?.discardedEpisodeCount ?? 0); + const sessionBinding = { + population: population.binding, + selection: selectionBinding, + admission: leadAdmissionBinding({ + projectedEpisodes: admittedEpisodes + zeroSignalDiscardedEpisodes, + admittedEpisodes, + zeroSignalDiscardedEpisodes, + retainedTaskEpisodes: source.taskEpisodes.length, + }, selectionBinding), + }; + return { source, selection: selected, sessionBinding }; } async function main(argv = process.argv.slice(2)) { diff --git a/scripts/session-analysis/session-population.mjs b/scripts/session-analysis/session-population.mjs new file mode 100644 index 0000000..8a9e3da --- /dev/null +++ b/scripts/session-analysis/session-population.mjs @@ -0,0 +1,244 @@ +import { stableFingerprint } from "./episode-contract.mjs"; +import { + createFactsRunContext, + prepareFactsSessionInventory, +} from "./session-core-facts.mjs"; + +export const SESSION_POPULATION_BINDING_SCHEMA_VERSION = 1; +export const SESSION_SELECTION_BINDING_SCHEMA_VERSION = 1; +export const SESSION_ADMISSION_BINDING_SCHEMA_VERSION = 1; + +const PRIVATE_POPULATION_IDS = new WeakMap(); + +function rows(value) { + return Array.isArray(value) ? value : []; +} + +function count(value) { + const numeric = Number(value ?? 0); + return Number.isInteger(numeric) && numeric >= 0 ? numeric : 0; +} + +function sessionIds(sessions) { + return [...new Set(rows(sessions).map((session) => String(session?.sessionId ?? "").trim()).filter(Boolean))].sort(); +} + +function samePopulation(left, right) { + return left?.schemaVersion === right?.schemaVersion + && left?.kind === right?.kind + && left?.scopeFingerprint === right?.scopeFingerprint + && left?.policyFingerprint === right?.policyFingerprint + && left?.eligible?.count === right?.eligible?.count + && left?.eligible?.fingerprint === right?.eligible?.fingerprint + && left?.omission?.exactIdentityAvailable === right?.omission?.exactIdentityAvailable + && left?.omission?.activeSessions === right?.omission?.activeSessions + && left?.omission?.homeSessionOnly === right?.omission?.homeSessionOnly + && left?.omission?.recencyInference === right?.omission?.recencyInference; +} + +function bindingError(code, message) { + return Object.assign(new Error(message), { code }); +} + +export function freezeSessionPopulation({ + scope = {}, + sessions = [], + excludedSessionId = null, + providerSessionId = null, + suppliedUntil = scope.until !== undefined && scope.until !== null && scope.until !== "", + startedAt = Date.now(), +} = {}) { + const platform = String(scope.platform ?? "unknown"); + const factsContext = createFactsRunContext({ + workspace: scope.workspace, + since: scope.since, + ...(suppliedUntil ? { until: scope.until } : {}), + ...(excludedSessionId ? { "exclude-session-id": excludedSessionId } : {}), + _factsStartedAt: startedAt, + }, platform, providerSessionId); + const prepared = prepareFactsSessionInventory(rows(sessions), factsContext); + const frozenSessions = Object.freeze(prepared.sessions.map((session) => Object.freeze(structuredClone(session)))); + const ids = sessionIds(frozenSessions); + const population = { + sessions: frozenSessions, + binding: Object.freeze({ + schemaVersion: SESSION_POPULATION_BINDING_SCHEMA_VERSION, + kind: "session-population-binding", + scopeFingerprint: stableFingerprint({ + platform, + workspace: scope.workspace ?? null, + since: scope.since ?? null, + until: scope.until ?? null, + }), + policyFingerprint: stableFingerprint({ + platform, + qoderHomeOnlyExcluded: platform === "qoder", + exactIdentityAvailable: Boolean(factsContext.excludedSessionId), + recencyInference: suppliedUntil ? "disabled-frozen-until" : "enabled-unfrozen-until", + }), + omission: Object.freeze({ + exactIdentityAvailable: Boolean(factsContext.excludedSessionId), + activeSessions: count(prepared.omitted.activeSessions), + homeSessionOnly: count(prepared.omitted.homeSessionOnly), + recencyInference: suppliedUntil ? "disabled-frozen-until" : "enabled-unfrozen-until", + }), + eligible: Object.freeze({ + count: ids.length, + fingerprint: stableFingerprint(ids), + }), + }), + }; + PRIVATE_POPULATION_IDS.set(population, new Set(ids)); + return Object.freeze(population); +} + +export function bindSessionSelection(population, selectedSessions = [], { + strategy = "stratified", + projectionPolicy = "session-projection-v1", +} = {}) { + const eligibleIds = PRIVATE_POPULATION_IDS.get(population); + if (!eligibleIds) { + throw bindingError("INVALID_SESSION_POPULATION", "selection requires a private frozen Session population"); + } + const selectedIds = sessionIds(selectedSessions); + if (selectedIds.some((id) => !eligibleIds.has(id))) { + throw bindingError( + "SESSION_SELECTION_OUTSIDE_POPULATION", + "selected Session population is not a subset of the frozen eligible population", + ); + } + return Object.freeze({ + schemaVersion: SESSION_SELECTION_BINDING_SCHEMA_VERSION, + kind: "session-selection-binding", + parentPopulationFingerprint: population.binding.eligible.fingerprint, + strategy: String(strategy), + selected: Object.freeze({ + count: selectedIds.length, + fingerprint: stableFingerprint(selectedIds), + }), + projectionPolicyFingerprint: stableFingerprint(String(projectionPolicy)), + }); +} + +export function sessionAdmissionBinding(data = {}, selection = {}) { + const admission = data.admission ?? {}; + const omitted = data.omitted ?? {}; + return Object.freeze({ + schemaVersion: SESSION_ADMISSION_BINDING_SCHEMA_VERSION, + kind: "session-admission-binding", + projectionPolicyFingerprint: selection.projectionPolicyFingerprint, + taskEpisodes: count(admission.taskEpisodes), + candidateEpisodes: count(admission.candidateEpisodes), + distinctRequests: count(admission.distinctRequests), + emittedCandidates: count(admission.emittedCandidates), + noRequest: count(omitted.noRequest), + selfAnalysis: count(omitted.selfAnalysis), + lowSignal: count(omitted.lowSignal), + duplicateRequests: count(omitted.duplicateRequests), + candidateBudget: count(omitted.candidateBudget), + }); +} + +export function leadAdmissionBinding(data = {}, selection = {}) { + return Object.freeze({ + schemaVersion: SESSION_ADMISSION_BINDING_SCHEMA_VERSION, + kind: "lead-admission-binding", + projectionPolicyFingerprint: selection.projectionPolicyFingerprint, + projectedEpisodes: count(data.projectedEpisodes), + admittedEpisodes: count(data.admittedEpisodes), + zeroSignalDiscardedEpisodes: count(data.zeroSignalDiscardedEpisodes), + retainedTaskEpisodes: count(data.retainedTaskEpisodes), + }); +} + +function populationErrors(expected, owner, binding) { + if (!binding || binding.kind !== "session-population-binding") { + return [`${owner} population binding is missing`]; + } + return samePopulation(expected, binding) ? [] : [`${owner} population binding does not match the frozen population`]; +} + +function selectionErrors(population, owner, selection) { + const errors = []; + if (!selection || selection.kind !== "session-selection-binding") { + return [`${owner} selection binding is missing`]; + } + if (selection.parentPopulationFingerprint !== population?.eligible?.fingerprint) { + errors.push(`${owner} selection is not bound to the frozen eligible population`); + } + if (count(selection.selected?.count) > count(population?.eligible?.count)) { + errors.push(`${owner} selected count exceeds the eligible population`); + } + if (selection.strategy === "all-eligible" + && (selection.selected?.count !== population?.eligible?.count + || selection.selected?.fingerprint !== population?.eligible?.fingerprint)) { + errors.push(`${owner} all-eligible selection does not equal the eligible population`); + } + return errors; +} + +function sessionAdmissionErrors(admission) { + if (!admission || admission.kind !== "session-admission-binding") { + return ["Session admission binding is missing"]; + } + const errors = []; + if (admission.taskEpisodes !== admission.candidateEpisodes + + admission.noRequest + admission.selfAnalysis + admission.lowSignal) { + errors.push("Session task Episode admission does not reconcile"); + } + if (admission.candidateEpisodes !== admission.distinctRequests + admission.duplicateRequests) { + errors.push("Session candidate Episode admission does not reconcile"); + } + if (admission.distinctRequests !== admission.emittedCandidates + admission.candidateBudget) { + errors.push("Session distinct-request admission does not reconcile"); + } + return errors; +} + +function leadAdmissionErrors(admission) { + if (!admission || admission.kind !== "lead-admission-binding") { + return ["lead admission binding is missing"]; + } + const errors = []; + if (admission.projectedEpisodes !== admission.admittedEpisodes + admission.zeroSignalDiscardedEpisodes) { + errors.push("lead projected Episode admission does not reconcile"); + } + if (admission.admittedEpisodes !== admission.retainedTaskEpisodes) { + errors.push("lead retained Episode admission does not reconcile"); + } + return errors; +} + +export function validateSessionPopulationBundle({ population, session, lead } = {}) { + const errors = [ + ...populationErrors(population, "Session", session?.population), + ...populationErrors(population, "lead", lead?.population), + ...selectionErrors(population, "Session", session?.selection), + ...selectionErrors(population, "lead", lead?.selection), + ...sessionAdmissionErrors(session?.admission), + ...leadAdmissionErrors(lead?.admission), + ]; + if (session?.admission?.projectionPolicyFingerprint !== session?.selection?.projectionPolicyFingerprint) { + errors.push("Session admission policy does not match its selection binding"); + } + if (lead?.admission?.projectionPolicyFingerprint !== lead?.selection?.projectionPolicyFingerprint) { + errors.push("lead admission policy does not match its selection binding"); + } + if (session?.selection?.selected?.fingerprint === lead?.selection?.selected?.fingerprint + && session?.selection?.projectionPolicyFingerprint === lead?.selection?.projectionPolicyFingerprint + && session?.admission?.taskEpisodes !== lead?.admission?.projectedEpisodes) { + errors.push("comparable Session and lead Episode totals do not match"); + } + return errors; +} + +export function assertSessionPopulationBundle(bindings) { + const errors = validateSessionPopulationBundle(bindings); + if (errors.length > 0) { + throw Object.assign(new Error(errors.join("; ")), { + code: "SESSION_POPULATION_BINDING_MISMATCH", + errors, + }); + } + return bindings; +} diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index d49ae14..3dae708 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -12,13 +12,116 @@ import { collectAgentCustomize } from "../scripts/harness-analysis/evidence-bund const NOW = new Date("2026-07-24T08:00:00.000Z"); +const POPULATION_BINDING = Object.freeze({ + schemaVersion: 1, + kind: "session-population-binding", + scopeFingerprint: "1111111111111111", + policyFingerprint: "2222222222222222", + omission: { + exactIdentityAvailable: true, + activeSessions: 1, + homeSessionOnly: 0, + recencyInference: "disabled-frozen-until", + }, + eligible: { count: 1, fingerprint: "3333333333333333" }, +}); + +const SESSION_SELECTION_BINDING = Object.freeze({ + schemaVersion: 1, + kind: "session-selection-binding", + parentPopulationFingerprint: POPULATION_BINDING.eligible.fingerprint, + strategy: "all-eligible", + selected: { count: 1, fingerprint: "3333333333333333" }, + projectionPolicyFingerprint: "4444444444444444", +}); + +const LEAD_SELECTION_BINDING = Object.freeze({ + ...SESSION_SELECTION_BINDING, + strategy: "stratified", + projectionPolicyFingerprint: "5555555555555555", +}); + +function sessionFacts(overrides = {}) { + return { + kind: "session-core-facts", + candidates: [], + scope: { eligibleSessions: 1, selectedSessions: 1 }, + admission: { + taskEpisodes: 1, + candidateEpisodes: 1, + distinctRequests: 1, + emittedCandidates: 1, + }, + omitted: { + noRequest: 0, + selfAnalysis: 0, + lowSignal: 0, + duplicateRequests: 0, + candidateBudget: 0, + activeSessions: 1, + homeSessionOnly: 0, + }, + populationBinding: POPULATION_BINDING, + selectionBinding: SESSION_SELECTION_BINDING, + admissionBinding: { + schemaVersion: 1, + kind: "session-admission-binding", + projectionPolicyFingerprint: SESSION_SELECTION_BINDING.projectionPolicyFingerprint, + taskEpisodes: 1, + candidateEpisodes: 1, + distinctRequests: 1, + emittedCandidates: 1, + noRequest: 0, + selfAnalysis: 0, + lowSignal: 0, + duplicateRequests: 0, + candidateBudget: 0, + }, + ...overrides, + }; +} + +function leadEvidence(overrides = {}) { + return { + evidence: "bounded", + summaryFacts: { + evidenceBoundary: { + manifest: { selection: { eligibleCount: 1, analyzedCount: 1 } }, + episodeCoverage: { episodeCount: 0 }, + }, + }, + sessionBinding: { + population: POPULATION_BINDING, + selection: LEAD_SELECTION_BINDING, + admission: { + schemaVersion: 1, + kind: "lead-admission-binding", + projectedEpisodes: 1, + admittedEpisodes: 0, + zeroSignalDiscardedEpisodes: 1, + retainedTaskEpisodes: 0, + projectionPolicyFingerprint: LEAD_SELECTION_BINDING.projectionPolicyFingerprint, + }, + }, + ...overrides, + }; +} + function dependencies(overrides = {}) { + const population = Object.freeze({ + sessions: Object.freeze([{ sessionId: "eligible-session" }]), + binding: POPULATION_BINDING, + }); return { now: () => NOW, - collectSessionEvidence: async () => availableLane({ kind: "session-core-facts", candidates: [] }), + collectSessionPopulation: async () => population, + collectSessionEvidence: async (_context, _options, received) => { + assert.equal(received.sessionPopulation, population); + return availableLane(sessionFacts()); + }, collectProjectHarness: async () => availableLane({ kind: "core-change-watch-evidence-pack" }), collectAgentCustomize: async () => availableLane({ kind: "agent-asset-baseline", status: "complete" }), - analyzeHarnessEvidence: async () => ({ evidence: "bounded", summaryFacts: { dimensions: [] } }), + analyzeHarnessEvidence: async () => leadEvidence(), ...overrides, }; } @@ -193,3 +296,102 @@ test("Qwen agentCustomize lane routes the provider and isolated config paths", a assert.equal(received["qwen-home"], "/tmp/fixture-qwen-home"); assert.equal(received["include-user-home"], true); }); + +test("shared Session population excludes the active session before both lanes hydrate", async () => { + const population = Object.freeze({ + sessions: Object.freeze([{ sessionId: "eligible-session" }]), + binding: POPULATION_BINDING, + }); + let lanePopulation; + let leadPopulation; + const result = await collectEvidenceBundle({ + workspace: ".", + platform: "codex", + depth: "normal", + }, dependencies({ + collectSessionPopulation: async () => population, + collectSessionEvidence: async (_context, _options, received) => { + lanePopulation = received.sessionPopulation; + return availableLane(sessionFacts()); + }, + analyzeHarnessEvidence: async (options) => { + leadPopulation = options.sessionPopulation; + return leadEvidence(); + }, + })); + + assert.equal(lanePopulation, population); + assert.equal(leadPopulation, population); + assert.equal(result.status, "complete"); + assert.equal(result.diagnostics.sessionPopulationBinding.status, "bound"); + assert.equal(result.diagnostics.sessionPopulationBinding.population.eligible.count, 1); + assert.doesNotMatch(JSON.stringify(result), /eligible-session/u); +}); + +test("Session population conflict fails closed with a redacted stable code", async () => { + const result = await collectEvidenceBundle({ workspace: ".", platform: "codex" }, dependencies({ + analyzeHarnessEvidence: async () => leadEvidence({ + sessionBinding: { + ...leadEvidence().sessionBinding, + population: { + ...POPULATION_BINDING, + eligible: { count: 2, fingerprint: "6666666666666666" }, + }, + }, + }), + })); + + assert.equal(result.status, "failed"); + assert.equal(result.lead.status, "unavailable"); + assert.equal(result.lead.error.code, "SESSION_POPULATION_BINDING_MISMATCH"); + assert.equal(result.diagnostics.sessionPopulationBinding.status, "conflict"); + assert.doesNotMatch(JSON.stringify(result), /eligible-session|private|sessionId/u); +}); + +test("Session population conflict rejects lead counts that contradict its binding", async () => { + const result = await collectEvidenceBundle({ workspace: ".", platform: "codex" }, dependencies({ + analyzeHarnessEvidence: async () => leadEvidence({ + summaryFacts: { + evidenceBoundary: { + manifest: { selection: { eligibleCount: 2, analyzedCount: 1 } }, + episodeCoverage: { episodeCount: 0 }, + }, + }, + }), + })); + + assert.equal(result.status, "failed"); + assert.equal(result.lead.error.code, "SESSION_POPULATION_BINDING_MISMATCH"); + assert.equal(result.diagnostics.sessionPopulationBinding.status, "conflict"); +}); + +test("Session facts reject counts that contradict the shared all-eligible population", async () => { + await assert.rejects( + collectSessionEvidence(freezeEvidenceBundleContext({ workspace: ".", platform: "codex" }, NOW), {}, { + sessionPopulation: { + sessions: [{ sessionId: "eligible-session" }], + binding: POPULATION_BINDING, + }, + createAnalyzer: async () => ({ + analyze: async () => sessionFacts({ + scope: { eligibleSessions: 1, selectedSessions: 0 }, + }), + }), + }), + (error) => error?.code === "SESSION_POPULATION_BINDING_MISMATCH", + ); +}); + +test("zero-signal Episode admission remains valid inside one bound population", async () => { + const result = await collectEvidenceBundle({ workspace: ".", platform: "codex" }, dependencies()); + + assert.equal(result.status, "complete"); + assert.equal(result.lead.status, "available"); + assert.deepEqual(result.diagnostics.sessionPopulationBinding.episodes, { + comparison: "not-comparable-selection-or-policy", + sessionTaskEpisodes: 1, + leadProjectedEpisodes: 1, + leadRetainedEpisodes: 0, + leadZeroSignalDiscardedEpisodes: 1, + }); +}); diff --git a/test/session-population.test.mjs b/test/session-population.test.mjs new file mode 100644 index 0000000..1f30f78 --- /dev/null +++ b/test/session-population.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function populationModule() { + return import("../scripts/session-analysis/session-population.mjs"); +} + +test("frozen population binding omits exact active and Qoder home-only sessions", async () => { + const { freezeSessionPopulation } = await populationModule(); + const population = freezeSessionPopulation({ + scope: { + platform: "qoder", + workspace: "/private/workspace", + since: "2026-07-01T00:00:00.000Z", + until: "2026-07-30T00:00:00.000Z", + }, + sessions: [ + { sessionId: "active-private", sourceRefs: [{ kind: "project-session", path: "/private/active.jsonl" }] }, + { sessionId: "home-private", sourceRefs: [{ kind: "home-session", path: "/private/home.jsonl" }] }, + { sessionId: "eligible-private", sourceRefs: [{ kind: "project-session", path: "/private/eligible.jsonl" }] }, + ], + excludedSessionId: "active-private", + suppliedUntil: true, + }); + + assert.deepEqual(population.sessions.map((session) => session.sessionId), ["eligible-private"]); + assert.equal(population.binding.omission.activeSessions, 1); + assert.equal(population.binding.omission.homeSessionOnly, 1); + assert.equal(population.binding.omission.exactIdentityAvailable, true); + assert.equal(population.binding.omission.recencyInference, "disabled-frozen-until"); + assert.equal(population.binding.eligible.count, 1); + assert.match(population.binding.eligible.fingerprint, /^[a-f0-9]{16}$/u); + assert.doesNotMatch(JSON.stringify(population.binding), /active-private|home-private|eligible-private|\/private/u); +}); + +test("selection binding rejects a session outside the frozen population", async () => { + const { bindSessionSelection, freezeSessionPopulation } = await populationModule(); + const population = freezeSessionPopulation({ + scope: { platform: "codex", workspace: "/workspace", until: "2026-07-30T00:00:00.000Z" }, + sessions: [{ sessionId: "eligible" }], + suppliedUntil: true, + }); + + assert.throws( + () => bindSessionSelection(population, [{ sessionId: "foreign" }], { + strategy: "stratified", + projectionPolicy: "lead-report-signal-v1", + }), + (error) => error?.code === "SESSION_SELECTION_OUTSIDE_POPULATION", + ); +}); + +test("binding validation preserves zero-signal lead admission and reconciles Session facts", async () => { + const { + bindSessionSelection, + freezeSessionPopulation, + sessionAdmissionBinding, + leadAdmissionBinding, + validateSessionPopulationBundle, + } = await populationModule(); + const population = freezeSessionPopulation({ + scope: { platform: "codex", workspace: "/workspace", until: "2026-07-30T00:00:00.000Z" }, + sessions: [{ sessionId: "eligible" }], + suppliedUntil: true, + }); + const sessionSelection = bindSessionSelection(population, population.sessions, { + strategy: "all-eligible", + projectionPolicy: "session-fact-candidates-v2", + }); + const leadSelection = bindSessionSelection(population, population.sessions, { + strategy: "stratified", + projectionPolicy: "lead-report-signal-v1", + }); + const sessionAdmission = sessionAdmissionBinding({ + admission: { taskEpisodes: 1, candidateEpisodes: 1, distinctRequests: 1, emittedCandidates: 1 }, + omitted: { noRequest: 0, selfAnalysis: 0, lowSignal: 0, duplicateRequests: 0, candidateBudget: 0 }, + }, sessionSelection); + const leadAdmission = leadAdmissionBinding({ + projectedEpisodes: 1, + admittedEpisodes: 0, + zeroSignalDiscardedEpisodes: 1, + retainedTaskEpisodes: 0, + }, leadSelection); + + assert.deepEqual(validateSessionPopulationBundle({ + population: population.binding, + session: { population: population.binding, selection: sessionSelection, admission: sessionAdmission }, + lead: { population: population.binding, selection: leadSelection, admission: leadAdmission }, + }), []); +}); diff --git a/test/task-loop-source.test.mjs b/test/task-loop-source.test.mjs index fc74a3f..8bf66ab 100644 --- a/test/task-loop-source.test.mjs +++ b/test/task-loop-source.test.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import test from "node:test"; import { buildCheckupScan } from "../scripts/coding-agent-practices/checkup/scan.mjs"; +import { freezeSessionPopulation } from "../scripts/session-analysis/session-population.mjs"; import { buildHarnessReviewPacket, validateHarnessReviewPacket, @@ -396,7 +397,7 @@ test("source generation keeps usage opt-in and rejects incomplete requested cens ); }); -test("requested usage reuses one cutoff and the initially discovered session inventory", async () => { +test("requested usage reuses one frozen population and emits its lead selection binding", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-frozen-usage-")); const workspace = path.join(root, "workspace"); const calls = []; @@ -405,7 +406,7 @@ test("requested usage reuses one cutoff and the initially discovered session inv firstSeen: "2026-07-17T08:00:00.000Z", lastSeen: "2026-07-17T08:05:00.000Z", sourceKinds: ["fixture"], - sourceRefs: [], + sourceRefs: [{ kind: "project-session", path: "/fixture/session.jsonl" }], })); const activity = { schemaVersion: 1, @@ -488,18 +489,32 @@ test("requested usage reuses one cutoff and the initially discovered session inv }; try { await mkdir(workspace, { recursive: true }); - const { source, selection } = await createTaskLoopSourceFromSessions({ + const sessionPopulation = freezeSessionPopulation({ + scope: { + platform: "qoder", + workspace, + until: "2026-07-17T09:00:00.000Z", + }, + sessions: initialSessions, + suppliedUntil: true, + }); + const { source, selection, sessionBinding } = await createTaskLoopSourceFromSessions({ analyzer, platform: "qoder", workspace, snapshotUntil: "2026-07-17T09:00:00.000Z", includeUsage: true, + sessionPopulation, qoderHome: path.join(root, ".qoder"), practiceInventory: { summary: { practiceCoverageRows: [] }, memories: { included: false, categories: [] } }, }); assert.equal(selection.eligibleCount, 2); assert.equal(source.sessionEvents.usageActivity.sessions.total, 2); assert.equal(source.sessionEvents.usageEfficiency.selection.eligibleSessionCount, 2); + assert.equal(sessionBinding.population.eligible.count, 2); + assert.equal(sessionBinding.selection.parentPopulationFingerprint, sessionPopulation.binding.eligible.fingerprint); + assert.equal(sessionBinding.admission.projectedEpisodes, sessionBinding.admission.admittedEpisodes + + sessionBinding.admission.zeroSignalDiscardedEpisodes); assert.equal(new Set(calls.map((call) => call.until)).size, 1); assert.deepEqual(calls.filter((call) => call.command === "insights").map((call) => call.inventory), [ ["session-a", "session-b"], From 4ec73be9a55d30caa8a2fe7f05ccd5a739391057 Mon Sep 17 00:00:00 2001 From: chendongdong Date: Thu, 30 Jul 2026 17:16:55 +0800 Subject: [PATCH 3/5] fix(report): improve HTML semantics and CJK wrapping Expose each fluency score as a validated progressbar and keep bounded Chinese word segments together in self-contained reports without changing English or embedded report data. Co-authored-by: Codex (GPT 5.6 Sol) --- CHANGELOG.md | 9 + .../2026-07-30-html-cjk-line-breaking.md | 95 ++++++++ ...30-html-dimension-progressbar-semantics.md | 76 +++++++ scripts/harness-analysis/renderers/html.mjs | 204 +++++++++++------- test/harness-report-render-cli.test.mjs | 130 +++++++++++ 5 files changed, 442 insertions(+), 72 deletions(-) create mode 100644 docs/specs/2026-07-30-html-cjk-line-breaking.md create mode 100644 docs/specs/2026-07-30-html-dimension-progressbar-semantics.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cc48eda..faffc2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ observable behavior and compatibility, not every internal refactor. before either Session facts or lead analysis hydrates it. Versioned redacted bindings fail closed on population, selection, or admission contradictions while preserving bounded lead selection and explicit zero-signal filtering. +- Self-contained HTML reports now expose every fluency-dimension score track as + a labeled progressbar with a zero-to-100 range and the displayed rounded + score. Report validation rejects incomplete, duplicated, invalid, or + score-mismatched dimension progressbar contracts. +- Chinese self-contained HTML reports now use standards-based language + segmentation to keep bounded word-like phrases together while preserving + normal wrapping around Latin text, paths, URLs, and longer content. Runtimes + without segmentation support fall back to readable escaped text, and English + reports remain unchanged. ## 0.3.0 - 2026-07-27 diff --git a/docs/specs/2026-07-30-html-cjk-line-breaking.md b/docs/specs/2026-07-30-html-cjk-line-breaking.md new file mode 100644 index 0000000..5586285 --- /dev/null +++ b/docs/specs/2026-07-30-html-cjk-line-breaking.md @@ -0,0 +1,95 @@ +# Keep Chinese phrases together in HTML reports + +## Traceability + +- Spec ID: 2026-07-30-html-cjk-line-breaking +- Status: Implemented +- Issue: None; focused follow-up to the reviewed HTML progressbar visual QA. + +## Intent + +Keep ordinary Chinese word-like phrases together when the self-contained HTML +report wraps headings and card copy. Segment visible `zh-CN` text with the +standards-based `Intl.Segmenter` API and emit bounded, nonvisual keep-together +spans so the result is deterministic across supported browsers. The generic +renderer must improve reviewed Chinese content without hardcoded phrases, +report-data mutation, runtime measurement, or a visual redesign. + +## Acceptance Scenarios + +- AC-1: A `zh-CN` HTML report uses `Intl.Segmenter` word segmentation for + visible text and wraps each ordinary all-Han word-like segment of at most + eight code points in ``. Representative phrases + including `口径`, `入口`, `结论`, and `命令` receive that deterministic markup. +- AC-2: Phrase markup remains scoped to visible Chinese report text. English + reports retain their current markup, wrapping behavior, and visible copy; + attributes, accessibility labels, copied prompts, and embedded report JSON + remain plain escaped text without phrase spans or invisible joiners. +- AC-3: Chinese headings, card titles, summaries, reasons, and ordinary + paragraphs continue to wrap between marked segments. Each keep-together span + is bounded to eight Han code points. Long tokens, paths, URLs, mixed Latin + text, and unrecognized segments remain escaped readable text and cannot gain + an unbounded no-wrap container. +- AC-4: The existing dimension progressbar role, label, range, rounded value, + count validation, CSS, score computation, report data, and artifact set remain + unchanged. +- AC-5: Focused HTML tests, the full package check, whitespace validation, and + the allowed-file audit pass. Before review completion, an environment with a + browser runtime inspects the same report at all three target widths. + +## Compatibility Boundary + +`Intl.Segmenter` is used only while generating the self-contained report. The +renderer accepts only `isWordLike` segments that consist entirely of Han code +points and are between two and eight code points long. All other segments use +the existing HTML escaping path. If `Intl.Segmenter` is absent or throws, the +entire value falls back to the same escaped readable text. CSS applies +`white-space: nowrap` only to the bounded phrase spans, leaving normal wrapping +available between spans and around Latin tokens, punctuation, paths, and URLs. + +## Non-goals + +- Hardcoding known report phrases or inserting word-joiner characters into + report data, visible copy, accessibility text, or copied prompts. +- Preventing ordinary Chinese paragraphs from wrapping or forcing a complete + sentence, heading, or card onto one line. +- Adding overflow, clipping, ellipsis, hidden content, fixed widths, custom + fonts, browser-specific JavaScript, dependencies, or generated artifacts. +- Changing English layout, scores, findings, dimensions, report schemas, + progressbar semantics, interactions, or artifact names. + +## Plan and Tasks + +1. Add a focused renderer regression for deterministic markup, escaping, + bounded long-token handling, mixed Latin/path/URL preservation, English + preservation, unavailable-segmenter fallback, and the four reviewed phrases. + Record the missing-markup failure before renderer implementation. (AC-1..AC-4) +2. Add one visible-text renderer using `Intl.Segmenter` and a bounded + `.cjk-phrase` keep-together rule. Route visible report copy through it while + leaving attributes, copied prompts, and serialized data unchanged. Remove + the insufficient browser-dependent `auto-phrase` contract. (AC-1..AC-4) +3. Update the public changelog and verify the exact reviewed Chinese report at + 375, 768, and 1280 pixels, including wrapping, overflow, clipping, console, + and preserved progressbar semantics. (AC-3..AC-5) + +## Test and Review Evidence + +- The initial focused test failed for the intended reason: the reviewed Chinese + phrases had no deterministic keep-together markup. It passed after the + standards-based segment renderer and bounded phrase span were added. +- Browser QA then exposed descendant-selector leakage: nested phrase spans in + the score orbit, metrics, and evidence grid inherited block, width, and label + typography rules. A focused regression failed on the missing direct-child + selector contract before those existing rules were scoped to their direct + label and value children; nested phrase spans now remain inline. +- The combined CJK and preserved progressbar suite passed 2/2: + `node --test --test-name-pattern='HTML CJK phrase|HTML dimension progressbar' test/harness-report-render-cli.test.mjs`. +- The target-owned HTML suite passed 6/6: + `node --test --test-name-pattern='HTML' test/harness-report-render-cli.test.mjs`. +- The coordinating PM ran the complete test suite serially with an isolated + temporary root: all 894 tests passed, followed by successful npm and runtime + zip package verification. This resolved the parallel temporary-ancestor + interference observed in two earlier Worker runs. +- The exact reviewed Chinese report retains its source scores, finding count, + progressbar count, embedded report data, and artifact set. Post-fix browser + review at 375, 768, and 1280 pixels remains the final visual gate. diff --git a/docs/specs/2026-07-30-html-dimension-progressbar-semantics.md b/docs/specs/2026-07-30-html-dimension-progressbar-semantics.md new file mode 100644 index 0000000..8f0bbfd --- /dev/null +++ b/docs/specs/2026-07-30-html-dimension-progressbar-semantics.md @@ -0,0 +1,76 @@ +# Give HTML dimension tracks progressbar semantics + +## Traceability + +- Spec ID: 2026-07-30-html-dimension-progressbar-semantics +- Status: Implemented +- Issue: None; focused accessibility-contract maintenance from a reviewed Better Harness finding. + +## Intent + +Make every fluency-dimension score track in the self-contained HTML report +expose the score that sighted readers already see to assistive technology. The +HTML validator must keep that semantic projection bound to the reviewed +dimension count and rounded score so incomplete or stale markup cannot pass +report validation. + +## Acceptance Scenarios + +- AC-1: Every rendered fluency-dimension card contains exactly one track with + `role="progressbar"`, a non-empty accessible label derived from the displayed + dimension label, `aria-valuemin="0"`, `aria-valuemax="100"`, and an + `aria-valuenow` equal to the integer score displayed beside that track. +- AC-2: The HTML report validator passes the canonical rendered dimension + tracks and rejects a missing or extra dimension progressbar, a progressbar + with a missing or invalid role, label, minimum, maximum, or current value, + and a current value that differs from the reviewed rounded score. +- AC-3: The renderer preserves existing CSS, layout, visible copy, dimension + ordering, score clamping and rounding, visual fill width, artifact names, + and generated report ownership. +- AC-4: The focused regression, full renderer CLI suite, documentation graph, + doc-link suite, full package check, whitespace check, and allowed-file audit + pass from the assigned repository state. + +## Non-goals + +- Redesigning the dimension cards, tracks, responsive layout, color, or CSS. +- Changing visible labels, summaries, localization, score computation, + clamping, rounding, or report data schemas. +- Editing generated reports, templates, installed plugin caches, dependencies, + custom SVG, or any report mode other than the canonical HTML renderer. +- Adding browser interaction, live-region announcements, or a new artifact. + +## Plan and Tasks + +1. Add one focused regression test that asserts the rendered per-dimension + progressbar contract and mutation-based validator rejection for missing, + invalid, count-mismatched, and score-mismatched semantics. Record the test + failing against the current renderer before implementation. (AC-1, AC-2) +2. Reuse the renderer's existing display label and rounded score when emitting + the complete progressbar attributes, without changing track CSS or fill + width. (AC-1, AC-3) +3. Extend `evaluateHtmlReport` to bind exactly one valid progressbar to each + reviewed dimension card and reject semantic count or score drift. (AC-2) +4. Update the public changelog, regenerate the Markdown routing graph required + for the new spec, run every assigned verification command, and mark this + spec Implemented only after the evidence passes. (AC-4) + +## Test and Review Evidence + +- The required red run failed for the intended contract reason: the current + renderer produced zero semantic progressbars for five reviewed dimensions + (`0 !== 5`). The same focused command passed after implementation: + `node --test --test-name-pattern='dimension progressbar' test/harness-report-render-cli.test.mjs`. +- The full renderer suite passed 15/15: + `node --test test/harness-report-render-cli.test.mjs`. +- Documentation graph generation and all six doc-link checks passed: + `node scripts/doc-link-graph/cli.mjs skills/better-harness && node --test test/doc-link-graph.test.mjs`. +- The complete package gate passed from a clean temporary root under + `/var/tmp` with 893 tests and successful npm/runtime-zip package verification: + `TMPDIR=/var/tmp/better-harness-t016.lxCc80 npm run check`. +- Scope and whitespace: + `git diff --check -- ` plus an allowed-file-only status audit. +- Risk review: the focused test removes and duplicates a progressbar, mutates + each semantic attribute class, and changes `aria-valuenow`; all mutations + fail validation while canonical output passes. The CSS and existing visual + fill-width expression are unchanged. diff --git a/scripts/harness-analysis/renderers/html.mjs b/scripts/harness-analysis/renderers/html.mjs index a98000a..e0afe3c 100644 --- a/scripts/harness-analysis/renderers/html.mjs +++ b/scripts/harness-analysis/renderers/html.mjs @@ -11,6 +11,24 @@ function escapeHtml(value) { .replace(/'/gu, "'"); } +const CJK_PHRASE = /^\p{Script=Han}{2,8}$/u; + +function renderVisibleText(value, language) { + const text = String(value ?? ""); + if (language !== "zh-CN" || typeof Intl.Segmenter !== "function") return escapeHtml(text); + try { + const segments = new Intl.Segmenter("zh-CN", { granularity: "word" }).segment(text); + return [...segments].map(({ segment, isWordLike }) => { + const escaped = escapeHtml(segment); + return isWordLike && CJK_PHRASE.test(segment) + ? `${escaped}` + : escaped; + }).join(""); + } catch { + return escapeHtml(text); + } +} + function list(value) { return Array.isArray(value) ? value : []; } @@ -87,11 +105,11 @@ function stateTone(state) { return "muted"; } -function metric(label, value, note = "") { +function metric(label, value, note = "", language = "en") { return `
- ${escapeHtml(label)} - ${escapeHtml(value)} - ${note ? `${escapeHtml(note)}` : ""} + ${renderVisibleText(label, language)} + ${renderVisibleText(value, language)} + ${note ? `${renderVisibleText(note, language)}` : ""}
`; } @@ -120,11 +138,11 @@ function evidenceScoreLabel(summary, language) { return copy(language, "Loop Effectiveness", "循环有效性"); } -function section(id, eyebrow, title, body, note = "") { +function section(id, eyebrow, title, body, note = "", language = "en") { return `
-
${escapeHtml(eyebrow)}

${escapeHtml(title)}

- ${note ? `

${escapeHtml(note)}

` : ""} +
${renderVisibleText(eyebrow, language)}

${renderVisibleText(title, language)}

+ ${note ? `

${renderVisibleText(note, language)}

` : ""}
${body}
`; @@ -133,17 +151,19 @@ function section(id, eyebrow, title, body, note = "") { function renderDimensionGrid(summary, language) { const dimensions = list(summary?.dimensions); if (dimensions.length === 0) { - return `

${copy(language, "No reviewed dimension scores are available.", "暂无已复核的维度分数。")}

`; + return `

${renderVisibleText(copy(language, "No reviewed dimension scores are available.", "暂无已复核的维度分数。"), language)}

`; } return `
${dimensions.map((row, index) => { const score = clamp(row.score); + const roundedScore = Math.round(score); + const label = row.label ?? row.id ?? `Dimension ${index + 1}`; const state = row.state ?? copy(language, "Reviewed", "已复核"); return `
-
${escapeHtml(row.label ?? row.id ?? `Dimension ${index + 1}`)}${escapeHtml(state)}
-
${Math.round(score)}/ 100
-
-

${escapeHtml(row.summary ?? row.scoreReason ?? "")}

+
${renderVisibleText(label, language)}${renderVisibleText(state, language)}
+
${roundedScore}/ 100
+
+

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

`; }).join("\n")}
`; @@ -179,12 +199,12 @@ function renderActivity(summary, language) { return `
- + ${dates.length > 0 ? `
${escapeHtml(dates[0])}${escapeHtml(dates.at(-1))}
` : ""}
- ${metric(copy(language, "Sessions reviewed", "已分析会话"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, copy(language, "all-eligible usage census", "全量合格用量普查"))} - ${metric(copy(language, "Long-session leads", "长会话线索"), formatNumber(longCount, language), copy(language, `longest ${formatNumber(longest, language)} min`, `最长 ${formatNumber(longest, language)} 分钟`))} + ${metric(copy(language, "Sessions reviewed", "已分析会话"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, copy(language, "all-eligible usage census", "全量合格用量普查"), language)} + ${metric(copy(language, "Long-session leads", "长会话线索"), formatNumber(longCount, language), copy(language, `longest ${formatNumber(longest, language)} min`, `最长 ${formatNumber(longest, language)} 分钟`), language)}
`; } @@ -197,22 +217,22 @@ function renderUsageLists(summary, language) { const renderRows = (rows, labelKey) => { const top = rows.slice().sort((a, b) => number(b.total ?? b.responseCount) - number(a.total ?? a.responseCount)).slice(0, 6); const max = Math.max(1, ...top.map((row) => number(row.total ?? row.responseCount))); - if (top.length === 0) return `

${copy(language, "No attributed rows were retained.", "没有保留已归属数据。")}

`; + if (top.length === 0) return `

${renderVisibleText(copy(language, "No attributed rows were retained.", "没有保留已归属数据。"), language)}

`; return `
${top.map((row) => { const value = number(row.total ?? row.responseCount); - return `
${escapeHtml(row[labelKey] ?? row.name ?? "Unknown")}${formatNumber(value, language)}
`; + return `
${renderVisibleText(row[labelKey] ?? row.name ?? "Unknown", language)}${formatNumber(value, language)}
`; }).join("")}
`; }; return `
-

${copy(language, "Model usage", "模型使用")}

${renderRows(modelRows, "model")}
-

${copy(language, "Skill usage", "Skill 使用")}

${renderRows(skillRows, "name")}
+

${renderVisibleText(copy(language, "Model usage", "模型使用"), language)}

${renderRows(modelRows, "model")}
+

${renderVisibleText(copy(language, "Skill usage", "Skill 使用"), language)}

${renderRows(skillRows, "name")}
`; } function renderFindings(reportData, language) { const dimensions = new Map(list(reportData.summary?.dimensions).map((row) => [row.id, row.label ?? row.id])); const findings = list(reportData.findings); - if (findings.length === 0) return `

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

`; + if (findings.length === 0) return `

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

`; return `
${findings.map((row, index) => { const prompt = findingPromptParts(row.aiFixPrompt, language); const expected = textLines(row.expectedOutput ?? row.expectedOutcome); @@ -227,27 +247,27 @@ function renderFindings(reportData, language) { return `
- ${escapeHtml(row.severity ?? "Low")} - ${refs[0] ? `${escapeHtml(refs[0])}` : ""} + ${renderVisibleText(row.severity ?? "Low", language)} + ${refs[0] ? `${renderVisibleText(refs[0], language)}` : ""}
-

${escapeHtml(title)}

-

${escapeHtml(row.reason ?? "")}

+

${renderVisibleText(title, language)}

+

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

- - + +
-
${copy(language, "Finding details", "Finding 详情")}

${escapeHtml(title)}

- ${escapeHtml(row.severity ?? "Low")} +
${renderVisibleText(copy(language, "Finding details", "Finding 详情"), language)}

${renderVisibleText(title, language)}

+ ${renderVisibleText(row.severity ?? "Low", language)}
-
${copy(language, "Cause", "原因")}

${escapeHtml(row.reason ?? "")}

- ${expected.length ? `
${copy(language, "Expected Output", "预期结果")}
    ${expected.map((item) => `
  1. ${escapeHtml(item)}
  2. `).join("")}
` : ""} - ${prompt.checks.length ? `
${copy(language, "Acceptance Checks", "验收检查")}
    ${prompt.checks.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` : ""} +
${renderVisibleText(copy(language, "Cause", "原因"), language)}

${renderVisibleText(row.reason ?? "", 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("")}
` : ""}
- - + +
`; @@ -258,20 +278,20 @@ function renderSuggestions(summary, language) { const suggestions = list(summary?.suggestions); if (suggestions.length === 0) return ""; return `
-
${copy(language, "Suggestions", "建议")}

${copy(language, "Useful next experiments", "值得尝试的下一步")}

${copy(language, "Advisory only · no AI Fix action", "仅供参考 · 不包含 AI 修复动作")}

+
${renderVisibleText(copy(language, "Suggestions", "建议"), language)}

${renderVisibleText(copy(language, "Useful next experiments", "值得尝试的下一步"), language)}

${renderVisibleText(copy(language, "Advisory only · no AI Fix action", "仅供参考 · 不包含 AI 修复动作"), language)}

${suggestions.map((row, index) => { const prerequisites = textLines(row.prerequisites); const blockedBy = textLines(row.blockedBy); return `
-
${escapeHtml(suggestionKindLabel(row.kind, language))}${escapeHtml(row.confidence)}
-

${escapeHtml(row.title ?? row.id ?? copy(language, "Suggestion", "建议"))}

-

${escapeHtml(row.reason)}

+
${renderVisibleText(suggestionKindLabel(row.kind, language), language)}${renderVisibleText(row.confidence, language)}
+

${renderVisibleText(row.title ?? row.id ?? copy(language, "Suggestion", "建议"), language)}

+

${renderVisibleText(row.reason, language)}

-
${copy(language, "Owner", "负责人")}
${escapeHtml(row.owner)}
-
${copy(language, "Next step", "下一步")}
${escapeHtml(row.nextStep)}
-
${copy(language, "Validation", "验证")}
${escapeHtml(row.validation)}
- ${prerequisites.length ? `
${copy(language, "Prerequisites", "前置条件")}
${prerequisites.map(escapeHtml).join(" · ")}
` : ""} - ${blockedBy.length ? `
${copy(language, "Blocked by", "阻塞项")}
${blockedBy.map(escapeHtml).join(" · ")}
` : ""} +
${renderVisibleText(copy(language, "Owner", "负责人"), language)}
${renderVisibleText(row.owner, language)}
+
${renderVisibleText(copy(language, "Next step", "下一步"), language)}
${renderVisibleText(row.nextStep, language)}
+
${renderVisibleText(copy(language, "Validation", "验证"), language)}
${renderVisibleText(row.validation, language)}
+ ${prerequisites.length ? `
${renderVisibleText(copy(language, "Prerequisites", "前置条件"), language)}
${prerequisites.map((item) => renderVisibleText(item, language)).join(" · ")}
` : ""} + ${blockedBy.length ? `
${renderVisibleText(copy(language, "Blocked by", "阻塞项"), language)}
${blockedBy.map((item) => renderVisibleText(item, language)).join(" · ")}
` : ""}
`; }).join("\n")}
@@ -284,12 +304,12 @@ function renderCustomize(summary, language) { const rows = list(practice.coverageRows); const bySurface = new Map(rows.map((row) => [row.surface, row])); const surfaces = [...new Set([...inspected, ...rows.map((row) => row.surface).filter(Boolean)])]; - if (surfaces.length === 0) return `

${copy(language, "No project customization inventory was retained.", "没有保留项目自定义能力清单。")}

`; + if (surfaces.length === 0) return `

${renderVisibleText(copy(language, "No project customization inventory was retained.", "没有保留项目自定义能力清单。"), language)}

`; return `
${surfaces.map((surface) => { const row = bySurface.get(surface) ?? {}; const scopes = list(row.scopes); const count = row.count; - return `
${escapeHtml(String(surface).slice(0, 1).toUpperCase())}

${escapeHtml(surface)}

${scopes.length ? escapeHtml(scopes.join(" · ")) : copy(language, "Inspected", "已检查")}${count !== undefined ? ` · ${formatNumber(count, language)}` : ""}

`; + return `
${renderVisibleText(String(surface).slice(0, 1).toUpperCase(), language)}

${renderVisibleText(surface, language)}

${scopes.length ? renderVisibleText(scopes.join(" · "), language) : renderVisibleText(copy(language, "Inspected", "已检查"), language)}${count !== undefined ? ` · ${formatNumber(count, language)}` : ""}

`; }).join("")}
`; } @@ -307,11 +327,11 @@ function renderEvidence(summary, language) { [copy(language, "Learning state", "学习状态"), learning.state ?? "—"], ]; const gaps = textLines(boundary.sourceGaps); - return `
${facts.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join("")}
- ${gaps.length ? `
${copy(language, "Source gaps", "来源缺口")}
    ${gaps.map((gap) => `
  • ${escapeHtml(gap)}
  • `).join("")}
` : ""} -

${copy(language, + return `

${facts.map(([label, value]) => `
${renderVisibleText(label, language)}${renderVisibleText(value, language)}
`).join("")}
+ ${gaps.length ? `
${renderVisibleText(copy(language, "Source gaps", "来源缺口"), language)}
    ${gaps.map((gap) => `
  • ${renderVisibleText(gap, language)}
  • `).join("")}
` : ""} +

${renderVisibleText(copy(language, "Activity totals describe volume, not quality or savings. Fluency conclusions come from the reviewed task sample and remain bounded by the retained evidence.", - "活动总量只描述用量,不直接代表质量、效率或节省。流畅度结论来自已复核任务样本,并受已保留证据边界约束。")}

`; + "活动总量只描述用量,不直接代表质量、效率或节省。流畅度结论来自已复核任务样本,并受已保留证据边界约束。"), language)}

`; } function renderHtmlBody(reportData) { @@ -335,24 +355,24 @@ function renderHtmlBody(reportData) { return `
- ${copy(language, "Harness Insights · Codex HTML", "Harness 洞察 · Codex HTML")} -

${escapeHtml(projectName)}

-

${escapeHtml(overview)}

-
${escapeHtml(summary.modelId ?? "Harness")}${copy(language, "Evidence-bound", "证据有界")}
+ ${renderVisibleText(copy(language, "Harness Insights · Codex HTML", "Harness 洞察 · Codex HTML"), language)} +

${renderVisibleText(projectName, language)}

+

${renderVisibleText(overview, language)}

+
${renderVisibleText(summary.modelId ?? "Harness", language)}${renderVisibleText(copy(language, "Evidence-bound", "证据有界"), language)}
-
${dimensions.length}${copy(language, "reviewed dimensions", "独立复核维度")}
+
${dimensions.length}${renderVisibleText(copy(language, "reviewed dimensions", "独立复核维度"), language)}
- ${metric(evidenceScoreLabel(summary, language), `${effectiveness} / 100`, copy(language, "Changes after later task outcomes", "等待后续任务结果后更新"))} - ${metric(copy(language, "Asset Health / Repair Progress", "资产健康 / 修复进度"), `${progress.score} / 100`, copy(language, `${progress.verified} verified · ${progress.partial} partial · ${progress.pending} pending`, `${progress.verified} 项已验证 · ${progress.partial} 项部分完成 · ${progress.pending} 项待处理`))} - ${metric(copy(language, "Sessions analyzed", "会话样本"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, selection.confidence ?? "")} - ${metric(copy(language, "Findings", "待处理 Finding"), findings.length, `${high} High · ${medium} Medium`)} + ${metric(evidenceScoreLabel(summary, language), `${effectiveness} / 100`, copy(language, "Changes after later task outcomes", "等待后续任务结果后更新"), language)} + ${metric(copy(language, "Asset Health / Repair Progress", "资产健康 / 修复进度"), `${progress.score} / 100`, copy(language, `${progress.verified} verified · ${progress.partial} partial · ${progress.pending} pending`, `${progress.verified} 项已验证 · ${progress.partial} 项部分完成 · ${progress.pending} 项待处理`), language)} + ${metric(copy(language, "Sessions analyzed", "会话样本"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, selection.confidence ?? "", language)} + ${metric(copy(language, "Findings", "待处理 Finding"), findings.length, `${high} High · ${medium} Medium`, language)}
- ${section("fluency", copy(language, "01 · Readiness", "01 · 就绪度"), copy(language, "Five-dimension fluency", "五维流畅度"), renderDimensionGrid(summary, language), copy(language, "Scores and states come from the reviewed source.", "分数与状态来自已复核 source。"))} - ${hasUsage ? section("activity", copy(language, "02 · Signals", "02 · 信号"), copy(language, "Project usage", "项目用量"), `${renderActivity(summary, language)}${renderUsageLists(summary, language)}`, copy(language, "Volume is context, not an outcome claim.", "用量是背景,不是结果结论。")) : ""} - ${section("findings", copy(language, "03 · Action", "03 · 行动"), copy(language, "Findings and recommendations", "Finding 与建议"), `${renderFindings(reportData, language)}${suggestions.length ? renderSuggestions(summary, language) : ""}`, taskLoopReport ? copy(language, `${findings.length} findings · ${suggestions.length} suggestions`, `${findings.length} 条 finding · ${suggestions.length} 条建议`) : copy(language, `${findings.length} reviewed items`, `${findings.length} 条已复核项`))} - ${section("customize", copy(language, "04 · Capability", "04 · 能力"), copy(language, "Agent Customize", "Agent 自定义"), renderCustomize(summary, language), copy(language, "Inspected project and authorized host surfaces.", "已检查的项目与授权宿主能力面。"))} - ${section("methodology", copy(language, "05 · Boundary", "05 · 边界"), copy(language, "Evidence and methodology", "证据与方法"), renderEvidence(summary, language), copy(language, "Reader-safe evidence only.", "仅使用读者安全证据。"))}`; + ${section("fluency", copy(language, "01 · Readiness", "01 · 就绪度"), copy(language, "Five-dimension fluency", "五维流畅度"), renderDimensionGrid(summary, language), copy(language, "Scores and states come from the reviewed source.", "分数与状态来自已复核 source。"), language)} + ${hasUsage ? section("activity", copy(language, "02 · Signals", "02 · 信号"), copy(language, "Project usage", "项目用量"), `${renderActivity(summary, language)}${renderUsageLists(summary, language)}`, copy(language, "Volume is context, not an outcome claim.", "用量是背景,不是结果结论。"), language) : ""} + ${section("findings", copy(language, "03 · Action", "03 · 行动"), copy(language, "Findings and recommendations", "Finding 与建议"), `${renderFindings(reportData, language)}${suggestions.length ? renderSuggestions(summary, language) : ""}`, taskLoopReport ? copy(language, `${findings.length} findings · ${suggestions.length} suggestions`, `${findings.length} 条 finding · ${suggestions.length} 条建议`) : copy(language, `${findings.length} reviewed items`, `${findings.length} 条已复核项`), language)} + ${section("customize", copy(language, "04 · Capability", "04 · 能力"), copy(language, "Agent Customize", "Agent 自定义"), renderCustomize(summary, language), copy(language, "Inspected project and authorized host surfaces.", "已检查的项目与授权宿主能力面。"), language)} + ${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) { @@ -369,6 +389,7 @@ export function renderHtml(reportData) { :root { color-scheme: dark; --bg:#111315; --panel:#1a1d20; --panel-2:#21252a; --line:#31363d; --text:#f5f7fa; --muted:#9aa3ad; --blue:#6cb8ff; --blue-2:#1b6ca8; --orange:#ff9a5a; --green:#53d69b; --red:#ff6b6b; } * { box-sizing:border-box; } html { background:var(--bg); scroll-behavior:smooth; } + .cjk-phrase { white-space:nowrap; } body { margin:0; min-width:320px; font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--text); background:radial-gradient(circle at 12% -10%,#22364a 0,transparent 34rem),var(--bg); line-height:1.55; } .sr-only { position:absolute!important; width:1px!important; height:1px!important; padding:0!important; margin:-1px!important; overflow:hidden!important; clip:rect(0,0,0,0)!important; white-space:nowrap!important; border:0!important; } body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.22; background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size:28px 28px; } @@ -381,12 +402,12 @@ export function renderHtml(reportData) { .hero-tags { display:flex; flex-wrap:wrap; gap:8px; } .score-orbit { width:174px; aspect-ratio:1; display:grid; place-content:center; text-align:center; border:14px solid #343a41; border-top-color:var(--blue); border-right-color:var(--blue-2); border-radius:50%; background:#1a2026; box-shadow:inset 0 0 0 1px var(--line); } .score-orbit strong { font-size:44px; line-height:1; } - .score-orbit span { width:110px; margin-top:7px; color:var(--muted); font-size:12px; } + .score-orbit > span { width:110px; margin-top:7px; color:var(--muted); font-size:12px; } .pill { display:inline-flex; align-items:center; min-height:26px; padding:3px 10px; border-radius:999px; color:#d4dae0; background:#30353b; font-size:12px; font-weight:700; } .pill.accent { color:#c9e8ff; background:#164e75; }.pill.good { color:#c9f9e5; background:#165c43; }.pill.warning { color:#ffe1c8; background:#754322; }.pill.danger { color:#ffd4d4; background:#762e31; }.pill.muted,.pill.neutral { color:#c8cdd3; background:#34383d; } .metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin:18px 0 54px; } .metric { min-height:126px; padding:22px 24px; border:1px solid var(--line); border-radius:20px; background:rgba(30,34,38,.9); } - .metric span,.metric small { display:block; color:var(--muted); }.metric strong { display:block; margin:5px 0 2px; font-size:34px; line-height:1.1; }.metric small { font-size:12px; } + .metric > span,.metric > small { display:block; color:var(--muted); }.metric > strong { display:block; margin:5px 0 2px; font-size:34px; line-height:1.1; }.metric > small { font-size:12px; } .section { margin-top:64px; scroll-margin-top:24px; }.section-heading { display:flex; justify-content:space-between; gap:30px; align-items:end; margin-bottom:20px; }.section-heading h2 { margin:5px 0 0; font-size:30px; letter-spacing:-.025em; }.section-heading > p { max-width:420px; margin:0; color:var(--muted); text-align:right; font-size:14px; } .dimension-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; }.dimension-card { min-height:230px; padding:19px; border:1px solid var(--line); border-radius:19px; background:linear-gradient(180deg,var(--panel-2),var(--panel)); }.dimension-top { display:flex; min-height:52px; justify-content:space-between; gap:8px; align-items:flex-start; font-weight:700; }.score-line { display:flex; align-items:baseline; gap:4px; margin-top:14px; }.score-line strong { font-size:34px; }.score-line span { color:var(--muted); font-size:12px; }.track { height:8px; margin:8px 0 16px; overflow:hidden; border-radius:999px; background:#30353a; }.track i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--blue-2),var(--blue)); }.dimension-card p { margin:0; color:var(--muted); font-size:13px; } .activity-layout { display:grid; grid-template-columns:minmax(0,1.8fr) minmax(230px,.7fr); gap:14px; }.activity-panel,.subpanel,.finding,.custom-grid article,.evidence-grid,.evidence-note { border:1px solid var(--line); border-radius:19px; background:var(--panel); }.activity-panel { padding:22px; overflow:hidden; }.heatmap { display:grid; grid-template-rows:repeat(7,13px); grid-auto-flow:column; grid-auto-columns:13px; gap:4px; min-height:115px; overflow-x:auto; padding-bottom:8px; }.heat-cell { border-radius:3px; background:#2b3035; }.heat-cell.l1{background:#174d70}.heat-cell.l2{background:#206f9e}.heat-cell.l3{background:#319bd1}.heat-cell.l4{background:#70c9ff}.heat-legend { display:flex; justify-content:space-between; color:var(--muted); font-size:11px; }.stacked-metrics { display:grid; gap:14px; }.stacked-metrics .metric { min-height:0; } @@ -394,7 +415,7 @@ export function renderHtml(reportData) { .finding-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }.finding-card { min-width:0; min-height:220px; display:flex; flex-direction:column; padding:18px; border:1px solid var(--line); border-radius:19px; background:linear-gradient(180deg,#1c2329,var(--panel)); }.finding-card-main { display:grid; gap:10px; }.finding-meta { display:flex; flex-wrap:wrap; gap:7px; }.finding-card h3 { min-height:2.8em; margin:0; display:-webkit-box; overflow:hidden; font-size:17px; line-height:1.4; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.finding-preview { margin:0; color:#c7ced6; display:-webkit-box; overflow:hidden; font-size:13px; line-height:1.55; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.finding-actions,.dialog-actions { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; margin-top:auto; padding-top:16px; }.action-button { min-height:38px; padding:8px 13px; border:1px solid transparent; border-radius:10px; color:var(--text); background:#30363d; font:inherit; font-size:13px; font-weight:700; cursor:pointer; }.action-button:hover { filter:brightness(1.12); }.action-button:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }.action-button.secondary { color:#cceaff; border-color:#286d9b; background:#164e75; }.action-button.ghost { color:#d4dae0; border-color:var(--line); background:transparent; }.finding-dialog,.manual-copy-dialog { width:min(880px,calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; padding:24px; border:1px solid var(--line); border-radius:20px; color:var(--text); background:var(--panel); box-shadow:0 30px 90px rgba(0,0,0,.55); }.finding-dialog::backdrop,.manual-copy-dialog::backdrop { background:rgba(4,8,12,.72); }.dialog-heading { display:flex; justify-content:space-between; gap:18px; align-items:flex-start; }.dialog-heading h3 { margin:6px 0 0; font-size:24px; }.dialog-section { margin-top:18px; }.dialog-section p { margin:7px 0 0; color:#d1d7de; }.dialog-section ol,.dialog-section ul { margin:8px 0 0; padding-left:22px; color:#d1d7de; }.manual-copy-dialog textarea { width:100%; min-height:260px; margin-top:14px; padding:14px; resize:vertical; border:1px solid var(--line); border-radius:12px; color:var(--text); background:#11161b; font:13px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; }.no-js .finding-actions,.no-js .manual-copy-dialog { display:none!important; }.no-js .finding-dialog:not([open]) { display:block; position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none; } .suggestion-block { margin-top:28px; padding-top:24px; border-top:1px solid var(--line); }.suggestion-heading { display:flex; justify-content:space-between; gap:20px; align-items:end; margin-bottom:14px; }.suggestion-heading h3 { margin:4px 0 0; font-size:20px; }.suggestion-heading p { margin:0; color:var(--muted); font-size:12px; }.suggestion-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; }.suggestion { padding:18px; border:1px solid var(--line); border-radius:17px; background:linear-gradient(180deg,#1b252d,var(--panel)); }.suggestion-top { display:flex; justify-content:space-between; gap:8px; }.suggestion h3 { margin:13px 0 7px; font-size:16px; }.suggestion > p { color:#c7ced6; font-size:13px; }.suggestion dl { display:grid; gap:9px; margin:14px 0 0; }.suggestion dl div { display:grid; gap:2px; }.suggestion dt { color:var(--muted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }.suggestion dd { margin:0; color:#d7dde4; font-size:12px; } .custom-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:12px; }.custom-grid article { display:flex; gap:14px; align-items:center; padding:18px; }.custom-grid h3 { margin:0; font-size:15px; }.custom-grid p { margin:3px 0 0; color:var(--muted); font-size:12px; }.custom-mark { display:grid; flex:0 0 38px; aspect-ratio:1; place-content:center; border-radius:12px; color:#d9efff; background:#164e75; font-weight:800; } - .evidence-grid { display:grid; grid-template-columns:repeat(3,1fr); overflow:hidden; }.evidence-grid div { padding:20px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); }.evidence-grid span,.evidence-grid strong { display:block; }.evidence-grid span { color:var(--muted); font-size:12px; }.evidence-grid strong { margin-top:4px; }.evidence-note { margin-top:14px; padding:20px; }.method-note,.empty { color:var(--muted); }.method-note { max-width:800px; margin:18px 0 0; font-size:13px; } + .evidence-grid { display:grid; grid-template-columns:repeat(3,1fr); overflow:hidden; }.evidence-grid div { padding:20px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); }.evidence-grid > div > span,.evidence-grid > div > strong { display:block; }.evidence-grid > div > span { color:var(--muted); font-size:12px; }.evidence-grid > div > strong { margin-top:4px; }.evidence-note { margin-top:14px; padding:20px; }.method-note,.empty { color:var(--muted); }.method-note { max-width:800px; margin:18px 0 0; font-size:13px; } footer { margin-top:64px; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; } @media (max-width:1000px) { .finding-list{grid-template-columns:repeat(2,minmax(0,1fr))} } @media (max-width:900px) { .metrics,.dimension-grid{grid-template-columns:repeat(2,1fr)}.activity-layout,.two-column{grid-template-columns:1fr}.evidence-grid{grid-template-columns:repeat(2,1fr)} } @@ -407,19 +428,19 @@ export function renderHtml(reportData) {
${renderHtmlBody(reportData)} -
${copy(reportData.language, "Generated from one reviewed Harness source · self-contained Codex HTML", "由同一份已复核 Harness source 生成 · 自包含 Codex HTML")}
+
${renderVisibleText(copy(reportData.language, "Generated from one reviewed Harness source · self-contained Codex HTML", "由同一份已复核 Harness source 生成 · 自包含 Codex HTML"), language)}
- ${copy(reportData.language, "Manual fallback", "手动复制")} -

${copy(reportData.language, "Copy the AI Fix prompt", "复制 AI 修复提示词")}

+ ${renderVisibleText(copy(reportData.language, "Manual fallback", "手动复制"), language)} +

${renderVisibleText(copy(reportData.language, "Copy the AI Fix prompt", "复制 AI 修复提示词"), language)}

-

${copy(reportData.language, "Automatic copy was blocked. The exact prompt is selected below.", "自动复制被阻止,下面已选中完整提示词。")}

+

${renderVisibleText(copy(reportData.language, "Automatic copy was blocked. The exact prompt is selected below.", "自动复制被阻止,下面已选中完整提示词。"), language)}

- +
@@ -516,8 +537,47 @@ export function evaluateHtmlReport(htmlText, reportData) { errors.push(`report.html suggestion row count ${suggestionRows} does not match reviewed suggestions ${list(reportData?.summary?.suggestions).length}`); } const dimensionRows = (text.match(/data-dimension-id=/gu) ?? []).length; - if (dimensionRows !== list(reportData?.summary?.dimensions).length) { - errors.push(`report.html dimension row count ${dimensionRows} does not match reviewed dimensions ${list(reportData?.summary?.dimensions).length}`); + const dimensions = list(reportData?.summary?.dimensions); + if (dimensionRows !== dimensions.length) { + errors.push(`report.html dimension row count ${dimensionRows} does not match reviewed dimensions ${dimensions.length}`); + } + const dimensionTracks = text.match(/
]*>/gu) ?? []; + if (dimensionTracks.length !== dimensions.length) { + errors.push(`report.html dimension progressbar count ${dimensionTracks.length} does not match reviewed dimensions ${dimensions.length}`); + } + for (const [index, dimension] of dimensions.entries()) { + const dimensionId = escapeHtml(dimension?.id ?? index); + const dimensionMarker = `data-dimension-id="${dimensionId}"`; + const markerCount = text.split(dimensionMarker).length - 1; + if (markerCount !== 1) { + errors.push(`report.html dimension ${dimensionId} card binding count ${markerCount} does not match expected 1`); + continue; + } + const cardStart = text.indexOf(dimensionMarker); + const cardEnd = text.indexOf("", cardStart); + const cardText = cardEnd < 0 ? "" : text.slice(cardStart, cardEnd); + const cardTracks = cardText.match(/
]*>/gu) ?? []; + if (cardTracks.length !== 1) { + errors.push(`report.html dimension ${dimensionId} progressbar count ${cardTracks.length} does not match expected 1`); + continue; + } + const attributes = Object.fromEntries( + [...cardTracks[0].matchAll(/([\w-]+)="([^"]*)"/gu)].map((match) => [match[1], match[2]]), + ); + const roundedScore = Math.round(clamp(dimension?.score)); + const label = dimension?.label ?? dimension?.id ?? `Dimension ${index + 1}`; + const expectedAttributes = { + role: "progressbar", + "aria-label": `${escapeHtml(label)} ${roundedScore} of 100`, + "aria-valuemin": "0", + "aria-valuemax": "100", + "aria-valuenow": String(roundedScore), + }; + for (const [attribute, expected] of Object.entries(expectedAttributes)) { + if (attributes[attribute] !== expected) { + errors.push(`report.html dimension ${dimensionId} ${attribute} ${attributes[attribute] ?? "missing"} does not match expected ${expected}`); + } + } } if (text.length < 5000) warnings.push("report.html is unexpectedly small for the self-contained visual contract"); return { diff --git a/test/harness-report-render-cli.test.mjs b/test/harness-report-render-cli.test.mjs index d51eeeb..43cefd1 100644 --- a/test/harness-report-render-cli.test.mjs +++ b/test/harness-report-render-cli.test.mjs @@ -623,6 +623,136 @@ test("HTML mode mirrors the reviewed Agent Work Loop reader sections without Can }); }); +test("HTML dimension progressbar semantics stay complete and score-bound", () => { + // Given: a canonical reviewed report with five fluency dimensions. + const reportData = { + ...sampleFindings(), + language: "en", + target: { name: "render-fixture", path: "/tmp/render-fixture" }, + }; + + // When: the report is rendered and its dimension progressbars are inspected. + const html = renderHtml(reportData); + const progressbars = html.match(/
]*>/gu) ?? []; + + // Then: every displayed rounded score has one complete semantic contract. + assert.equal(progressbars.length, reportData.summary.dimensions.length); + for (const dimension of reportData.summary.dimensions) { + const score = Math.round(dimension.score); + assert.ok(progressbars.includes( + `
`, + )); + } + assert.equal(evaluateHtmlReport(html, reportData).status, "pass"); + + const firstProgressbar = progressbars[0] ?? ""; + const firstProgressbarMarkup = html.match(/
]*>]*><\/i><\/div>/u)?.[0] ?? ""; + assert.notEqual(firstProgressbarMarkup, ""); + const mutateFirstProgressbar = (pattern, replacement) => html.replace( + firstProgressbar, + firstProgressbar.replace(pattern, replacement), + ); + const mutations = [ + ["missing progressbar", html.replace(firstProgressbarMarkup, "")], + ["extra progressbar", html.replace(firstProgressbarMarkup, `${firstProgressbarMarkup}${firstProgressbarMarkup}`)], + ["missing role", mutateFirstProgressbar(' role="progressbar"', "")], + ["invalid role", mutateFirstProgressbar('role="progressbar"', 'role="meter"')], + ["missing label", mutateFirstProgressbar(/ aria-label="[^"]+"/u, "")], + ["invalid label", mutateFirstProgressbar(/aria-label="[^"]+"/u, 'aria-label=""')], + ["invalid minimum", mutateFirstProgressbar('aria-valuemin="0"', 'aria-valuemin="1"')], + ["invalid maximum", mutateFirstProgressbar('aria-valuemax="100"', 'aria-valuemax="99"')], + ["invalid current value", mutateFirstProgressbar(/aria-valuenow="[^"]+"/u, 'aria-valuenow="invalid"')], + ["score mismatch", mutateFirstProgressbar('aria-valuenow="62"', 'aria-valuenow="61"')], + ]; + + for (const [label, mutatedHtml] of mutations) { + assert.equal( + evaluateHtmlReport(mutatedHtml, reportData).status, + "fail", + `${label} mutation must fail validation`, + ); + } +}); + +test("HTML CJK phrase breaking emits bounded deterministic markup without changing report data", () => { + // Given: reviewed phrases plus escaping, long-token, path, URL, and mixed-Latin boundaries. + const fixture = sampleFindings(); + const reportData = { + ...fixture, + language: "zh-CN", + target: { name: "render-fixture", path: "/tmp/render-fixture" }, + summary: { + ...fixture.summary, + overview: "同一证据包给出两套互相冲突的口径,并保留 recommendedReads 与 app/api/example.py。", + dimensions: fixture.summary.dimensions.map((dimension, index) => ({ + ...dimension, + label: ["任务理解", "可控执行", "改动验证", "可靠交付", "经验沉淀"][index], + summary: index === 1 + ? "项目具有工作入口,但画像没有投影这些命令,代理仍需猜测验证路线。" + : dimension.summary, + })), + }, + findings: fixture.findings.map((finding, index) => ({ + ...finding, + title: index === 0 ? "同一证据包给出相反结论" : "项目画像漏掉工作入口", + reason: index === 0 + ? "安全