diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 7c4bb50..89d5ec5 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -131,15 +131,15 @@ dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs -b23dfa041d9f4597d144601ab8569e69ba748a0d7a075c3ef01154eacc2640ae 7018 src/main/git-validator-policy.cjs -ca050e9820528b555e6a1dfd89ac8be2f9a0cec6e91254897e6e3b6e116a7eb2 26317 src/main/git-validator-service.cjs +e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs +3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs 3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs 2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs 00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs 0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs -2e2d0b26480b395906b7881e50b596c9a7701e6e534497d04d541bf3f3b3c057 15673 src/main/ipc/repository-handlers.cjs +629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs @@ -173,10 +173,10 @@ cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 977af9585074f0a404830c5a93de6939b4897d2eb98ba8ae75c3cf0023dea673 5782 src/renderer/events.js a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js -4b0a64610da0c446f15a43e4753b26f29de72e07793e2fa7b7322d4f07cf8050 27806 src/renderer/mock-deployment-bridge.js +9fa522dad0088e2981c4ca5c392e93d6c92e04ff23fd1083d9040a3aa52d2b55 28101 src/renderer/mock-deployment-bridge.js cbb21f5f4b299f24d6cea9070fafcd14315d3ad6259bc8e11c0e93350084fff5 20032 src/renderer/mock-repository-bridge.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js -abe196f5ecdd73e7b6ca67a41c90e55bfc507e084f786227264cc780b1ce83a3 78001 src/renderer/styles.css +6776e0adb690b8f36274bfc5b31e3140054893c46ba64621c4f56a0d91b52fc2 78323 src/renderer/styles.css 1703e64533b7e2717b27c5776296c7dd76331e6f97e8005aea9fd688f1aee3ae 94834 src/renderer/views.js 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -191,7 +191,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs -33bc892963e89b868235b959498308a456b1285057bda524ba7c1d5a9ee2159e 8763 tests/browser/forgeflow.spec.mjs +2d57a66bb2a6461e4c3266f14998d505ba0582135b0bbcf8fce9f1542a70007a 10479 tests/browser/forgeflow.spec.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs @@ -202,8 +202,8 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs -c00bbd8eae5cef7856c8283d6b40dedb81083bf57ad762e89ab79e0f312da351 3271 tests/git-validator-policy.test.mjs -73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs +61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs +2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs diff --git a/src/main/git-validator-policy.cjs b/src/main/git-validator-policy.cjs index 80cfb91..2c8805b 100644 --- a/src/main/git-validator-policy.cjs +++ b/src/main/git-validator-policy.cjs @@ -20,6 +20,8 @@ function normalizePolicy(policy = {}) { enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null, severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {}, blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))], + blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))] + .filter((severity) => ["warning", "error"].includes(severity)), allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true, maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)), }; @@ -57,7 +59,7 @@ function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) { suppressed: Boolean(suppression), suppression: suppression || null, expiredSuppression: expiredSuppression || null, - blocking: !suppression && status !== "pass" && (status === "error" || policy.blockingChecks.includes(check.id)), + blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)), }; }); return { policy, checks: relevant }; diff --git a/src/main/git-validator-service.cjs b/src/main/git-validator-service.cjs index aee97b0..623f10f 100644 --- a/src/main/git-validator-service.cjs +++ b/src/main/git-validator-service.cjs @@ -484,12 +484,23 @@ class GitValidatorService { throw new Error("Unsupported Git Validator repair action."); } + async resolveRepairCheck(repository, candidate) { + const checkId = String(candidate?.id || candidate?.checkId || "").trim(); + if (!checkId) throw new Error("A current Git Validator check ID is required."); + const report = await this.scan(repository); + const current = report.checks.find((check) => check.id === checkId); + if (!current?.fixAction) + throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing."); + if (candidate?.fixAction && candidate.fixAction !== current.fixAction) + throw new Error("The Git Validator repair request is stale. Scan again before repairing."); + return current; + } summarize(repository, checks) { const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0); const earned = checks.reduce( (sum, check) => sum + - (check.status === "pass" + (check.status === "pass" || check.suppressed ? check.weight : check.status === "warning" ? check.weight * 0.45 @@ -512,8 +523,9 @@ class GitValidatorService { checks, summary: { passed: checks.filter((check) => check.status === "pass").length, - warnings: checks.filter((check) => check.status === "warning").length, - errors: checks.filter((check) => check.status === "error").length, + warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length, + errors: checks.filter((check) => check.status === "error" && !check.suppressed).length, + suppressed: checks.filter((check) => check.suppressed).length, repairable: checks.filter((check) => check.fixAction).length, }, }; diff --git a/src/main/ipc/repository-handlers.cjs b/src/main/ipc/repository-handlers.cjs index 475be03..f689da9 100644 --- a/src/main/ipc/repository-handlers.cjs +++ b/src/main/ipc/repository-handlers.cjs @@ -373,7 +373,8 @@ function registerRepositoryIpc({ }); register("git-validator:preview-repair", async ({ fullName, check }) => { const repository = await resolveRepository({ fullName }); - return gitValidator.previewRepair(repository, check); + const currentCheck = await gitValidator.resolveRepairCheck(repository, check); + return gitValidator.previewRepair(repository, currentCheck); }); register("git-validator:export", async ({ fullName, format = "json" }) => { const repository = await resolveRepository({ fullName }); @@ -390,18 +391,19 @@ function registerRepositoryIpc({ "add-editorconfig", "protect-default-branch", ]); - if (!allowed.has(check?.fixAction)) + const currentCheck = await gitValidator.resolveRepairCheck(repository, check); + if (!allowed.has(currentCheck.fixAction)) throw new Error("Unsupported Git Validator repair request."); - const result = await gitValidator.repair(repository, check); + const result = await gitValidator.repair(repository, currentCheck); await audit.append("git-validator.repair", { repository: repository.fullName, - checkId: check.id, - action: check.fixAction, + checkId: currentCheck.id, + action: currentCheck.fixAction, }); await diagnostics.info("git-validator.repair.completed", { repository: repository.fullName, - checkId: check.id, - action: check.fixAction, + checkId: currentCheck.id, + action: currentCheck.fixAction, }); return result; }); diff --git a/src/renderer/mock-deployment-bridge.js b/src/renderer/mock-deployment-bridge.js index db8b33c..a6cdf58 100644 --- a/src/renderer/mock-deployment-bridge.js +++ b/src/renderer/mock-deployment-bridge.js @@ -461,13 +461,15 @@ function createMockDeploymentBridge(context) { }, async gitValidatorScan(fullName) { await wait(260); + const policy = state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 }; + const activeWarnings = 3; return { repository: fullName, checkedAt: iso(), score: 78, grade: "Good", - policy: state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 }, - ready: true, + policy, + ready: 78 >= policy.requiredScore && (policy.id === "minimal" || activeWarnings === 0), commitSha: "8cbaf303aa3bb9b4023a7c89aa13fb70ce612847", trend: { newlyFound: ["working-tree"], resolved: ["editorconfig"], regressions: [], suppressions: [] }, expiredSuppressions: [], @@ -572,7 +574,12 @@ function createMockDeploymentBridge(context) { }; }, async gitValidatorSetPolicy(_fullName, policy) { - state.gitValidatorPolicy = { id: policy.id, label: policy.id[0].toUpperCase() + policy.id.slice(1) }; + const requiredScores = { minimal: 55, standard: 70, strict: 82, production: 90 }; + state.gitValidatorPolicy = { + id: policy.id, + label: policy.id[0].toUpperCase() + policy.id.slice(1), + requiredScore: requiredScores[policy.id] || 70, + }; return clone(state.gitValidatorPolicy); }, async gitValidatorSuppress(_fullName, suppression) { diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 72772c6..aab56c6 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -937,6 +937,21 @@ select:focus-visible { min-height: 0; overflow: hidden; } +.repo-content > .tab-page, +.repo-content > .validator-page { + height: 100%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.repo-content > .validator-empty, +.repo-content > .empty-state { + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; +} .changes-layout { height: 100%; min-height: 0; @@ -1286,7 +1301,7 @@ html[data-theme="light"] .diff-line.remove { padding: 18px; display: grid; gap: 14px; - overflow: auto; + align-content: start; } .validator-empty { min-height: 360px; @@ -2572,9 +2587,8 @@ kbd { font: 11px var(--font-mono); } .tab-page { - min-height: 100%; + min-height: 0; padding: 18px 19px 42px; - overflow: auto; } .git-tools-grid { display: grid; diff --git a/tests/browser/forgeflow.spec.mjs b/tests/browser/forgeflow.spec.mjs index b141511..c4d004b 100644 --- a/tests/browser/forgeflow.spec.mjs +++ b/tests/browser/forgeflow.spec.mjs @@ -60,6 +60,24 @@ async function assertSurface(page) { expect(audit.headings.length).toBeGreaterThan(0); } +async function assertScrollableWhenOverflowing(page, selector) { + const target = page.locator(selector); + await expect(target).toBeVisible(); + await expect(target).toHaveCSS("overflow-y", /auto|scroll/); + let metrics; + await expect.poll(async () => { + metrics = await target.evaluate((element) => ({ + connected: element.isConnected, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + return metrics.connected && metrics.clientHeight > 0; + }).toBe(true); + if (metrics.scrollHeight > metrics.clientHeight + 1) { + await target.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await expect.poll(() => target.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); + } +} test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => { await assertSurface(page); for (const view of ["overview", "deployments", "settings"]) { @@ -86,13 +104,28 @@ test("repository changes, Git tools and Git Validator complete their primary flo } await page.locator('[data-action="repo-tab"][data-tab="validator"]').click(); await expect(page.locator(".validator-score")).toBeVisible(); + await assertScrollableWhenOverflowing(page, ".validator-page"); await expect(page.locator("#validator-policy")).toBeVisible(); await page.locator("#validator-policy").selectOption("production"); await expect(page.locator(".validator-hero")).toContainText(/Production policy/i); + await expect(page.locator(".validator-hero")).toContainText(/review required/i); await page.keyboard.press("Tab"); await expect(page.locator(":focus")).toBeVisible(); }); +test("every long application surface retains a working vertical scroll owner", async ({ page }) => { + for (const view of ["overview", "deployments", "diagnostics", "settings"]) { + await page.locator(`.nav-button[data-view="${view}"]`).click(); + await assertScrollableWhenOverflowing(page, ".main-canvas"); + } + await page.locator('[data-action="select-repo"]').first().click(); + for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) { + await page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`).click(); + const scrollRoot = page.locator(".repo-content > .tab-page, .repo-content > .validator-page"); + if (await scrollRoot.count()) + await assertScrollableWhenOverflowing(page, ".repo-content > .tab-page, .repo-content > .validator-page"); + } +}); test("deployment inventory supports dense workloads without ambiguous blank cards", async ({ page }) => { await page.locator('.nav-button[data-action="navigate"][data-view="deployments"]').click(); await expect(page.locator(".deploy-card, .server-inventory-panel .tool-row").first()).toBeVisible(); diff --git a/tests/git-validator-policy.test.mjs b/tests/git-validator-policy.test.mjs index 9158bd8..6cf6cb7 100644 --- a/tests/git-validator-policy.test.mjs +++ b/tests/git-validator-policy.test.mjs @@ -23,6 +23,20 @@ test("Git Validator policies enforce score, blockers and enabled checks", () => assert.equal(governed.checks[0].blocking, true); }); +test("built-in policies enforce their declared blocking severities", () => { + const finding = [{ id: "readme", status: "warning", category: "Documentation", weight: 5 }]; + assert.equal(applyPolicy(finding, { id: "minimal" }, []).checks[0].blocking, false); + for (const id of ["standard", "strict", "production"]) + assert.equal(applyPolicy(finding, { id }, []).checks[0].blocking, true, `${id} must block active warnings`); +}); + +test("documented suppressions remove active blockers", () => { + const now = new Date("2026-07-01T00:00:00.000Z"); + const suppression = validateSuppression({ checkId: "readme", reason: "Tracked remediation work", author: "Jens", expiresAt: "2026-07-08T00:00:00.000Z", evidence: "ticket:FF-7" }, normalizePolicy({ id: "standard" }), now); + const check = applyPolicy([{ id: "readme", status: "warning", category: "Documentation", weight: 5 }], { id: "standard" }, [suppression], now).checks[0]; + assert.equal(check.suppressed, true); + assert.equal(check.blocking, false); +}); test("suppressions require accountable evidence and reactivate after expiry", () => { const now = new Date("2026-07-01T00:00:00.000Z"); const suppression = validateSuppression({ checkId: "signed-tags", reason: "Tracked under release hardening", author: "Jens", ticket: "FF-42", expiresAt: "2026-07-08T00:00:00.000Z", scope: "repository", evidence: "sha:abc" }, normalizePolicy({ id: "standard" }), now); diff --git a/tests/git-validator.test.mjs b/tests/git-validator.test.mjs index 1763ea8..70910ac 100644 --- a/tests/git-validator.test.mjs +++ b/tests/git-validator.test.mjs @@ -100,6 +100,24 @@ test("Git Validator recognizes remote aliases and secret-shaped tracked paths", assert.equal(isSensitiveTrackedPath(".env.example"), false); }); +test("Git Validator rejects stale or forged repair requests", async () => { + const validator = new GitValidatorService({ git: new GitService() }); + validator.scan = async () => ({ + checks: [{ id: "local-safety", fixAction: "configure-local-safety", status: "warning" }], + }); + assert.equal( + (await validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "configure-local-safety" })).id, + "local-safety", + ); + await assert.rejects( + validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "align-origin" }), + /stale/i, + ); + await assert.rejects( + validator.resolveRepairCheck({}, { id: "resolved-check", fixAction: "align-origin" }), + /resolved|no longer repairable/i, + ); +}); test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-")); t.after(() => rm(root, { recursive: true, force: true }));