From a0733875c4cdc252f169c11ef3cc2f5f8d5333de Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:24:27 +0200 Subject: [PATCH] feat: deliver policy-driven Git Validator 2.0 --- SOURCE_MANIFEST.txt | 103 +++++++++++------------ main.cjs | 1 + preload.cjs | 4 + reports/architecture-audit.json | 108 +++++++++++++----------- reports/architecture-audit.md | 4 +- src/main/config-store.cjs | 39 ++++++++- src/main/git-validator-policy.cjs | 87 ++++++++++++++++++++ src/main/git-validator-service.cjs | 109 ++++++++++++++++++++++++- src/main/ipc/repository-handlers.cjs | 21 +++++ src/main/unraid-access-methods.cjs | 2 +- src/main/unraid-inventory-methods.cjs | 2 +- src/main/unraid-preflight-methods.cjs | 10 +-- src/renderer/actions/shell.js | 59 +++++++++---- src/renderer/events.js | 2 + src/renderer/mock-deployment-bridge.js | 17 ++++ src/renderer/views.js | 8 +- tests/git-validator-policy.test.mjs | 53 ++++++++++++ tests/renderer-workflow.test.mjs | 6 +- 18 files changed, 502 insertions(+), 133 deletions(-) create mode 100644 src/main/git-validator-policy.cjs create mode 100644 tests/git-validator-policy.test.mjs diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index f722ba4..5cbdcb9 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -15,40 +15,41 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md -1101ae8a14a4f0cf0a56a3a73a60e7a231e07ea7872d936904c605abf6298a34 288319 coverage/tmp/coverage-16016-1785339516187-0.json -d655dca9239887ef791e144308830fe97ae99b3531449944254d9733b27a1cae 111306 coverage/tmp/coverage-20500-1785339516683-0.json -9f13cddf4882c263f5da10e82eda888aa80d60eba5a4880646fde183ea41451f 329357 coverage/tmp/coverage-23076-1785339516366-0.json -bd1b7d71c9b2d6a89c7b96c0da51d634d7e8f9e335822f9601998eb7d1264b66 367435 coverage/tmp/coverage-30956-1785339516246-0.json -a27032411a329ff88d2ca8b0365f1c9c04f86951763bfdbdc6b1c76dcb4dbb10 327419 coverage/tmp/coverage-33844-1785339515162-0.json -59c785d1cabcee6d1069421425732d67ede026fdf907922724b894ec60f23f7c 303089 coverage/tmp/coverage-36076-1785339516543-0.json -6ccdab75e41e28365282114305041c342bf0924b488673d4afbebe8dc0b2bf4d 426817 coverage/tmp/coverage-39452-1785339516633-0.json -7019945829625166aafaf3fac38ed2095c9b6ccef4db451b103c8e641c5b31c4 301396 coverage/tmp/coverage-39516-1785339515312-0.json -204f45a7f69e83e727b5d06713b59fe7cf76fa2b8acfb9909b8a226cfebe6919 299000 coverage/tmp/coverage-41400-1785339515472-0.json -28305a502a85cc95ad37adab0c80abe7ad9923e1fb7739c8b3c01694cfd5303a 335944 coverage/tmp/coverage-43744-1785339515697-0.json -61d18db7fece14e6ffc192cd0c93556c176276c5738438a6cbc96bac4b6ebab7 282471 coverage/tmp/coverage-44320-1785339516169-0.json -10ebff421ee2f56d9a2b3ea87ec43d397609111a1e028a810472757dc1fbc6c2 346825 coverage/tmp/coverage-44612-1785339515256-0.json -f9179452c930d41972809015ef90e2d5cdbfd9a460f50420fb2131869f510f8d 354683 coverage/tmp/coverage-50832-1785339515755-0.json -6265af5161de8807eff28cba2834f6a29c93216b4b4ac3fb0955f14e7a8d677e 89173 coverage/tmp/coverage-52036-1785339515055-0.json -bd8a4f824da29801902c1f305d3804f249efb6233d44f902c04a0c35e2d4ba76 342639 coverage/tmp/coverage-52864-1785339515537-0.json -ca7431ed712d082aca6348fdeda4c67207fdab56656e0feda5fad74e60ccf4db 336007 coverage/tmp/coverage-53260-1785339515757-0.json -f834e80b44c37263816fa5ac9f29870658690453aa826db1f8a6841e5ed554b2 335634 coverage/tmp/coverage-53596-1785339519653-0.json -b8c977c8dc0f34e968c59071c376ec1e19ddc45440e3ff96d844f48eda12be05 284302 coverage/tmp/coverage-55056-1785339515769-0.json -40cad1955f780f9fd42cdcbe61d04acba2d3f7662bb27639f16e5f0820df83b3 321055 coverage/tmp/coverage-55928-1785339516726-0.json -b9559b72bc441f91eed9e65122636e5e76c8ffcb2dbfe0501fea8860d6868b12 320075 coverage/tmp/coverage-56416-1785339517848-0.json -2e027822e3475abb5caef8d1326f76d5e2f0a0512656bfd9747135a86bf1e4f3 344498 coverage/tmp/coverage-56544-1785339516170-0.json -30eada55184a94693954401a5cb9e1f37ebaee78075ed569b7ad0762aae0d123 344612 coverage/tmp/coverage-56608-1785339518494-0.json -09ac59a2ff03d78b2b025a266af53e27b67c6f33a562afba9da62b89938c8991 318324 coverage/tmp/coverage-57192-1785339516055-0.json -1b35c0e476c27889b191455271562550e146e0b4129721e8938836e9e5e25414 344933 coverage/tmp/coverage-57204-1785339516743-0.json -9fb213f290c39ec6c4d89dab2e86dde5109551a0c0f64b6b1cc020557e4a515d 302345 coverage/tmp/coverage-57868-1785339516192-0.json -993ece7e08147c4d88bce3fe791aacb6af20c487a9ad3f59f0d8ed25a0661892 304507 coverage/tmp/coverage-58136-1785339515403-0.json -344bd1ec56b83794737ea3c0c889fe5629a0baf4993d550048deb6fe1c2f1f03 352004 coverage/tmp/coverage-58704-1785339515317-0.json -3106fd06d0269f55a759c491d8d73a844ad9b13db96a0ed839407630daad1cd4 310268 coverage/tmp/coverage-58996-1785339516528-0.json -2a1b4078c040a602b68d7c684e7b4a70b330ea424866bc043baa5f3f7eb867aa 360892 coverage/tmp/coverage-59356-1785339516204-0.json -deb5210ec65db9e7aaf85bb4b3bda1ec5c9fdc89709b048712ca4a82836f84b7 358381 coverage/tmp/coverage-59756-1785339516948-0.json -3d435d5982a18f5876080c2cf0e181beb0e3759d838c429e1d1d4e6dcbe97634 302512 coverage/tmp/coverage-59972-1785339516032-0.json -0dbfa219b3a04e5854b09c803e5092b09320b2c87ebfb9c6ed69f88c2730944c 360066 coverage/tmp/coverage-59988-1785339547987-0.json -bdce38d30ef6798ae45146d58a16f794910c9016fe3f0591aa6b65613cc600a0 345121 coverage/tmp/coverage-60804-1785339522999-0.json -de23d1f8a3a19c8297c97fc44d0cccd2dff77242d7789112c5177738c0ab40a7 296834 coverage/tmp/coverage-61136-1785339515987-0.json +04a3058e507c938447d35564e503cf89b76670d8ea45f5209db074dfa2625b91 303090 coverage/tmp/coverage-14228-1785341379444-0.json +0f86a35f2e6a48c0789263a4e36190760b0dfa45ce9a1128555b4dd77da2df5b 352006 coverage/tmp/coverage-14596-1785341377618-0.json +4ae93dacf27d8ec7e84bd00ab52398a0d99cecb25cf2244be8e6d89824c3d5db 345122 coverage/tmp/coverage-15236-1785341392510-0.json +2607418ea469b58eb6190e4cabde794c0773b7746afb0fdd2d401c99a353752a 358520 coverage/tmp/coverage-17880-1785341380606-0.json +ea98763aaacb035c1c75fca8f50784fc0cc89b2dedf2ddfa80c0eb32d633af27 342639 coverage/tmp/coverage-19480-1785341377315-0.json +fdaef396ca19f7e1f62ea7e6f2172fa615c7e7f26b157e80c896199f5ba3e77e 329358 coverage/tmp/coverage-19612-1785341379233-0.json +d2808be6bce7d7d3d4662def01522170b8cef6c802813db92ade8c27b335fbbe 346825 coverage/tmp/coverage-21808-1785341377499-0.json +7f788bab2eb9ada8e3c3f7fc1d6948f001018721eccb54e87b98a8916e30695b 299000 coverage/tmp/coverage-24368-1785341377951-0.json +d3584daefb19adb8e0e7cd3ca6ffc7f6992636a692a4a3b186da94281950aa8f 302346 coverage/tmp/coverage-25148-1785341378806-0.json +e4045202501f53544351ae8f0aba6cdcccef9d08d735f4d0c83634af5d3033b3 344499 coverage/tmp/coverage-29628-1785341379071-0.json +bf66bf3c80bfed0d145dc2a73363e47089a74858d3cece5dbef2756bfbfd623b 360893 coverage/tmp/coverage-31472-1785341379151-0.json +0d52bfc33883af8fccdc37c436edd95eb83f54c65de87fa09ad03719995574e4 335635 coverage/tmp/coverage-36800-1785341386017-0.json +bcb44d6e6369e3cb5fd5306a02097fadd44e6d85eeac5490e7660f841ba86ea3 288320 coverage/tmp/coverage-37176-1785341378929-0.json +da4b2512633624ef659682bf16af77d92799bd228e0d69a985fa23246460ce09 111308 coverage/tmp/coverage-44380-1785341379522-0.json +96a9f4b2bb3a429dbe5286c7f6c13c39b0e0de882d46724853083ea3d9ac9f6e 317635 coverage/tmp/coverage-44612-1785341382147-0.json +705a92db802fac29c0bbe5aa5727ad35350ddfd03bc98efc24d68529f5a41a99 360058 coverage/tmp/coverage-44984-1785341422918-0.json +279e70e06e6366bf15bb60f6079c38580d006832ba200ba5cebd9bac8a4e4f02 321057 coverage/tmp/coverage-48188-1785341379614-0.json +20758be193868d8f9275dd1eaf35b6bfc652f4682ebc89e4c4aa481e63dd1664 301398 coverage/tmp/coverage-49592-1785341377478-0.json +a4275d02b93b13deadcc554844b321fe186ad272e676f93ccf8f84772bc2760c 284667 coverage/tmp/coverage-49952-1785341378477-0.json +d704dd8bab1e3fcf52d4e4762e6a3e4eb9818073a2fc1d622a2afe4425ec9cc4 336008 coverage/tmp/coverage-50036-1785341378337-0.json +e4dc4ec12d779464d974f7d5c9713f63db6776596698fdc4ce03d18e773a34d9 354684 coverage/tmp/coverage-50728-1785341378056-0.json +0487ef221d132f9ada2b57cb216fb0ff167f903cea8df46c61b39b9fa52bd38a 282952 coverage/tmp/coverage-51204-1785341379590-0.json +42be90ace3af568e4eace7f1a3a5974856360707839c793b3b092ede0f6c5ad1 344613 coverage/tmp/coverage-51236-1785341383324-0.json +1b5a01cc5508a78e28b27f7a46878d54aaca027deb0e93f34f445d9dd32116f1 335945 coverage/tmp/coverage-54004-1785341378407-0.json +b46e7b4d5f95b167803a7d0481163a01026b5ac2e9c32e2ba40afa8a4791163e 428588 coverage/tmp/coverage-55224-1785341380041-0.json +b175e49795c65f6011dbf04aefd75cb7ebee5c2b248edb50e7df1780f2a599b8 367436 coverage/tmp/coverage-55472-1785341379481-0.json +2a53a441228fb74c1f7a488d49c1545e53640af5c1ca720352919c86527e5c19 344934 coverage/tmp/coverage-56192-1785341380236-0.json +dd2c111bc050afda47ed7379f88651cda579a965cfed56ddf2b3ca0e5d748ee1 304508 coverage/tmp/coverage-5636-1785341377677-0.json +8ce33572e4a3ba6e63b85155dc0ebba61b3e6fd203ef17b762735639dab72467 360058 coverage/tmp/coverage-56392-1785341444543-0.json +9efac9c2f67e00fbc2ddd22ff17886ffa212b55b76e21597df1a85417e55ef0c 89175 coverage/tmp/coverage-56440-1785341377010-0.json +4b6892c586091b5bf3eead8d8687a318ef2d82874fc0c9efaab25430ee819600 296835 coverage/tmp/coverage-58592-1785341378685-0.json +81f7395f307d0c4776331a5125653388650dea21876ace362ec92e3ce1efefda 318326 coverage/tmp/coverage-58856-1785341378523-0.json +5370cdb8a3384f62e68314bd4bf0efa78d9db57ced656b011d3b1e995369596e 327419 coverage/tmp/coverage-59512-1785341377212-0.json +7e7571c84f4bcdcdd39ba6fcaa2ac9ea1583eeff6cf59ea4c46c601add76e40a 310269 coverage/tmp/coverage-60040-1785341379655-0.json +d19fda134998419dd45ecd943d587c26cf80742b140de46449f8ec4bcf9191a9 302513 coverage/tmp/coverage-61124-1785341378630-0.json 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md @@ -118,17 +119,17 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE -910e179eb2743989725e7fe18efd291ff098b4335aae4b9eb530b579efa6fd3f 13654 main.cjs +1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md f3e2a262e251eb165f342dc3c184625ec10d56e536b5a987199b9944f463eee9 181069 package-lock.json 43725a143cb927cc70bd823d789788d785d872119c008043c270bbf73948851f 4882 package.json -86876cd502f51ad65e9c9280b189a80579df99ba65c81daf338048693d6ec78f 11732 preload.cjs +69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md -adbed8dcc399e1c55393391f81ca6a8789840ce17ee942980d2593813aee166d 13680 reports/architecture-audit.json -9683d4988b4fbdfeef6cbb8c50e515e34c3c09bc55fb5f5913c6bb14e592f0c2 1105 reports/architecture-audit.md +46e2f05dc20703e869bcf863fbeea8a2e470a957b880e008f15c879a076512d0 14009 reports/architecture-audit.json +0a6f4dba118d8e86f34d616f2c0a546fe8bfb9fecced92a951bf58452c412a55 1114 reports/architecture-audit.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 @@ -145,7 +146,7 @@ e8c5d00737d4c5e2c37ff683e569e8d7a0510be233db3840460c78be69f2c4bc 17163 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -565787043e9825859301c203a269ca801e0c2c417f37417f0dde17238433d712 30410 src/main/config-store.cjs +04b97b9b02ad8746d2229d40ba0585088118ecd18e1b5f3a1911a4f3b63831d6 32271 src/main/config-store.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs 7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs @@ -153,14 +154,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 -3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs +b23dfa041d9f4597d144601ab8569e69ba748a0d7a075c3ef01154eacc2640ae 7018 src/main/git-validator-policy.cjs +ca050e9820528b555e6a1dfd89ac8be2f9a0cec6e91254897e6e3b6e116a7eb2 26317 src/main/git-validator-service.cjs 2faaef0eeec1e473db82b94243674e3812ef5869358b0ec6abf8d645a8794623 20768 src/main/gitea-service.cjs 8c3c7b9bf0893276f5ab520efba17fc143c64377128d754411cd911180025c4a 5743 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 -7c6628caf16228500f15309766cdbbe54f8ea8238a32052123abdc445bb7d51f 14478 src/main/ipc/repository-handlers.cjs +2e2d0b26480b395906b7881e50b596c9a7701e6e534497d04d541bf3f3b3c057 15673 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 @@ -168,12 +170,12 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs -e880894fb9626d804617e8e993b88e9cdc8b9972baad4c3751e86932da45b7b3 25506 src/main/unraid-access-methods.cjs +90504e27bfabcd2f927ba29930fad9bb65520fdf871a2a9e49cbcefcb837d84c 25492 src/main/unraid-access-methods.cjs 2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs 803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs 2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs -08f082a2d902ce65dcf65e20a2f90631542b515eecb31cfe09520b5b0ec32ba4 35212 src/main/unraid-inventory-methods.cjs -b385460ea1ea53bb9f10463a0b036a0128efe35ef4f8a453e76181d990ef68e9 26487 src/main/unraid-preflight-methods.cjs +de0378e1344ff102552b01018451346c30c16a76eb00e7c4349c82f7b39d52a6 35210 src/main/unraid-inventory-methods.cjs +9e682411f73450f595b5cc4dfb28939c6c58b9547d0a91e287c3efb4955e8840 26299 src/main/unraid-preflight-methods.cjs d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs @@ -183,21 +185,21 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js 4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js -b17be89568a5602d67dc226bf8ca4464731c88664de8c72a880493c96abc1963 16825 src/renderer/actions/shell.js -d33bdc89a17fbe921dacbb035dee84c9cc10c161259a02772196d9f26a17c4e9 24089 src/renderer/app.js +5d8110918b2957889047e38eb4ab2953b2b4476394d9328bd40ae6e4246e65ef 18584 src/renderer/actions/shell.js +100077a82f14d5252753d017369ecef48ed6d00532166ae5a0b356ead549e02b 24087 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 5d6c87d51e55c9e976cf83ce242be0c28cf831d9c52082f02e745193500e6348 47992 src/renderer/dialogs.js -7abdd25671edcc63b134a13f013890c67a5988d2d12c0e969506d75abe83ec3c 5630 src/renderer/events.js +977af9585074f0a404830c5a93de6939b4897d2eb98ba8ae75c3cf0023dea673 5782 src/renderer/events.js a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js -6da0d3e9fcbe6040cf30bdefb566dd8a7a26df2352233124abd8a011034f477e 26667 src/renderer/mock-deployment-bridge.js +19603be9f471ba695c66f17fc027ab427b68ce146af80d52e3c94624a5ba1c35 27712 src/renderer/mock-deployment-bridge.js 26065ffa2359cd27b9c9b5b9fb67bcad83ba0f118960c7c024e7e9392dbb16a3 20032 src/renderer/mock-repository-bridge.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css -314d0db4d5ae2042ab76516daf452bc7c49c3e667dfc06c731dd3f47b153e12a 93872 src/renderer/views.js +1703e64533b7e2717b27c5776296c7dd76331e6f97e8005aea9fd688f1aee3ae 94834 src/renderer/views.js 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs @@ -220,6 +222,7 @@ 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 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs 771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs @@ -228,7 +231,7 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs -676a5a791e94dcda11ee1f1826d5c4de2f94389072b4f53a5228a57cb0c5d9e0 9493 tests/renderer-workflow.test.mjs +629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs diff --git a/main.cjs b/main.cjs index e76d425..bb5f94d 100644 --- a/main.cjs +++ b/main.cjs @@ -300,6 +300,7 @@ app git, gitea, diagnostics, + store, }); repositoryMonitor = new RepositoryMonitor({ store, diff --git a/preload.cjs b/preload.cjs index 848e9ff..1839444 100644 --- a/preload.cjs +++ b/preload.cjs @@ -142,6 +142,10 @@ contextBridge.exposeInMainWorld( troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }), + gitValidatorSetPolicy: (fullName, policy) => invoke('git-validator:set-policy', { fullName, policy }), + gitValidatorSuppress: (fullName, suppression) => invoke('git-validator:suppress', { fullName, suppression }), + gitValidatorPreviewRepair: (fullName, check) => invoke('git-validator:preview-repair', { fullName, check }), + gitValidatorExport: (fullName, format = 'json') => invoke('git-validator:export', { fullName, format }), gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }), diagnosticsStatus: () => invoke('diagnostics:status'), clearDiagnostics: () => invoke('diagnostics:clear'), diff --git a/reports/architecture-audit.json b/reports/architecture-audit.json index b2945c4..8c61d00 100644 --- a/reports/architecture-audit.json +++ b/reports/architecture-audit.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-29T16:10:50.148Z", + "generatedAt": "2026-07-29T16:20:01.552Z", "thresholds": { "preferredMaximumLines": 750, "justificationRequiredLines": 1000 @@ -7,6 +7,21 @@ "over750": [], "over1000": [], "cyclomaticHotspots": [ + { + "file": "src/renderer/actions/shell.js", + "lines": 506, + "branches": 98, + "functions": 84, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "updates" + ], + "hotspotScore": 128 + }, { "file": "src/main/server-inventory.cjs", "lines": 578, @@ -22,20 +37,6 @@ ], "hotspotScore": 119 }, - { - "file": "src/renderer/actions/shell.js", - "lines": 481, - "branches": 89, - "functions": 81, - "ipcHandlers": 0, - "responsibilities": [ - "inventory", - "deployment", - "git", - "renderer" - ], - "hotspotScore": 109 - }, { "file": "src/main/git-service.cjs", "lines": 632, @@ -49,6 +50,21 @@ } ], "mixedResponsibilityModules": [ + { + "file": "src/renderer/actions/shell.js", + "lines": 506, + "branches": 98, + "functions": 84, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "updates" + ], + "hotspotScore": 128 + }, { "file": "src/main/server-inventory.cjs", "lines": 578, @@ -66,7 +82,7 @@ }, { "file": "src/renderer/app.js", - "lines": 669, + "lines": 667, "branches": 74, "functions": 110, "ipcHandlers": 0, @@ -80,20 +96,6 @@ ], "hotspotScore": 114 }, - { - "file": "src/renderer/actions/shell.js", - "lines": 481, - "branches": 89, - "functions": 81, - "ipcHandlers": 0, - "responsibilities": [ - "inventory", - "deployment", - "git", - "renderer" - ], - "hotspotScore": 109 - }, { "file": "src/main/ipc.cjs", "lines": 749, @@ -128,8 +130,8 @@ }, { "file": "src/renderer/views.js", - "lines": 695, - "branches": 52, + "lines": 693, + "branches": 53, "functions": 138, "ipcHandlers": 0, "responsibilities": [ @@ -140,7 +142,7 @@ "security", "updates" ], - "hotspotScore": 92 + "hotspotScore": 93 }, { "file": "src/renderer/actions/setup-and-settings.js", @@ -203,7 +205,7 @@ }, { "file": "main.cjs", - "lines": 442, + "lines": 443, "branches": 33, "functions": 53, "ipcHandlers": 0, @@ -236,9 +238,9 @@ }, { "file": "src/main/config-store.cjs", - "lines": 615, + "lines": 652, "branches": 47, - "functions": 82, + "functions": 86, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -341,9 +343,9 @@ }, { "file": "src/renderer/events.js", - "lines": 170, - "branches": 30, - "functions": 25, + "lines": 172, + "branches": 31, + "functions": 26, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -352,6 +354,20 @@ "renderer", "security" ], + "hotspotScore": 61 + }, + { + "file": "src/main/git-validator-service.cjs", + "lines": 588, + "branches": 40, + "functions": 74, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], "hotspotScore": 60 }, { @@ -429,9 +445,9 @@ }, { "file": "preload.cjs", - "lines": 158, + "lines": 162, "branches": 2, - "functions": 119, + "functions": 123, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -446,9 +462,9 @@ }, { "file": "src/renderer/mock-deployment-bridge.js", - "lines": 661, + "lines": 678, "branches": 11, - "functions": 83, + "functions": 87, "ipcHandlers": 0, "responsibilities": [ "inventory", @@ -655,10 +671,10 @@ }, { "file": "src/main/ipc/repository-handlers.cjs", - "lines": 390, + "lines": 411, "branches": 16, - "functions": 75, - "ipcHandlers": 47, + "functions": 79, + "ipcHandlers": 51, "responsibilities": [ "git", "ipc" diff --git a/reports/architecture-audit.md b/reports/architecture-audit.md index d39937e..58db484 100644 --- a/reports/architecture-audit.md +++ b/reports/architecture-audit.md @@ -1,6 +1,6 @@ # ForgeFlow architecture audit -Generated 2026-07-29T16:10:50.148Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. +Generated 2026-07-29T16:20:01.552Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. ## Files above 750 lines @@ -18,8 +18,8 @@ No findings. | File | Lines | Decisions | Functions | IPC handlers | Responsibilities | |---|---:|---:|---:|---:|---| +| `src/renderer/actions/shell.js` | 506 | 98 | 84 | 0 | inventory, deployment, git, renderer, updates | | `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates | -| `src/renderer/actions/shell.js` | 481 | 89 | 81 | 0 | inventory, deployment, git, renderer | | `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git | ## Interpretation diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index d255ce5..432735a 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -7,7 +7,7 @@ const { safeStorage } = require('electron'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const DEFAULT_CONFIG = { - schemaVersion: 12, + schemaVersion: 13, setupComplete: false, appearance: 'dark', gitea: { baseUrl: '', user: null, encryptedToken: null }, @@ -16,6 +16,7 @@ const DEFAULT_CONFIG = { deploymentProfiles: {}, deploymentStates: {}, inventoryReviewDecisions: {}, + gitValidator: { policies: {}, suppressions: {}, trends: {} }, favorites: [], updates: { owner: 'Jens', @@ -67,6 +68,11 @@ class ConfigStore { workspaceRoots: uniqueStrings(source.workspaceRoots), repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {}, inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {}, + gitValidator: { + policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {}, + suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {}, + trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {} + }, deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => { if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile; @@ -144,6 +150,36 @@ class ConfigStore { return this.saveQueue; } + getGitValidatorState(fullName) { + const key = String(fullName || '').toLowerCase(); + return { + policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }), + suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []), + trends: structuredClone(this.data.gitValidator.trends[key] || []) + }; + } + + async setGitValidatorPolicy(fullName, policy) { + const key = String(fullName || '').toLowerCase(); + this.data.gitValidator.policies[key] = structuredClone(policy); + await this.save(); + return this.getGitValidatorState(key); + } + + async addGitValidatorSuppression(fullName, suppression) { + const key = String(fullName || '').toLowerCase(); + this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250); + await this.save(); + return this.getGitValidatorState(key); + } + + async appendGitValidatorTrend(fullName, trend) { + const key = String(fullName || '').toLowerCase(); + this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100); + await this.save(); + return this.getGitValidatorState(key); + } + async createRecoverySnapshot(reason = 'configuration-change') { await this.saveQueue.catch(() => {}); const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change'; @@ -602,6 +638,7 @@ class ConfigStore { repositoryMappings: { ...this.data.repositoryMappings }, deploymentProfiles: structuredClone(this.data.deploymentProfiles), deploymentStates: structuredClone(this.data.deploymentStates), + gitValidator: structuredClone(this.data.gitValidator), favorites: [...this.data.favorites], updates: { ...this.data.updates }, servers: this.data.servers.map((server) => this.getPublicServer(server)), diff --git a/src/main/git-validator-policy.cjs b/src/main/git-validator-policy.cjs new file mode 100644 index 0000000..80cfb91 --- /dev/null +++ b/src/main/git-validator-policy.cjs @@ -0,0 +1,87 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const PROFILE_DEFINITIONS = Object.freeze({ + minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 }, + standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 }, + strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 }, + production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 }, +}); + +function normalizePolicy(policy = {}) { + const id = String(policy.id || policy.profile || "standard").toLowerCase(); + const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard; + const custom = id === "organization" ? policy : {}; + return { + id, + label: custom.label || base.label || "Organization custom", + requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))), + 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))], + allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true, + maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)), + }; +} + +function validateSuppression(input, policy, now = new Date()) { + if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions."); + const checkId = String(input?.checkId || "").trim(); + const reason = String(input?.reason || "").trim(); + const author = String(input?.author || "").trim(); + const scope = String(input?.scope || "repository").trim(); + const evidence = String(input?.evidence || "").trim(); + const expiresAt = new Date(input?.expiresAt || ""); + if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters."); + if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future."); + const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000); + if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`); + return { + id: crypto.randomUUID(), checkId, reason, author, + createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null, + expiresAt: expiresAt.toISOString(), scope, evidence, + }; +} + +function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) { + const policy = normalizePolicy(policyInput); + const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null; + const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => { + const status = policy.severityOverrides[check.id] || check.status; + const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now); + const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now); + return { + ...check, + status, + suppressed: Boolean(suppression), + suppression: suppression || null, + expiredSuppression: expiredSuppression || null, + blocking: !suppression && status !== "pass" && (status === "error" || policy.blockingChecks.includes(check.id)), + }; + }); + return { policy, checks: relevant }; +} + +function buildTrend(previous, report) { + const prior = new Map((previous?.checks || []).map((check) => [check.id, check])); + const current = new Map(report.checks.map((check) => [check.id, check])); + const active = (check) => check && check.status !== "pass" && !check.suppressed; + const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id); + const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id); + const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id); + return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) }; +} + +function exportReport(report, format = "json") { + if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` }; + const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n"); + const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`; + if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown }; + if (format !== "html") throw new Error("Unsupported Git Validator export format."); + const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); + const htmlRows = report.checks.map((check) => `${escape(check.id)}${escape(check.category)}${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}${escape(check.detail)}`).join(""); + return { extension: "html", mimeType: "text/html", content: `Git assurance — ${escape(report.repository)}

Git assurance — ${escape(report.repository)}

Policy: ${escape(report.policy.label)} · Score: ${report.score}/100 · Commit: ${escape(report.commitSha || "unknown")}

${htmlRows}
CheckCategoryStatusEvidence
` }; +} + +module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport }; diff --git a/src/main/git-validator-service.cjs b/src/main/git-validator-service.cjs index 0dfc920..aee97b0 100644 --- a/src/main/git-validator-service.cjs +++ b/src/main/git-validator-service.cjs @@ -4,6 +4,7 @@ const fs = require("node:fs/promises"); const path = require("node:path"); const { run } = require("./process-runner.cjs"); const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); +const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs"); const RECOMMENDED_GITIGNORE = `# Local configuration and secrets .env @@ -67,6 +68,7 @@ function result(id, category, title, status, detail, options = {}) { fixAction: options.fixAction || null, safe: options.safe === true, confirmation: options.confirmation || null, + evidence: options.evidence || null, }; } @@ -84,10 +86,11 @@ function isSensitiveTrackedPath(filePath) { } class GitValidatorService { - constructor({ git, gitea, diagnostics }) { + constructor({ git, gitea, diagnostics, store }) { this.git = git; this.gitea = gitea; this.diagnostics = diagnostics; + this.store = store; } async config(root, key, { local = true } = {}) { @@ -176,7 +179,7 @@ class GitValidatorService { { weight: 35 }, ), ); - return this.summarize(repository, checks); + return this.finalize(repository, checks, null); } const root = await this.git.ensureRepository(repository.localPath); @@ -378,7 +381,107 @@ class GitValidatorService { { weight: 7 }, ), ); - return this.summarize(repository, checks); + await this.addAssuranceChecks(root, tracked, lowerFiles, checks); + return this.finalize(repository, checks, status); + } + + async addAssuranceChecks(root, tracked, lowerFiles, checks) { + const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file))); + const fileCheck = (id, category, title, patterns, detail, weight = 5) => { + const present = has(...patterns); + checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight })); + }; + fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8); + fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6); + fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5); + fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7); + fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4); + fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3); + fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4); + fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7); + fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6); + + const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file)); + checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) })); + const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file)); + checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) })); + + const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file)); + const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n"); + const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref)); + checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) })); + const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText); + checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 })); + + const [commitSignature, tagSignature, recentSubjects] = await Promise.all([ + run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"), + run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""), + run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []), + ]); + checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 })); + checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 })); + const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject)); + checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 })); + + const releaseFiles = { + "release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/, + "release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/, + "release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/, + }; + for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4); + checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 })); + } + + async finalize(repository, checks, status) { + const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] }; + const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions); + const report = this.summarize(repository, governedChecks); + report.policy = policy; + report.commitSha = status?.head || status?.branch?.oid || null; + report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => { + const categoryChecks = governedChecks.filter((check) => check.category === category); + return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)]; + })); + report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking); + report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id); + report.trend = buildTrend(repositoryState.trends.at(-1), report); + if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend); + return report; + } + + async setPolicy(repository, policyInput) { + const policy = normalizePolicy(policyInput); + if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable."); + await this.store.setGitValidatorPolicy(repository.fullName, policy); + return policy; + } + + async suppress(repository, input) { + const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } }; + const suppression = validateSuppression(input, normalizePolicy(state.policy)); + await this.store.addGitValidatorSuppression(repository.fullName, suppression); + return suppression; + } + + export(report, format) { return exportReport(report, format); } + + async previewRepair(repository, check) { + if (!check?.fixAction) throw new Error("This validator check has no repair action."); + const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null; + const fileDefinitions = { + "add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE], + "add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES], + "add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG], + }; + if (fileDefinitions[check.fixAction]) { + const [name, content] = fileDefinitions[check.fixAction]; + if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`); + return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false }; + } + if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false }; + if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false }; + if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true }; + throw new Error("Unsupported Git Validator repair action."); } summarize(repository, checks) { diff --git a/src/main/ipc/repository-handlers.cjs b/src/main/ipc/repository-handlers.cjs index f52e99b..475be03 100644 --- a/src/main/ipc/repository-handlers.cjs +++ b/src/main/ipc/repository-handlers.cjs @@ -359,6 +359,27 @@ function registerRepositoryIpc({ }); return report; }); + register("git-validator:set-policy", async ({ fullName, policy }) => { + const repository = await resolveRepository({ fullName }); + const saved = await gitValidator.setPolicy(repository, policy); + await audit.append("git-validator.policy.changed", { repository: repository.fullName, policy: saved.id }); + return saved; + }); + register("git-validator:suppress", async ({ fullName, suppression }) => { + const repository = await resolveRepository({ fullName }); + const saved = await gitValidator.suppress(repository, suppression); + await audit.append("git-validator.finding.suppressed", { repository: repository.fullName, checkId: saved.checkId, expiresAt: saved.expiresAt, ticket: saved.ticket }); + return saved; + }); + register("git-validator:preview-repair", async ({ fullName, check }) => { + const repository = await resolveRepository({ fullName }); + return gitValidator.previewRepair(repository, check); + }); + register("git-validator:export", async ({ fullName, format = "json" }) => { + const repository = await resolveRepository({ fullName }); + const report = await gitValidator.scan(repository); + return gitValidator.export(report, format); + }); register("git-validator:repair", async ({ fullName, check }) => { const repository = await resolveRepository({ fullName }); const allowed = new Set([ diff --git a/src/main/unraid-access-methods.cjs b/src/main/unraid-access-methods.cjs index 1bdd7ed..24c30f0 100644 --- a/src/main/unraid-access-methods.cjs +++ b/src/main/unraid-access-methods.cjs @@ -106,7 +106,7 @@ function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIden const deployKey = await this.gitea.ensureReadOnlyDeployKey({ owner, repo, - title: `ForgeFlow · ${server.name} · read-only`, + title: `ForgeFlow · ${server.name} · read-only`, publicKey, }); const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; diff --git a/src/main/unraid-inventory-methods.cjs b/src/main/unraid-inventory-methods.cjs index 7d77666..962f97d 100644 --- a/src/main/unraid-inventory-methods.cjs +++ b/src/main/unraid-inventory-methods.cjs @@ -199,7 +199,7 @@ function createUnraidInventoryMethods({ const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`; return { id: profileId, - name: `${server.name} · ${workload.displayName}`, + name: `${server.name} · ${workload.displayName}`, environment: "production", provider: "ssh-unraid", branch: workload.metadata?.branch || repository.defaultBranch || "main", diff --git a/src/main/unraid-preflight-methods.cjs b/src/main/unraid-preflight-methods.cjs index b16b957..6004286 100644 --- a/src/main/unraid-preflight-methods.cjs +++ b/src/main/unraid-preflight-methods.cjs @@ -288,7 +288,7 @@ function createUnraidPreflightMethods({ connectionCapabilities = connection.capabilities || {}; checks.push({ id: "ssh", - label: "Desktop → Unraid SSH", + label: "Desktop → Unraid SSH", status: "pass", detail: `${server.username}@${server.host}:${server.port}`, }); @@ -327,7 +327,7 @@ function createUnraidPreflightMethods({ } catch (error) { checks.push({ id: "ssh", - label: "Desktop → Unraid SSH", + label: "Desktop → Unraid SSH", status: "fail", detail: error.message, }); @@ -349,7 +349,7 @@ function createUnraidPreflightMethods({ const access = await this.probeServerGitAccess({ repository, profile, server }); checks.push({ id: "server-git-access", - label: "Unraid → Gitea read access", + label: "Unraid → Gitea read access", status: access.ready ? "pass" : "fail", detail: access.ready ? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.` @@ -359,7 +359,7 @@ function createUnraidPreflightMethods({ }); } else checks.push({ id: "transfer-path", - label: "Desktop → Unraid transfer", + label: "Desktop → Unraid transfer", status: "pass", detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.", }); @@ -387,7 +387,7 @@ function createUnraidPreflightMethods({ id: `write-path:${target.id}`, label: target.label, status: "fail", - detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, + detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, repairAction: "repair-deployment-write-access", repairLabel: "Fix write access", }); diff --git a/src/renderer/actions/shell.js b/src/renderer/actions/shell.js index 7071467..e0f6954 100644 --- a/src/renderer/actions/shell.js +++ b/src/renderer/actions/shell.js @@ -75,8 +75,14 @@ async function handleShellActions(event, target, action, repository) { } else if (action === "git-validator-repair") { const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)]; if (!check?.fixAction) return; - if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`)) + let preview; + try { + preview = await window.forgeflow.gitValidatorPreviewRepair(repository.fullName, check); + } catch (error) { + showToast("Preview failed", error.message, "error"); return; + } + if (!confirm(`${check.confirmation || `Apply ${check.title}?`}\n\nReviewable change:\n${preview.diff}\n\nNothing will be committed or pushed.`)) return; setLoading(true, `Repairing ${check.title}…`); try { await window.forgeflow.gitValidatorRepair(repository.fullName, check); @@ -90,27 +96,46 @@ async function handleShellActions(event, target, action, repository) { } finally { setLoading(false); } - } else if (action === "git-validator-repair-safe") { - const checks = (ui.gitValidation?.checks || []).filter( - (check) => check.fixAction && check.safe, - ); - setLoading(true, `Applying ${checks.length} safe Git fixes…`); - let repaired = 0; + } else if (action === "git-validator-policy") { + setLoading(true, "Applying assurance policy…"); try { - for (const check of checks) { - await window.forgeflow.gitValidatorRepair(repository.fullName, check); - repaired += 1; - } - await refreshRepositories(false, true); - ui.gitValidation = await window.forgeflow.gitValidatorScan( - repository.fullName, - ); - showToast("Safe Git fixes applied", `${repaired} repaired.`, "success"); + await window.forgeflow.gitValidatorSetPolicy(repository.fullName, { id: target.value }); + ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName); + showToast("Policy updated", ui.gitValidation.policy.label, "success"); } catch (error) { - showToast("Safe repair stopped", error.message, "error"); + showToast("Policy update failed", error.message, "error"); } finally { setLoading(false); } + } else if (action === "git-validator-export") { + try { + const exported = await window.forgeflow.gitValidatorExport(repository.fullName, target.dataset.format || "markdown"); + const url = URL.createObjectURL(new Blob([exported.content], { type: exported.mimeType })); + const link = document.createElement("a"); + link.href = url; + link.download = `${repository.name || "repository"}-git-assurance.${exported.extension}`; + link.click(); + URL.revokeObjectURL(url); + showToast("Report exported", link.download, "success"); + } catch (error) { + showToast("Export failed", error.message, "error"); + } + } else if (action === "git-validator-suppress") { + const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)]; + if (!check) return; + const reason = prompt("Reason for this temporary exception (minimum 10 characters):", "Accepted temporarily while remediation is tracked."); + if (!reason) return; + const author = prompt("Exception owner:", ui.boot?.state?.gitea?.user?.login || ""); + if (!author) return; + const ticket = prompt("Ticket reference (optional):", "") || ""; + const expiresAt = new Date(Date.now() + 7 * 86_400_000).toISOString(); + try { + await window.forgeflow.gitValidatorSuppress(repository.fullName, { checkId: check.id, reason, author, ticket, expiresAt, scope: "repository", evidence: `${ui.gitValidation.commitSha || "unknown"}:${check.id}:${check.status}` }); + ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName); + showToast("Exception documented", `Expires ${formatDate(expiresAt)}.`, "success"); + } catch (error) { + showToast("Exception rejected", error.message, "error"); + } } else if (action === "toggle-favorite") { ui.boot.state = await window.forgeflow.favoriteRepository( repository.fullName, diff --git a/src/renderer/events.js b/src/renderer/events.js index 689c5c9..e44bbda 100644 --- a/src/renderer/events.js +++ b/src/renderer/events.js @@ -55,6 +55,8 @@ app.addEventListener("change", async (event) => { } else if (event.target.id === "action-profile-select") { ui.selectedProfileId = event.target.value; render(); + } else if (event.target.id === "validator-policy") { + await handleShellActions(event, event.target, "git-validator-policy", selectedRepository()); } else if (event.target.id === "profile-provider") { ui.modal.provider = event.target.value; render(); diff --git a/src/renderer/mock-deployment-bridge.js b/src/renderer/mock-deployment-bridge.js index 6c37df6..cec7a98 100644 --- a/src/renderer/mock-deployment-bridge.js +++ b/src/renderer/mock-deployment-bridge.js @@ -466,6 +466,11 @@ function createMockDeploymentBridge(context) { checkedAt: iso(), score: 78, grade: "Good", + policy: { id: "standard", label: "Standard", requiredScore: 70 }, + ready: true, + commitSha: "8cbaf303aa3bb9b4023a7c89aa13fb70ce612847", + trend: { newlyFound: ["working-tree"], resolved: ["editorconfig"], regressions: [], suppressions: [] }, + expiredSuppressions: [], summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 }, checks: [ { @@ -566,6 +571,18 @@ function createMockDeploymentBridge(context) { ], }; }, + async gitValidatorSetPolicy(_fullName, policy) { + return { id: policy.id, label: policy.id[0].toUpperCase() + policy.id.slice(1) }; + }, + async gitValidatorSuppress(_fullName, suppression) { + return { ...suppression, id: `suppression-${Date.now()}`, createdAt: iso() }; + }, + async gitValidatorPreviewRepair(_fullName, check) { + return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ reviewed configuration change\n", remoteMutation: check.fixAction === "protect-default-branch" }; + }, + async gitValidatorExport(fullName, format) { + return { extension: format === "markdown" ? "md" : format, mimeType: "text/plain", content: `# Git assurance — ${fullName}\n` }; + }, async gitValidatorRepair() { await wait(180); return { repaired: true }; diff --git a/src/renderer/views.js b/src/renderer/views.js index bfba6e7..c87ed03 100644 --- a/src/renderer/views.js +++ b/src/renderer/views.js @@ -390,14 +390,12 @@ function renderGitValidator(repository) { return `
${projectIllustration("diagnostics")}
Repository assurance

Validate Git best practices

Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.

`; const tone = report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger"; - const safeFixes = report.checks.filter( - (check) => check.fixAction && check.safe, - ); const groups = report.checks.reduce((grouped, check) => { (grouped[check.category] ||= []).push(check); return grouped; }, {}); - return `
${report.score}/ 100
Git assurance score

${escapeHtml(report.grade)}

${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical

${projectIllustration("diagnostics")}
${safeFixes.length ? `` : ""}
${Object.entries( + const trend = report.trend || {}; + return `
${report.score}/ 100
${escapeHtml(report.policy?.label || "Standard")} policy · ${report.ready ? "release-ready" : "review required"}

${escapeHtml(report.grade)}

${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical

${trend.newlyFound?.length || 0} new · ${trend.resolved?.length || 0} resolved · ${trend.regressions?.length || 0} regressions · ${report.expiredSuppressions?.length || 0} expired exceptions

${projectIllustration("diagnostics")}
${Object.entries( groups, ) .map( @@ -405,7 +403,7 @@ function renderGitValidator(repository) { `

${escapeHtml(category)}

${checks.filter((check) => check.status === "pass").length}/${checks.length} passed
${checks .map((check) => { const checkIndex = report.checks.indexOf(check); - return `
${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}
${escapeHtml(check.title)}

${escapeHtml(check.detail)}

${check.fixAction ? `` : `${check.status === "pass" ? "Best practice" : "Review"}`}
`; + return `
${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}
${escapeHtml(check.title)}

${escapeHtml(check.detail)}

${check.suppressed ? `Suppressed until ${formatDate(check.suppression.expiresAt)} · ${escapeHtml(check.suppression.reason)}` : check.expiredSuppression ? `Exception expired; finding is active again.` : ""}
${check.fixAction ? `` : check.status !== "pass" && !check.suppressed ? `` : `${check.suppressed ? "Suppressed" : check.status === "pass" ? "Best practice" : "Review"}`}
`; }) .join("")}
`, ) diff --git a/tests/git-validator-policy.test.mjs b/tests/git-validator-policy.test.mjs new file mode 100644 index 0000000..9158bd8 --- /dev/null +++ b/tests/git-validator-policy.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + normalizePolicy, + validateSuppression, + applyPolicy, + buildTrend, + exportReport, +} = require("../src/main/git-validator-policy.cjs"); + +test("Git Validator policies enforce score, blockers and enabled checks", () => { + const policy = normalizePolicy({ id: "organization", label: "ITWorx", requiredScore: 88, enabledChecks: ["security", "readme"], blockingChecks: ["security"] }); + const governed = applyPolicy([ + { id: "security", status: "warning", category: "Security", weight: 10 }, + { id: "readme", status: "pass", category: "Collaboration", weight: 5 }, + { id: "ignored", status: "error", category: "Other", weight: 5 }, + ], policy, []); + assert.equal(governed.policy.requiredScore, 88); + assert.deepEqual(governed.checks.map((check) => check.id), ["security", "readme"]); + assert.equal(governed.checks[0].blocking, true); +}); + +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); + let governed = applyPolicy([{ id: "signed-tags", status: "warning", category: "Governance", weight: 5 }], { id: "standard" }, [suppression], now); + assert.equal(governed.checks[0].suppressed, true); + governed = applyPolicy(governed.checks, { id: "standard" }, [suppression], new Date("2026-07-09T00:00:00.000Z")); + assert.equal(governed.checks[0].suppressed, false); + assert.equal(governed.checks[0].expiredSuppression.id, suppression.id); + assert.throws(() => validateSuppression({ checkId: "x", reason: "short", author: "a", expiresAt: "2026-07-02", evidence: "x" }, normalizePolicy(), now), /requires/); +}); + +test("trends report new, resolved and regressed findings without false precision", () => { + const previous = { checks: [{ id: "a", status: "warning" }, { id: "b", status: "error" }, { id: "c", status: "pass" }] }; + const report = { score: 74, categories: { Security: 50 }, checkedAt: "2026-07-02T00:00:00Z", commitSha: "abc", checks: [{ id: "a", status: "error" }, { id: "b", status: "pass" }, { id: "c", status: "warning", suppressed: true }] }; + const trend = buildTrend(previous, report); + assert.deepEqual(trend.regressions, ["a"]); + assert.deepEqual(trend.resolved.sort(), ["b"]); + assert.deepEqual(trend.suppressions, ["c"]); +}); + +test("reports export as JSON, Markdown and standalone escaped HTML", () => { + const report = { repository: "jens/", score: 80, commitSha: "abc", policy: { label: "Production" }, checks: [{ id: "readme", category: "Collaboration", status: "pass", detail: "Safe & ready" }] }; + assert.doesNotThrow(() => JSON.parse(exportReport(report, "json").content)); + assert.match(exportReport(report, "markdown").content, /\| readme \|/); + const html = exportReport(report, "html").content; + assert.match(html, //); + assert.match(html, /jens\/<app>/); +}); diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index 14ab6c6..b1a0046 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -221,12 +221,14 @@ test("Git Validator exposes scored best-practice checks and bounded repairs", as "utf8", ); assert.match(renderer, /function renderGitValidator/); - assert.match(renderer, /git-validator-repair-safe/); - assert.match(renderer, /check\.safe/); + assert.match(renderer, /gitValidatorPreviewRepair/); + assert.match(renderer, /git-validator-suppress/); + assert.match(renderer, /git-validator-policy/); assert.match(styles, /\.validator-score/); assert.match(styles, /@container \(max-width: 900px\)/); assert.match(preload, /gitValidatorScan/); assert.match(preload, /gitValidatorRepair/); + assert.match(preload, /gitValidatorExport/); }); test("renderer guards accessible names, labels and uncertain inventory evidence", async () => {