@maycombhq/atticus 0.0.0-stage → 2.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +177 -0
- package/README.md +17 -2
- package/THIRD-PARTY-LICENSES.txt +2607 -0
- package/dist/bin.js +47 -0
- package/dist/chunk-23W7VWKR.js +2609 -0
- package/dist/chunk-27NKUS3X.js +4330 -0
- package/dist/chunk-2ANOROX3.js +2522 -0
- package/dist/chunk-2BF2EYJV.js +174 -0
- package/dist/chunk-2BFCZ3RK.js +315 -0
- package/dist/chunk-2DIQQJCB.js +378 -0
- package/dist/chunk-2RMYG36Q.js +1227 -0
- package/dist/chunk-2VHF7OXM.js +55 -0
- package/dist/chunk-3ALD2W2A.js +95 -0
- package/dist/chunk-3C7EYRFZ.js +41 -0
- package/dist/chunk-3G4YI4ZU.js +16 -0
- package/dist/chunk-3HI6Z55Y.js +5949 -0
- package/dist/chunk-3USYZGRF.js +324 -0
- package/dist/chunk-42EVSKME.js +37 -0
- package/dist/chunk-4AK7IBVP.js +915 -0
- package/dist/chunk-4BKGMUIK.js +53 -0
- package/dist/chunk-4BN53TG3.js +343 -0
- package/dist/chunk-4NDAAFQT.js +103 -0
- package/dist/chunk-4Q3NN7VF.js +32 -0
- package/dist/chunk-4RS5KZQD.js +25 -0
- package/dist/chunk-4SKZDI62.js +22 -0
- package/dist/chunk-4WBVUHXN.js +69 -0
- package/dist/chunk-5GDRCDQB.js +32 -0
- package/dist/chunk-5GGOLAKC.js +856 -0
- package/dist/chunk-5KLGBXG5.js +48 -0
- package/dist/chunk-5WG5YP4T.js +34 -0
- package/dist/chunk-64EQYWCA.js +180 -0
- package/dist/chunk-652QWTLZ.js +242 -0
- package/dist/chunk-65NYPGRC.js +766 -0
- package/dist/chunk-66YVAGGM.js +46 -0
- package/dist/chunk-6BNC6EQ4.js +41 -0
- package/dist/chunk-6BVONWJ7.js +50 -0
- package/dist/chunk-6D2CQ5II.js +41 -0
- package/dist/chunk-6IU67AEK.js +325 -0
- package/dist/chunk-6J5OKA4Y.js +49 -0
- package/dist/chunk-6JCPI55B.js +92 -0
- package/dist/chunk-6JUBZBMW.js +56 -0
- package/dist/chunk-6M5NK3K3.js +415 -0
- package/dist/chunk-6QSERWXH.js +53 -0
- package/dist/chunk-6TACQN3N.js +41 -0
- package/dist/chunk-76WKMA4J.js +14 -0
- package/dist/chunk-7AVHLZGD.js +45 -0
- package/dist/chunk-7EFILTCI.js +67 -0
- package/dist/chunk-7EKXJZ2Y.js +2902 -0
- package/dist/chunk-7EOBHUMC.js +133 -0
- package/dist/chunk-7FBGJFEE.js +66 -0
- package/dist/chunk-7JUKDRSZ.js +1726 -0
- package/dist/chunk-7SW2QWLU.js +212 -0
- package/dist/chunk-7WD63HU2.js +143 -0
- package/dist/chunk-7ZUIMTOU.js +108 -0
- package/dist/chunk-A3GY6SOA.js +95 -0
- package/dist/chunk-A7UPUQFY.js +2356 -0
- package/dist/chunk-AA6YULYB.js +376 -0
- package/dist/chunk-ABWHYWNI.js +101 -0
- package/dist/chunk-AIOQPLA5.js +540 -0
- package/dist/chunk-AVJDSJ2F.js +18 -0
- package/dist/chunk-B2WLAWNJ.js +374 -0
- package/dist/chunk-BKVWTZ6A.js +211 -0
- package/dist/chunk-BOHJZTEQ.js +1991 -0
- package/dist/chunk-BWZ77RQT.js +25 -0
- package/dist/chunk-C6EKFH7J.js +169 -0
- package/dist/chunk-CAQZUNOH.js +1150 -0
- package/dist/chunk-CEXSU4S6.js +1217 -0
- package/dist/chunk-CKSITVS4.js +16101 -0
- package/dist/chunk-CNUKCS2T.js +2741 -0
- package/dist/chunk-CPA6HPVR.js +3206 -0
- package/dist/chunk-CR3MTH32.js +207 -0
- package/dist/chunk-CSZ4C5N6.js +307 -0
- package/dist/chunk-D3PO4DOZ.js +261 -0
- package/dist/chunk-D46WUV3T.js +33 -0
- package/dist/chunk-DE76JROX.js +80 -0
- package/dist/chunk-DFBCSHVT.js +69 -0
- package/dist/chunk-DHPACDG7.js +104 -0
- package/dist/chunk-DKJA76CJ.js +113 -0
- package/dist/chunk-DPUYDDQG.js +164 -0
- package/dist/chunk-DPZHZQKE.js +285 -0
- package/dist/chunk-E36QOJMD.js +47 -0
- package/dist/chunk-EADP3PEN.js +450 -0
- package/dist/chunk-EBEW3XXQ.js +166 -0
- package/dist/chunk-EDS2L2LD.js +106 -0
- package/dist/chunk-EFHQ3TWP.js +637 -0
- package/dist/chunk-EPTBZ3GP.js +101 -0
- package/dist/chunk-EYWIDJFU.js +130 -0
- package/dist/chunk-F7HYOUFF.js +287 -0
- package/dist/chunk-FHH4I4ZV.js +128 -0
- package/dist/chunk-FITWGOJ2.js +131 -0
- package/dist/chunk-FQXYPF7E.js +147 -0
- package/dist/chunk-G2B4ZQGH.js +240 -0
- package/dist/chunk-GBRFVT63.js +45 -0
- package/dist/chunk-GDLULHTC.js +2919 -0
- package/dist/chunk-GLUX4EEP.js +583 -0
- package/dist/chunk-GQ4N4WKF.js +1142 -0
- package/dist/chunk-GT4I4ZWM.js +38 -0
- package/dist/chunk-HFQCFSJR.js +60 -0
- package/dist/chunk-HITVBIJS.js +1247 -0
- package/dist/chunk-HLJAXMM4.js +233 -0
- package/dist/chunk-HVUPKCGJ.js +162 -0
- package/dist/chunk-I3E7CX4H.js +155 -0
- package/dist/chunk-I4JRCD6M.js +184 -0
- package/dist/chunk-ICY36XCH.js +34 -0
- package/dist/chunk-IHMLFRVV.js +228 -0
- package/dist/chunk-IPKHE5D6.js +1963 -0
- package/dist/chunk-J4K3KVLD.js +131 -0
- package/dist/chunk-JBTK5OP2.js +55 -0
- package/dist/chunk-JCZIKA4C.js +163 -0
- package/dist/chunk-JGZCG5VM.js +189 -0
- package/dist/chunk-JIITQXFB.js +4087 -0
- package/dist/chunk-JMSCMUCA.js +271 -0
- package/dist/chunk-JZU3H6UQ.js +54 -0
- package/dist/chunk-K65BJIMT.js +49 -0
- package/dist/chunk-KEM4SDDP.js +3898 -0
- package/dist/chunk-KQDZXGNR.js +14 -0
- package/dist/chunk-L24C55VN.js +7923 -0
- package/dist/chunk-L6SGQIR3.js +471 -0
- package/dist/chunk-LR3R6Z2A.js +400 -0
- package/dist/chunk-M766ZJ6W.js +119 -0
- package/dist/chunk-MC3H7736.js +19910 -0
- package/dist/chunk-MGYZM27B.js +205 -0
- package/dist/chunk-MMOSZPWJ.js +273 -0
- package/dist/chunk-MVZVGYXH.js +1611 -0
- package/dist/chunk-N7IXEHK3.js +549 -0
- package/dist/chunk-NE2W3OO7.js +230 -0
- package/dist/chunk-NN2ZQEB4.js +38 -0
- package/dist/chunk-NXFPS2LR.js +182 -0
- package/dist/chunk-O3WI5KWW.js +31 -0
- package/dist/chunk-OLQJTJJU.js +76 -0
- package/dist/chunk-ONLS5QA5.js +102 -0
- package/dist/chunk-OT2O76OS.js +1496 -0
- package/dist/chunk-PAMPPUR6.js +4500 -0
- package/dist/chunk-PCOCACY3.js +385 -0
- package/dist/chunk-PCVDV3UZ.js +430 -0
- package/dist/chunk-PRPPGYZG.js +104 -0
- package/dist/chunk-Q4D5X7QB.js +264 -0
- package/dist/chunk-Q5AMFEWD.js +119 -0
- package/dist/chunk-QRAOANID.js +214 -0
- package/dist/chunk-QSXK2RGH.js +688 -0
- package/dist/chunk-QZSX3F7C.js +389 -0
- package/dist/chunk-R6RY4Q5T.js +51 -0
- package/dist/chunk-R6XF6UR3.js +149 -0
- package/dist/chunk-R77KLRB3.js +166 -0
- package/dist/chunk-RRTKKSLQ.js +251 -0
- package/dist/chunk-RTEYOSJY.js +29 -0
- package/dist/chunk-RUVFXE2L.js +666 -0
- package/dist/chunk-RVH5SUW5.js +69 -0
- package/dist/chunk-RZPYKDWS.js +26 -0
- package/dist/chunk-S2A7YC3Q.js +41433 -0
- package/dist/chunk-S3QNRWYR.js +25 -0
- package/dist/chunk-SJRGMG6A.js +174 -0
- package/dist/chunk-SP2I63OO.js +252 -0
- package/dist/chunk-T2UYZSCQ.js +545 -0
- package/dist/chunk-T6AIP7H5.js +3851 -0
- package/dist/chunk-T6XQL73F.js +94 -0
- package/dist/chunk-TANFV3MZ.js +531 -0
- package/dist/chunk-TDD55CJH.js +119 -0
- package/dist/chunk-TV45EUGM.js +51 -0
- package/dist/chunk-TXLH6WHQ.js +2078 -0
- package/dist/chunk-TYAID7B7.js +408 -0
- package/dist/chunk-UBBC2XSG.js +873 -0
- package/dist/chunk-UGLN7QHV.js +27 -0
- package/dist/chunk-UNM6ZOKP.js +878 -0
- package/dist/chunk-V2YTAIQO.js +1075 -0
- package/dist/chunk-VN3DP4LQ.js +43 -0
- package/dist/chunk-VOGCSYOI.js +16 -0
- package/dist/chunk-VYDBJ73M.js +32 -0
- package/dist/chunk-W3BQAZ3Q.js +34 -0
- package/dist/chunk-W5BVQF44.js +36 -0
- package/dist/chunk-WVPJSRRJ.js +52 -0
- package/dist/chunk-WY6PP6AA.js +53 -0
- package/dist/chunk-XAKWMCXI.js +285 -0
- package/dist/chunk-XPCM366L.js +70 -0
- package/dist/chunk-XPTOBS2D.js +48 -0
- package/dist/chunk-XY3P7JW7.js +3479 -0
- package/dist/chunk-XZRON6IV.js +131 -0
- package/dist/chunk-YEDDOYSC.js +27809 -0
- package/dist/chunk-YISXLPHN.js +496 -0
- package/dist/chunk-YMZ3TNYB.js +18 -0
- package/dist/chunk-YY6ZQQBP.js +203 -0
- package/dist/chunk-YYQN46FY.js +334 -0
- package/dist/chunk-Z6LF7HQN.js +22 -0
- package/dist/chunk-ZCJDOK2S.js +116 -0
- package/dist/chunk-ZDPAS56H.js +76 -0
- package/dist/chunk-ZMAINWBW.js +18 -0
- package/dist/chunk-ZMFPW66A.js +22 -0
- package/dist/chunk-ZNCEKR4J.js +296 -0
- package/dist/chunk-ZVM6AKKM.js +47 -0
- package/dist/files.json +1888 -0
- package/dist/fixtures/forge/README.md +19 -0
- package/dist/fixtures/forge/expected/discrepancies.yaml +54 -0
- package/dist/fixtures/forge/expected/inputs.yaml +38 -0
- package/dist/fixtures/forge/expected/scan.yaml +342 -0
- package/dist/fixtures/forge/inputs/diagrams/forge-context.drawio +23 -0
- package/dist/fixtures/forge/inputs/partner-specs/northwind-pay-openapi.yaml +11 -0
- package/dist/fixtures/forge/inputs/tickets/ado-stories.json +4 -0
- package/dist/fixtures/forge/inputs/tickets/closed-service-requests.json +5 -0
- package/dist/fixtures/forge/inputs/wiki/integration-patterns.md +11 -0
- package/dist/fixtures/forge/inputs/wiki/ledger-solution-spec.md +9 -0
- package/dist/fixtures/forge/inputs/wiki/snow-firewall-request.md +4 -0
- package/dist/fixtures/forge/metarepo/judgments/reaches/84af5b6af24f7ca8c23013f3.yaml +17 -0
- package/dist/fixtures/forge/metarepo/judgments/reaches/c3917555edebddce8cecb647.yaml +17 -0
- package/dist/fixtures/forge/metarepo/judgments/reaches/c5fafd6601f8ca9e34e0bbd0.yaml +17 -0
- package/dist/fixtures/forge/metarepo/org-pack/agents/architecture-critic.yaml +9 -0
- package/dist/fixtures/forge/metarepo/org-pack/agents/story-writer.yaml +10 -0
- package/dist/fixtures/forge/metarepo/org-pack/checklists/architecture.yaml +115 -0
- package/dist/fixtures/forge/metarepo/org-pack/dialects/forge.yaml +111 -0
- package/dist/fixtures/forge/metarepo/org-pack/gates.yaml +22 -0
- package/dist/fixtures/forge/metarepo/org-pack/pack.yaml +11 -0
- package/dist/fixtures/forge/metarepo/org-pack/patterns/A.EB.PUB.yaml +12 -0
- package/dist/fixtures/forge/metarepo/org-pack/patterns/D.JDBC.yaml +12 -0
- package/dist/fixtures/forge/metarepo/org-pack/patterns/S.DIR.BFF.yaml +12 -0
- package/dist/fixtures/forge/metarepo/org-pack/patterns/S.GW.SOR.yaml +13 -0
- package/dist/fixtures/forge/metarepo/org-pack/patterns/S.GW.VND.yaml +14 -0
- package/dist/fixtures/forge/metarepo/org-pack/process/change-templates/feature.md +37 -0
- package/dist/fixtures/forge/metarepo/org-pack/process/skills/add-forge-endpoint/SKILL.md +17 -0
- package/dist/fixtures/forge/metarepo/org-pack/process/snow/firewall-rule.yaml +14 -0
- package/dist/fixtures/forge/metarepo/org-pack/process/snow/gateway-route.yaml +13 -0
- package/dist/fixtures/forge/metarepo/org-pack/profiles/forge/profile.yaml +12 -0
- package/dist/fixtures/forge/metarepo/org-pack/profiles/forge/repos.manifest.yaml +44 -0
- package/dist/fixtures/forge/metarepo/org-pack/roles.yaml +14 -0
- package/dist/fixtures/forge/metarepo/org-pack/runtime.yaml +14 -0
- package/dist/fixtures/forge/metarepo/org-pack/sources.yaml +3 -0
- package/dist/fixtures/forge/repos/forge-events/topics/payments.initiated.yaml +4 -0
- package/dist/fixtures/forge/repos/forge-events/topics/transfers.completed.yaml +4 -0
- package/dist/fixtures/forge/repos/forge-gateway/proxies/ledger-v1/apiproxy/ledger-v1.xml +3 -0
- package/dist/fixtures/forge/repos/forge-gateway/proxies/ledger-v1/apiproxy/policies/SpikeArrest.xml +3 -0
- package/dist/fixtures/forge/repos/forge-gateway/proxies/ledger-v1/apiproxy/policies/VerifyOAuth.xml +4 -0
- package/dist/fixtures/forge/repos/forge-gateway/proxies/ledger-v1/apiproxy/proxies/default.xml +14 -0
- package/dist/fixtures/forge/repos/forge-gateway/proxies/ledger-v1/apiproxy/targets/ledger.xml +5 -0
- package/dist/fixtures/forge/repos/forge-infra/alerts/ledger-api.yaml +5 -0
- package/dist/fixtures/forge/repos/forge-infra/k8s/ledger-api.yaml +15 -0
- package/dist/fixtures/forge/repos/forge-infra/k8s/portal-bff.yaml +7 -0
- package/dist/fixtures/forge/repos/forge-infra/terraform/ledger-db.tf +9 -0
- package/dist/fixtures/forge/repos/forge-ledger/api/ledger-openapi.yaml +31 -0
- package/dist/fixtures/forge/repos/forge-ledger/forge.yaml +4 -0
- package/dist/fixtures/forge/repos/forge-ledger/src/main/java/forge/ledger/LedgerEndpoints.java +13 -0
- package/dist/fixtures/forge/repos/forge-ledger/src/main/java/forge/ledger/LedgerEvents.java +11 -0
- package/dist/fixtures/forge/repos/forge-ledger/src/main/java/forge/ledger/NorthwindClient.java +10 -0
- package/dist/fixtures/forge/repos/forge-ledger/src/main/resources/application.yml +3 -0
- package/dist/fixtures/forge/repos/forge-ledger/src/main/resources/db/migration/V1__create_account.sql +5 -0
- package/dist/fixtures/forge/repos/forge-ledger/src/main/resources/db/migration/V2__create_entry.sql +7 -0
- package/dist/fixtures/forge/repos/forge-portal-bff/pom.xml +1 -0
- package/dist/fixtures/forge/repos/forge-portal-bff/src/main/java/forge/portal/AuditPublisher.java +12 -0
- package/dist/fixtures/forge/repos/forge-portal-bff/src/main/java/forge/portal/BalanceController.java +16 -0
- package/dist/fixtures/forge/repos/forge-portal-bff/src/main/java/forge/portal/LedgerClient.java +13 -0
- package/dist/fixtures/forge/repos/forge-portal-bff/src/main/java/forge/portal/PaymentEvents.java +13 -0
- package/dist/fixtures/forge/repos/forge-portal-bff/src/main/resources/application.yml +8 -0
- package/dist/fixtures/forge/repos/forge-portal-ui/package.json +1 -0
- package/dist/fixtures/forge/repos/forge-portal-ui/src/api/balance.ts +5 -0
- package/dist/fixtures/forge/repos/forge-reports-job/src/main/java/forge/reports/DailyEntriesJob.java +19 -0
- package/dist/fixtures/forge/repos/forge-reports-job/src/main/resources/application.yml +3 -0
- package/dist/fixtures/forge-change/README.md +41 -0
- package/dist/fixtures/forge-change/expected/findings.yaml +301 -0
- package/dist/fixtures/forge-change/expected/stories.yaml +101 -0
- package/dist/fixtures/forge-change/judgments/personal-data/0b5f3dbf5257a22275d8c74b.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/2759a66770134c417440bf2d.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/31b1ea2f14d0737f1aca79fd.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/49afd70c08228e65ad6ca1ed.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/761cabcb8a39f5e5866d2dd6.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/adae902b90cc8ffa0c765145.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/ba11bb933105ec9493e065dd.yaml +18 -0
- package/dist/fixtures/forge-change/judgments/personal-data/bdbcd1cdac3021c68063c3e7.yaml +18 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/brief.md +35 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/change.yaml +120 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/data.md +18 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/design.md +29 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/interfaces.md +17 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/mappings.md +26 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/proposal.md +35 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/snow.md +12 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/specs/transfer-limits/spec.md +19 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/stories/s1-refuse-over-limit.md +17 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/stories/s2-show-remaining.md +16 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/stories/s3-audit-refusals.md +18 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/stories/s4-velocity-check.md +16 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/stories.md +8 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/tasks.md +12 -0
- package/dist/fixtures/forge-change/openspec/changes/add-transfer-limit/ux.md +7 -0
- package/dist/fixtures/forge-change/org-pack/agents/code-and-test-critic.yaml +13 -0
- package/dist/fixtures/forge-change/org-pack/agents/complete-critic.yaml +12 -0
- package/dist/fixtures/forge-change/org-pack/agents/safe-critic.yaml +12 -0
- package/dist/fixtures/forge-change/org-pack/agents/ship-critic.yaml +11 -0
- package/dist/fixtures/forge-change/org-pack/agents/true-critic.yaml +11 -0
- package/dist/fixtures/forge-change/org-pack/checklists/review.yaml +54 -0
- package/dist/fixtures/forge-change/org-pack/gates.yaml +25 -0
- package/dist/fixtures/forge-change/org-pack/teams/team-ledger/checklists/ledger-team.yaml +227 -0
- package/dist/fixtures/forge-change/recordings/complete-critic/2ad84f84bf39c1d5412697e69fe23034d6983ec45a00314bf0975e627589a3c5.json +12 -0
- package/dist/fixtures/forge-change/recordings/complete-critic/417c98d8e54a4387d0fd93441c49f44c2868958739070d855c1dce0c41e24db3.json +12 -0
- package/dist/fixtures/forge-change/recordings/complete-critic/50203ee22e9cb3ef047295edb6538fe7d5695e1e4b139f3c929013dc6ce47021.json +12 -0
- package/dist/fixtures/forge-change/recordings/complete-critic/89f18d67f5d9c325bf35e2a9e020f9ddb6ee13b8523315037f0905cc57c040b1.json +12 -0
- package/dist/fixtures/forge-change/recordings/complete-critic/b4097867d26ae8eadc8b9efa8cdd86897013358035f0b40086c299a59c4ba121.json +12 -0
- package/dist/fixtures/forge-change/recordings/safe-critic/302d06f49664dd67ad2e21ab9cf74c1b0f7d3f750709c6ed8eff2b70983274e2.json +12 -0
- package/dist/fixtures/forge-change/recordings/safe-critic/32f1049c9a5ab4a759543290db4644ec6e221cc45fdfdcf06193837c6a8ce0b9.json +12 -0
- package/dist/fixtures/forge-change/recordings/safe-critic/4e4a84bfc04b9f26ca4250319a879901ff7a1c081c71e46a117675fee05026b0.json +12 -0
- package/dist/fixtures/forge-change/recordings/safe-critic/604af0fcdd6df6b76208fa88305fdc15c37606c2a4d1ae816477e46ac8c2dd04.json +12 -0
- package/dist/fixtures/forge-change/recordings/safe-critic/f0bf50af11f14c50d07904f0dffe1c8c20fb0cd89770645a8824c1d30a443955.json +12 -0
- package/dist/fixtures/forge-change/recordings/ship-critic/350954b58833b2c04880c67db45a1b328b7fd541d9f0d943b63905b8875488a6.json +12 -0
- package/dist/fixtures/forge-change/recordings/ship-critic/4af95c6c96a206777cec9de1f9c2d8c55a36ddc9b8db2f81d6eb83217b835efa.json +12 -0
- package/dist/fixtures/forge-change/recordings/ship-critic/76c34522a49cf7587a4e5689a5030d384603fa780b8aba97d27815cbc829683e.json +12 -0
- package/dist/fixtures/forge-change/recordings/ship-critic/a3b2cd1ace48587e5994333db03b34c3a0f9dec4f1932f1730ddfb687f6918c8.json +12 -0
- package/dist/fixtures/forge-change/recordings/ship-critic/de288a4fa64d0fc20890f9a787080ce6eac5203b7b9b72d884df11e8cf111835.json +12 -0
- package/dist/fixtures/forge-change/recordings/story-writer/1ddb00eac2aaf91a5ec5d9dfd220116be9f75aec6546a4a3a3b050ed89af2864.json +12 -0
- package/dist/packages/assistant-kit/frameworks-kit/README.md +86 -0
- package/dist/packages/assistant-kit/frameworks-kit/checklist.md +11 -0
- package/dist/packages/assistant-kit/frameworks-kit/examples/tide-lantern.yaml +56 -0
- package/dist/packages/assistant-kit/frameworks-kit/examples/tide-platform.yaml +43 -0
- package/dist/packages/assistant-kit/frameworks-kit/prompts/write-framework-model.md +12 -0
- package/dist/packages/assistant-kit/kit/README.md +98 -0
- package/dist/packages/assistant-kit/kit/c2-reference.md +334 -0
- package/dist/packages/assistant-kit/kit/checklist.md +39 -0
- package/dist/packages/assistant-kit/kit/examples/house-http-client.yaml +102 -0
- package/dist/packages/assistant-kit/kit/examples/tide-apigee.yaml +56 -0
- package/dist/packages/assistant-kit/kit/examples/tide-apim.yaml +32 -0
- package/dist/packages/assistant-kit/kit/examples/tide-kong.yaml +44 -0
- package/dist/packages/assistant-kit/kit/prompts/write-dialect.md +34 -0
- package/dist/packages/assistant-kit/kit/worked-example.md +198 -0
- package/dist/packages/conformance/sample/README.md +4 -0
- package/dist/packages/conformance/sample/inputs/notes.md +3 -0
- package/dist/packages/conformance/sample/inputs/systems.csv +3 -0
- package/dist/packages/conformance/sample/repo/api/greeter.proto +6 -0
- package/dist/packages/conformance/sample/repo/api/openapi.yaml +10 -0
- package/dist/packages/conformance/sample/repo/api/schema.graphql +3 -0
- package/dist/packages/conformance/sample/repo/db/V1__create_greeting.sql +4 -0
- package/dist/packages/conformance/sample/repo/package.json +7 -0
- package/dist/packages/conformance/sample/repo/src/main/java/sample/HelloController.java +10 -0
- package/dist/packages/conformance/sample/repo/web/app.ts +4 -0
- package/dist/packages/evals/datasets/critic-lenses.yaml +62 -0
- package/dist/packages/evals/datasets/dialect-writer.yaml +14 -0
- package/dist/packages/evals/datasets/feature-namer.yaml +34 -0
- package/dist/packages/evals/datasets/glossary-definer.yaml +25 -0
- package/dist/packages/evals/datasets/story-writer.yaml +13 -0
- package/dist/packages/evals/datasets/template-writer.yaml +16 -0
- package/dist/packages/evals/datasets/test-writer.yaml +18 -0
- package/dist/packages/executors/reference/reference-executor.mjs +233 -0
- package/dist/packages/extractors/dialects/argocd.yaml +45 -0
- package/dist/packages/extractors/dialects/fixtures/jaxrs/accounts-service/src/main/java/forge/accounts/AccountResource.java +47 -0
- package/dist/packages/extractors/dialects/fixtures/jaxrs/accounts-service/src/main/java/forge/accounts/AuditClient.java +12 -0
- package/dist/packages/extractors/dialects/fixtures/jaxrs/accounts-service/src/main/java/forge/accounts/LedgerClient.java +17 -0
- package/dist/packages/extractors/dialects/fixtures/micronaut/statements-service/src/main/java/forge/statements/LedgerClient.java +14 -0
- package/dist/packages/extractors/dialects/fixtures/micronaut/statements-service/src/main/java/forge/statements/RatesClient.java +11 -0
- package/dist/packages/extractors/dialects/fixtures/micronaut/statements-service/src/main/java/forge/statements/StatementController.java +36 -0
- package/dist/packages/extractors/dialects/fixtures/micronaut/statements-service/src/main/resources/application.yml +7 -0
- package/dist/packages/extractors/dialects/fixtures/quarkus-reactive-messaging/fraud-service/src/main/java/forge/fraud/FraudScorer.java +27 -0
- package/dist/packages/extractors/dialects/fixtures/quarkus-reactive-messaging/fraud-service/src/main/resources/application.properties +4 -0
- package/dist/packages/extractors/dialects/fixtures/terraform-aws/forge-aws-infra/envs/prod/api.tf +38 -0
- package/dist/packages/extractors/dialects/fixtures/terraform-aws/forge-aws-infra/envs/prod/main.tf +95 -0
- package/dist/packages/extractors/dialects/fixtures/terraform-aws/forge-aws-infra/envs/prod/terraform.tfvars +1 -0
- package/dist/packages/extractors/dialects/fixtures/terraform-aws/forge-aws-infra/envs/prod/variables.tf +4 -0
- package/dist/packages/extractors/dialects/fixtures/terraform-azure/forge-azure-infra/main.tf +102 -0
- package/dist/packages/extractors/dialects/fixtures/terraform-gcp/forge-gcp-infra/main.tf +51 -0
- package/dist/packages/extractors/dialects/harness.yaml +101 -0
- package/dist/packages/extractors/dialects/jaxrs.yaml +155 -0
- package/dist/packages/extractors/dialects/micronaut.yaml +207 -0
- package/dist/packages/extractors/dialects/quarkus-reactive-messaging.yaml +158 -0
- package/dist/packages/extractors/dialects/tekton.yaml +71 -0
- package/dist/packages/extractors/dialects/terraform-aws.yaml +267 -0
- package/dist/packages/extractors/dialects/terraform-azure.yaml +212 -0
- package/dist/packages/extractors/dialects/terraform-gcp.yaml +109 -0
- package/dist/packages/extractors/grammars/README.md +11 -0
- package/dist/packages/extractors/grammars/tree-sitter-groovy.LICENSE +21 -0
- package/dist/packages/extractors/grammars/tree-sitter-groovy.wasm +0 -0
- package/dist/packages/extractors/grammars/vendored.json +11 -0
- package/dist/packages/learn/lessons/architect-diagrams-and-code.yaml +69 -0
- package/dist/packages/learn/lessons/business-analyst-story-bar.yaml +94 -0
- package/dist/packages/learn/lessons/delivery-lead-release-readiness.yaml +44 -0
- package/dist/packages/learn/lessons/designer-features-in-user-words.yaml +57 -0
- package/dist/packages/learn/lessons/developer-find-your-way.yaml +60 -0
- package/dist/packages/learn/lessons/everyone-first-hour.yaml +74 -0
- package/dist/packages/learn/lessons/platform-lead-coverage-and-defences.yaml +58 -0
- package/dist/packages/learn/lessons/product-owner-first-change.yaml +167 -0
- package/dist/packages/learn/lessons/qa-tests-and-trace.yaml +53 -0
- package/dist/packages/learn/lessons/team-lead-initiative.yaml +146 -0
- package/dist/packages/naysayer/constitution/constitution.yaml +286 -0
- package/dist/packages/naysayer/starter/review.yaml +256 -0
- package/dist/packages/sdk/npm/index.js +8892 -0
- package/dist/packages/speckit/rules/drift.yaml +78 -0
- package/dist/packages/taskrun/rules/conform.yaml +38 -0
- package/dist/packages/testgen/rules/trace.yaml +49 -0
- package/dist/packages/ui/dist/assets/elk-api-tma0MCuh.js +2 -0
- package/dist/packages/ui/dist/assets/elk-worker.min--dAJroGl.js +6401 -0
- package/dist/packages/ui/dist/assets/elk-worker.min-DdYmS5kP.js +2 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- package/dist/packages/ui/dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- package/dist/packages/ui/dist/assets/index-B54fm9FQ.js +29 -0
- package/dist/packages/ui/dist/assets/index-DD_hufQz.css +1 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/packages/ui/dist/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-devanagari-600-normal-BtY48IV_.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-devanagari-600-normal-DhS7ScYx.woff2 +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-devanagari-700-normal-BQQOj9BB.woff2 +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-devanagari-700-normal-DBRwWuxA.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-600-normal-CXCVEoA9.woff2 +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-600-normal-Dx02qI2_.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-700-normal-BPDUZo87.woff2 +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-700-normal-e0Uj8ZlN.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-ext-600-normal-BleIgkPp.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-ext-600-normal-C9ZjJQAt.woff2 +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-ext-700-normal-BPaQ7tlD.woff +0 -0
- package/dist/packages/ui/dist/assets/rajdhani-latin-ext-700-normal-S6aYESQK.woff2 +0 -0
- package/dist/packages/ui/dist/favicon.svg +1 -0
- package/dist/packages/ui/dist/index.html +15 -0
- package/dist/third-party.json +575 -0
- package/dist/vendor/@fission-ai/openspec/bin/openspec.js +85986 -0
- package/dist/vendor/@fission-ai/openspec/package.json +82 -0
- package/dist/vendor/@fission-ai/openspec/schemas/spec-driven/schema.yaml +248 -0
- package/dist/vendor/@fission-ai/openspec/schemas/spec-driven/templates/design.md +21 -0
- package/dist/vendor/@fission-ai/openspec/schemas/spec-driven/templates/proposal.md +34 -0
- package/dist/vendor/@fission-ai/openspec/schemas/spec-driven/templates/spec.md +13 -0
- package/dist/vendor/@fission-ai/openspec/schemas/spec-driven/templates/tasks.md +11 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-c_sharp.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-go.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-java.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-javascript.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-kotlin.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-php.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-python.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-ruby.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-rust.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-scala.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-tsx.wasm +0 -0
- package/dist/vendor/tree-sitter-wasms/out/tree-sitter-typescript.wasm +0 -0
- package/dist/vendor/web-tree-sitter/tree-sitter.wasm +0 -0
- package/package.json +36 -3
|
@@ -0,0 +1,2522 @@
|
|
|
1
|
+
import { createRequire as __atticusCreateRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath as __atticusFileURLToPath } from "node:url";
|
|
3
|
+
var require = __atticusCreateRequire(import.meta.url);
|
|
4
|
+
var __atticusUrl = (p) => new URL("./" + p, import.meta.url).href;
|
|
5
|
+
var __atticusPath = (p) => __atticusFileURLToPath(__atticusUrl(p));
|
|
6
|
+
import {
|
|
7
|
+
buildStub,
|
|
8
|
+
generate,
|
|
9
|
+
parseSpec
|
|
10
|
+
} from "./chunk-65NYPGRC.js";
|
|
11
|
+
import {
|
|
12
|
+
loadPlanContext,
|
|
13
|
+
parseStory
|
|
14
|
+
} from "./chunk-CKSITVS4.js";
|
|
15
|
+
import {
|
|
16
|
+
docOf,
|
|
17
|
+
fingerprint,
|
|
18
|
+
hopRoutes,
|
|
19
|
+
readRound,
|
|
20
|
+
roundsOf,
|
|
21
|
+
sectionLines,
|
|
22
|
+
writeFinding
|
|
23
|
+
} from "./chunk-3HI6Z55Y.js";
|
|
24
|
+
import {
|
|
25
|
+
changeFolder
|
|
26
|
+
} from "./chunk-XY3P7JW7.js";
|
|
27
|
+
import {
|
|
28
|
+
baseClonePath,
|
|
29
|
+
changeWorktreesDir,
|
|
30
|
+
readChangeRepos,
|
|
31
|
+
worktreePath
|
|
32
|
+
} from "./chunk-EFHQ3TWP.js";
|
|
33
|
+
import {
|
|
34
|
+
buildContextPack,
|
|
35
|
+
toRunSummary
|
|
36
|
+
} from "./chunk-CNUKCS2T.js";
|
|
37
|
+
import {
|
|
38
|
+
AgentDefinition,
|
|
39
|
+
ChecklistPack,
|
|
40
|
+
Finding,
|
|
41
|
+
RecordId,
|
|
42
|
+
changeBranch,
|
|
43
|
+
external_exports,
|
|
44
|
+
normalizePath,
|
|
45
|
+
require_dist,
|
|
46
|
+
taskBranch,
|
|
47
|
+
toCanonicalYaml
|
|
48
|
+
} from "./chunk-S2A7YC3Q.js";
|
|
49
|
+
import {
|
|
50
|
+
writeFileAtomic
|
|
51
|
+
} from "./chunk-D3PO4DOZ.js";
|
|
52
|
+
import {
|
|
53
|
+
__toESM
|
|
54
|
+
} from "./chunk-TV45EUGM.js";
|
|
55
|
+
|
|
56
|
+
// packages/testgen/dist/generate.js
|
|
57
|
+
var import_yaml2 = __toESM(require_dist(), 1);
|
|
58
|
+
import { readdir, readFile as readFile3 } from "node:fs/promises";
|
|
59
|
+
import { join as join5, relative, sep } from "node:path";
|
|
60
|
+
|
|
61
|
+
// packages/testgen/dist/errors.js
|
|
62
|
+
var TestgenError = class extends Error {
|
|
63
|
+
code;
|
|
64
|
+
constructor(code, message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.name = "TestgenError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// packages/testgen/dist/words.js
|
|
72
|
+
var words = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
73
|
+
function camel(s, max = 60) {
|
|
74
|
+
const w = words(s).map((x) => x.toLowerCase());
|
|
75
|
+
if (!w.length)
|
|
76
|
+
return "test";
|
|
77
|
+
const out = w.map((x, i) => i === 0 ? x : x.charAt(0).toUpperCase() + x.slice(1)).join("");
|
|
78
|
+
const id = /^[0-9]/.test(out) ? `t${out}` : out;
|
|
79
|
+
return id.slice(0, max);
|
|
80
|
+
}
|
|
81
|
+
function pascal(s, max = 60) {
|
|
82
|
+
const c = camel(s, max);
|
|
83
|
+
return c.charAt(0).toUpperCase() + c.slice(1);
|
|
84
|
+
}
|
|
85
|
+
function slug(s) {
|
|
86
|
+
return s.toLowerCase().replace(/\{[^}]*\}/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
87
|
+
}
|
|
88
|
+
function uniqueNames(names) {
|
|
89
|
+
const seen = /* @__PURE__ */ new Map();
|
|
90
|
+
return names.map((n) => {
|
|
91
|
+
const k = seen.get(n) ?? 0;
|
|
92
|
+
seen.set(n, k + 1);
|
|
93
|
+
return k === 0 ? n : `${n}${k + 1}`;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
var quote = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ");
|
|
97
|
+
var oneLine = (s, max = 120) => s.replace(/[\r\n]+/g, " ").replace(/[`*]/g, "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
98
|
+
|
|
99
|
+
// packages/testgen/dist/generators/types.js
|
|
100
|
+
function layout(repo) {
|
|
101
|
+
switch (repo.language) {
|
|
102
|
+
case "java":
|
|
103
|
+
return { features: "src/test/resources/features", json: "src/test/resources/atticus" };
|
|
104
|
+
case "node":
|
|
105
|
+
return { features: "test/features", json: "test/atticus" };
|
|
106
|
+
default:
|
|
107
|
+
return { features: "features", json: "atticus" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// packages/testgen/dist/generators/generic.js
|
|
112
|
+
var gherkinGenerator = {
|
|
113
|
+
id: "gherkin",
|
|
114
|
+
version: "1.0.0",
|
|
115
|
+
generic: true,
|
|
116
|
+
appliesTo: () => true,
|
|
117
|
+
render(model, repo, ctx) {
|
|
118
|
+
const scenarios = repo.cases.filter((c) => c.type === "acceptance");
|
|
119
|
+
if (!scenarios.length)
|
|
120
|
+
return [];
|
|
121
|
+
const s = model.story;
|
|
122
|
+
const lines = [
|
|
123
|
+
`@change-${model.change} @story-${s.id}`,
|
|
124
|
+
`Feature: ${oneLine(s.title)}`,
|
|
125
|
+
...s.asA && s.iWant && s.soThat ? [
|
|
126
|
+
` As ${/^an?\s/i.test(s.asA) ? s.asA : `a ${s.asA}`}`,
|
|
127
|
+
` I want ${s.iWant}`,
|
|
128
|
+
` So that ${s.soThat}`
|
|
129
|
+
] : [],
|
|
130
|
+
...s.tracesTo ? [` Traces to ${s.tracesTo}.`] : []
|
|
131
|
+
];
|
|
132
|
+
for (const c of scenarios) {
|
|
133
|
+
lines.push("");
|
|
134
|
+
if (c.trace)
|
|
135
|
+
lines.push(` # ${c.trace.file}:${c.trace.line}${c.trace.name ? ` (${c.trace.name})` : ""}`);
|
|
136
|
+
lines.push(` ${[`@criterion-${c.criterion}`, ...c.tags.map((t) => `@${t}`)].join(" ")}`);
|
|
137
|
+
lines.push(` Scenario: ${oneLine(c.name)}`);
|
|
138
|
+
if (c.given)
|
|
139
|
+
lines.push(` Given ${c.given}`);
|
|
140
|
+
if (c.when)
|
|
141
|
+
lines.push(` When ${c.when}`);
|
|
142
|
+
lines.push(` Then ${c.then}`);
|
|
143
|
+
}
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
path: `${layout(repo).features}/${s.id}.feature`,
|
|
147
|
+
content: `${lines.join("\n")}
|
|
148
|
+
`,
|
|
149
|
+
style: "hash",
|
|
150
|
+
source: ctx.source,
|
|
151
|
+
cases: scenarios.map((c) => c.id)
|
|
152
|
+
}
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
function jsonCase(c) {
|
|
157
|
+
const { title: _t, ...rest } = c;
|
|
158
|
+
void _t;
|
|
159
|
+
return rest;
|
|
160
|
+
}
|
|
161
|
+
var jsonGenerator = {
|
|
162
|
+
id: "json",
|
|
163
|
+
version: "1.0.0",
|
|
164
|
+
generic: true,
|
|
165
|
+
appliesTo: () => true,
|
|
166
|
+
render(model, repo, ctx) {
|
|
167
|
+
const doc = {
|
|
168
|
+
format: "atticus-tests/1",
|
|
169
|
+
change: model.change,
|
|
170
|
+
story: model.story,
|
|
171
|
+
...repo.repo ? { repo: repo.repo } : {},
|
|
172
|
+
...repo.component ? { component: repo.component } : {},
|
|
173
|
+
strategy: model.strategy.required,
|
|
174
|
+
cases: repo.cases.map(jsonCase)
|
|
175
|
+
};
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
path: `${layout(repo).json}/${model.story.id}.tests.json`,
|
|
179
|
+
content: `${JSON.stringify(doc, null, 2)}
|
|
180
|
+
`,
|
|
181
|
+
style: "json",
|
|
182
|
+
source: ctx.source,
|
|
183
|
+
cases: repo.cases.map((c) => c.id)
|
|
184
|
+
}
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// packages/testgen/dist/generators/junit.js
|
|
190
|
+
var CODE = /^[A-Z][A-Z0-9]*_[A-Z0-9_]+$/;
|
|
191
|
+
var textBlock = (v) => {
|
|
192
|
+
const json2 = JSON.stringify(v, null, 2).replace(/\\/g, "\\\\").replace(/"""/g, '\\"""');
|
|
193
|
+
return `"""
|
|
194
|
+
${json2.split("\n").map((l) => ` ${l}`).join("\n")}
|
|
195
|
+
"""`;
|
|
196
|
+
};
|
|
197
|
+
function statusMatcher(status) {
|
|
198
|
+
if (/^\d{3}$/.test(status))
|
|
199
|
+
return `.statusCode(${status})`;
|
|
200
|
+
const d = /^([1-5])XX$/i.exec(status)?.[1];
|
|
201
|
+
return d ? `.statusCode(allOf(greaterThanOrEqualTo(${d}00), lessThan(${Number(d) + 1}00)))` : ".statusCode(greaterThanOrEqualTo(400))";
|
|
202
|
+
}
|
|
203
|
+
function bodyChecks(fields, body) {
|
|
204
|
+
const out = [];
|
|
205
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
206
|
+
for (const [k, v] of Object.entries(body))
|
|
207
|
+
if (typeof v === "string" && CODE.test(v))
|
|
208
|
+
out.push(`.body("${quote(k)}", equalTo("${quote(v)}"))`);
|
|
209
|
+
}
|
|
210
|
+
for (const f of fields)
|
|
211
|
+
if (!out.some((o) => o.startsWith(`.body("${f}"`)))
|
|
212
|
+
out.push(`.body("$", hasKey("${quote(f)}"))`);
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
function authLine(c) {
|
|
216
|
+
if (c.request.auth === "none")
|
|
217
|
+
return [];
|
|
218
|
+
if (c.request.auth === "no-scope")
|
|
219
|
+
return [
|
|
220
|
+
`.auth().oauth2(env("ATTICUS_TOKEN_WITHOUT_${c.request.scope.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}"))`
|
|
221
|
+
];
|
|
222
|
+
switch (c.request.authType) {
|
|
223
|
+
case "basic":
|
|
224
|
+
return [`.auth().preemptive().basic(env("ATTICUS_USER"), env("ATTICUS_PASSWORD"))`];
|
|
225
|
+
case "apiKey":
|
|
226
|
+
case "api-key":
|
|
227
|
+
return [`.header(env("ATTICUS_API_KEY_HEADER", "X-API-Key"), env("ATTICUS_API_KEY"))`];
|
|
228
|
+
case "mtls":
|
|
229
|
+
case "mutualTLS":
|
|
230
|
+
return [
|
|
231
|
+
`// mTLS: the client certificate comes from the test environment (RestAssured keyStore).`
|
|
232
|
+
];
|
|
233
|
+
default:
|
|
234
|
+
return [`.auth().oauth2(env("ATTICUS_TOKEN"))`];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
var header = (pkg) => [
|
|
238
|
+
`package ${pkg};`,
|
|
239
|
+
"",
|
|
240
|
+
"import static io.restassured.RestAssured.given;",
|
|
241
|
+
"import static org.hamcrest.Matchers.*;",
|
|
242
|
+
"",
|
|
243
|
+
"import io.restassured.http.ContentType;",
|
|
244
|
+
"import org.junit.jupiter.api.Assumptions;",
|
|
245
|
+
"import org.junit.jupiter.api.DisplayName;",
|
|
246
|
+
"import org.junit.jupiter.api.Test;",
|
|
247
|
+
""
|
|
248
|
+
];
|
|
249
|
+
var ENV = [
|
|
250
|
+
" private static String env(String name) {",
|
|
251
|
+
' return env(name, "");',
|
|
252
|
+
" }",
|
|
253
|
+
"",
|
|
254
|
+
" private static String env(String name, String fallback) {",
|
|
255
|
+
" String v = System.getenv(name);",
|
|
256
|
+
" return v == null || v.isEmpty() ? fallback : v;",
|
|
257
|
+
" }"
|
|
258
|
+
];
|
|
259
|
+
function apiMethod(c, name, baseVar) {
|
|
260
|
+
const lines = [` @Test`, ` @DisplayName("${quote(c.name)}")`, ` void ${name}() {`];
|
|
261
|
+
if (c.request.auth === "no-scope") {
|
|
262
|
+
const v = `ATTICUS_TOKEN_WITHOUT_${c.request.scope.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
263
|
+
lines.push(` Assumptions.assumeFalse(env("${v}").isEmpty(), "Set ${v} to a token without ${quote(c.request.scope)}.");`);
|
|
264
|
+
}
|
|
265
|
+
if (c.kind === "error" || c.why.includes("needs data"))
|
|
266
|
+
lines.push(` // ${c.why}`);
|
|
267
|
+
const hasBody = c.request.body !== void 0;
|
|
268
|
+
const dataVar = c.data && (c.kind === "happy" || c.kind === "error") ? c.data.name : "body";
|
|
269
|
+
if (hasBody)
|
|
270
|
+
lines.push(` String ${dataVar} = ${textBlock(c.request.body)};`);
|
|
271
|
+
lines.push(` given()`, ` .baseUri(${baseVar})`);
|
|
272
|
+
for (const a of authLine(c))
|
|
273
|
+
lines.push(` ${a}`);
|
|
274
|
+
for (const [k, v] of Object.entries(c.request.pathParams))
|
|
275
|
+
lines.push(` .pathParam("${quote(k)}", ${JSON.stringify(v)})`);
|
|
276
|
+
for (const [k, v] of Object.entries(c.request.query))
|
|
277
|
+
lines.push(` .queryParam("${quote(k)}", ${JSON.stringify(v)})`);
|
|
278
|
+
if (hasBody)
|
|
279
|
+
lines.push(` .contentType(ContentType.JSON)`, ` .body(${dataVar})`);
|
|
280
|
+
lines.push(` .when()`, ` .${c.method.toLowerCase()}("${quote(c.path)}")`, ` .then()`);
|
|
281
|
+
const checks = [
|
|
282
|
+
statusMatcher(c.expect.status),
|
|
283
|
+
...c.kind === "happy" || c.kind === "error" ? bodyChecks(c.expect.fields, c.expect.body) : []
|
|
284
|
+
];
|
|
285
|
+
checks.forEach((ch, i) => lines.push(` ${ch}${i === checks.length - 1 ? ";" : ""}`));
|
|
286
|
+
lines.push(" }");
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
289
|
+
function apiClass(m, repo, pkg, cls, cases) {
|
|
290
|
+
const names = uniqueNames(cases.map((c) => camel(c.name)));
|
|
291
|
+
const body = cases.flatMap((c, i) => ["", ...apiMethod(c, names[i], "BASE_URL")]);
|
|
292
|
+
return [
|
|
293
|
+
...header(pkg),
|
|
294
|
+
`/** API tests for story ${m.story.id} (change ${m.change}) on ${repo.component ?? repo.repo}. Base URL: ATTICUS_BASE_URL. */`,
|
|
295
|
+
`class ${cls} {`,
|
|
296
|
+
` private static final String BASE_URL = env("ATTICUS_BASE_URL", "http://localhost:8080");`,
|
|
297
|
+
...body,
|
|
298
|
+
"",
|
|
299
|
+
...ENV,
|
|
300
|
+
"}",
|
|
301
|
+
""
|
|
302
|
+
].join("\n");
|
|
303
|
+
}
|
|
304
|
+
function contractMethod(c, name) {
|
|
305
|
+
const lines = [` @Test`, ` @DisplayName("${quote(c.name)}")`, ` void ${name}() {`];
|
|
306
|
+
for (const ch of c.checks)
|
|
307
|
+
lines.push(` // ${ch}`);
|
|
308
|
+
for (const g of c.gaps)
|
|
309
|
+
lines.push(` // Gap: ${g}`);
|
|
310
|
+
if (c.topic) {
|
|
311
|
+
lines.push(` Assumptions.abort("Connect the event test harness: ${quote(c.role === "consumer" ? `deliver a ${c.topic} message to ${c.consumer}` : `capture what ${c.provider} publishes to ${c.topic}`)}.");`, " }");
|
|
312
|
+
return lines;
|
|
313
|
+
}
|
|
314
|
+
const base = c.role === "consumer" ? "STUB_URL" : "BASE_URL";
|
|
315
|
+
const hasBody = c.request.example !== void 0;
|
|
316
|
+
if (hasBody)
|
|
317
|
+
lines.push(` String body = ${textBlock(c.request.example)};`);
|
|
318
|
+
lines.push(` given()`, ` .baseUri(${base})`);
|
|
319
|
+
if (c.role === "provider")
|
|
320
|
+
lines.push(` .auth().oauth2(env("ATTICUS_TOKEN"))`);
|
|
321
|
+
for (const m of (c.path ?? "").matchAll(/\{([^}]+)\}/g))
|
|
322
|
+
lines.push(` .pathParam("${quote(m[1])}", 1)`);
|
|
323
|
+
if (hasBody)
|
|
324
|
+
lines.push(` .contentType(ContentType.JSON)`, ` .body(body)`);
|
|
325
|
+
lines.push(` .when()`, ` .${(c.method ?? "get").toLowerCase()}("${quote(c.path ?? "/")}")`, ` .then()`);
|
|
326
|
+
const checks = [
|
|
327
|
+
c.response.status ? statusMatcher(c.response.status) : ".statusCode(lessThan(400))",
|
|
328
|
+
...c.response.fields.map((f) => `.body("$", hasKey("${quote(f.split(/[.[]/)[0])}"))`)
|
|
329
|
+
];
|
|
330
|
+
checks.forEach((ch, i) => lines.push(` ${ch}${i === checks.length - 1 ? ";" : ""}`));
|
|
331
|
+
lines.push(" }");
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
function contractClass(m, pkg, cls, cases) {
|
|
335
|
+
const names = uniqueNames(cases.map((c) => camel(c.name)));
|
|
336
|
+
return [
|
|
337
|
+
...header(pkg),
|
|
338
|
+
`/**`,
|
|
339
|
+
` * Contract tests for story ${m.story.id} (change ${m.change}). Consumer tests run against a stub`,
|
|
340
|
+
` * of the provider (atticus stub <provider>, at ATTICUS_STUB_URL); provider tests against the`,
|
|
341
|
+
` * service at ATTICUS_BASE_URL.`,
|
|
342
|
+
` */`,
|
|
343
|
+
`class ${cls} {`,
|
|
344
|
+
` private static final String BASE_URL = env("ATTICUS_BASE_URL", "http://localhost:8080");`,
|
|
345
|
+
` private static final String STUB_URL = env("ATTICUS_STUB_URL", "http://127.0.0.1:4010");`,
|
|
346
|
+
...cases.flatMap((c, i) => ["", ...contractMethod(c, names[i])]),
|
|
347
|
+
"",
|
|
348
|
+
...ENV,
|
|
349
|
+
"}",
|
|
350
|
+
""
|
|
351
|
+
].join("\n");
|
|
352
|
+
}
|
|
353
|
+
function dataClass(m, pkg, cls, cases) {
|
|
354
|
+
const names = uniqueNames(cases.map((c) => camel(c.name)));
|
|
355
|
+
const body = cases.flatMap((c, i) => {
|
|
356
|
+
const lines = [
|
|
357
|
+
"",
|
|
358
|
+
` @Test`,
|
|
359
|
+
` @DisplayName("${quote(c.name)}")`,
|
|
360
|
+
` void ${names[i]}() throws Exception {`
|
|
361
|
+
];
|
|
362
|
+
for (const ch of c.checks)
|
|
363
|
+
lines.push(` // ${ch}`);
|
|
364
|
+
if (c.kind === "migration") {
|
|
365
|
+
lines.push(` Assumptions.abort("Run ${quote(c.migration ?? "the migration")} on a copy of ${quote(c.store)} in the pipeline, then run this class.");`, " }");
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
lines.push(` try (Connection db = connect()) {`);
|
|
369
|
+
if (c.kind === "table") {
|
|
370
|
+
lines.push(` assertTrue(exists(db, "${quote(c.table)}", null), "table ${quote(c.table)} exists");`);
|
|
371
|
+
for (const col of c.columns ?? [])
|
|
372
|
+
lines.push(` assertTrue(exists(db, "${quote(c.table)}", "${quote(col.name)}"), "column ${quote(col.name)} (${quote(col.type)}) exists");`);
|
|
373
|
+
} else {
|
|
374
|
+
lines.push(` assertTrue(exists(db, "${quote(c.table)}", "${quote(c.column)}"), "column ${quote(c.table)}.${quote(c.column)} exists");`);
|
|
375
|
+
if (c.notNull)
|
|
376
|
+
lines.push(` assertEquals("NO", nullable(db, "${quote(c.table)}", "${quote(c.column)}"), "${quote(c.column)} is not null");`);
|
|
377
|
+
}
|
|
378
|
+
lines.push(" }", " }");
|
|
379
|
+
return lines;
|
|
380
|
+
});
|
|
381
|
+
return [
|
|
382
|
+
`package ${pkg};`,
|
|
383
|
+
"",
|
|
384
|
+
"import static org.junit.jupiter.api.Assertions.*;",
|
|
385
|
+
"",
|
|
386
|
+
"import java.sql.Connection;",
|
|
387
|
+
"import java.sql.DriverManager;",
|
|
388
|
+
"import java.sql.PreparedStatement;",
|
|
389
|
+
"import java.sql.ResultSet;",
|
|
390
|
+
"import org.junit.jupiter.api.Assumptions;",
|
|
391
|
+
"import org.junit.jupiter.api.DisplayName;",
|
|
392
|
+
"import org.junit.jupiter.api.Test;",
|
|
393
|
+
"",
|
|
394
|
+
`/** Data tests for story ${m.story.id} (change ${m.change}): run against a migrated copy of the database at ATTICUS_DB_URL. */`,
|
|
395
|
+
`class ${cls} {`,
|
|
396
|
+
...body,
|
|
397
|
+
"",
|
|
398
|
+
" private static Connection connect() throws Exception {",
|
|
399
|
+
' String url = System.getenv("ATTICUS_DB_URL");',
|
|
400
|
+
' Assumptions.assumeTrue(url != null && !url.isEmpty(), "Set ATTICUS_DB_URL to a migrated copy of the database.");',
|
|
401
|
+
' return DriverManager.getConnection(url, System.getenv("ATTICUS_DB_USER"), System.getenv("ATTICUS_DB_PASSWORD"));',
|
|
402
|
+
" }",
|
|
403
|
+
"",
|
|
404
|
+
" private static boolean exists(Connection db, String table, String column) throws Exception {",
|
|
405
|
+
" String sql = column == null",
|
|
406
|
+
' ? "select 1 from information_schema.tables where lower(table_name) = lower(?)"',
|
|
407
|
+
' : "select 1 from information_schema.columns where lower(table_name) = lower(?) and lower(column_name) = lower(?)";',
|
|
408
|
+
" try (PreparedStatement q = db.prepareStatement(sql)) {",
|
|
409
|
+
" q.setString(1, table);",
|
|
410
|
+
" if (column != null) q.setString(2, column);",
|
|
411
|
+
" try (ResultSet r = q.executeQuery()) {",
|
|
412
|
+
" return r.next();",
|
|
413
|
+
" }",
|
|
414
|
+
" }",
|
|
415
|
+
" }",
|
|
416
|
+
"",
|
|
417
|
+
" private static String nullable(Connection db, String table, String column) throws Exception {",
|
|
418
|
+
' String sql = "select is_nullable from information_schema.columns where lower(table_name) = lower(?) and lower(column_name) = lower(?)";',
|
|
419
|
+
" try (PreparedStatement q = db.prepareStatement(sql)) {",
|
|
420
|
+
" q.setString(1, table);",
|
|
421
|
+
" q.setString(2, column);",
|
|
422
|
+
" try (ResultSet r = q.executeQuery()) {",
|
|
423
|
+
" return r.next() ? r.getString(1) : null;",
|
|
424
|
+
" }",
|
|
425
|
+
" }",
|
|
426
|
+
" }",
|
|
427
|
+
"}",
|
|
428
|
+
""
|
|
429
|
+
].join("\n");
|
|
430
|
+
}
|
|
431
|
+
function eventClass(m, pkg, cls, cases) {
|
|
432
|
+
const names = uniqueNames(cases.map((c) => camel(c.name)));
|
|
433
|
+
return [
|
|
434
|
+
`package ${pkg};`,
|
|
435
|
+
"",
|
|
436
|
+
"import org.junit.jupiter.api.Assumptions;",
|
|
437
|
+
"import org.junit.jupiter.api.DisplayName;",
|
|
438
|
+
"import org.junit.jupiter.api.Test;",
|
|
439
|
+
"",
|
|
440
|
+
`/** Event tests for story ${m.story.id} (change ${m.change}). Connect them to the team's event test harness. */`,
|
|
441
|
+
`class ${cls} {`,
|
|
442
|
+
...cases.flatMap((c, i) => [
|
|
443
|
+
"",
|
|
444
|
+
` @Test`,
|
|
445
|
+
` @DisplayName("${quote(c.name)}")`,
|
|
446
|
+
` void ${names[i]}() {`,
|
|
447
|
+
...c.checks.map((ch) => ` // ${ch}`),
|
|
448
|
+
` Assumptions.abort("Connect the event test harness for ${quote(c.topic)}.");`,
|
|
449
|
+
" }"
|
|
450
|
+
]),
|
|
451
|
+
"}",
|
|
452
|
+
""
|
|
453
|
+
].join("\n");
|
|
454
|
+
}
|
|
455
|
+
var junitGenerator = {
|
|
456
|
+
id: "junit5-restassured",
|
|
457
|
+
version: "1.0.0",
|
|
458
|
+
generic: false,
|
|
459
|
+
appliesTo: (repo) => repo.language === "java",
|
|
460
|
+
render(model, repo, ctx) {
|
|
461
|
+
const pkgName = `atticus.${model.change.replace(/[^a-z0-9]+/g, "_")}`;
|
|
462
|
+
const dir = `src/test/java/atticus/${model.change.replace(/[^a-z0-9]+/g, "_")}`;
|
|
463
|
+
const story = pascal(model.story.id);
|
|
464
|
+
const out = [];
|
|
465
|
+
const file2 = (suffix, content2, cases) => out.push({
|
|
466
|
+
path: `${dir}/${story}${suffix}.java`,
|
|
467
|
+
content: content2,
|
|
468
|
+
style: "slash",
|
|
469
|
+
source: ctx.source,
|
|
470
|
+
cases: cases.map((c) => c.id)
|
|
471
|
+
});
|
|
472
|
+
const api = repo.cases.filter((c) => c.type === "api");
|
|
473
|
+
const contracts = repo.cases.filter((c) => c.type === "contract");
|
|
474
|
+
const data = repo.cases.filter((c) => c.type === "data");
|
|
475
|
+
const events = repo.cases.filter((c) => c.type === "event");
|
|
476
|
+
if (api.length)
|
|
477
|
+
file2("ApiTest", apiClass(model, repo, pkgName, `${story}ApiTest`, api), api);
|
|
478
|
+
if (contracts.length)
|
|
479
|
+
file2("ContractTest", contractClass(model, pkgName, `${story}ContractTest`, contracts), contracts);
|
|
480
|
+
if (data.length)
|
|
481
|
+
file2("DataTest", dataClass(model, pkgName, `${story}DataTest`, data), data);
|
|
482
|
+
if (events.length)
|
|
483
|
+
file2("EventTest", eventClass(model, pkgName, `${story}EventTest`, events), events);
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// packages/testgen/dist/generators/node.js
|
|
489
|
+
var CODE2 = /^[A-Z][A-Z0-9]*_[A-Z0-9_]+$/;
|
|
490
|
+
var json = (v, indent) => JSON.stringify(v, null, 2).split("\n").map((l, i) => i === 0 ? l : `${indent}${l}`).join("\n");
|
|
491
|
+
function statusCheck(status) {
|
|
492
|
+
if (/^\d{3}$/.test(status))
|
|
493
|
+
return `expect(res.status).toBe(${status});`;
|
|
494
|
+
const d = /^([1-5])XX$/i.exec(status)?.[1] ?? "4";
|
|
495
|
+
return `expect(Math.floor(res.status / 100)).toBe(${d});`;
|
|
496
|
+
}
|
|
497
|
+
var url = (base, path, params, query) => {
|
|
498
|
+
const p = path.replace(/\{([^}]+)\}/g, (_, k) => `\${encodeURIComponent(String(${JSON.stringify(params[k] ?? 1)}))}`);
|
|
499
|
+
const q = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
|
500
|
+
return `\`\${${base}}${p}${q ? `?${q}` : ""}\``;
|
|
501
|
+
};
|
|
502
|
+
function headers(c) {
|
|
503
|
+
const h = [];
|
|
504
|
+
if (c.request.body !== void 0)
|
|
505
|
+
h.push(`"content-type": "application/json"`);
|
|
506
|
+
if (c.request.auth === "valid") {
|
|
507
|
+
if (c.request.authType === "basic")
|
|
508
|
+
h.push('authorization: `Basic ${Buffer.from(`${env("ATTICUS_USER")}:${env("ATTICUS_PASSWORD")}`).toString("base64")}`');
|
|
509
|
+
else if (c.request.authType === "apiKey" || c.request.authType === "api-key")
|
|
510
|
+
h.push(`[env("ATTICUS_API_KEY_HEADER", "x-api-key")]: env("ATTICUS_API_KEY")`);
|
|
511
|
+
else
|
|
512
|
+
h.push('authorization: `Bearer ${env("ATTICUS_TOKEN")}`');
|
|
513
|
+
} else if (c.request.auth === "no-scope") {
|
|
514
|
+
const v = `ATTICUS_TOKEN_WITHOUT_${c.request.scope.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
515
|
+
h.push(`authorization: \`Bearer \${env("${v}")}\``);
|
|
516
|
+
}
|
|
517
|
+
return h;
|
|
518
|
+
}
|
|
519
|
+
function apiTest(c) {
|
|
520
|
+
const lines = [];
|
|
521
|
+
const skip = c.request.auth === "no-scope";
|
|
522
|
+
const v = skip ? `ATTICUS_TOKEN_WITHOUT_${c.request.scope.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}` : "";
|
|
523
|
+
lines.push(` it${skip ? `.skipIf(!process.env.${v})` : ""}("${quote(c.name)}", async () => {`);
|
|
524
|
+
if (c.kind === "error")
|
|
525
|
+
lines.push(` // ${c.why}`);
|
|
526
|
+
const dataVar = c.data && (c.kind === "happy" || c.kind === "error") ? c.data.name : "body";
|
|
527
|
+
if (c.request.body !== void 0)
|
|
528
|
+
lines.push(` const ${dataVar} = ${json(c.request.body, " ")};`);
|
|
529
|
+
const h = headers(c);
|
|
530
|
+
lines.push(` const res = await fetch(${url("BASE_URL", c.path, c.request.pathParams, c.request.query)}, {`, ` method: "${c.method}",`, ...h.length ? [` headers: { ${h.join(", ")} },`] : [], ...c.request.body !== void 0 ? [` body: JSON.stringify(${dataVar}),`] : [], ` });`, ` ${statusCheck(c.expect.status)}`);
|
|
531
|
+
if (c.kind === "happy" || c.kind === "error") {
|
|
532
|
+
const checks = [];
|
|
533
|
+
const b = c.expect.body;
|
|
534
|
+
if (b && typeof b === "object" && !Array.isArray(b)) {
|
|
535
|
+
for (const [k, val] of Object.entries(b))
|
|
536
|
+
if (typeof val === "string" && CODE2.test(val))
|
|
537
|
+
checks.push(` expect(json).toHaveProperty("${quote(k)}", "${quote(val)}");`);
|
|
538
|
+
}
|
|
539
|
+
for (const f of c.expect.fields)
|
|
540
|
+
if (!checks.some((x) => x.includes(`"${f}"`)))
|
|
541
|
+
checks.push(` expect(json).toHaveProperty("${quote(f)}");`);
|
|
542
|
+
if (checks.length)
|
|
543
|
+
lines.push(` const json = (await res.json()) as Record<string, unknown>;`, ...checks);
|
|
544
|
+
}
|
|
545
|
+
lines.push(" });");
|
|
546
|
+
return lines;
|
|
547
|
+
}
|
|
548
|
+
function contractTest(c) {
|
|
549
|
+
const lines = [` // ${c.checks.join("; ")}`, ...c.gaps.map((g) => ` // Gap: ${g}`)];
|
|
550
|
+
if (c.topic)
|
|
551
|
+
return [...lines, ` it.todo("${quote(c.name)}");`];
|
|
552
|
+
const base = c.role === "consumer" ? "STUB_URL" : "BASE_URL";
|
|
553
|
+
const params = Object.fromEntries([...(c.path ?? "").matchAll(/\{([^}]+)\}/g)].map((m) => [m[1], 1]));
|
|
554
|
+
lines.push(` it("${quote(c.name)}", async () => {`);
|
|
555
|
+
if (c.request.example !== void 0)
|
|
556
|
+
lines.push(` const body = ${json(c.request.example, " ")};`);
|
|
557
|
+
const h = [
|
|
558
|
+
...c.request.example !== void 0 ? [`"content-type": "application/json"`] : [],
|
|
559
|
+
...c.role === "provider" ? ['authorization: `Bearer ${env("ATTICUS_TOKEN")}`'] : []
|
|
560
|
+
];
|
|
561
|
+
lines.push(` const res = await fetch(${url(base, c.path ?? "/", params, {})}, {`, ` method: "${c.method ?? "GET"}",`, ...h.length ? [` headers: { ${h.join(", ")} },`] : [], ...c.request.example !== void 0 ? [` body: JSON.stringify(body),`] : [], ` });`, c.response.status ? ` ${statusCheck(c.response.status)}` : ` expect(res.status).toBeLessThan(400);`);
|
|
562
|
+
if (c.response.fields.length)
|
|
563
|
+
lines.push(` const json = (await res.json()) as Record<string, unknown>;`, ...c.response.fields.map((f) => ` expect(json).toHaveProperty("${quote(f.split(/[.[]/)[0])}");`));
|
|
564
|
+
lines.push(" });");
|
|
565
|
+
return lines;
|
|
566
|
+
}
|
|
567
|
+
var todo = (c) => [
|
|
568
|
+
` // ${c.checks.join("; ")}`,
|
|
569
|
+
` it.todo("${quote(c.name)}");`
|
|
570
|
+
];
|
|
571
|
+
function file(m, runner, what, body, bases) {
|
|
572
|
+
return [
|
|
573
|
+
runner === "vitest" ? `import { describe, expect, it } from "vitest";` : `import { describe, expect, it } from "@jest/globals";`,
|
|
574
|
+
"",
|
|
575
|
+
`const env = (name: string, fallback = "") => process.env[name] || fallback;`,
|
|
576
|
+
...bases,
|
|
577
|
+
"",
|
|
578
|
+
`describe("${quote(`${what} for story ${m.story.id} (change ${m.change})`)}", () => {`,
|
|
579
|
+
...body,
|
|
580
|
+
"});",
|
|
581
|
+
""
|
|
582
|
+
].join("\n");
|
|
583
|
+
}
|
|
584
|
+
function generator(runner) {
|
|
585
|
+
return {
|
|
586
|
+
id: `${runner}-fetch`,
|
|
587
|
+
version: "1.0.0",
|
|
588
|
+
generic: false,
|
|
589
|
+
appliesTo: (repo) => repo.language === "node" && (repo.runner ?? "vitest") === runner,
|
|
590
|
+
render(model, repo, ctx) {
|
|
591
|
+
const dir = "test/atticus";
|
|
592
|
+
const out = [];
|
|
593
|
+
const add = (kind, content2, cases) => out.push({
|
|
594
|
+
path: `${dir}/${model.story.id}.${kind}.test.ts`,
|
|
595
|
+
content: content2,
|
|
596
|
+
style: "slash",
|
|
597
|
+
source: ctx.source,
|
|
598
|
+
cases: cases.map((c) => c.id)
|
|
599
|
+
});
|
|
600
|
+
const BASE = `const BASE_URL = env("ATTICUS_BASE_URL", "http://localhost:3000");`;
|
|
601
|
+
const STUB = `const STUB_URL = env("ATTICUS_STUB_URL", "http://127.0.0.1:4010");`;
|
|
602
|
+
const api = repo.cases.filter((c) => c.type === "api");
|
|
603
|
+
const contracts = repo.cases.filter((c) => c.type === "contract");
|
|
604
|
+
const rest = repo.cases.filter((c) => c.type === "data" || c.type === "event");
|
|
605
|
+
const fixSkip = (lines) => runner === "jest" ? lines.map((l) => l.replace(/it\.skipIf\((![^)]+)\)\(/, (_, cond) => `(${cond} ? it.skip : it)(`)) : lines;
|
|
606
|
+
if (api.length)
|
|
607
|
+
add("api", file(model, runner, "API tests", fixSkip(api.flatMap((c, i) => [...i ? [""] : [], ...apiTest(c)])), [BASE]), api);
|
|
608
|
+
if (contracts.length)
|
|
609
|
+
add("contract", file(model, runner, "Contract tests", contracts.flatMap((c, i) => [...i ? [""] : [], ...contractTest(c)]), [BASE, STUB]), contracts);
|
|
610
|
+
if (rest.length)
|
|
611
|
+
add("data-and-events", file(model, runner, "Data and event tests", rest.flatMap((c, i) => [...i ? [""] : [], ...todo(c)]), []), rest);
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
var vitestGenerator = generator("vitest");
|
|
617
|
+
var jestGenerator = generator("jest");
|
|
618
|
+
|
|
619
|
+
// packages/testgen/dist/generators/index.js
|
|
620
|
+
var BUILT_IN_GENERATORS = [
|
|
621
|
+
gherkinGenerator,
|
|
622
|
+
jsonGenerator,
|
|
623
|
+
junitGenerator,
|
|
624
|
+
vitestGenerator,
|
|
625
|
+
jestGenerator
|
|
626
|
+
];
|
|
627
|
+
function generatorsFor(repo, o = {}) {
|
|
628
|
+
const all = [...o.plugins ?? [], ...BUILT_IN_GENERATORS];
|
|
629
|
+
const generic = all.filter((g2) => g2.generic);
|
|
630
|
+
const concrete = all.filter((g2) => !g2.generic);
|
|
631
|
+
if (o.framework && ["gherkin", "json"].includes(o.framework))
|
|
632
|
+
return { generators: generic };
|
|
633
|
+
if (o.framework) {
|
|
634
|
+
const g2 = concrete.find((x) => x.id === o.framework);
|
|
635
|
+
return { generators: g2 ? [...generic, g2] : generic };
|
|
636
|
+
}
|
|
637
|
+
const g = concrete.find((x) => x.appliesTo(repo));
|
|
638
|
+
return g ? { generators: [...generic, g] } : {
|
|
639
|
+
generators: generic,
|
|
640
|
+
...repo.repo ? {
|
|
641
|
+
note: `No test framework suits ${repo.repo} (its language isn't known): only Gherkin and JSON were written.`
|
|
642
|
+
} : {}
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// packages/testgen/dist/header.js
|
|
647
|
+
import { createHash } from "node:crypto";
|
|
648
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
649
|
+
import { dirname, join } from "node:path";
|
|
650
|
+
var sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
651
|
+
var TAG = "atticus-sha256";
|
|
652
|
+
var TEXT = (source, hash) => `Generated by Atticus from ${source}. Edit the source instead: Atticus regenerates this file and keeps hand edits only until --force. ${TAG}: ${hash}`;
|
|
653
|
+
var LINE_RE = new RegExp(`^(?:#|//) Generated by Atticus from (.+?)\\. .*${TAG}: ([0-9a-f]{64})$`);
|
|
654
|
+
var JSON_RE = new RegExp(`^\\{\\n "\\$generated": "Generated by Atticus from (.+?)\\. .*${TAG}: ([0-9a-f]{64})",\\n`);
|
|
655
|
+
function withHeader(f) {
|
|
656
|
+
const hash = sha256(f.content);
|
|
657
|
+
if (f.style === "json") {
|
|
658
|
+
if (!f.content.startsWith("{\n"))
|
|
659
|
+
throw new Error(`${f.path}: JSON content must be an object`);
|
|
660
|
+
return `{
|
|
661
|
+
"$generated": "${TEXT(f.source, hash)}",
|
|
662
|
+
${f.content.slice(2)}`;
|
|
663
|
+
}
|
|
664
|
+
return `${f.style === "hash" ? "#" : "//"} ${TEXT(f.source, hash)}
|
|
665
|
+
${f.content}`;
|
|
666
|
+
}
|
|
667
|
+
function readHeader(text) {
|
|
668
|
+
const t = text.replace(/\r\n/g, "\n");
|
|
669
|
+
const j = JSON_RE.exec(t);
|
|
670
|
+
if (j)
|
|
671
|
+
return { source: j[1], hash: j[2], body: `{
|
|
672
|
+
${t.slice(j[0].length)}` };
|
|
673
|
+
const nl = t.indexOf("\n");
|
|
674
|
+
const first = nl < 0 ? t : t.slice(0, nl);
|
|
675
|
+
const m = LINE_RE.exec(first);
|
|
676
|
+
if (m)
|
|
677
|
+
return { source: m[1], hash: m[2], body: t.slice(nl + 1) };
|
|
678
|
+
return void 0;
|
|
679
|
+
}
|
|
680
|
+
async function writeGenerated(dir, files, o = {}) {
|
|
681
|
+
const out = [];
|
|
682
|
+
for (const f of files) {
|
|
683
|
+
if (f.path.startsWith("/") || f.path.split("/").includes("..") || /^[A-Za-z]:/.test(f.path))
|
|
684
|
+
throw new Error(`refusing to write outside the target folder: ${f.path}`);
|
|
685
|
+
const full = join(dir, ...f.path.split("/"));
|
|
686
|
+
const text = withHeader(f);
|
|
687
|
+
const current = await readFile(full, "utf8").then((t) => t.replace(/\r\n/g, "\n")).catch(() => void 0);
|
|
688
|
+
let action;
|
|
689
|
+
if (current === void 0)
|
|
690
|
+
action = "created";
|
|
691
|
+
else if (current === text)
|
|
692
|
+
action = "unchanged";
|
|
693
|
+
else {
|
|
694
|
+
const h = readHeader(current);
|
|
695
|
+
if (!h)
|
|
696
|
+
action = o.force ? "updated" : "kept-not-generated";
|
|
697
|
+
else if (sha256(h.body) !== h.hash)
|
|
698
|
+
action = o.force ? "updated" : "kept-edited";
|
|
699
|
+
else
|
|
700
|
+
action = "updated";
|
|
701
|
+
}
|
|
702
|
+
const write = !o.check && (action === "created" || action === "updated");
|
|
703
|
+
if (write) {
|
|
704
|
+
await mkdir(dirname(full), { recursive: true });
|
|
705
|
+
const tmp = `${full}.atticus-tmp`;
|
|
706
|
+
await writeFile(tmp, text, "utf8");
|
|
707
|
+
await rename(tmp, full);
|
|
708
|
+
}
|
|
709
|
+
out.push({ path: f.path, action, written: write });
|
|
710
|
+
}
|
|
711
|
+
return out;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// packages/testgen/dist/spec.js
|
|
715
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
716
|
+
import { join as join2 } from "node:path";
|
|
717
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
718
|
+
function candidates(ctx, hop, places) {
|
|
719
|
+
const out = [];
|
|
720
|
+
const calleeRepo = ctx.repoOf(hop.callee);
|
|
721
|
+
const place = calleeRepo ? places.get(calleeRepo) : void 0;
|
|
722
|
+
const contract = hop.contract.replace(/[`*]/g, "").trim();
|
|
723
|
+
if (contract && !/^(none|n\/a|-|tbd)$/i.test(contract)) {
|
|
724
|
+
if (place?.dir)
|
|
725
|
+
out.push({ file: join2(place.dir, contract), label: `${calleeRepo}:${contract}` });
|
|
726
|
+
out.push({ file: join2(ctx.root, contract), label: contract });
|
|
727
|
+
}
|
|
728
|
+
const ep = endpointFor(ctx, hop);
|
|
729
|
+
for (const e of ep?.evidence ?? []) {
|
|
730
|
+
if (e.source !== "spec" || !e.path)
|
|
731
|
+
continue;
|
|
732
|
+
const p = e.repo ? places.get(e.repo) : void 0;
|
|
733
|
+
if (e.repo && p?.dir)
|
|
734
|
+
out.push({ file: join2(p.dir, e.path), label: `${e.repo}:${e.path}` });
|
|
735
|
+
else if (!e.repo)
|
|
736
|
+
out.push({ file: join2(ctx.root, e.path), label: e.path });
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
function endpointFor(ctx, hop) {
|
|
741
|
+
if (!hop.method || !hop.path)
|
|
742
|
+
return void 0;
|
|
743
|
+
const p = normalizePath(hop.path);
|
|
744
|
+
return ctx.records.find((r) => r.kind === "Endpoint" && r.component === hop.callee && (r.method ?? "").toUpperCase() === hop.method && normalizePath(r.normalizedPath ?? r.pathOrTopic) === p);
|
|
745
|
+
}
|
|
746
|
+
var stripReadOnly = (schema) => {
|
|
747
|
+
if (!schema?.properties)
|
|
748
|
+
return schema;
|
|
749
|
+
return {
|
|
750
|
+
...schema,
|
|
751
|
+
properties: Object.fromEntries(Object.entries(schema.properties).filter(([, s]) => !s.readOnly))
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
function securityOf(doc, op) {
|
|
755
|
+
const reqs = Array.isArray(op.security) ? op.security : Array.isArray(doc.security) ? doc.security : [];
|
|
756
|
+
const schemes = isObj(doc.components) && isObj(doc.components.securitySchemes) ? doc.components.securitySchemes : isObj(doc.securityDefinitions) ? doc.securityDefinitions : {};
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const r of reqs) {
|
|
759
|
+
if (!isObj(r))
|
|
760
|
+
continue;
|
|
761
|
+
for (const [name, scopes] of Object.entries(r)) {
|
|
762
|
+
const s = isObj(schemes[name]) ? schemes[name] : {};
|
|
763
|
+
const type = s.type === "http" ? String(s.scheme ?? "http").toLowerCase() : String(s.type ?? "unknown");
|
|
764
|
+
out.push({ scheme: name, type, scopes: Array.isArray(scopes) ? scopes.map(String) : [] });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
var pick = (content2) => {
|
|
770
|
+
if (!isObj(content2))
|
|
771
|
+
return void 0;
|
|
772
|
+
const k = Object.keys(content2).find((x) => x.toLowerCase() === "application/json") ?? Object.keys(content2).find((x) => /json/.test(x));
|
|
773
|
+
return k && isObj(content2[k]) ? content2[k] : void 0;
|
|
774
|
+
};
|
|
775
|
+
function findOperation(ctx, hop, places) {
|
|
776
|
+
const tried = [];
|
|
777
|
+
if (!hop.method || !hop.path)
|
|
778
|
+
return { tried };
|
|
779
|
+
const want = normalizePath(hop.path);
|
|
780
|
+
for (const c of candidates(ctx, hop, places)) {
|
|
781
|
+
if (!existsSync(c.file) || tried.includes(c.label))
|
|
782
|
+
continue;
|
|
783
|
+
tried.push(c.label);
|
|
784
|
+
let doc;
|
|
785
|
+
let stub;
|
|
786
|
+
try {
|
|
787
|
+
const text = readFileSync(c.file, "utf8");
|
|
788
|
+
doc = parseSpec(text);
|
|
789
|
+
stub = buildStub(text);
|
|
790
|
+
} catch {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const route = stub.routes.find((r) => {
|
|
794
|
+
if (r.method !== hop.method)
|
|
795
|
+
return false;
|
|
796
|
+
const p = normalizePath(r.path);
|
|
797
|
+
return p === want || stub.basePaths.some((b) => normalizePath(`${b}${r.path}`) === want);
|
|
798
|
+
});
|
|
799
|
+
if (!route)
|
|
800
|
+
continue;
|
|
801
|
+
const item = isObj(doc.paths) && isObj(doc.paths[route.path]) ? doc.paths[route.path] : {};
|
|
802
|
+
const op = isObj(item[route.method.toLowerCase()]) ? item[route.method.toLowerCase()] : {};
|
|
803
|
+
const media = pick(isObj(op.requestBody) ? op.requestBody.content : void 0);
|
|
804
|
+
const bodySchema = stripReadOnly(route.body?.schema);
|
|
805
|
+
const requestExample = media?.example !== void 0 ? media.example : route.body?.json && bodySchema ? generate(bodySchema) : void 0;
|
|
806
|
+
const ok = route.responses.find((r) => /^2/.test(r.status));
|
|
807
|
+
const okMedia = (() => {
|
|
808
|
+
const responses = isObj(op.responses) ? op.responses : {};
|
|
809
|
+
const r = ok ? responses[ok.status] : void 0;
|
|
810
|
+
return pick(isObj(r) ? r.content : void 0);
|
|
811
|
+
})();
|
|
812
|
+
const okSchema = okMedia && isObj(okMedia.schema) ? okMedia.schema : void 0;
|
|
813
|
+
return {
|
|
814
|
+
op: {
|
|
815
|
+
source: c.label,
|
|
816
|
+
route,
|
|
817
|
+
...requestExample !== void 0 ? { requestExample } : {},
|
|
818
|
+
security: securityOf(doc, op),
|
|
819
|
+
requestFields: Object.keys(bodySchema?.properties ?? {}),
|
|
820
|
+
responseFields: Object.keys(okSchema?.properties ?? (isObj(ok?.body) ? ok.body : {}))
|
|
821
|
+
},
|
|
822
|
+
tried
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
return { tried };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// packages/testgen/dist/targets.js
|
|
829
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
830
|
+
import { join as join3 } from "node:path";
|
|
831
|
+
var isDir = (p) => {
|
|
832
|
+
try {
|
|
833
|
+
return statSync(p).isDirectory();
|
|
834
|
+
} catch {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
function repoPlace(ctx, story, repo, changeRepos) {
|
|
839
|
+
for (const t of story.tasks.filter((x) => x.repo === repo)) {
|
|
840
|
+
const d = join3(changeWorktreesDir(ctx.root, ctx.change.id), ".tasks", t.id, repo);
|
|
841
|
+
if (isDir(d))
|
|
842
|
+
return { repo, dir: d, kind: "task-worktree", task: t.id };
|
|
843
|
+
}
|
|
844
|
+
const listed = changeRepos?.repos.find((r) => r.id === repo);
|
|
845
|
+
for (const d of [
|
|
846
|
+
...listed ? [join3(ctx.root, ...listed.worktree.split("/"))] : [],
|
|
847
|
+
worktreePath(ctx.root, ctx.change.id, repo)
|
|
848
|
+
])
|
|
849
|
+
if (isDir(d))
|
|
850
|
+
return { repo, dir: d, kind: "worktree" };
|
|
851
|
+
const base = baseClonePath(ctx.root, repo);
|
|
852
|
+
if (isDir(base))
|
|
853
|
+
return { repo, dir: base, kind: "base-clone" };
|
|
854
|
+
return { repo, kind: "none" };
|
|
855
|
+
}
|
|
856
|
+
var JAVA_TYPES = /\b(java|jvm|kotlin|spring|quarkus|micronaut|jakarta|scala)\b/i;
|
|
857
|
+
var NODE_TYPES = /node|spa|frontend|react|angular|vue|svelte|next|nest|express|typescript|javascript/i;
|
|
858
|
+
function languageOf(place, type) {
|
|
859
|
+
if (place.dir) {
|
|
860
|
+
const has = (p) => existsSync2(join3(place.dir, p));
|
|
861
|
+
if (has("pom.xml") || has("build.gradle") || has("build.gradle.kts") || has("src/main/java"))
|
|
862
|
+
return "java";
|
|
863
|
+
if (has("package.json"))
|
|
864
|
+
return "node";
|
|
865
|
+
}
|
|
866
|
+
if (type && JAVA_TYPES.test(type))
|
|
867
|
+
return "java";
|
|
868
|
+
if (type && NODE_TYPES.test(type))
|
|
869
|
+
return "node";
|
|
870
|
+
return void 0;
|
|
871
|
+
}
|
|
872
|
+
function nodeRunner(place) {
|
|
873
|
+
if (!place.dir)
|
|
874
|
+
return "vitest";
|
|
875
|
+
try {
|
|
876
|
+
const pkg = JSON.parse(readFileSync2(join3(place.dir, "package.json"), "utf8"));
|
|
877
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
878
|
+
if (deps.vitest)
|
|
879
|
+
return "vitest";
|
|
880
|
+
if (deps.jest)
|
|
881
|
+
return "jest";
|
|
882
|
+
} catch {
|
|
883
|
+
}
|
|
884
|
+
return "vitest";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// packages/testgen/dist/model.js
|
|
888
|
+
var NEEDS = {
|
|
889
|
+
api: ["integration", "e2e", "regression", "security"],
|
|
890
|
+
contract: ["contract"],
|
|
891
|
+
data: ["data-migration", "integration"],
|
|
892
|
+
event: ["contract", "integration"]
|
|
893
|
+
};
|
|
894
|
+
var below = (min, integer) => integer ? min - 1 : min >= 1 ? min - 1 : min > 0 ? 0 : min - 1;
|
|
895
|
+
var typeOf = (s) => (Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type) ?? (s.properties ? "object" : void 0);
|
|
896
|
+
function mutations(name, s) {
|
|
897
|
+
const out = [];
|
|
898
|
+
const t = typeOf(s);
|
|
899
|
+
if (s.enum?.length) {
|
|
900
|
+
let v = "INVALID";
|
|
901
|
+
while (s.enum.includes(v))
|
|
902
|
+
v += "-1";
|
|
903
|
+
out.push({ key: "enum", value: v, title: `refuses ${name} outside ${s.enum.join(", ")}` });
|
|
904
|
+
} else if (t === "integer" || t === "number")
|
|
905
|
+
out.push({ key: "type", value: "abc", title: `refuses ${name} that is not a number` });
|
|
906
|
+
else if (t === "string")
|
|
907
|
+
out.push({ key: "type", value: 12345, title: `refuses ${name} that is not text` });
|
|
908
|
+
else if (t === "boolean")
|
|
909
|
+
out.push({ key: "type", value: "yes", title: `refuses ${name} that is not true or false` });
|
|
910
|
+
if (typeof s.minimum === "number")
|
|
911
|
+
out.push({
|
|
912
|
+
key: "minimum",
|
|
913
|
+
value: below(s.minimum, t === "integer"),
|
|
914
|
+
title: `refuses ${name} below ${s.minimum}`
|
|
915
|
+
});
|
|
916
|
+
if (typeof s.exclusiveMinimum === "number")
|
|
917
|
+
out.push({
|
|
918
|
+
key: "exclusive-minimum",
|
|
919
|
+
value: s.exclusiveMinimum,
|
|
920
|
+
title: `refuses ${name} of ${s.exclusiveMinimum}`
|
|
921
|
+
});
|
|
922
|
+
if (typeof s.maximum === "number")
|
|
923
|
+
out.push({ key: "maximum", value: s.maximum + 1, title: `refuses ${name} above ${s.maximum}` });
|
|
924
|
+
if (typeof s.exclusiveMaximum === "number")
|
|
925
|
+
out.push({
|
|
926
|
+
key: "exclusive-maximum",
|
|
927
|
+
value: s.exclusiveMaximum,
|
|
928
|
+
title: `refuses ${name} of ${s.exclusiveMaximum}`
|
|
929
|
+
});
|
|
930
|
+
if (typeof s.minLength === "number" && s.minLength > 0)
|
|
931
|
+
out.push({
|
|
932
|
+
key: "min-length",
|
|
933
|
+
value: "x".repeat(s.minLength - 1),
|
|
934
|
+
title: `refuses ${name} shorter than ${s.minLength} characters`
|
|
935
|
+
});
|
|
936
|
+
if (typeof s.maxLength === "number")
|
|
937
|
+
out.push({
|
|
938
|
+
key: "max-length",
|
|
939
|
+
value: "x".repeat(s.maxLength + 1),
|
|
940
|
+
title: `refuses ${name} longer than ${s.maxLength} characters`
|
|
941
|
+
});
|
|
942
|
+
if (s.format && ["date", "date-time", "email", "uuid", "uri"].includes(s.format))
|
|
943
|
+
out.push({
|
|
944
|
+
key: "format",
|
|
945
|
+
value: `not-a-${s.format}`,
|
|
946
|
+
title: `refuses ${name} that is not a ${s.format}`
|
|
947
|
+
});
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
var CODE3 = /\b[A-Z][A-Z0-9]*_[A-Z0-9_]+\b/g;
|
|
951
|
+
function apiCases(hop, op, endpoint, acceptance) {
|
|
952
|
+
const notes = [];
|
|
953
|
+
const method = hop.method;
|
|
954
|
+
const path = op?.route.path ?? hop.path;
|
|
955
|
+
const base = `api.${slug(`${method} ${path}`)}`;
|
|
956
|
+
const common = {
|
|
957
|
+
component: hop.callee,
|
|
958
|
+
method,
|
|
959
|
+
path,
|
|
960
|
+
...op?.route.operationId ? { operationId: op.route.operationId } : {},
|
|
961
|
+
.../^if-/.test(hop.id) ? { interfaceId: hop.id } : {},
|
|
962
|
+
...endpoint ? { endpoint: endpoint.id } : {}
|
|
963
|
+
};
|
|
964
|
+
const pathParams = {};
|
|
965
|
+
const query = {};
|
|
966
|
+
for (const p of op?.route.params ?? []) {
|
|
967
|
+
const v = p.schema ? generate(p.schema) : "1";
|
|
968
|
+
if (p.in === "path")
|
|
969
|
+
pathParams[p.name] = v;
|
|
970
|
+
else if (p.in === "query" && p.required)
|
|
971
|
+
query[p.name] = v;
|
|
972
|
+
}
|
|
973
|
+
for (const m of path.matchAll(/\{([^}]+)\}/g))
|
|
974
|
+
if (!(m[1] in pathParams))
|
|
975
|
+
pathParams[m[1]] = "1";
|
|
976
|
+
const authType = op?.security[0]?.type ?? (endpoint?.auth && endpoint.auth !== "none" ? endpoint.auth : void 0);
|
|
977
|
+
const req = (o = {}) => ({
|
|
978
|
+
pathParams,
|
|
979
|
+
query,
|
|
980
|
+
...op?.requestExample !== void 0 ? { body: op.requestExample } : {},
|
|
981
|
+
auth: authType ? "valid" : "none",
|
|
982
|
+
...authType ? { authType } : {},
|
|
983
|
+
...o
|
|
984
|
+
});
|
|
985
|
+
const cases = [];
|
|
986
|
+
const data = op?.requestExample !== void 0 ? { id: `data.${slug(`${method} ${path}`)}`, name: "" } : void 0;
|
|
987
|
+
const statuses = new Set((op?.route.responses ?? []).map((r) => r.status));
|
|
988
|
+
const bad = statuses.has("400") ? "400" : statuses.has("422") && !op?.route.responses.find((r) => r.status === "422")?.body ? "422" : "4XX";
|
|
989
|
+
const ok = op?.route.responses.find((r) => /^2/.test(r.status));
|
|
990
|
+
const okStatus = op ? String(op.route.status) : method === "POST" ? "201" : "200";
|
|
991
|
+
cases.push({
|
|
992
|
+
type: "api",
|
|
993
|
+
id: `${base}.happy`,
|
|
994
|
+
kind: "happy",
|
|
995
|
+
title: `${method} ${path} accepts a valid request`,
|
|
996
|
+
name: "",
|
|
997
|
+
...common,
|
|
998
|
+
request: req(),
|
|
999
|
+
expect: {
|
|
1000
|
+
status: okStatus,
|
|
1001
|
+
...ok?.body !== void 0 && ok.example !== "none" ? { body: ok.body } : {},
|
|
1002
|
+
fields: op?.responseFields ?? []
|
|
1003
|
+
},
|
|
1004
|
+
why: op ? `The spec (${op.source}) answers ${okStatus} for a valid request.` : "The design calls this operation; no spec was found.",
|
|
1005
|
+
...data ? { data } : {}
|
|
1006
|
+
});
|
|
1007
|
+
if (!op)
|
|
1008
|
+
notes.push(`No spec has ${method} ${hop.path} for ${hop.callee}: only the happy path was generated.`);
|
|
1009
|
+
const schema = op?.route.body?.schema;
|
|
1010
|
+
const example = op?.requestExample;
|
|
1011
|
+
if (schema?.properties && example && typeof example === "object" && !Array.isArray(example)) {
|
|
1012
|
+
for (const f of schema.required ?? []) {
|
|
1013
|
+
const body = { ...example };
|
|
1014
|
+
delete body[f];
|
|
1015
|
+
cases.push({
|
|
1016
|
+
type: "api",
|
|
1017
|
+
id: `${base}.missing-${slug(f)}`,
|
|
1018
|
+
kind: "negative",
|
|
1019
|
+
title: `${method} ${path} refuses a request without ${f}`,
|
|
1020
|
+
name: "",
|
|
1021
|
+
...common,
|
|
1022
|
+
request: req({ body }),
|
|
1023
|
+
expect: { status: bad, fields: [] },
|
|
1024
|
+
why: `${f} is required by the spec.`
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
for (const [f, s] of Object.entries(schema.properties)) {
|
|
1028
|
+
if (s.readOnly)
|
|
1029
|
+
continue;
|
|
1030
|
+
for (const m of mutations(f, s))
|
|
1031
|
+
cases.push({
|
|
1032
|
+
type: "api",
|
|
1033
|
+
id: `${base}.${slug(f)}-${m.key}`,
|
|
1034
|
+
kind: "negative",
|
|
1035
|
+
title: `${method} ${path} ${m.title}`,
|
|
1036
|
+
name: "",
|
|
1037
|
+
...common,
|
|
1038
|
+
request: req({ body: { ...example, [f]: m.value } }),
|
|
1039
|
+
expect: { status: bad, fields: [] },
|
|
1040
|
+
why: `The spec constrains ${f} (${m.key.replace(/-/g, " ")}).`
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
for (const p of op?.route.params ?? []) {
|
|
1045
|
+
if (p.in !== "path" || !p.schema)
|
|
1046
|
+
continue;
|
|
1047
|
+
for (const m of mutations(p.name, p.schema))
|
|
1048
|
+
cases.push({
|
|
1049
|
+
type: "api",
|
|
1050
|
+
id: `${base}.${slug(p.name)}-${m.key}`,
|
|
1051
|
+
kind: "negative",
|
|
1052
|
+
title: `${method} ${path} ${m.title.replace("refuses ", "refuses a path ")}`,
|
|
1053
|
+
name: "",
|
|
1054
|
+
...common,
|
|
1055
|
+
request: req({ pathParams: { ...pathParams, [p.name]: m.value } }),
|
|
1056
|
+
expect: { status: bad, fields: [] },
|
|
1057
|
+
why: `The spec constrains the path parameter ${p.name} (${m.key.replace(/-/g, " ")}).`
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
for (const r of op?.route.responses ?? []) {
|
|
1061
|
+
if (!/^[45]\d\d$/.test(r.status) || ["400", "401", "403"].includes(r.status))
|
|
1062
|
+
continue;
|
|
1063
|
+
const said = `${r.description ?? ""} ${JSON.stringify(r.body ?? "")}`;
|
|
1064
|
+
const codes = [...new Set(said.match(CODE3) ?? [])];
|
|
1065
|
+
const scenario = acceptance.find((a) => codes.some((c) => a.then.includes(c)) || new RegExp(`\\b${r.status}\\b`).test(a.then));
|
|
1066
|
+
cases.push({
|
|
1067
|
+
type: "api",
|
|
1068
|
+
id: `${base}.status-${r.status}`,
|
|
1069
|
+
kind: "error",
|
|
1070
|
+
title: `${method} ${path} answers ${r.status}${r.description ? `: ${r.description}` : ""}`,
|
|
1071
|
+
name: "",
|
|
1072
|
+
...common,
|
|
1073
|
+
request: req(),
|
|
1074
|
+
expect: { status: r.status, ...r.body !== void 0 ? { body: r.body } : {}, fields: [] },
|
|
1075
|
+
why: `The spec documents ${r.status}${r.description ? `: ${r.description}` : ""}. The request needs data in that state${scenario ? ` (${scenario.trace?.name || `criterion ${scenario.criterion}`})` : ""}.`,
|
|
1076
|
+
...scenario ? { scenario: scenario.id } : {},
|
|
1077
|
+
...data ? { data } : {}
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
if (authType) {
|
|
1081
|
+
cases.push({
|
|
1082
|
+
type: "api",
|
|
1083
|
+
id: `${base}.no-credentials`,
|
|
1084
|
+
kind: "auth",
|
|
1085
|
+
title: `${method} ${path} refuses a request without credentials`,
|
|
1086
|
+
name: "",
|
|
1087
|
+
...common,
|
|
1088
|
+
request: req({ auth: "none" }),
|
|
1089
|
+
expect: { status: "401", fields: [] },
|
|
1090
|
+
why: `The operation needs ${authType} credentials.`
|
|
1091
|
+
});
|
|
1092
|
+
for (const s of op?.security ?? [])
|
|
1093
|
+
for (const scope of s.scopes)
|
|
1094
|
+
cases.push({
|
|
1095
|
+
type: "api",
|
|
1096
|
+
id: `${base}.no-scope-${slug(scope)}`,
|
|
1097
|
+
kind: "auth",
|
|
1098
|
+
title: `${method} ${path} refuses a token without scope ${scope}`,
|
|
1099
|
+
name: "",
|
|
1100
|
+
...common,
|
|
1101
|
+
request: req({ auth: "no-scope", scope }),
|
|
1102
|
+
expect: { status: "403", fields: [] },
|
|
1103
|
+
why: `The spec asks for scope ${scope}.`
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
return { cases, notes };
|
|
1107
|
+
}
|
|
1108
|
+
function mappedFields(text) {
|
|
1109
|
+
const rows = text.split("\n").filter((l) => l.trim().startsWith("|"));
|
|
1110
|
+
if (rows.length < 2)
|
|
1111
|
+
return [];
|
|
1112
|
+
const cells2 = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.replace(/[`*]/g, "").trim());
|
|
1113
|
+
const head = cells2(rows[0]).map((h) => h.toLowerCase());
|
|
1114
|
+
const ti = head.findIndex((h) => /^target( field)?$/.test(h));
|
|
1115
|
+
if (ti < 0)
|
|
1116
|
+
return [];
|
|
1117
|
+
return [
|
|
1118
|
+
...new Set(rows.slice(1).map(cells2).filter((c) => !c.every((x) => /^:?-+:?$/.test(x) || x === "")).map((c) => c[ti] ?? "").filter((f) => /^[A-Za-z_][\w.[\]-]*$/.test(f)))
|
|
1119
|
+
];
|
|
1120
|
+
}
|
|
1121
|
+
var topicOf = (op) => /^(publish|subscribe|send|receive)\s+(\S+)/i.exec(op.trim());
|
|
1122
|
+
function contractCases(ctx, hop, op, tried) {
|
|
1123
|
+
if (!/^planned-(new|modify)$/.test(hop.status))
|
|
1124
|
+
return [];
|
|
1125
|
+
const mapping = ctx.mapping(hop);
|
|
1126
|
+
const notes = ctx.notes(hop).map((n) => n.text).join("\n");
|
|
1127
|
+
const fields = mappedFields(mapping);
|
|
1128
|
+
const errors = [];
|
|
1129
|
+
for (const m of `${notes}
|
|
1130
|
+
${mapping}`.matchAll(/HTTP\s+(\d{3})/g)) {
|
|
1131
|
+
const around = `${notes}
|
|
1132
|
+
${mapping}`;
|
|
1133
|
+
const code = (around.match(CODE3) ?? [])[0];
|
|
1134
|
+
if (!errors.some((e) => e.status === m[1]))
|
|
1135
|
+
errors.push({ status: m[1], ...code ? { code } : {} });
|
|
1136
|
+
}
|
|
1137
|
+
const t = topicOf(hop.operation);
|
|
1138
|
+
const base = `contract.${slug(t ? `${t[1]} ${t[2]}` : `${hop.method} ${hop.path}`)}`;
|
|
1139
|
+
const common = {
|
|
1140
|
+
status: hop.status,
|
|
1141
|
+
.../^if-/.test(hop.id) ? { interfaceId: hop.id } : {}
|
|
1142
|
+
};
|
|
1143
|
+
if (t) {
|
|
1144
|
+
const publish = /^(publish|send)$/i.test(t[1]);
|
|
1145
|
+
const publisher = publish ? hop.caller : hop.callee;
|
|
1146
|
+
const subscriber = publish ? hop.callee : hop.caller;
|
|
1147
|
+
const shared2 = {
|
|
1148
|
+
...common,
|
|
1149
|
+
consumer: subscriber,
|
|
1150
|
+
provider: publisher,
|
|
1151
|
+
topic: t[2],
|
|
1152
|
+
request: { fields },
|
|
1153
|
+
response: { fields: [] },
|
|
1154
|
+
errors: [],
|
|
1155
|
+
gaps: []
|
|
1156
|
+
};
|
|
1157
|
+
return [
|
|
1158
|
+
{
|
|
1159
|
+
type: "contract",
|
|
1160
|
+
id: `${base}.subscriber`,
|
|
1161
|
+
title: `${subscriber} handles a ${t[2]} message from ${publisher}`,
|
|
1162
|
+
name: "",
|
|
1163
|
+
role: "consumer",
|
|
1164
|
+
...shared2,
|
|
1165
|
+
checks: [
|
|
1166
|
+
`${subscriber} accepts a message on ${t[2]}`,
|
|
1167
|
+
...fields.length ? [`it reads ${fields.join(", ")}`] : []
|
|
1168
|
+
]
|
|
1169
|
+
},
|
|
1170
|
+
{
|
|
1171
|
+
type: "contract",
|
|
1172
|
+
id: `${base}.publisher`,
|
|
1173
|
+
title: `${publisher} publishes ${t[2]} as ${subscriber} expects`,
|
|
1174
|
+
name: "",
|
|
1175
|
+
role: "provider",
|
|
1176
|
+
...shared2,
|
|
1177
|
+
checks: [
|
|
1178
|
+
`${publisher} publishes to ${t[2]}`,
|
|
1179
|
+
...fields.length ? [`the message carries ${fields.join(", ")}`] : []
|
|
1180
|
+
]
|
|
1181
|
+
}
|
|
1182
|
+
];
|
|
1183
|
+
}
|
|
1184
|
+
if (!hop.method || !hop.path)
|
|
1185
|
+
return [];
|
|
1186
|
+
const route = hopRoutes(ctx.files, hop).map((id) => ctx.get("GatewayRoute", id)).find((r) => r !== void 0);
|
|
1187
|
+
const consumerPath = route ? `${route.basepath.replace(/\/+$/, "")}${hop.path}` : hop.path;
|
|
1188
|
+
const gaps = [];
|
|
1189
|
+
const req = [];
|
|
1190
|
+
const res = [];
|
|
1191
|
+
const read = hop.method === "GET" || hop.method === "DELETE" || hop.method === "HEAD";
|
|
1192
|
+
for (const f of fields) {
|
|
1193
|
+
const top = f.split(/[.[]/)[0];
|
|
1194
|
+
if (op?.requestFields.includes(top))
|
|
1195
|
+
req.push(f);
|
|
1196
|
+
else if (op?.responseFields.includes(top))
|
|
1197
|
+
res.push(f);
|
|
1198
|
+
else {
|
|
1199
|
+
(read ? res : req).push(f);
|
|
1200
|
+
if (op)
|
|
1201
|
+
gaps.push(`The consumer maps ${f}, which ${hop.callee}'s spec (${op.source}) doesn't define.`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (!op)
|
|
1205
|
+
gaps.push(tried.length ? `${tried.join(", ")} ${tried.length === 1 ? "has" : "have"} no ${hop.method} ${hop.path}: the provider's side can't be checked until the spec has it.` : `No spec for ${hop.callee} was found (interfaces.md contract: ${hop.contract.trim() || "none"}): the provider's side can't be checked.`);
|
|
1206
|
+
const statuses = new Set((op?.route.responses ?? []).map((r) => r.status));
|
|
1207
|
+
for (const e of errors)
|
|
1208
|
+
if (op && !statuses.has(e.status))
|
|
1209
|
+
gaps.push(`The design says ${hop.method} ${hop.path} answers ${e.status}, which the spec doesn't document.`);
|
|
1210
|
+
const okStatus = op ? String(op.route.status) : void 0;
|
|
1211
|
+
const required = op?.route.body?.schema?.required ?? [];
|
|
1212
|
+
const shared = {
|
|
1213
|
+
...common,
|
|
1214
|
+
consumer: hop.caller,
|
|
1215
|
+
provider: hop.callee,
|
|
1216
|
+
method: hop.method,
|
|
1217
|
+
path: hop.path,
|
|
1218
|
+
consumerPath,
|
|
1219
|
+
request: {
|
|
1220
|
+
fields: req,
|
|
1221
|
+
...op?.requestExample !== void 0 ? { example: op.requestExample } : {}
|
|
1222
|
+
},
|
|
1223
|
+
response: {
|
|
1224
|
+
...okStatus ? { status: okStatus } : {},
|
|
1225
|
+
fields: res,
|
|
1226
|
+
...op?.route.responses.find((r) => /^2/.test(r.status))?.body !== void 0 ? { example: op.route.responses.find((r) => /^2/.test(r.status)).body } : {}
|
|
1227
|
+
},
|
|
1228
|
+
errors,
|
|
1229
|
+
gaps,
|
|
1230
|
+
...op ? { spec: op.source } : {}
|
|
1231
|
+
};
|
|
1232
|
+
return [
|
|
1233
|
+
{
|
|
1234
|
+
type: "contract",
|
|
1235
|
+
id: `${base}.consumer`,
|
|
1236
|
+
title: `${hop.caller} calls ${hop.method} ${consumerPath} as ${hop.callee}'s contract says`,
|
|
1237
|
+
name: "",
|
|
1238
|
+
role: "consumer",
|
|
1239
|
+
...shared,
|
|
1240
|
+
checks: [
|
|
1241
|
+
`${hop.caller} sends ${hop.method} ${consumerPath}${req.length ? ` with ${req.join(", ")}` : ""}`,
|
|
1242
|
+
...res.length ? [`it reads ${res.join(", ")} from the answer`] : [],
|
|
1243
|
+
...errors.map((e) => `it handles ${e.status}${e.code ? ` ${e.code}` : ""}`)
|
|
1244
|
+
]
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
type: "contract",
|
|
1248
|
+
id: `${base}.provider`,
|
|
1249
|
+
title: `${hop.callee} serves ${hop.method} ${hop.path} as ${hop.caller} expects`,
|
|
1250
|
+
name: "",
|
|
1251
|
+
role: "provider",
|
|
1252
|
+
...shared,
|
|
1253
|
+
checks: [
|
|
1254
|
+
`${hop.callee} serves ${hop.method} ${hop.path}`,
|
|
1255
|
+
...required.length ? [`it requires ${required.join(", ")}`] : [],
|
|
1256
|
+
...okStatus ? [`it answers ${okStatus}${res.length ? ` with ${res.join(", ")}` : ""}`] : [],
|
|
1257
|
+
...errors.map((e) => `it answers ${e.status}${e.code ? ` with ${e.code}` : ""}`)
|
|
1258
|
+
]
|
|
1259
|
+
}
|
|
1260
|
+
];
|
|
1261
|
+
}
|
|
1262
|
+
function dataCases(d) {
|
|
1263
|
+
const common = { store: d.store, table: d.table, tableId: d.tableId };
|
|
1264
|
+
const cases = [];
|
|
1265
|
+
const gaps = [];
|
|
1266
|
+
const base = `data.${slug(d.table)}`;
|
|
1267
|
+
if (d.newTable) {
|
|
1268
|
+
cases.push({
|
|
1269
|
+
type: "data",
|
|
1270
|
+
id: `${base}.table`,
|
|
1271
|
+
title: `table ${d.table} exists with its columns`,
|
|
1272
|
+
name: "",
|
|
1273
|
+
kind: "table",
|
|
1274
|
+
...common,
|
|
1275
|
+
columns: d.newColumns,
|
|
1276
|
+
checks: [
|
|
1277
|
+
`${d.store} has table ${d.table}`,
|
|
1278
|
+
...d.newColumns.map((c) => `column ${c.name} is ${c.type}`)
|
|
1279
|
+
]
|
|
1280
|
+
});
|
|
1281
|
+
} else {
|
|
1282
|
+
for (const c of d.newColumns) {
|
|
1283
|
+
const m = new RegExp(`add column\\s+${c.name}\\s+[\\w(),]+([^,;]*)`, "i").exec(d.change);
|
|
1284
|
+
const rest = m?.[1] ?? "";
|
|
1285
|
+
const notNull = /\bnot null\b/i.test(rest);
|
|
1286
|
+
const def = /\bdefault\s+('[^']*'|\S+)/i.exec(rest)?.[1];
|
|
1287
|
+
cases.push({
|
|
1288
|
+
type: "data",
|
|
1289
|
+
id: `${base}.${slug(c.name)}`,
|
|
1290
|
+
title: `column ${d.table}.${c.name} exists as ${c.type}`,
|
|
1291
|
+
name: "",
|
|
1292
|
+
kind: "column",
|
|
1293
|
+
...common,
|
|
1294
|
+
column: c.name,
|
|
1295
|
+
columnType: c.type,
|
|
1296
|
+
...notNull ? { notNull } : {},
|
|
1297
|
+
...def ? { default: def } : {},
|
|
1298
|
+
checks: [
|
|
1299
|
+
`${d.table}.${c.name} is ${c.type}`,
|
|
1300
|
+
...notNull ? [`${d.table}.${c.name} is not null`] : [],
|
|
1301
|
+
...def ? [`existing rows get ${def}`] : []
|
|
1302
|
+
]
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (d.migration)
|
|
1307
|
+
cases.push({
|
|
1308
|
+
type: "data",
|
|
1309
|
+
id: `${base}.migration`,
|
|
1310
|
+
title: `migration ${d.migration} applies on a copy of ${d.store}`,
|
|
1311
|
+
name: "",
|
|
1312
|
+
kind: "migration",
|
|
1313
|
+
...common,
|
|
1314
|
+
migration: d.migration,
|
|
1315
|
+
checks: [
|
|
1316
|
+
`${d.migration} applies cleanly on a copy of ${d.store}`,
|
|
1317
|
+
`afterwards ${d.newTable ? `table ${d.table} exists` : `${d.newColumns.map((c) => `${d.table}.${c.name}`).join(", ")} exist`}`
|
|
1318
|
+
]
|
|
1319
|
+
});
|
|
1320
|
+
else if (d.newTable || d.newColumns.length)
|
|
1321
|
+
gaps.push(`data.md names no migration for ${d.table}: the migration test can't be generated.`);
|
|
1322
|
+
return { cases, gaps };
|
|
1323
|
+
}
|
|
1324
|
+
function eventCases(hop, fields, firstWhen) {
|
|
1325
|
+
const t = topicOf(hop.operation);
|
|
1326
|
+
if (!t)
|
|
1327
|
+
return [];
|
|
1328
|
+
const publish = /^(publish|send)$/i.test(t[1]);
|
|
1329
|
+
const publisher = publish ? hop.caller : hop.callee;
|
|
1330
|
+
const subscriber = publish ? hop.callee : hop.caller;
|
|
1331
|
+
const base = `event.${slug(t[2])}`;
|
|
1332
|
+
const common = {
|
|
1333
|
+
topic: t[2],
|
|
1334
|
+
publisher,
|
|
1335
|
+
subscriber,
|
|
1336
|
+
fields,
|
|
1337
|
+
.../^if-/.test(hop.id) ? { interfaceId: hop.id } : {}
|
|
1338
|
+
};
|
|
1339
|
+
return [
|
|
1340
|
+
{
|
|
1341
|
+
type: "event",
|
|
1342
|
+
id: `${base}.publish`,
|
|
1343
|
+
title: `${publisher} publishes ${t[2]}${firstWhen ? ` when ${firstWhen}` : ""}`,
|
|
1344
|
+
name: "",
|
|
1345
|
+
role: "publisher",
|
|
1346
|
+
...common,
|
|
1347
|
+
checks: [
|
|
1348
|
+
`a message is published to ${t[2]}${firstWhen ? ` when ${firstWhen}` : ""}`,
|
|
1349
|
+
...fields.length ? [`it carries ${fields.join(", ")}`] : []
|
|
1350
|
+
]
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
type: "event",
|
|
1354
|
+
id: `${base}.consume`,
|
|
1355
|
+
title: `${subscriber} handles ${t[2]}`,
|
|
1356
|
+
name: "",
|
|
1357
|
+
role: "subscriber",
|
|
1358
|
+
...common,
|
|
1359
|
+
checks: [
|
|
1360
|
+
`${subscriber} processes a message from ${t[2]} once, even if it is delivered twice`
|
|
1361
|
+
]
|
|
1362
|
+
}
|
|
1363
|
+
];
|
|
1364
|
+
}
|
|
1365
|
+
function buildTestModel(ctx, story, changeRepos) {
|
|
1366
|
+
const required = ctx.tests.required;
|
|
1367
|
+
const rowKinds = ctx.tests.matrix ?? ctx.tests.required;
|
|
1368
|
+
const row = ctx.tests.row ? `${ctx.tests.row.changeType}/${ctx.tests.row.risk}` : void 0;
|
|
1369
|
+
const askedBy = /* @__PURE__ */ new Map();
|
|
1370
|
+
for (const a of ctx.tests.added ?? [])
|
|
1371
|
+
for (const k of a.kinds)
|
|
1372
|
+
if (!rowKinds.includes(k) && !askedBy.has(k))
|
|
1373
|
+
askedBy.set(k, { factor: a.factor, why: a.why });
|
|
1374
|
+
const kinds = [
|
|
1375
|
+
{ type: "acceptance", generated: true, why: "Every acceptance criterion gets a scenario." }
|
|
1376
|
+
];
|
|
1377
|
+
for (const [type, needs] of Object.entries(NEEDS)) {
|
|
1378
|
+
const hit = needs.filter((n) => rowKinds.includes(n));
|
|
1379
|
+
const added = needs.filter((n) => askedBy.has(n));
|
|
1380
|
+
const fromRow = hit.length ? `test-strategy.yaml${row ? ` (${row})` : ""} asks for ${hit.join(", ")}.` : "";
|
|
1381
|
+
const fromRisk = added.map((n) => {
|
|
1382
|
+
const a = askedBy.get(n);
|
|
1383
|
+
return `The change's ${a.factor} risk asks for ${n} tests ${a.why}.`;
|
|
1384
|
+
}).join(" ");
|
|
1385
|
+
kinds.push({
|
|
1386
|
+
type,
|
|
1387
|
+
generated: hit.length + added.length > 0,
|
|
1388
|
+
why: [fromRow, fromRisk].filter(Boolean).join(" ") || `test-strategy.yaml${row ? ` (${row})` : ""} asks for ${required.join(", ") || "nothing"}, not ${needs.join(" or ")}.`
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
const on = (t) => kinds.find((k) => k.type === t).generated;
|
|
1392
|
+
const notes = [];
|
|
1393
|
+
const gaps = [];
|
|
1394
|
+
if (ctx.tests.note)
|
|
1395
|
+
notes.push(ctx.tests.note);
|
|
1396
|
+
if (story.linkedBy === "inferred")
|
|
1397
|
+
notes.push(`${story.id} has no links section: its interfaces and data changes were found by what it mentions.`);
|
|
1398
|
+
const accTags = ["e2e", "regression", "accessibility"].filter((k) => required.includes(k));
|
|
1399
|
+
const acceptance = story.criteria.map((c) => ({
|
|
1400
|
+
type: "acceptance",
|
|
1401
|
+
id: `acceptance.c${c.n}`,
|
|
1402
|
+
title: c.trace?.name || `Criterion ${c.n}`,
|
|
1403
|
+
name: "",
|
|
1404
|
+
criterion: c.n,
|
|
1405
|
+
given: c.given,
|
|
1406
|
+
when: c.when,
|
|
1407
|
+
then: c.then,
|
|
1408
|
+
text: c.text,
|
|
1409
|
+
...c.trace ? { trace: c.trace } : {},
|
|
1410
|
+
tags: accTags
|
|
1411
|
+
}));
|
|
1412
|
+
if (!story.criteria.length)
|
|
1413
|
+
gaps.push(`${story.id} has no acceptance criteria: no scenario was generated.`);
|
|
1414
|
+
const places = /* @__PURE__ */ new Map();
|
|
1415
|
+
const allRepos = new Set(story.repos);
|
|
1416
|
+
for (const h of story.hops)
|
|
1417
|
+
for (const c of [h.caller, h.callee]) {
|
|
1418
|
+
const r = ctx.repoOf(c);
|
|
1419
|
+
if (r)
|
|
1420
|
+
allRepos.add(r);
|
|
1421
|
+
}
|
|
1422
|
+
const ownerRepo = (d) => {
|
|
1423
|
+
const s = ctx.get("DataStore", d.store);
|
|
1424
|
+
return s?.ownerComponent ? ctx.repoOf(s.ownerComponent) : void 0;
|
|
1425
|
+
};
|
|
1426
|
+
for (const d of story.data) {
|
|
1427
|
+
const r = ownerRepo(d);
|
|
1428
|
+
if (r)
|
|
1429
|
+
allRepos.add(r);
|
|
1430
|
+
}
|
|
1431
|
+
for (const r of allRepos)
|
|
1432
|
+
places.set(r, repoPlace(ctx, story, r, changeRepos));
|
|
1433
|
+
const repoCases = new Map([...allRepos].map((r) => [r, []]));
|
|
1434
|
+
const add = (repo, cs) => {
|
|
1435
|
+
if (!repo || !cs.length)
|
|
1436
|
+
return false;
|
|
1437
|
+
repoCases.set(repo, [...repoCases.get(repo) ?? [], ...cs]);
|
|
1438
|
+
return true;
|
|
1439
|
+
};
|
|
1440
|
+
const elsewhere = [];
|
|
1441
|
+
for (const h of story.hops) {
|
|
1442
|
+
const calleeRepo = ctx.repoOf(h.callee);
|
|
1443
|
+
const isRest = !!h.method && !!h.path && !topicOf(h.operation);
|
|
1444
|
+
const found = isRest ? findOperation(ctx, h, places) : { tried: [] };
|
|
1445
|
+
const op = found.op;
|
|
1446
|
+
if (isRest && on("api")) {
|
|
1447
|
+
if (calleeRepo) {
|
|
1448
|
+
const a = apiCases(h, op, endpointFor(ctx, h), acceptance);
|
|
1449
|
+
add(calleeRepo, a.cases);
|
|
1450
|
+
notes.push(...a.notes);
|
|
1451
|
+
} else
|
|
1452
|
+
notes.push(`${h.callee} has no repo here, so its ${h.method} ${h.path} gets no API tests; ${h.caller}'s contract tests cover the call.`);
|
|
1453
|
+
}
|
|
1454
|
+
if (on("contract")) {
|
|
1455
|
+
for (const c of contractCases(ctx, h, op, found.tried)) {
|
|
1456
|
+
const repo = ctx.repoOf(c.role === "consumer" ? c.consumer : c.provider);
|
|
1457
|
+
if (!add(repo, [c]))
|
|
1458
|
+
elsewhere.push(c);
|
|
1459
|
+
if (c.role === "provider")
|
|
1460
|
+
gaps.push(...c.gaps);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (on("event") && topicOf(h.operation)) {
|
|
1464
|
+
const evs = eventCases(h, mappedFields(ctx.mapping(h)), story.criteria[0]?.when);
|
|
1465
|
+
for (const e of evs)
|
|
1466
|
+
add(ctx.repoOf(e.role === "publisher" ? e.publisher : e.subscriber), [e]);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (on("data"))
|
|
1470
|
+
for (const d of story.data) {
|
|
1471
|
+
const dc = dataCases(d);
|
|
1472
|
+
const repo = ownerRepo(d);
|
|
1473
|
+
if (!add(repo, dc.cases) && dc.cases.length)
|
|
1474
|
+
notes.push(`No repo owns ${d.store}, so the data tests for ${d.table} were not written.`);
|
|
1475
|
+
gaps.push(...dc.gaps);
|
|
1476
|
+
}
|
|
1477
|
+
const primary = story.repos.find((r) => places.has(r)) ?? [...repoCases.keys()].find((r) => repoCases.get(r).length);
|
|
1478
|
+
if (primary)
|
|
1479
|
+
repoCases.set(primary, [...acceptance, ...repoCases.get(primary) ?? []]);
|
|
1480
|
+
const repos = [];
|
|
1481
|
+
for (const [repo, cases] of repoCases) {
|
|
1482
|
+
if (!cases.length)
|
|
1483
|
+
continue;
|
|
1484
|
+
const place = places.get(repo) ?? { repo, kind: "none" };
|
|
1485
|
+
const manifest = ctx.lifecycle.orgPack.profiles.flatMap((p) => p.repos?.repos ?? []).find((r) => r.id === repo);
|
|
1486
|
+
const language = languageOf(place, manifest?.type);
|
|
1487
|
+
const component = ctx.componentOfRepo(repo);
|
|
1488
|
+
repos.push({
|
|
1489
|
+
repo,
|
|
1490
|
+
...component ? { component } : {},
|
|
1491
|
+
...language ? { language } : {},
|
|
1492
|
+
...language === "node" ? { runner: nodeRunner(place) } : {},
|
|
1493
|
+
place,
|
|
1494
|
+
primary: repo === primary,
|
|
1495
|
+
cases
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
if (!primary && acceptance.length)
|
|
1499
|
+
repos.push({ repo: "", place: { repo: "", kind: "none" }, primary: true, cases: acceptance });
|
|
1500
|
+
return {
|
|
1501
|
+
change: ctx.change.id,
|
|
1502
|
+
story: {
|
|
1503
|
+
id: story.id,
|
|
1504
|
+
path: story.path,
|
|
1505
|
+
title: story.title,
|
|
1506
|
+
...story.parsed.who ? {
|
|
1507
|
+
asA: story.parsed.who.asA,
|
|
1508
|
+
iWant: story.parsed.who.iWant,
|
|
1509
|
+
soThat: story.parsed.who.soThat
|
|
1510
|
+
} : {},
|
|
1511
|
+
...story.tracesTo ? { tracesTo: story.tracesTo } : {}
|
|
1512
|
+
},
|
|
1513
|
+
strategy: {
|
|
1514
|
+
required,
|
|
1515
|
+
...row ? { row } : {},
|
|
1516
|
+
...ctx.tests.note ? { note: ctx.tests.note } : {}
|
|
1517
|
+
},
|
|
1518
|
+
kinds,
|
|
1519
|
+
repos: repos.sort((a, b) => Number(b.primary) - Number(a.primary) || a.repo.localeCompare(b.repo)),
|
|
1520
|
+
elsewhere,
|
|
1521
|
+
linkedBy: story.linkedBy,
|
|
1522
|
+
notes: [...new Set(notes)],
|
|
1523
|
+
gaps: [...new Set(gaps)]
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
var allCases = (m) => [
|
|
1527
|
+
...m.repos.flatMap((r) => r.cases),
|
|
1528
|
+
...m.elsewhere
|
|
1529
|
+
];
|
|
1530
|
+
|
|
1531
|
+
// packages/testgen/dist/names.js
|
|
1532
|
+
var import_yaml = __toESM(require_dist(), 1);
|
|
1533
|
+
var TEST_WRITER = "test-writer";
|
|
1534
|
+
function testWriter(orgPack) {
|
|
1535
|
+
const own = orgPack?.agents.find((a) => a.id === TEST_WRITER);
|
|
1536
|
+
if (own)
|
|
1537
|
+
return own;
|
|
1538
|
+
return AgentDefinition.parse({
|
|
1539
|
+
schemaVersion: 1,
|
|
1540
|
+
id: TEST_WRITER,
|
|
1541
|
+
role: "qa",
|
|
1542
|
+
description: "Names the scenarios, test cases and test data of tests Atticus generates from a story, in plain words.",
|
|
1543
|
+
inputs: ["stories", "specs", "catalog-slice"],
|
|
1544
|
+
outputArtifact: "code",
|
|
1545
|
+
tokenBudget: orgPack?.runtime.budgets.defaultTokens ?? 15e4,
|
|
1546
|
+
allowedRuntimes: ["hand-off", "model", "headless-claude-code", "mock", "replay"]
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
var INSTRUCTIONS = `# Test writer
|
|
1550
|
+
|
|
1551
|
+
Atticus has generated the tests for one story; their structure, steps, requests and checks are decided. You only name things, in plain, short English:
|
|
1552
|
+
|
|
1553
|
+
- scenarios: a title for each Gherkin scenario, at most 80 characters, saying what is checked;
|
|
1554
|
+
- cases: a test name for each test case, at most 80 characters, starting with a verb;
|
|
1555
|
+
- data: a name for each piece of test data, two to five words, saying what it is (it becomes a variable name).
|
|
1556
|
+
|
|
1557
|
+
Use only what the material says. Don't add cases or change what they check. Write YAML and nothing else:
|
|
1558
|
+
|
|
1559
|
+
scenarios:
|
|
1560
|
+
- id: <scenario id, exactly as given>
|
|
1561
|
+
name: <title>
|
|
1562
|
+
cases:
|
|
1563
|
+
- id: <case id, exactly as given>
|
|
1564
|
+
name: <test name>
|
|
1565
|
+
data:
|
|
1566
|
+
- id: <data id, exactly as given>
|
|
1567
|
+
name: <data name>`;
|
|
1568
|
+
function namingMaterial(m) {
|
|
1569
|
+
const cases = allCases(m);
|
|
1570
|
+
const data = /* @__PURE__ */ new Map();
|
|
1571
|
+
for (const c of cases)
|
|
1572
|
+
if (c.type === "api" && c.data && !data.has(c.data.id))
|
|
1573
|
+
data.set(c.data.id, `request body for ${c.method} ${c.path}: ${JSON.stringify(c.request.body)}`);
|
|
1574
|
+
return toCanonicalYaml({
|
|
1575
|
+
story: { id: m.story.id, title: m.story.title },
|
|
1576
|
+
scenarios: cases.filter((c) => c.type === "acceptance").map((c) => ({
|
|
1577
|
+
id: c.id,
|
|
1578
|
+
given: c.given,
|
|
1579
|
+
when: c.when,
|
|
1580
|
+
then: c.then,
|
|
1581
|
+
...c.trace?.name ? { specScenario: c.trace.name } : {}
|
|
1582
|
+
})),
|
|
1583
|
+
cases: cases.filter((c) => c.type !== "acceptance").map((c) => ({ id: c.id, what: c.title })),
|
|
1584
|
+
data: [...data].map(([id, what]) => ({ id, what }))
|
|
1585
|
+
}, ["story", "scenarios", "cases", "data", "id", "title", "given", "when", "then", "what"]).trimEnd();
|
|
1586
|
+
}
|
|
1587
|
+
var Named = external_exports.object({ id: external_exports.string().min(1), name: external_exports.string().min(1) });
|
|
1588
|
+
var Answer = external_exports.object({
|
|
1589
|
+
scenarios: external_exports.array(external_exports.unknown()).default([]),
|
|
1590
|
+
cases: external_exports.array(external_exports.unknown()).default([]),
|
|
1591
|
+
data: external_exports.array(external_exports.unknown()).default([])
|
|
1592
|
+
});
|
|
1593
|
+
function applyNames(m, output) {
|
|
1594
|
+
const discarded = [];
|
|
1595
|
+
let data;
|
|
1596
|
+
try {
|
|
1597
|
+
data = (0, import_yaml.parse)(output.replace(/^```(?:ya?ml)?\s*\n|\n```\s*$/g, ""));
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
return [{ reason: `the answer isn't YAML: ${e.message.split("\n")[0]}` }];
|
|
1600
|
+
}
|
|
1601
|
+
const a = Answer.safeParse(data);
|
|
1602
|
+
if (!a.success)
|
|
1603
|
+
return [{ reason: "the answer has no scenarios, cases or data lists" }];
|
|
1604
|
+
const cases = allCases(m);
|
|
1605
|
+
const byId = new Map(cases.map((c) => [c.id, c]));
|
|
1606
|
+
const apply = (list, ok, set) => {
|
|
1607
|
+
for (const raw of list) {
|
|
1608
|
+
const n = Named.safeParse(raw);
|
|
1609
|
+
if (!n.success) {
|
|
1610
|
+
discarded.push({ reason: "an entry without an id and a name" });
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
const name = oneLine(n.data.name, 100);
|
|
1614
|
+
if (!ok(n.data.id)) {
|
|
1615
|
+
discarded.push({ id: n.data.id, reason: "no such id in the generated tests" });
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
if (!name) {
|
|
1619
|
+
discarded.push({ id: n.data.id, reason: "an empty name" });
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
set(n.data.id, name);
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
apply(a.data.scenarios, (id) => byId.get(id)?.type === "acceptance", (id, name) => {
|
|
1626
|
+
for (const c of cases)
|
|
1627
|
+
if (c.id === id)
|
|
1628
|
+
c.name = name;
|
|
1629
|
+
});
|
|
1630
|
+
apply(a.data.cases, (id) => !!byId.get(id) && byId.get(id).type !== "acceptance", (id, name) => {
|
|
1631
|
+
for (const c of cases)
|
|
1632
|
+
if (c.id === id)
|
|
1633
|
+
c.name = name;
|
|
1634
|
+
});
|
|
1635
|
+
const dataIds = new Set(cases.flatMap((c) => c.type === "api" && c.data ? [c.data.id] : []));
|
|
1636
|
+
apply(a.data.data, (id) => dataIds.has(id), (id, name) => {
|
|
1637
|
+
for (const c of cases)
|
|
1638
|
+
if (c.type === "api" && c.data?.id === id)
|
|
1639
|
+
c.data = { id, name: camel(name, 40) };
|
|
1640
|
+
});
|
|
1641
|
+
return discarded;
|
|
1642
|
+
}
|
|
1643
|
+
function fallbackNames(m) {
|
|
1644
|
+
for (const c of allCases(m)) {
|
|
1645
|
+
if (!c.name)
|
|
1646
|
+
c.name = c.title;
|
|
1647
|
+
if (c.type === "api" && c.data && !c.data.name)
|
|
1648
|
+
c.data = { id: c.data.id, name: camel(`${c.method} ${c.path} request`, 40) };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
async function nameTests(m, o) {
|
|
1652
|
+
if (!o.runtime) {
|
|
1653
|
+
fallbackNames(m);
|
|
1654
|
+
return { by: "fallback", discarded: [] };
|
|
1655
|
+
}
|
|
1656
|
+
const agent = testWriter(o.orgPack);
|
|
1657
|
+
const levels = o.orgPack?.pack?.classification ?? [
|
|
1658
|
+
"public",
|
|
1659
|
+
"internal",
|
|
1660
|
+
"confidential",
|
|
1661
|
+
"restricted"
|
|
1662
|
+
];
|
|
1663
|
+
const sources = [
|
|
1664
|
+
{
|
|
1665
|
+
id: "instructions",
|
|
1666
|
+
kind: "instruction",
|
|
1667
|
+
origin: "atticus/testgen",
|
|
1668
|
+
text: INSTRUCTIONS,
|
|
1669
|
+
classification: "public",
|
|
1670
|
+
trusted: true
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
id: "tests",
|
|
1674
|
+
kind: "artifact",
|
|
1675
|
+
origin: `atticus tests generate ${m.story.id} (deterministic)`,
|
|
1676
|
+
text: namingMaterial(m),
|
|
1677
|
+
classification: "internal",
|
|
1678
|
+
priority: 100
|
|
1679
|
+
}
|
|
1680
|
+
];
|
|
1681
|
+
const pack = buildContextPack({
|
|
1682
|
+
change: m.change,
|
|
1683
|
+
agent: agent.id,
|
|
1684
|
+
budget: o.orgPack?.runtime.budgets.perAgent[agent.id] ?? agent.tokenBudget,
|
|
1685
|
+
sources,
|
|
1686
|
+
levels,
|
|
1687
|
+
maxClassification: levels.includes("internal") ? "internal" : levels[0],
|
|
1688
|
+
now: o.now
|
|
1689
|
+
});
|
|
1690
|
+
let run;
|
|
1691
|
+
if (o.resume)
|
|
1692
|
+
run = await o.runtime.result(o.resume);
|
|
1693
|
+
else {
|
|
1694
|
+
const prepared = await o.runtime.prepare({
|
|
1695
|
+
change: m.change,
|
|
1696
|
+
agent,
|
|
1697
|
+
pack,
|
|
1698
|
+
outputPath: `tests/${m.story.id}/names.yaml`
|
|
1699
|
+
});
|
|
1700
|
+
run = await o.runtime.run(prepared.runId);
|
|
1701
|
+
}
|
|
1702
|
+
if (run.state === "waiting" || run.state === "prepared") {
|
|
1703
|
+
fallbackNames(m);
|
|
1704
|
+
return {
|
|
1705
|
+
by: "fallback",
|
|
1706
|
+
discarded: [],
|
|
1707
|
+
waiting: run.runId,
|
|
1708
|
+
promptSha256: pack.manifest.promptSha256
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
if (run.output === void 0 || run.state === "failed" || run.state === "cancelled") {
|
|
1712
|
+
fallbackNames(m);
|
|
1713
|
+
return {
|
|
1714
|
+
by: "fallback",
|
|
1715
|
+
discarded: [
|
|
1716
|
+
{ reason: `the test writer ${run.state}: ${run.errors.join("; ") || "no output"}` }
|
|
1717
|
+
],
|
|
1718
|
+
run: toRunSummary(run),
|
|
1719
|
+
promptSha256: pack.manifest.promptSha256
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
const discarded = applyNames(m, run.output);
|
|
1723
|
+
fallbackNames(m);
|
|
1724
|
+
return { by: "ai", discarded, run: toRunSummary(run), promptSha256: pack.manifest.promptSha256 };
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// packages/testgen/dist/story.js
|
|
1728
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
1729
|
+
import { join as join4 } from "node:path";
|
|
1730
|
+
var GWT = /^given\s+(.+?),?\s+when\s+(.+?),?\s+then\s+(.+?)\.?(?:\s*\(([^()]+?):(\d+)\))?\.?\s*$/i;
|
|
1731
|
+
var content = (s) => new Set(s.toLowerCase().replace(/[^a-z0-9_ ]+/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP.has(w)));
|
|
1732
|
+
var STOP = /* @__PURE__ */ new Set([
|
|
1733
|
+
"the",
|
|
1734
|
+
"and",
|
|
1735
|
+
"with",
|
|
1736
|
+
"that",
|
|
1737
|
+
"has",
|
|
1738
|
+
"its",
|
|
1739
|
+
"for",
|
|
1740
|
+
"asks",
|
|
1741
|
+
"when",
|
|
1742
|
+
"then",
|
|
1743
|
+
"given",
|
|
1744
|
+
"error"
|
|
1745
|
+
]);
|
|
1746
|
+
function overlap(a, b) {
|
|
1747
|
+
const x = content(a);
|
|
1748
|
+
const y = content(b);
|
|
1749
|
+
if (!x.size || !y.size)
|
|
1750
|
+
return 0;
|
|
1751
|
+
let n = 0;
|
|
1752
|
+
for (const w of x)
|
|
1753
|
+
if (y.has(w))
|
|
1754
|
+
n++;
|
|
1755
|
+
return n / (x.size + y.size - n);
|
|
1756
|
+
}
|
|
1757
|
+
function traceCriterion(text, ref, scenarios) {
|
|
1758
|
+
if (ref) {
|
|
1759
|
+
const hit = scenarios.find((x) => x.file === ref.file && x.s.line === ref.line);
|
|
1760
|
+
return { file: ref.file, line: ref.line, name: hit?.s.name ?? "" };
|
|
1761
|
+
}
|
|
1762
|
+
let best;
|
|
1763
|
+
for (const x of scenarios) {
|
|
1764
|
+
const score = overlap(text, `${x.s.when.join(" ")} ${x.s.then.join(" ")}`);
|
|
1765
|
+
if (score >= 0.5 && (!best || score > best.score))
|
|
1766
|
+
best = { ...x, score };
|
|
1767
|
+
}
|
|
1768
|
+
return best ? { file: best.file, line: best.s.line, name: best.s.name } : void 0;
|
|
1769
|
+
}
|
|
1770
|
+
function splitCriterion(text) {
|
|
1771
|
+
text = text.replace(/\s*\(also accepts tasks? [^()]*\)\s*$/i, "");
|
|
1772
|
+
const m = GWT.exec(text.trim());
|
|
1773
|
+
if (!m)
|
|
1774
|
+
return { given: "", when: "", then: text.trim().replace(/\.$/, "") };
|
|
1775
|
+
return {
|
|
1776
|
+
given: m[1].trim(),
|
|
1777
|
+
when: m[2].trim(),
|
|
1778
|
+
then: m[3].trim(),
|
|
1779
|
+
...m[4] ? { ref: { file: m[4].trim(), line: Number(m[5]) } } : {}
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
var LINK_IF = /^Interface\s+(\S+?):\s*(\S+)\s*→\s*(\S+)\s+(.+?)\s*(?:\(([^)]*)\))?\s*$/;
|
|
1783
|
+
var LINK_REC = /^Record\s+([a-z0-9][a-z0-9._-]*?)[:.]?(?:\s|$)/;
|
|
1784
|
+
function links(doc) {
|
|
1785
|
+
const s = doc.sections.find((x) => x.level === 2 && /^links$/i.test(x.heading));
|
|
1786
|
+
const interfaces = [];
|
|
1787
|
+
const records = [];
|
|
1788
|
+
if (!s)
|
|
1789
|
+
return { interfaces, records };
|
|
1790
|
+
for (const l of sectionLines(doc, s)) {
|
|
1791
|
+
const b = /^\s*[-*]\s+(.*)$/.exec(l.text)?.[1]?.trim();
|
|
1792
|
+
if (!b)
|
|
1793
|
+
continue;
|
|
1794
|
+
const i = LINK_IF.exec(b);
|
|
1795
|
+
if (i) {
|
|
1796
|
+
const line = /interfaces\.md:(\d+)/.exec(i[5] ?? "")?.[1];
|
|
1797
|
+
interfaces.push({
|
|
1798
|
+
id: i[1].replace(/[()]/g, ""),
|
|
1799
|
+
caller: i[2],
|
|
1800
|
+
callee: i[3],
|
|
1801
|
+
operation: i[4].trim(),
|
|
1802
|
+
...line ? { line: Number(line) } : {}
|
|
1803
|
+
});
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
const r = LINK_REC.exec(b);
|
|
1807
|
+
if (r)
|
|
1808
|
+
records.push(r[1]);
|
|
1809
|
+
}
|
|
1810
|
+
return { interfaces, records };
|
|
1811
|
+
}
|
|
1812
|
+
var opKey = (op) => {
|
|
1813
|
+
const m = /^([A-Za-z]+)\s+(\S+)/.exec(op.trim());
|
|
1814
|
+
return m ? `${m[1].toUpperCase()} ${/^\//.test(m[2]) ? normalizePath(m[2]) : m[2]}` : op.trim();
|
|
1815
|
+
};
|
|
1816
|
+
var TASK = /^\s*[-*]\s+\[[ xX]?\]\s+(\S+)\s+(.*)$/;
|
|
1817
|
+
function storyTasks(ctx, story) {
|
|
1818
|
+
const doc = docOf(ctx.files, "tasks.md");
|
|
1819
|
+
if (!doc)
|
|
1820
|
+
return [];
|
|
1821
|
+
const out = [];
|
|
1822
|
+
let repo = "";
|
|
1823
|
+
doc.lines.forEach((l, i) => {
|
|
1824
|
+
const h = /^##\s+(.+?)\s*$/.exec(l);
|
|
1825
|
+
if (h) {
|
|
1826
|
+
repo = h[1].replace(/[`*]/g, "").trim().split(/\s+/)[0].toLowerCase();
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
const m = TASK.exec(l);
|
|
1830
|
+
if (!m || !repo || !RecordId.safeParse(m[1].toLowerCase()).success)
|
|
1831
|
+
return;
|
|
1832
|
+
const stories = [...m[2].matchAll(/\bstor(?:y|ies)\s+([a-z0-9][a-z0-9._-]*)/gi)].map((s) => s[1].toLowerCase().replace(/[.,)]+$/, ""));
|
|
1833
|
+
if (stories.includes(story))
|
|
1834
|
+
out.push({ id: m[1].toLowerCase(), repo, text: m[2], line: i + 1 });
|
|
1835
|
+
});
|
|
1836
|
+
return out;
|
|
1837
|
+
}
|
|
1838
|
+
async function loadStory(ctx, storyId) {
|
|
1839
|
+
if (!RecordId.safeParse(storyId).success)
|
|
1840
|
+
throw new TestgenError("invalid", `"${storyId}" is not a story id.`);
|
|
1841
|
+
const path = `stories/${storyId}.md`;
|
|
1842
|
+
let text;
|
|
1843
|
+
try {
|
|
1844
|
+
text = await readFile2(join4(ctx.dir, "stories", `${storyId}.md`), "utf8");
|
|
1845
|
+
} catch {
|
|
1846
|
+
throw new TestgenError("not-found", `${ctx.change.id} has no story ${storyId} (no ${path}). Plan the stories first: atticus plan stories ${ctx.change.id}`);
|
|
1847
|
+
}
|
|
1848
|
+
const parsed = parseStory(path, text);
|
|
1849
|
+
const scenarios = ctx.requirements.flatMap((r) => r.scenarios.map((s) => ({ file: r.file, s })));
|
|
1850
|
+
const criteriaLines = (() => {
|
|
1851
|
+
const s = parsed.doc.sections.find((x) => x.level === 2 && /acceptance criteria/i.test(x.heading));
|
|
1852
|
+
return s ? sectionLines(parsed.doc, s).filter((l2) => /^\s*[-*]\s+/.test(l2.text)) : [];
|
|
1853
|
+
})();
|
|
1854
|
+
const criteria = parsed.criteria.map((c, i) => {
|
|
1855
|
+
const p = splitCriterion(c);
|
|
1856
|
+
const trace = traceCriterion(c, p.ref, scenarios);
|
|
1857
|
+
return {
|
|
1858
|
+
n: i + 1,
|
|
1859
|
+
text: c,
|
|
1860
|
+
given: p.given,
|
|
1861
|
+
when: p.when,
|
|
1862
|
+
then: p.then,
|
|
1863
|
+
...trace ? { trace } : {},
|
|
1864
|
+
line: criteriaLines[i]?.n ?? 1
|
|
1865
|
+
};
|
|
1866
|
+
});
|
|
1867
|
+
const l = links(parsed.doc);
|
|
1868
|
+
let hops = [];
|
|
1869
|
+
let linkedBy = "links";
|
|
1870
|
+
if (l.interfaces.length) {
|
|
1871
|
+
for (const li of l.interfaces) {
|
|
1872
|
+
const h = (li.line ? ctx.hops.find((x) => x.line === li.line) : void 0) ?? (li.id !== "new" ? ctx.hops.find((x) => x.id === li.id) : void 0) ?? ctx.hops.find((x) => x.caller === li.caller && x.callee === li.callee && opKey(x.operation) === opKey(li.operation));
|
|
1873
|
+
if (h && !hops.includes(h))
|
|
1874
|
+
hops.push(h);
|
|
1875
|
+
}
|
|
1876
|
+
} else {
|
|
1877
|
+
linkedBy = "inferred";
|
|
1878
|
+
const said = [
|
|
1879
|
+
text,
|
|
1880
|
+
...ctx.requirements.find((r) => parsed.traces?.includes(`${r.file}:${r.line}`))?.text ?? []
|
|
1881
|
+
].join("\n");
|
|
1882
|
+
hops = ctx.hops.filter((h) => {
|
|
1883
|
+
const op = h.path ?? h.operation.replace(/^\S+\s+/, "");
|
|
1884
|
+
return op && said.includes(op);
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
const tasks = storyTasks(ctx, storyId);
|
|
1888
|
+
let data = ctx.data.filter((d) => l.records.includes(d.tableId));
|
|
1889
|
+
if (!data.length && !l.records.length) {
|
|
1890
|
+
const taskText = tasks.map((t) => t.text).join("\n");
|
|
1891
|
+
data = ctx.data.filter((d) => d.migration && taskText.includes(d.migration) || taskText.includes(`table ${d.table}`));
|
|
1892
|
+
if (data.length)
|
|
1893
|
+
linkedBy = "inferred";
|
|
1894
|
+
}
|
|
1895
|
+
const repos = [
|
|
1896
|
+
.../* @__PURE__ */ new Set([...parsed.repos.map((r) => r.toLowerCase()), ...tasks.map((t) => t.repo)])
|
|
1897
|
+
].filter((r) => RecordId.safeParse(r).success);
|
|
1898
|
+
return {
|
|
1899
|
+
id: storyId,
|
|
1900
|
+
path,
|
|
1901
|
+
parsed,
|
|
1902
|
+
title: parsed.title ?? storyId,
|
|
1903
|
+
criteria,
|
|
1904
|
+
hops,
|
|
1905
|
+
data,
|
|
1906
|
+
repos,
|
|
1907
|
+
tasks,
|
|
1908
|
+
linkedBy,
|
|
1909
|
+
...parsed.traces ? { tracesTo: /^(\S+:\d+)/.exec(parsed.traces)?.[1] ?? parsed.traces } : {}
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// packages/testgen/dist/generate.js
|
|
1914
|
+
var ManifestFile = external_exports.strictObject({
|
|
1915
|
+
schemaVersion: external_exports.literal(1),
|
|
1916
|
+
change: RecordId,
|
|
1917
|
+
story: RecordId,
|
|
1918
|
+
strategy: external_exports.array(external_exports.string()),
|
|
1919
|
+
names: external_exports.enum(["ai", "fallback"]),
|
|
1920
|
+
files: external_exports.array(external_exports.strictObject({
|
|
1921
|
+
path: external_exports.string().min(1),
|
|
1922
|
+
repo: external_exports.string(),
|
|
1923
|
+
generator: external_exports.string().min(1),
|
|
1924
|
+
cases: external_exports.array(external_exports.string())
|
|
1925
|
+
})),
|
|
1926
|
+
cases: external_exports.array(external_exports.strictObject({
|
|
1927
|
+
id: external_exports.string().min(1),
|
|
1928
|
+
type: external_exports.enum(["acceptance", "api", "contract", "data", "event"]),
|
|
1929
|
+
repo: external_exports.string(),
|
|
1930
|
+
name: external_exports.string(),
|
|
1931
|
+
criterion: external_exports.number().int().positive().optional(),
|
|
1932
|
+
scenario: external_exports.string().optional(),
|
|
1933
|
+
endpoint: external_exports.string().optional(),
|
|
1934
|
+
interface: external_exports.string().optional(),
|
|
1935
|
+
method: external_exports.string().optional(),
|
|
1936
|
+
path: external_exports.string().optional(),
|
|
1937
|
+
table: external_exports.string().optional(),
|
|
1938
|
+
topic: external_exports.string().optional(),
|
|
1939
|
+
role: external_exports.string().optional()
|
|
1940
|
+
}))
|
|
1941
|
+
});
|
|
1942
|
+
var posix = (p) => p.split(sep).join("/");
|
|
1943
|
+
function manifestCase(c, repo) {
|
|
1944
|
+
const base = { id: c.id, type: c.type, repo, name: c.name };
|
|
1945
|
+
switch (c.type) {
|
|
1946
|
+
case "acceptance":
|
|
1947
|
+
return {
|
|
1948
|
+
...base,
|
|
1949
|
+
criterion: c.criterion,
|
|
1950
|
+
...c.trace ? { scenario: `${c.trace.file}:${c.trace.line}` } : {}
|
|
1951
|
+
};
|
|
1952
|
+
case "api":
|
|
1953
|
+
return {
|
|
1954
|
+
...base,
|
|
1955
|
+
...c.scenario ? { criterion: Number(c.scenario.replace(/^acceptance\.c/, "")) } : {},
|
|
1956
|
+
method: c.method,
|
|
1957
|
+
path: c.path,
|
|
1958
|
+
...c.endpoint ? { endpoint: c.endpoint } : {},
|
|
1959
|
+
...c.interfaceId ? { interface: c.interfaceId } : {}
|
|
1960
|
+
};
|
|
1961
|
+
case "contract":
|
|
1962
|
+
return {
|
|
1963
|
+
...base,
|
|
1964
|
+
role: c.role,
|
|
1965
|
+
...c.method ? { method: c.method } : {},
|
|
1966
|
+
...c.path ? { path: c.path } : {},
|
|
1967
|
+
...c.topic ? { topic: c.topic } : {},
|
|
1968
|
+
...c.interfaceId ? { interface: c.interfaceId } : {}
|
|
1969
|
+
};
|
|
1970
|
+
case "data":
|
|
1971
|
+
return { ...base, table: c.tableId };
|
|
1972
|
+
case "event":
|
|
1973
|
+
return {
|
|
1974
|
+
...base,
|
|
1975
|
+
topic: c.topic,
|
|
1976
|
+
role: c.role,
|
|
1977
|
+
...c.interfaceId ? { interface: c.interfaceId } : {}
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
async function generateTests(root, changeId, storyId, o = {}) {
|
|
1982
|
+
const known = [...o.generators ?? [], ...BUILT_IN_GENERATORS].map((g) => g.id);
|
|
1983
|
+
if (o.framework && !known.includes(o.framework))
|
|
1984
|
+
throw new TestgenError("invalid", `There is no test framework ${o.framework}. Frameworks: ${known.join(", ")}.`);
|
|
1985
|
+
const ctx = await loadPlanContext(root, changeId, { requireApprovedDesign: false }).catch((e) => {
|
|
1986
|
+
throw new TestgenError("not-found", e.message);
|
|
1987
|
+
});
|
|
1988
|
+
if (ctx.change.state === "archived")
|
|
1989
|
+
throw new TestgenError("refused", `${ctx.change.id} is archived; its tests can't be generated again.`);
|
|
1990
|
+
const story = await loadStory(ctx, storyId);
|
|
1991
|
+
const changeRepos = await readChangeRepos(root, changeId);
|
|
1992
|
+
const model = buildTestModel(ctx, story, changeRepos);
|
|
1993
|
+
const names = await nameTests(model, {
|
|
1994
|
+
...o.runtime ? { runtime: o.runtime } : {},
|
|
1995
|
+
orgPack: ctx.lifecycle.orgPack,
|
|
1996
|
+
now: o.now ?? /* @__PURE__ */ new Date(),
|
|
1997
|
+
...o.resume ? { resume: o.resume } : {}
|
|
1998
|
+
});
|
|
1999
|
+
const changeRel = posix(relative(root, ctx.dir));
|
|
2000
|
+
const source = `${changeRel}/${story.path}`;
|
|
2001
|
+
const files = [];
|
|
2002
|
+
const notes = [...model.notes];
|
|
2003
|
+
for (const repo of model.repos) {
|
|
2004
|
+
const inWorktree = repo.place.kind === "task-worktree" || repo.place.kind === "worktree";
|
|
2005
|
+
const dir = inWorktree ? repo.place.dir : join5(ctx.dir, "tests", story.id, ...repo.repo ? [repo.repo] : []);
|
|
2006
|
+
const where = inWorktree ? repo.place.kind : "change-folder";
|
|
2007
|
+
const chosen = generatorsFor(repo, {
|
|
2008
|
+
...o.framework ? { framework: o.framework } : {},
|
|
2009
|
+
...o.generators ? { plugins: o.generators } : {}
|
|
2010
|
+
});
|
|
2011
|
+
if (chosen.note)
|
|
2012
|
+
notes.push(chosen.note);
|
|
2013
|
+
for (const g of chosen.generators) {
|
|
2014
|
+
const rendered = g.render(model, repo, { source });
|
|
2015
|
+
const results = await writeGenerated(dir, rendered, {
|
|
2016
|
+
...o.force ? { force: true } : {},
|
|
2017
|
+
...o.check ? { check: true } : {}
|
|
2018
|
+
});
|
|
2019
|
+
results.forEach((r, i) => files.push({
|
|
2020
|
+
repo: repo.repo,
|
|
2021
|
+
where,
|
|
2022
|
+
generator: g.id,
|
|
2023
|
+
path: posix(relative(root, join5(dir, ...r.path.split("/")))),
|
|
2024
|
+
action: r.action,
|
|
2025
|
+
written: r.written,
|
|
2026
|
+
cases: rendered[i].cases ?? []
|
|
2027
|
+
}));
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
for (const f of files.filter((x) => x.action === "kept-edited"))
|
|
2031
|
+
notes.push(`${f.path} was edited by a person, so it was kept; use --force to replace it.`);
|
|
2032
|
+
for (const f of files.filter((x) => x.action === "kept-not-generated"))
|
|
2033
|
+
notes.push(`${f.path} wasn't written by Atticus, so it was kept; use --force to replace it.`);
|
|
2034
|
+
const manifestRel = `${changeRel}/tests/${story.id}/manifest.yaml`;
|
|
2035
|
+
if (!o.check) {
|
|
2036
|
+
const m = ManifestFile.parse({
|
|
2037
|
+
schemaVersion: 1,
|
|
2038
|
+
change: model.change,
|
|
2039
|
+
story: story.id,
|
|
2040
|
+
strategy: model.strategy.required,
|
|
2041
|
+
names: names.by,
|
|
2042
|
+
files: files.map((f) => ({
|
|
2043
|
+
path: f.path,
|
|
2044
|
+
repo: f.repo,
|
|
2045
|
+
generator: f.generator,
|
|
2046
|
+
cases: f.cases
|
|
2047
|
+
})),
|
|
2048
|
+
cases: model.repos.flatMap((r) => r.cases.map((c) => manifestCase(c, r.repo)))
|
|
2049
|
+
});
|
|
2050
|
+
await writeFileAtomic(join5(root, ...manifestRel.split("/")), toCanonicalYaml(m, ["schemaVersion", "change", "story"]));
|
|
2051
|
+
}
|
|
2052
|
+
return {
|
|
2053
|
+
change: model.change,
|
|
2054
|
+
story: story.id,
|
|
2055
|
+
model,
|
|
2056
|
+
names,
|
|
2057
|
+
files,
|
|
2058
|
+
manifest: manifestRel,
|
|
2059
|
+
notes: [...new Set(notes)],
|
|
2060
|
+
gaps: model.gaps
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
async function readManifests(changeDir) {
|
|
2064
|
+
const manifests = [];
|
|
2065
|
+
const problems = [];
|
|
2066
|
+
let dirs;
|
|
2067
|
+
try {
|
|
2068
|
+
dirs = (await readdir(join5(changeDir, "tests"), { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
2069
|
+
} catch {
|
|
2070
|
+
return { manifests, problems };
|
|
2071
|
+
}
|
|
2072
|
+
for (const d of dirs) {
|
|
2073
|
+
const text = await readFile3(join5(changeDir, "tests", d, "manifest.yaml"), "utf8").catch(() => void 0);
|
|
2074
|
+
if (text === void 0)
|
|
2075
|
+
continue;
|
|
2076
|
+
const r = ManifestFile.safeParse((0, import_yaml2.parse)(text));
|
|
2077
|
+
if (r.success)
|
|
2078
|
+
manifests.push(r.data);
|
|
2079
|
+
else
|
|
2080
|
+
problems.push(`tests/${d}/manifest.yaml: ${r.error.issues[0]?.message ?? "invalid"}`);
|
|
2081
|
+
}
|
|
2082
|
+
return { manifests, problems };
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// packages/testgen/dist/trace.js
|
|
2086
|
+
var import_yaml3 = __toESM(require_dist(), 1);
|
|
2087
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
2088
|
+
import { readdir as readdir2, readFile as readFile4 } from "node:fs/promises";
|
|
2089
|
+
import { fileURLToPath } from "node:url";
|
|
2090
|
+
import { join as join7, relative as relative2, sep as sep2 } from "node:path";
|
|
2091
|
+
|
|
2092
|
+
// packages/testgen/dist/rounds.js
|
|
2093
|
+
import { join as join6 } from "node:path";
|
|
2094
|
+
var sig = (fs) => fs.map((f) => `${fingerprint(f)}|${f.state}|${f.severity}`).sort().join("\n");
|
|
2095
|
+
async function writeGateRound(root, change, gate, fresh, now) {
|
|
2096
|
+
const rounds = await roundsOf(root, change, gate);
|
|
2097
|
+
const last = rounds.at(-1);
|
|
2098
|
+
const prev = last ? (await readRound(root, change, gate, last)).findings.map((s) => s.finding) : [];
|
|
2099
|
+
const byPrint = new Map(prev.map((f) => [fingerprint(f), f]));
|
|
2100
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2101
|
+
const next = [];
|
|
2102
|
+
for (const f of fresh) {
|
|
2103
|
+
const before = byPrint.get(fingerprint(f));
|
|
2104
|
+
let id = f.id;
|
|
2105
|
+
for (let i = 2; taken.has(id); i++)
|
|
2106
|
+
id = `${f.id}-${i}`;
|
|
2107
|
+
taken.add(id);
|
|
2108
|
+
const merged = Finding.safeParse({
|
|
2109
|
+
...f,
|
|
2110
|
+
id,
|
|
2111
|
+
createdAt: before?.createdAt ?? f.createdAt,
|
|
2112
|
+
...before?.resolution ? { state: before.state, resolution: before.resolution } : {}
|
|
2113
|
+
});
|
|
2114
|
+
next.push(merged.success ? merged.data : { ...f, id });
|
|
2115
|
+
}
|
|
2116
|
+
if (sig(next) === sig(prev) && (last !== void 0 || next.length === 0)) {
|
|
2117
|
+
const findings2 = last ? (await readRound(root, change, gate, last)).findings : [];
|
|
2118
|
+
return { gate, ...last ? { round: last } : {}, unchanged: true, findings: findings2 };
|
|
2119
|
+
}
|
|
2120
|
+
const round = (last ?? 0) + 1;
|
|
2121
|
+
const findings = [];
|
|
2122
|
+
for (const f of next.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
2123
|
+
const g = Finding.parse({ ...f, round, gate, change });
|
|
2124
|
+
await writeFinding(root, g);
|
|
2125
|
+
findings.push({ finding: g, path: `review/${gate}/${round}/${g.id}.yaml` });
|
|
2126
|
+
}
|
|
2127
|
+
await writeFileAtomic(join6(changeFolder(root, change), "review", gate, String(round), "meta", "round.yaml"), toCanonicalYaml({
|
|
2128
|
+
schemaVersion: 1,
|
|
2129
|
+
change,
|
|
2130
|
+
gate,
|
|
2131
|
+
round,
|
|
2132
|
+
createdAt: now,
|
|
2133
|
+
findings: findings.map((f) => ({
|
|
2134
|
+
id: f.finding.id,
|
|
2135
|
+
rule: f.finding.rule,
|
|
2136
|
+
severity: f.finding.severity
|
|
2137
|
+
}))
|
|
2138
|
+
}, ["schemaVersion", "change", "gate", "round", "createdAt"]));
|
|
2139
|
+
return { gate, round, unchanged: false, findings };
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// packages/testgen/dist/trace.js
|
|
2143
|
+
var __atticus_import_meta_url = __atticusUrl("packages/testgen/dist/trace.js");
|
|
2144
|
+
var TRACE_GATE = "trace";
|
|
2145
|
+
var TEST_FILE = /(^|\/)(src\/test|tests?|__tests__|spec|specs|e2e)\/|\.(test|spec)\.[a-z]+$|Tests?\.(java|kt|cs|py|go|scala)$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$|\.feature$/;
|
|
2146
|
+
var SKIP = /* @__PURE__ */ new Set([
|
|
2147
|
+
"node_modules",
|
|
2148
|
+
".git",
|
|
2149
|
+
"target",
|
|
2150
|
+
"build",
|
|
2151
|
+
"dist",
|
|
2152
|
+
".gradle",
|
|
2153
|
+
".idea",
|
|
2154
|
+
".atticus"
|
|
2155
|
+
]);
|
|
2156
|
+
var posix2 = (p) => p.split(sep2).join("/");
|
|
2157
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2158
|
+
async function walk(dir, rel = "", out = []) {
|
|
2159
|
+
let entries;
|
|
2160
|
+
try {
|
|
2161
|
+
entries = await readdir2(join7(dir, rel), { withFileTypes: true });
|
|
2162
|
+
} catch {
|
|
2163
|
+
return out;
|
|
2164
|
+
}
|
|
2165
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2166
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
2167
|
+
if (e.isDirectory()) {
|
|
2168
|
+
if (!SKIP.has(e.name))
|
|
2169
|
+
await walk(dir, p, out);
|
|
2170
|
+
} else if (e.isFile() && TEST_FILE.test(p))
|
|
2171
|
+
out.push(p);
|
|
2172
|
+
}
|
|
2173
|
+
return out;
|
|
2174
|
+
}
|
|
2175
|
+
function pathPattern(path) {
|
|
2176
|
+
const segs = normalizePath(path).split("/").filter(Boolean);
|
|
2177
|
+
const body = segs.map((s) => s === "{}" ? "[^/\"'`\\s?]+" : escape(s)).join("/");
|
|
2178
|
+
return new RegExp(`/${body}(?=["'\`?\\s)]|$)`, "m");
|
|
2179
|
+
}
|
|
2180
|
+
function requirementsOf(ctx) {
|
|
2181
|
+
const out = [];
|
|
2182
|
+
for (const r of ctx.requirements) {
|
|
2183
|
+
if (!r.scenarios.length)
|
|
2184
|
+
out.push({ file: r.file, line: r.line, requirement: r.name, key: `${r.file}:${r.line}` });
|
|
2185
|
+
for (const s of r.scenarios)
|
|
2186
|
+
out.push({
|
|
2187
|
+
file: r.file,
|
|
2188
|
+
line: s.line,
|
|
2189
|
+
requirement: r.name,
|
|
2190
|
+
scenario: s.name,
|
|
2191
|
+
key: `${r.file}:${s.line}`
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
return out;
|
|
2195
|
+
}
|
|
2196
|
+
async function storyIds(dir) {
|
|
2197
|
+
return (await readdir2(join7(dir, "stories")).catch(() => [])).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, "")).sort();
|
|
2198
|
+
}
|
|
2199
|
+
async function codeOf(ctx, story, dirOf, git, problems, notStarted) {
|
|
2200
|
+
const out = /* @__PURE__ */ new Set();
|
|
2201
|
+
for (const t of story.tasks) {
|
|
2202
|
+
const text = await readFile4(join7(ctx.dir, "tasks", t.id, "task-run.yaml"), "utf8").catch(() => void 0);
|
|
2203
|
+
if (text) {
|
|
2204
|
+
const run = (0, import_yaml3.parse)(text);
|
|
2205
|
+
for (const f of Array.isArray(run.changed) ? run.changed : [])
|
|
2206
|
+
out.add(`${t.repo}:${String(f)}`);
|
|
2207
|
+
}
|
|
2208
|
+
const dir = dirOf(t.repo);
|
|
2209
|
+
if (!git || !dir || !existsSync3(join7(dir, ".git")))
|
|
2210
|
+
continue;
|
|
2211
|
+
try {
|
|
2212
|
+
const r = await git([
|
|
2213
|
+
"diff",
|
|
2214
|
+
"--name-only",
|
|
2215
|
+
`${changeBranch(ctx.change.id)}...${taskBranch(ctx.change.id, t.id)}`
|
|
2216
|
+
], dir);
|
|
2217
|
+
for (const f of r.stdout.split("\n").map((x) => x.trim()).filter(Boolean))
|
|
2218
|
+
out.add(`${t.repo}:${f}`);
|
|
2219
|
+
} catch (e) {
|
|
2220
|
+
const branch = `refs/heads/${taskBranch(ctx.change.id, t.id)}`;
|
|
2221
|
+
const exists = await git(["rev-parse", "--verify", "--quiet", branch], dir).then(() => true, () => false);
|
|
2222
|
+
if (exists)
|
|
2223
|
+
problems.push(`${t.repo}: no diff for task ${t.id} (${e.message.split("\n")[0]})`);
|
|
2224
|
+
else if (!text)
|
|
2225
|
+
notStarted.add(`${t.repo} ${t.id}`);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
return [...out].sort();
|
|
2229
|
+
}
|
|
2230
|
+
var isTaskPlace = (dir) => /[\\/]\.tasks[\\/]/.test(dir);
|
|
2231
|
+
async function traceMatrix(root, changeId, o = {}) {
|
|
2232
|
+
const ctx = await loadPlanContext(root, changeId, { requireApprovedDesign: false }).catch((e) => {
|
|
2233
|
+
throw new TestgenError("not-found", e.message);
|
|
2234
|
+
});
|
|
2235
|
+
const problems = [];
|
|
2236
|
+
const notStarted = /* @__PURE__ */ new Set();
|
|
2237
|
+
const requirements = requirementsOf(ctx);
|
|
2238
|
+
const stories = [];
|
|
2239
|
+
for (const id of await storyIds(ctx.dir))
|
|
2240
|
+
stories.push(await loadStory(ctx, id));
|
|
2241
|
+
const { manifests, problems: mp } = await readManifests(ctx.dir);
|
|
2242
|
+
problems.push(...mp);
|
|
2243
|
+
const manifestOf = new Map(manifests.map((m) => [m.story, m]));
|
|
2244
|
+
const changeRepos = await readChangeRepos(root, changeId);
|
|
2245
|
+
const repos = /* @__PURE__ */ new Set([
|
|
2246
|
+
...changeRepos?.repos.map((r) => r.id) ?? [],
|
|
2247
|
+
...stories.flatMap((s) => s.repos)
|
|
2248
|
+
]);
|
|
2249
|
+
const generatedPaths = new Set(manifests.flatMap((m) => m.files.map((f) => f.path)));
|
|
2250
|
+
const restHops = ctx.hops.filter((h) => h.method && h.path?.startsWith("/"));
|
|
2251
|
+
const scenarioNames = requirements.filter((r) => r.scenario && r.scenario.split(/\s+/).length >= 2);
|
|
2252
|
+
const found = [];
|
|
2253
|
+
const dirs = /* @__PURE__ */ new Map();
|
|
2254
|
+
for (const repo of [...repos].sort()) {
|
|
2255
|
+
const storiesAt = /* @__PURE__ */ new Map();
|
|
2256
|
+
for (const s of stories.filter((x) => x.repos.includes(repo) || x.tasks.some((t) => t.repo === repo))) {
|
|
2257
|
+
const d = repoPlace(ctx, s, repo, changeRepos).dir;
|
|
2258
|
+
if (!d)
|
|
2259
|
+
continue;
|
|
2260
|
+
if (!storiesAt.has(d))
|
|
2261
|
+
storiesAt.set(d, /* @__PURE__ */ new Set());
|
|
2262
|
+
storiesAt.get(d).add(s.id);
|
|
2263
|
+
}
|
|
2264
|
+
const places = [...storiesAt.keys()];
|
|
2265
|
+
const fallback = repoPlace(ctx, { tasks: [] }, repo, changeRepos).dir;
|
|
2266
|
+
if (fallback && !places.includes(fallback))
|
|
2267
|
+
places.push(fallback);
|
|
2268
|
+
if (places[0])
|
|
2269
|
+
dirs.set(repo, fallback && !isTaskPlace(fallback) ? fallback : places[0]);
|
|
2270
|
+
const copies = /* @__PURE__ */ new Map();
|
|
2271
|
+
for (const dir of places) {
|
|
2272
|
+
for (const rel of await walk(dir)) {
|
|
2273
|
+
const text = (await readFile4(join7(dir, rel), "utf8").catch(() => "")).replace(/\r\n/g, "\n");
|
|
2274
|
+
if (text.length > 512 * 1024)
|
|
2275
|
+
continue;
|
|
2276
|
+
const key = `${rel}\0${text}`;
|
|
2277
|
+
const c = copies.get(key);
|
|
2278
|
+
if (c)
|
|
2279
|
+
c.dirs.push(dir);
|
|
2280
|
+
else
|
|
2281
|
+
copies.set(key, { rel, text, dirs: [dir] });
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
for (const { rel, text, dirs: at } of copies.values()) {
|
|
2285
|
+
const refs = {
|
|
2286
|
+
stories: stories.filter((s) => new RegExp(`\\b${escape(s.id)}\\b`).test(text)).map((s) => s.id),
|
|
2287
|
+
scenarios: scenarioNames.filter((r) => text.toLowerCase().includes(r.scenario.toLowerCase())).map((r) => r.key),
|
|
2288
|
+
endpoints: [
|
|
2289
|
+
...new Set(restHops.filter((h) => pathPattern(h.path).test(text)).map((h) => `${h.method} ${h.path}`))
|
|
2290
|
+
]
|
|
2291
|
+
};
|
|
2292
|
+
if (!refs.stories.length && !refs.scenarios.length && !refs.endpoints.length)
|
|
2293
|
+
continue;
|
|
2294
|
+
const own = at.find((d) => refs.stories.some((id) => storiesAt.get(d)?.has(id)));
|
|
2295
|
+
const shared = at.find((d) => !isTaskPlace(d));
|
|
2296
|
+
const dir = own ?? shared ?? at[0];
|
|
2297
|
+
const path = posix2(relative2(root, join7(dir, rel)));
|
|
2298
|
+
if (generatedPaths.has(path))
|
|
2299
|
+
continue;
|
|
2300
|
+
found.push({ kind: "found", path, repo, refs });
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
found.sort((a, b) => a.path.localeCompare(b.path));
|
|
2304
|
+
const capabilities = ctx.records.filter((r) => r.kind === "Capability");
|
|
2305
|
+
const rows = [];
|
|
2306
|
+
const gaps = [];
|
|
2307
|
+
const coveredReqs = /* @__PURE__ */ new Set();
|
|
2308
|
+
for (const s of stories) {
|
|
2309
|
+
const m = manifestOf.get(s.id);
|
|
2310
|
+
const gen = (m?.files ?? []).map((f) => ({
|
|
2311
|
+
kind: "generated",
|
|
2312
|
+
path: f.path,
|
|
2313
|
+
repo: f.repo,
|
|
2314
|
+
cases: f.cases
|
|
2315
|
+
}));
|
|
2316
|
+
const records = [
|
|
2317
|
+
.../* @__PURE__ */ new Set([
|
|
2318
|
+
...s.hops.flatMap((h) => {
|
|
2319
|
+
const ep = endpointFor(ctx, h);
|
|
2320
|
+
const i = /^if-/.test(h.id) && ctx.get("Interface", h.id) ? [h.id] : [];
|
|
2321
|
+
return [
|
|
2322
|
+
...i,
|
|
2323
|
+
...ep ? [ep.id] : [],
|
|
2324
|
+
...!i.length && !ep ? [`new: ${h.caller} \u2192 ${h.callee} ${h.operation.trim()}`] : []
|
|
2325
|
+
];
|
|
2326
|
+
}),
|
|
2327
|
+
...s.data.map((d) => ctx.get("Table", d.tableId) ? d.tableId : `new: table ${d.tableId}`)
|
|
2328
|
+
])
|
|
2329
|
+
];
|
|
2330
|
+
const ids = new Set(records);
|
|
2331
|
+
const features = capabilities.filter((c) => [...c.entryPoints, ...c.records].some((r) => ids.has(r))).map((c) => c.id).sort();
|
|
2332
|
+
const code = await codeOf(ctx, s, (r) => dirs.get(r), o.git, problems, notStarted);
|
|
2333
|
+
const storyFound = found.filter((f) => f.refs.stories.includes(s.id));
|
|
2334
|
+
for (const c of s.criteria) {
|
|
2335
|
+
const req = c.trace ? requirements.find((r) => r.key === `${c.trace.file}:${c.trace.line}`) : void 0;
|
|
2336
|
+
if (req)
|
|
2337
|
+
coveredReqs.add(req.key);
|
|
2338
|
+
const caseId = `acceptance.c${c.n}`;
|
|
2339
|
+
const linked = new Set((m?.cases ?? []).filter((x) => x.id === caseId || x.type !== "acceptance" && x.criterion === c.n).map((x) => x.id));
|
|
2340
|
+
const tests = [
|
|
2341
|
+
...gen.filter((g) => g.cases.some((x) => linked.has(x))),
|
|
2342
|
+
...found.filter((f) => req && f.refs.scenarios.includes(req.key) || f.refs.stories.includes(s.id))
|
|
2343
|
+
];
|
|
2344
|
+
rows.push({
|
|
2345
|
+
...req ? { requirement: req } : {},
|
|
2346
|
+
story: s.id,
|
|
2347
|
+
criterion: { n: c.n, text: c.text },
|
|
2348
|
+
tests,
|
|
2349
|
+
code,
|
|
2350
|
+
records,
|
|
2351
|
+
features
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
if (s.tracesTo)
|
|
2355
|
+
for (const r of requirements.filter((x) => x.key === s.tracesTo || `${x.file}:${x.line}` === s.tracesTo))
|
|
2356
|
+
coveredReqs.add(r.key);
|
|
2357
|
+
const other = gen.filter((g) => !g.cases.every((x) => x.startsWith("acceptance.")) || !s.criteria.length);
|
|
2358
|
+
if (other.length || !s.criteria.length)
|
|
2359
|
+
rows.push({ story: s.id, tests: other, code, records, features });
|
|
2360
|
+
const hasTest = gen.length > 0 || storyFound.length > 0 || found.some((f) => f.refs.scenarios.some((k) => s.criteria.some((c) => c.trace && `${c.trace.file}:${c.trace.line}` === k)));
|
|
2361
|
+
if (!hasTest)
|
|
2362
|
+
gaps.push({
|
|
2363
|
+
kind: "story-without-test",
|
|
2364
|
+
subject: s.id,
|
|
2365
|
+
summary: `Story ${s.id} has no test: none generated and none in its repos names it.`,
|
|
2366
|
+
artifact: s.path,
|
|
2367
|
+
locator: { heading: s.title },
|
|
2368
|
+
evidence: [{ source: "artifact", path: s.path, line: 1 }]
|
|
2369
|
+
});
|
|
2370
|
+
const traced = s.criteria.some((c) => c.trace) || !!s.tracesTo;
|
|
2371
|
+
if (gen.length && !traced)
|
|
2372
|
+
gaps.push({
|
|
2373
|
+
kind: "test-without-requirement",
|
|
2374
|
+
subject: s.id,
|
|
2375
|
+
summary: `The tests of ${s.id} trace to no requirement: its criteria match no spec scenario.`,
|
|
2376
|
+
artifact: s.path,
|
|
2377
|
+
locator: { heading: s.title },
|
|
2378
|
+
evidence: [{ source: "artifact", path: s.path, line: 1 }]
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
for (const r of requirements)
|
|
2382
|
+
if (!coveredReqs.has(r.key)) {
|
|
2383
|
+
rows.push({ requirement: r, tests: [], code: [], records: [], features: [] });
|
|
2384
|
+
gaps.push({
|
|
2385
|
+
kind: "requirement-without-story",
|
|
2386
|
+
subject: r.key,
|
|
2387
|
+
summary: `${r.scenario ? `Scenario "${r.scenario}" of requirement "${r.requirement}"` : `Requirement "${r.requirement}"`} has no story.`,
|
|
2388
|
+
artifact: r.file,
|
|
2389
|
+
locator: { line: r.line },
|
|
2390
|
+
evidence: [{ source: "artifact", path: r.file, line: r.line }]
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
for (const f of found)
|
|
2394
|
+
if (!f.refs.stories.length && !f.refs.scenarios.length)
|
|
2395
|
+
gaps.push({
|
|
2396
|
+
kind: "test-without-requirement",
|
|
2397
|
+
subject: f.path,
|
|
2398
|
+
summary: `${f.path.split("/").pop()} in ${f.repo} tests ${f.refs.endpoints.join(", ")} but names no story or spec scenario.`,
|
|
2399
|
+
artifact: "tests",
|
|
2400
|
+
locator: { heading: f.path },
|
|
2401
|
+
evidence: [{ source: "code", repo: f.repo, path: f.path }]
|
|
2402
|
+
});
|
|
2403
|
+
for (const h of restHops.filter((x) => /^planned-(new|modify)$/.test(x.status))) {
|
|
2404
|
+
const p = normalizePath(h.path);
|
|
2405
|
+
const generated = manifests.some((m) => m.cases.some((c) => (c.type === "api" || c.type === "contract") && c.method === h.method && c.path && normalizePath(c.path) === p));
|
|
2406
|
+
const inRepo = found.some((f) => f.refs.endpoints.includes(`${h.method} ${h.path}`));
|
|
2407
|
+
if (generated || inRepo)
|
|
2408
|
+
continue;
|
|
2409
|
+
gaps.push({
|
|
2410
|
+
kind: "endpoint-without-test",
|
|
2411
|
+
subject: `${h.caller}-${h.callee}-${h.method}-${h.path}`,
|
|
2412
|
+
summary: `${h.method} ${h.path} (${h.caller} to ${h.callee}, ${h.status}) has no API or contract test.`,
|
|
2413
|
+
artifact: "interfaces.md",
|
|
2414
|
+
locator: { line: h.line },
|
|
2415
|
+
evidence: [{ source: "artifact", path: "interfaces.md", line: h.line }]
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
if (notStarted.size)
|
|
2419
|
+
problems.push(`Not started yet (no task branch): ${[...notStarted].sort().join(", ")}.`);
|
|
2420
|
+
return {
|
|
2421
|
+
change: ctx.change.id,
|
|
2422
|
+
requirements,
|
|
2423
|
+
stories: stories.map((s) => s.id),
|
|
2424
|
+
rows,
|
|
2425
|
+
gaps: gaps.sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject)),
|
|
2426
|
+
found,
|
|
2427
|
+
problems
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
var RULES_FILE = fileURLToPath(new URL("../rules/trace.yaml", __atticus_import_meta_url));
|
|
2431
|
+
async function loadTraceRules() {
|
|
2432
|
+
const pack = ChecklistPack.parse((0, import_yaml3.parse)(await readFile4(RULES_FILE, "utf8")));
|
|
2433
|
+
return new Map(pack.rules.map((r) => [r.id, r]));
|
|
2434
|
+
}
|
|
2435
|
+
var FIX = {
|
|
2436
|
+
"requirement-without-story": "Plan a story for the scenario (atticus plan stories), or take the scenario out of the spec.",
|
|
2437
|
+
"story-without-test": "Generate its tests (atticus tests generate) or write one that names the story or its scenarios.",
|
|
2438
|
+
"endpoint-without-test": "Link the interface to a story and generate its API and contract tests, or write a test that calls it.",
|
|
2439
|
+
"test-without-requirement": "Trace the story to a spec scenario, add the requirement to the spec, or remove the test."
|
|
2440
|
+
};
|
|
2441
|
+
async function traceFindings(m, now) {
|
|
2442
|
+
const rules = await loadTraceRules();
|
|
2443
|
+
return m.gaps.map((g) => {
|
|
2444
|
+
const rule = rules.get(`trace.${g.kind}`);
|
|
2445
|
+
return Finding.parse({
|
|
2446
|
+
schemaVersion: 1,
|
|
2447
|
+
id: `${g.kind}-${slug(g.subject)}`.slice(0, 120).replace(/-+$/, ""),
|
|
2448
|
+
kind: "issue",
|
|
2449
|
+
change: m.change,
|
|
2450
|
+
gate: TRACE_GATE,
|
|
2451
|
+
round: 1,
|
|
2452
|
+
rule: rule.id,
|
|
2453
|
+
layer: rule.layer,
|
|
2454
|
+
lens: rule.lens,
|
|
2455
|
+
severity: rule.severity,
|
|
2456
|
+
artifact: g.artifact,
|
|
2457
|
+
locator: g.locator,
|
|
2458
|
+
summary: g.summary.length <= 200 ? g.summary : `${g.summary.slice(0, 197)}...`,
|
|
2459
|
+
why: rule.description ?? rule.title,
|
|
2460
|
+
fix: { text: FIX[g.kind] },
|
|
2461
|
+
evidence: g.evidence,
|
|
2462
|
+
waivable: false,
|
|
2463
|
+
state: "open",
|
|
2464
|
+
createdAt: now
|
|
2465
|
+
});
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
async function writeTraceFindings(root, m, now) {
|
|
2469
|
+
return writeGateRound(root, m.change, TRACE_GATE, await traceFindings(m, now), now);
|
|
2470
|
+
}
|
|
2471
|
+
function cells(r) {
|
|
2472
|
+
return [
|
|
2473
|
+
r.requirement ? `${r.requirement.requirement} (${r.requirement.file}:${r.requirement.line})` : "",
|
|
2474
|
+
r.requirement?.scenario ?? "",
|
|
2475
|
+
r.story ?? "",
|
|
2476
|
+
r.criterion ? `${r.criterion.n}. ${r.criterion.text}` : r.story ? "(story)" : "",
|
|
2477
|
+
r.tests.map((t) => `${t.path}${t.kind === "found" ? " (found)" : ""}`).join("; "),
|
|
2478
|
+
r.code.join("; "),
|
|
2479
|
+
r.records.join("; "),
|
|
2480
|
+
r.features.join("; ")
|
|
2481
|
+
];
|
|
2482
|
+
}
|
|
2483
|
+
var HEAD = [
|
|
2484
|
+
"Requirement",
|
|
2485
|
+
"Scenario",
|
|
2486
|
+
"Story",
|
|
2487
|
+
"Criterion",
|
|
2488
|
+
"Tests",
|
|
2489
|
+
"Code",
|
|
2490
|
+
"Records",
|
|
2491
|
+
"Features"
|
|
2492
|
+
];
|
|
2493
|
+
function matrixMarkdown(m) {
|
|
2494
|
+
const md = (s) => s.replace(/\|/g, "\\|") || " ";
|
|
2495
|
+
const lines = [
|
|
2496
|
+
`# Traceability: ${m.change}`,
|
|
2497
|
+
"",
|
|
2498
|
+
`| ${HEAD.join(" | ")} |`,
|
|
2499
|
+
`| ${HEAD.map(() => "---").join(" | ")} |`,
|
|
2500
|
+
...m.rows.map((r) => `| ${cells(r).map(md).join(" | ")} |`),
|
|
2501
|
+
"",
|
|
2502
|
+
m.gaps.length ? "## Gaps" : "No gaps.",
|
|
2503
|
+
...m.gaps.length ? ["", ...m.gaps.map((g) => `- ${g.kind}: ${g.summary}`)] : []
|
|
2504
|
+
];
|
|
2505
|
+
return `${lines.join("\n")}
|
|
2506
|
+
`;
|
|
2507
|
+
}
|
|
2508
|
+
function matrixCsv(m) {
|
|
2509
|
+
const q = (s) => /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
2510
|
+
return `${[HEAD.map((h) => h.toLowerCase()), ...m.rows.map(cells)].map((r) => r.map(q).join(",")).join("\n")}
|
|
2511
|
+
`;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
export {
|
|
2515
|
+
slug,
|
|
2516
|
+
generateTests,
|
|
2517
|
+
writeGateRound,
|
|
2518
|
+
traceMatrix,
|
|
2519
|
+
writeTraceFindings,
|
|
2520
|
+
matrixMarkdown,
|
|
2521
|
+
matrixCsv
|
|
2522
|
+
};
|