diff --git a/.hypatia-ignore b/.hypatia-ignore new file mode 100644 index 00000000..7068d2af --- /dev/null +++ b/.hypatia-ignore @@ -0,0 +1,275 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# .hypatia-ignore — repo-scoped exemptions for the estate governance +# anti-pattern scanner. Format (per the bundle's `is_exempt`): each +# non-comment line is `${rule}:${path}`, matched as fixed-string +# whole-line equality (`grep -qxF`). Comment (#) and blank lines never +# match the rule:path shape and are tolerated. +# +# ─── ReScript → AffineScript migration baseline (2026-06-02) ────────── +# +# ReScript (.res) is banned-in-new-code (CLAUDE.md, 2026-04-30 — migrate +# to AffineScript). This file BASELINES the repo's PRE-EXISTING ReScript +# surface so the `cicd_rules/banned_language_file` gate is green while +# the migration proceeds. It is grandfathered debt, NOT an endorsement. +# +# IMPORTANT: NEW .res files are deliberately NOT listed here and remain +# flagged. To add ReScript debt you must edit this snapshot — keep that +# friction. +# +# Evidence / tracking: an empirical run of AffineScript's own +# `res-to-affine --partial` converter over the 75 first-party .res files +# produced 0 compilable ports (397 TODO holes), confirming the port is a +# human effort, not a mechanical transpile. The migration is tracked at +# affinescript#488 and seeded by the `claude/affinescript-migration-wip` +# branch. The vendored gui/lib/rescript-webapi/ library should be +# replaced by AffineScript's native affinescript-dom, not hand-ported. +# +cicd_rules/banned_language_file:forge-ops/src/commands/ForgeOpsCmd.res +cicd_rules/banned_language_file:forge-ops/src/components/ForgeOps.res +cicd_rules/banned_language_file:forge-ops/src/components/ForgeOpsDiffViewer.res +cicd_rules/banned_language_file:forge-ops/src/components/ForgeOpsMirrorPanel.res +cicd_rules/banned_language_file:forge-ops/src/components/ForgeOpsProtectionEditor.res +cicd_rules/banned_language_file:forge-ops/src/components/ForgeOpsRepoList.res +cicd_rules/banned_language_file:forge-ops/src/components/ForgeOpsSettingsGrid.res +cicd_rules/banned_language_file:forge-ops/src/core/ForgeOpsCatalog.res +cicd_rules/banned_language_file:forge-ops/src/core/ForgeOpsEngine.res +cicd_rules/banned_language_file:forge-ops/src/core/ForgeOpsPolicy.res +cicd_rules/banned_language_file:forge-ops/src/model/ForgeOpsModel.res +cicd_rules/banned_language_file:forge-ops/src/modules/ForgeOpsModule.res +cicd_rules/banned_language_file:forge-ops/src/modules/RuntimeBridge.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_animationframe.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_app.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_cmd.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_debug.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_ex.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_html.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_html_cmds.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_http.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_json.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_mouse.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_navigation.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_promise.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_random.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_result.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_sub.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_svg.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_svg_attributes.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_svg_events.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_task.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/tea_time.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/vdom.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/web.res +cicd_rules/banned_language_file:gui/lib/rescript-tea/src/web_xmlhttprequest.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Canvas/Webapi__Canvas__Canvas2d.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Canvas/Webapi__Canvas__WebGl.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__AnimationEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Attr.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__BeforeUnloadEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__CdataSection.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__CharacterData.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ChildNode.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ClipboardEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__CloseEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Comment.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__CompositionEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__CssStyleDeclaration.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__CustomEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DataTransfer.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DataTransferItem.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DataTransferItemList.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Document.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DocumentFragment.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DocumentType.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DomImplementation.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DomRect.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DomStringMap.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DomTokenList.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__DragEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Element.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ErrorEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Event.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__EventTarget.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__FocusEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__GlobalEventHandlers.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__History.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlButtonElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlCollection.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlDocument.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlFieldSetElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlFormControlsCollection.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlFormElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlImageElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlInputElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlObjectElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlOptGroupElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlOptionElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlOptionsCollection.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlOutputElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlSelectElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__HtmlTextAreaElement.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__IdbVersionChangeEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Image.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__InputEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__KeyboardEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Location.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__MouseEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__MutationObserver.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__MutationRecord.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__NamedNodeMap.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Node.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__NodeFilter.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__NodeIterator.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__NodeList.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__NonDocumentTypeChildNode.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__NonElementParentNode.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__PageTransitionEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ParentNode.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__PointerEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__PopStateEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ProcessingInstruction.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ProgressEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__PromiseRejectionEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__RadioNodeList.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Range.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__RectList.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__RelatedEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Selection.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ShadowRoot.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Slotable.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__StaticRange.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__StorageEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__SvgZoomEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Text.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__TimeEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__TouchEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__TrackEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__TransitionEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__TreeWalker.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Types.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__UiEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__ValidityState.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__WebGlContextEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__WheelEvent.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Dom/Webapi__Dom__Window.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Fetch/Webapi__Fetch__AbortController.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/ResizeObserver/Webapi__ResizeObserver__ResizeObserverEntry.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Base64.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Blob.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Canvas.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Dom.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Fetch.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__File.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__FileList.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__FormData.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__IntersectionObserver.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Iterator.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Navigator.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Performance.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__ReadableStream.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__ResizeObserver.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__Url.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__WebSocket.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__WindowOrWorkerGlobalScope.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__WorkerGlobalScope.res +cicd_rules/banned_language_file:gui/lib/rescript-webapi/src/Webapi/Webapi__WorkerNavigator.res +cicd_rules/banned_language_file:gui/src/App.res +cicd_rules/banned_language_file:gui/src/Graph.res +cicd_rules/banned_language_file:gui/src/Model.res +cicd_rules/banned_language_file:gui/src/Msg.res +cicd_rules/banned_language_file:gui/src/RuntimeBridge.res +cicd_rules/banned_language_file:gui/src/Update.res +cicd_rules/banned_language_file:gui/src/View.res +cicd_rules/banned_language_file:gui/src/bindings/Backend.res +cicd_rules/banned_language_file:gui/src/bindings/D3.res +cicd_rules/banned_language_file:gui/src/bindings/D3_Drag.res +cicd_rules/banned_language_file:gui/src/bindings/D3_Force.res +cicd_rules/banned_language_file:gui/src/bindings/D3_Selection.res +cicd_rules/banned_language_file:gui/src/bindings/D3_Zoom.res +cicd_rules/banned_language_file:gui/src/bindings/Fetch.res +cicd_rules/banned_language_file:gui/src/modules/PanllBridge.res +cicd_rules/banned_language_file:gui/src/modules/ReposystemModule.res +cicd_rules/banned_language_file:recon-silly-ation/src/ArangoClient.res +cicd_rules/banned_language_file:recon-silly-ation/src/CCCPCompliance.res +cicd_rules/banned_language_file:recon-silly-ation/src/CLI.res +cicd_rules/banned_language_file:recon-silly-ation/src/ConflictResolver.res +cicd_rules/banned_language_file:recon-silly-ation/src/Deduplicator.res +cicd_rules/banned_language_file:recon-silly-ation/src/EnforcementBot.res +cicd_rules/banned_language_file:recon-silly-ation/src/GraphVisualizer.res +cicd_rules/banned_language_file:recon-silly-ation/src/HaskellBridge.res +cicd_rules/banned_language_file:recon-silly-ation/src/LLMIntegration.res +cicd_rules/banned_language_file:recon-silly-ation/src/LogicEngine.res +cicd_rules/banned_language_file:recon-silly-ation/src/PackShipper.res +cicd_rules/banned_language_file:recon-silly-ation/src/Pipeline.res +cicd_rules/banned_language_file:recon-silly-ation/src/Protocol.res +cicd_rules/banned_language_file:recon-silly-ation/src/ReconForth.res +cicd_rules/banned_language_file:recon-silly-ation/src/SecurityScheme.res +cicd_rules/banned_language_file:recon-silly-ation/src/Types.res +cicd_rules/banned_language_file:recon-silly-ation/tests/ArangoClientTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/Benchmarks.res +cicd_rules/banned_language_file:recon-silly-ation/tests/CCCPComplianceTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/ConflictResolverTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/DeduplicatorTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/EnforcementBotTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/GraphVisualizerTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/IntegrationTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/LogicEngineTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/PackShipperTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/PipelineTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/PropertyTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/ProtocolTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/SecuritySchemeTest.res +cicd_rules/banned_language_file:recon-silly-ation/tests/TestRunner.res +cicd_rules/banned_language_file:recon-silly-ation/tests/TypesTest.res +cicd_rules/banned_language_file:tools/dispatcher/src/CLI.res +cicd_rules/banned_language_file:tools/dispatcher/src/cli/Main.res +cicd_rules/banned_language_file:tools/dispatcher/src/engine/Executor.res +cicd_rules/banned_language_file:tools/dispatcher/src/executors/DocRenderer.res +cicd_rules/banned_language_file:tools/dispatcher/src/executors/IntegrationOps.res +cicd_rules/banned_language_file:tools/dispatcher/src/executors/SeoUpdater.res +cicd_rules/banned_language_file:tools/dispatcher/src/types/Audit.res +cicd_rules/banned_language_file:tools/dispatcher/src/types/BotDispatch.res +cicd_rules/banned_language_file:tools/dispatcher/src/types/Plan.res +cicd_rules/banned_language_file:tools/dispatcher/src/validators/IntegrationValidator.res +cicd_rules/banned_language_file:tools/hud/frontend/src/Api.res +cicd_rules/banned_language_file:tools/hud/frontend/src/App.res +cicd_rules/banned_language_file:tools/hud/frontend/src/SEOWidget.res +cicd_rules/banned_language_file:tools/hud/frontend/src/Tea.res +# +# ─── ATS2 — ALLOWED language; central rule over-reaches (2026-06-02) ── +# +# ATS2 (.dats/.sats) is NOT banned (repo-owner directive, 2026-06-02). +# scaffoldia/repo-batcher's ATS2 core is intentional, fiction-reviewed, +# verified ATS2 (STATE.a2ml; issue #56 / PR #59). The estate's central +# `cicd_rules/banned_language_file` enforce list over-reaches by listing +# ATS2 alongside genuinely-banned languages ("use Idris2 or Rust/SPARK"). +# These entries stop reposystem CI flagging allowed ATS2 *pending the +# central rule dropping ATS2* (tracking issue filed separately; carry +# through hypatia + gitbot-fleet). THIS IS NOT MIGRATION DEBT. +# +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/ffi/c_exports.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/ffi/cli_root.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/effects.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/effects.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/file_replace.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/file_replace.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/git_sync.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/git_sync.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/github_settings.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/github_settings.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/license_update.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/license_update.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/spdx_audit.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/spdx_audit.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/types.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/types.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/workflow_update.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/operations/workflow_update.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/utils/string_utils.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/utils/string_utils.sats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/validation/spdx.dats +cicd_rules/banned_language_file:scaffoldia/repo-batcher/src/ats2/validation/spdx.sats diff --git a/CHANGELOG.md b/CHANGELOG.md index dec0d948..fd72ac47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation +- docs: verify standalone-vs-local status of the 7 Phase-2 sub-projects + flag submodule-wiring drift - docs: record tech-debt audit findings (2026-05-26) (#74) - docs(repo-batcher): correct status to verified L1–L8 build-out (post #59) (#61) - docs(repo-batcher): correct to verified-accurate state (no V ever; Zig stub unimplemented) (#58) diff --git a/ECOSYSTEM-STATUS.adoc b/ECOSYSTEM-STATUS.adoc index c485da6a..886755ce 100644 --- a/ECOSYSTEM-STATUS.adoc +++ b/ECOSYSTEM-STATUS.adoc @@ -11,6 +11,12 @@ image:https://img.shields.io/badge/Philosophy-Palimpsest-indigo.svg[Palimpsest,l [.lead] Current status of all tools in the reposystem-centered ecosystem. Updated: 2026-02-07 +NOTE: The standalone-vs-local status of `scaffoldia` and +`stateful-artefacts-for-gitforges` was independently verified on 2026-06-02 — +both are *local-only* (no standalone forge repo). See +`docs/PHASE-2-STANDALONE-VS-LOCAL-VERIFICATION-2026-06-02.adoc`. Other rows +retain their 2026-02-07 values and have not been re-verified. + == Quick Status Overview [cols="2,1,1,2", options="header"] @@ -48,9 +54,9 @@ Current status of all tools in the reposystem-centered ecosystem. Updated: 2026- | Not Started | **scaffoldia** -| ❓ Unknown -| ? -| Status Unknown +| 🗂️ Local-only +| n/a +| Verified 2026-06-02 (no standalone repo) | **.git-private-farm** | ❓ Unknown @@ -58,9 +64,9 @@ Current status of all tools in the reposystem-centered ecosystem. Updated: 2026- | Status Unknown | **stateful-artefacts-for-gitforges** -| ❓ Unknown -| ? -| Status Unknown +| 🗂️ Local-only +| n/a +| Verified 2026-06-02 (no standalone repo) |=== _^*^ git-hud STATE.scm shows 0% but significant work completed (see below)_ @@ -346,11 +352,13 @@ git-seo batch repos.txt # Bulk analysis * Could be integrated into reposystem or git-dispatcher === scaffoldia -**Repository:** Likely `github.com/hyperpolymath/scaffoldia` +**Repository:** None — *local-only* (verified 2026-06-02). The self-declared +`github.com/hyperpolymath/scaffoldia` does not exist on the forge; the project +lives only as a vendored tree (197 files) inside `reposystem`. **Purpose:** Scaffolding and templating for new repositories. -**Status:** ❓ **Status Unknown** +**Status:** 🗂️ **Local-only** (see `docs/PHASE-2-STANDALONE-VS-LOCAL-VERIFICATION-2026-06-02.adoc`) **Expected Integration:** @@ -371,11 +379,15 @@ git-seo batch repos.txt # Bulk analysis * Integrate with reposystem for private repo tracking === stateful-artefacts-for-gitforges -**Repository:** Likely `github.com/hyperpolymath/stateful-artefacts-for-gitforges` +**Repository:** None — *local-only* (verified 2026-06-02). Neither +`github.com/hyperpolymath/stateful-artefacts-for-gitforges` nor the shorter +`github.com/hyperpolymath/stateful-artefacts` exists on the forge; the project +lives only as a vendored tree (97 files, directory `stateful-artefacts/`) +inside `reposystem`. **Purpose:** Forge artifact hydration pipeline. -**Status:** ❓ **Status Unknown** +**Status:** 🗂️ **Local-only** (see `docs/PHASE-2-STANDALONE-VS-LOCAL-VERIFICATION-2026-06-02.adoc`) **Expected Integration:** diff --git a/docs/PHASE-2-STANDALONE-VS-LOCAL-VERIFICATION-2026-06-02.adoc b/docs/PHASE-2-STANDALONE-VS-LOCAL-VERIFICATION-2026-06-02.adoc new file mode 100644 index 00000000..0a3a294c --- /dev/null +++ b/docs/PHASE-2-STANDALONE-VS-LOCAL-VERIFICATION-2026-06-02.adoc @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Reposystem Phase 2 — Standalone-vs-Local Verification (7 sub-projects) +:toc: left +:toclevels: 3 +:icons: font + +[.lead] +Establishes, against *ground truth*, whether each of the seven Phase-2 +sub-projects embedded in `reposystem` is a **standalone** repository (its own +forge repo, properly referenced as a submodule) or **local-only** (vendored as +a plain directory tree inside `reposystem`). This resolves the "Status +Unknown" / "Likely `github.com/...`" hedges in `ECOSYSTEM-STATUS.adoc`. + +[cols="1,3",options="header"] +|=== +| Field | Value +| Verification date | 2026-06-02 +| Scope | `reposystem` `HEAD` working tree + GitHub Search API (token-scoped) +| Method | Mechanical (`git ls-tree`, `.gitmodules`, metadata grep, forge search) — no claim is asserted without the command that produced it (see <>). +|=== + +NOTE: This document follows the estate anti-fiction rule (cf. the CRG-D +demotion for fabricated paths in `docs/governance/CRG-AUDIT-2026-04-18.adoc`): +every cell below is reproducible from the commands in <>. +Facts that *could not* be verified from this environment are labelled as such, +not guessed. + +[#scope] +== Scope: what "the 7 sub-projects" means + +The seven are defined by a ground-truth-derivable property: they are the only +embedded directories carrying *full* standalone-Hyperpolymath project +scaffolding — i.e. *both* `.machine_readable/` *and* `.github/`. + +[source,sh] +---- +for d in */; do d="${d%/}" + [ -d "$d/.machine_readable" ] && [ -d "$d/.github" ] && echo "$d" +done +---- + +yields exactly: `bitfuckit`, `contractiles`, `gui`, `recon-silly-ation`, +`rpa-elysium`, `scaffoldia`, `stateful-artefacts`. + +Deliberately excluded: `git-morph` (has `.machine_readable/` but no +`.github/`); `git-seo`, `forge-ops`, `tui`, `vexometer` (neither). The +`.gitmodules`-declared but *content-absent* entries `total-recall`, +`total-upgrade`, `git-scripts`, `git-reticulator` are not in this set but are +covered in <> because they bear directly on the standalone-vs-local +picture. + +[#method] +== Method (how each fact was derived) + +[cols="1,3",options="header"] +|=== +| Fact | Source of truth +| Storage mode | `git ls-tree HEAD` — mode `160000` = gitlink (submodule pointer); `040000` = tree (local directory). +| Submodule declaration | `.gitmodules`. +| Self-declared remote | grep of each sub-project's own metadata (`.machine_readable/`, README, configs) for `(github\|gitlab\|bitbucket).com/...`. +| Local footprint | `git ls-files \| wc -l`. +| Forge existence | GitHub Search API via `search_repositories` (`user:hyperpolymath in:name `), executed 2026-06-02. +|=== + +.Caveats on the forge-existence check (read before trusting a "no") +[WARNING] +==== +* The GitHub result reflects only what is visible in the *search index to the + token used*. A *private* repo not shared with that token would read as + absent. A repo created in the last few minutes may not yet be indexed. +* Index freshness is corroborated: `rpa-elysium` returned `updated_at` + 2026-06-01, so the index is current to within ~1 day of this verification. +* **GitLab cannot be queried from this environment.** For the two + GitLab-declared submodules (`total-recall`, `total-upgrade`) forge existence + is marked *unverified*, not "no". +==== + +== Verdict — the 7 Phase-2 sub-projects + +All seven are stored as **trees** (local directories), each with substantial +real content (not stubs). Only `rpa-elysium` has a confirmed standalone repo — +and its submodule wiring is broken. + +[cols="2,1,3,1,1,2,2",options="header"] +|=== +| Sub-project | Files | Self-declared remote | In `.gitmodules`? | Stored as | Standalone repo (GitHub, 2026-06-02) | Verdict + +| `rpa-elysium` +| 171 +| `github.com/hyperpolymath/rpa-elysium` + GitLab +| *yes* (github) +| tree (local) +| **YES** — Rust, created 2025-12-02, updated 2026-06-01, 4★ +| *STANDALONE* — but *wiring broken*: declared a submodule yet committed as a local tree (see <>) + +| `bitfuckit` +| 158 +| `github.com/hyperpolymath/bitfuckit` +| no +| tree (local) +| not found +| *LOCAL-ONLY* + +| `contractiles` +| 236 +| `github.com/hyperpolymath/contractiles` +| no +| tree (local) +| not found +| *LOCAL-ONLY* + +| `gui` +| 250 +| _(none; references `reposystem` only)_ +| no +| tree (local) +| not found (`reposystem-gui` also absent) +| *LOCAL-ONLY* — reposystem's own web-GUI component; `EXPLAINME.adoc` calls it a "web GUI sub-project" + +| `recon-silly-ation` +| 114 +| `github.com/hyperpolymath/recon-silly-ation` +| no +| tree (local) +| not found +| *LOCAL-ONLY* + +| `scaffoldia` +| 197 +| `github.com/hyperpolymath/scaffoldia` +| no +| tree (local) +| not found +| *LOCAL-ONLY* + +| `stateful-artefacts` +| 97 +| `github.com/hyperpolymath/stateful-artefacts` +| no +| tree (local) +| not found (alt name `stateful-artefacts-for-gitforges` also absent) +| *LOCAL-ONLY* +|=== + +=== rpa-elysium — STANDALONE, submodule wiring BROKEN + +`rpa-elysium` is the one genuine standalone of the seven: the GitHub repo +exists and is active. But `.gitmodules` declares it a submodule +(`git@github.com:hyperpolymath/rpa-elysium.git`) while `git ls-tree HEAD` +records it as an ordinary `040000 tree` of 171 vendored files — not a `160000` +gitlink. The declaration and the committed object therefore disagree: a +`git submodule` operation cannot reconcile them. This is the textbook +"committed a submodule's working copy as a plain directory" defect. + +=== bitfuckit, contractiles, recon-silly-ation, scaffoldia, stateful-artefacts — LOCAL-ONLY + +Each self-declares a `github.com/hyperpolymath/` home, but no such repo +is present in the GitHub search index visible to this token (2026-06-02). +Treat the self-declared remotes as *aspirational*, not existing. Each is a +full local tree (97–236 files), so the content lives only inside `reposystem` +today. (`stateful-artefacts` is additionally subject to a *name* ambiguity: +`ECOSYSTEM-STATUS.adoc` calls it `stateful-artefacts-for-gitforges`; both names +return zero on the forge.) + +=== gui — LOCAL-ONLY by design + +`gui` declares no remote of its own and only references `reposystem`. It is the +project's own ReScript+Deno web front-end, not a separable product. No +standalone repo is expected and none exists. + +[#drift] +== Adjacent finding: submodule-wiring drift across the embedded set + +The standalone-vs-local audit surfaced broader gitlink drift. `git ls-tree HEAD` +reports exactly four `160000` gitlinks; `.gitmodules` declares five submodules. +They do not line up. + +[cols="2,1,2,2,3",options="header"] +|=== +| Path | In `.gitmodules`? | Stored as | Forge repo exists? | Status + +| `git-scripts` +| yes (github) +| gitlink `c155498` +| YES (GitHub) +| ✅ consistent submodule + +| `git-reticulator` +| yes (github) +| gitlink `bef65e4` +| YES (GitHub) +| ✅ consistent submodule + +| `rpa-elysium` +| yes (github) +| *tree* (local, 171 files) +| YES (GitHub) +| ⚠️ declared submodule, committed as a tree → broken gitlink + +| `total-upgrade` +| yes (gitlab) +| *tree* (local, empty stub `84d1361`) +| _unverified (GitLab)_ +| ⚠️ declared submodule, committed as an empty RSR-scaffold tree + +| `total-recall` +| yes (gitlab) +| *tree* (local, empty stub `84d1361`) +| _unverified (GitLab)_ +| ⚠️ declared submodule, committed as an empty stub *byte-identical* to `total-upgrade` (same tree hash) + +| `avatar-fabrication-facility` +| *no* +| gitlink `f8207cd` +| *no* (GitHub) +| ⛔ orphan gitlink — no `.gitmodules` URL **and** no forge repo; `git submodule` cannot resolve it + +| `claim-forge` +| *no* +| gitlink `905bd22` +| *no* (GitHub) +| ⛔ orphan gitlink — no `.gitmodules` URL **and** no forge repo +|=== + +Notes: + +* `total-recall` and `total-upgrade` share tree hash + `84d1361d98374e10d45b5fb5692044d98bc6144e` — an empty RSR scaffold + (`README.adoc` = single newline, `STATE.adoc` empty). One is a stale copy of + the other. +* The orphan gitlinks (`avatar-fabrication-facility`, `claim-forge`) are the + concrete cause of the "`git submodule update --recursive` aborts on a + URL-less entry" hazard noted in `ESTATE-ORGANIZATION.adoc` (which attributes + it to `.git-private-farm`). In `reposystem` itself, `.git-private-farm` is + *not* a top-level gitlink — it only appears vendored under + `bitfuckit/scripts/git-private-farm/`. + +== Documentation corrected by this verification + +`ECOSYSTEM-STATUS.adoc` carried "❓ Status Unknown" / "Repository: Likely +`github.com/...`" for `scaffoldia` and `stateful-artefacts-for-gitforges`. +Both are now verified *local-only* (no standalone forge repo as of +2026-06-02); those two rows and detail sections are updated to cite this +report. `.git-private-farm` is left "Unknown" — it was *not* verified here. + +[#remediation] +== Recommended remediation (NOT executed — gated by estate policy) + +Per `ESTATE-ORGANIZATION.adoc`, flat clones are the source of truth and +`reposystem` "owns no code history; pins nothing". Reconciliation is therefore +left to the estate's deliberate, separately-gated tooling rather than performed +unilaterally here: + +. *Decide each local-only project's intended home.* If `bitfuckit`, + `contractiles`, `recon-silly-ation`, `scaffoldia`, `stateful-artefacts` are + meant to be standalone, create the forge repos and convert the trees to + submodules; otherwise drop their aspirational self-declared remotes. `gui` + stays local by design. +. *Fix `rpa-elysium`* — the repo exists; either de-vendor the tree and add it + as a real gitlink, or remove it from `.gitmodules` if it is intentionally + vendored. +. *Resolve `total-recall` / `total-upgrade`* — both are empty stubs declared as + GitLab submodules; confirm the GitLab repos, then wire one gitlink each (and + delete the duplicate). +. *Prune the orphan gitlinks* `avatar-fabrication-facility` and `claim-forge` + (no URL, no forge repo) using the *destructive*, explicitly-gated path: ++ +[source,sh] +---- +just aggregator-drift # classify (A2ML) +sh scripts/fix-stale-submodule-urls.sh # dry-run classify +sh scripts/fix-stale-submodule-urls.sh --prune-nonexistent # destructive +---- + +[#reproduction] +== Reproduction + +[source,sh] +---- +# (1) The 7: dirs with both .machine_readable/ and .github/ +for d in */; do d="${d%/}" + [ -d "$d/.machine_readable" ] && [ -d "$d/.github" ] && echo "$d"; done + +# (2) Storage mode (160000 gitlink vs 040000 tree) + declared submodules +git ls-tree HEAD | grep -E '160000|bitfuckit|contractiles|gui|recon-silly|rpa-elysium|scaffoldia|stateful-artefacts|total-' +cat .gitmodules + +# (3) Local footprint +for d in bitfuckit contractiles gui recon-silly-ation rpa-elysium scaffoldia stateful-artefacts; do + printf '%-22s %s\n' "$d" "$(git ls-files "$d" | wc -l)"; done + +# (4) Forge existence (GitHub Search API, run 2026-06-02) +# search_repositories user:hyperpolymath in:name bitfuckit OR contractiles OR gui OR rpa-elysium +# search_repositories user:hyperpolymath in:name recon-silly-ation OR scaffoldia OR stateful-artefacts +# -> only rpa-elysium present; alt names stateful-artefacts-for-gitforges / reposystem-gui also absent + +# (5) Duplicate stub +git ls-tree 84d1361d98374e10d45b5fb5692044d98bc6144e # total-recall == total-upgrade +---- diff --git a/migration/affinescript/README.adoc b/migration/affinescript/README.adoc new file mode 100644 index 00000000..8ef0ede4 --- /dev/null +++ b/migration/affinescript/README.adoc @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += AffineScript migration — WIP skeletons (DO NOT MERGE INTO PRODUCTION AS-IS) +:toc: preamble + +[.lead] +Tool-generated migration *skeletons* for reposystem's first-party ReScript, +produced as the human-in-the-loop starting point for the +ReScript→AffineScript port. *These do not compile and are not a finished +port.* The working `.res` sources are retained and untouched. + +== Status (verified 2026-06-02) + +[cols="2,1",options="header"] +|=== +| Metric | Value +| First-party `.res` inputs | 75 +| Declarations auto-translated | *0* +| Skeletons that compile (`affinescript`) | *0 / 75* +| Migration markers | 151 (144 `untyped-exception`, 7 `raw-js`) +| `TODO` holes to fill by hand | *397* +|=== + +The numbers come from running AffineScript's own converter and compiler — not +from estimation. This is exactly what AffineScript's tooling is designed to +emit: a partial port whose header states it "DELIBERATELY does NOT type-check". + +== How these were generated (reproducible) + +[source,sh] +---- +# AffineScript toolchain built from source (apt-provided OCaml libs; the opam +# registry opam.ocaml.org is network-blocked 403 in CI/sandbox): +# apt-get install ocaml-dune libcmdliner-ocaml-dev libfmt-ocaml-dev \ +# libsedlex-ocaml-dev libmenhir-ocaml-dev ... +# git clone https://github.com/hyperpolymath/affinescript && cd affinescript +# dune build tools/res-to-affine/main.exe bin/ + +CONV=affinescript/_build/default/tools/res-to-affine/main.exe +for f in $(git ls-files '*.res' | grep -v '/lib/'); do + "$CONV" --partial "$f" -o "migration/affinescript/${f%.res}.affine" +done +# compile-check: affinescript check → 0/75 pass +---- + +== What still has to happen (this is a human effort) + +. Fill the 397 `TODO` holes: `untyped-exception` paths → `Result[E,A]` / + `Validation[E,A]`; `raw-js` (`%%raw`, `@val external`) → typed `extern` / + effect handlers; module-load side effects → explicit registration. +. Re-decompose effectful/stateful code under AffineScript's affine + effect + type system — not a line-by-line transform. +. Replace the vendored `gui/lib/rescript-webapi/` (138 files, *excluded* from + this run) with AffineScript's native `affinescript-dom` — do not hand-port a + third-party binding library. +. Type-check each module with `affinescript check`, then delete the + corresponding `.res` once its port lands and the `.hypatia-ignore` baseline + line is removed. + +Tracked upstream: `affinescript#488` (partial-port mode). The estate's +`res-to-affine` corpus methodology is the model (see that repo's +`tools/res-to-affine/CORPUS-RUN.md`). diff --git a/migration/affinescript/forge-ops/src/commands/ForgeOpsCmd.affine b/migration/affinescript/forge-ops/src/commands/ForgeOpsCmd.affine new file mode 100644 index 00000000..c98b8128 --- /dev/null +++ b/migration/affinescript/forge-ops/src/commands/ForgeOpsCmd.affine @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/commands/ForgeOpsCmd.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 16 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 26: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 42: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 62: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 78: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 102: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 127: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 147: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 167: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 191: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 215: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 239: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 263: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 287: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 311: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 334: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { +// - [untyped-exception] line 357: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// ->Promise.catch(_err => { + +module ForgeOpsCmd; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Gossamer Command Wrappers — TEA commands for forge API operations. + /// + /// Each function wraps a Gossamer IPC handler from the Rust backend, + /// using `Tea_Cmd.call` to bridge async Gossamer invocations into the TEA loop. + /// + /// Supports three forges: GitHub (gh API), GitLab (REST v4), Bitbucket (REST 2.0). + /// Local-first: all config is cached in ~/.config/forgeops/ + + /// Gossamer IPC bridge — replaces Tauri's @tauri-apps/api/core invoke. + let invoke = RuntimeBridge.invoke + + // ============================================================================ + // Connection / token verification + // ============================================================================ + + /// Verify API tokens for all three forges. Returns connection status JSON. + let verifyTokens = (tagger: result => 'msg): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_verify_tokens", ()) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error("Forge token verification failed"))) + Promise.resolve() + }) + ->ignore + }) + } + + /// Verify a single forge's API token. + let verifyForgeToken = (forge: string, tagger: result => 'msg): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_verify_forge_token", {"forge": forge}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`${forge} token verification failed`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Repository listing + // ============================================================================ + + /// List all repos from a specific forge. + let listRepos = (forge: string, tagger: result => 'msg): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_list_repos", {"forge": forge}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to list ${forge} repos`))) + Promise.resolve() + }) + ->ignore + }) + } + + /// List all repos from all forges and merge by name. + let listAllRepos = (tagger: result => 'msg): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_list_all_repos", ()) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error("Failed to list repos from all forges"))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Repo settings + // ============================================================================ + + /// Get settings for a specific repo on a specific forge. + let getRepoSettings = ( + forge: string, + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_get_repo_settings", {"forge": forge, "repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to get ${forge} settings for ${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + /// Update a single repo setting. + let updateSetting = ( + forge: string, + repoName: string, + settingId: string, + value: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke( + "forgeops_update_setting", + {"forge": forge, "repo_name": repoName, "setting_id": settingId, "value": value}, + ) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to update ${settingId} on ${forge}/${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Mirror operations + // ============================================================================ + + /// Get mirror status for all repos. + let getMirrorStatus = (tagger: result => 'msg): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_get_mirror_status", ()) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error("Failed to get mirror status"))) + Promise.resolve() + }) + ->ignore + }) + } + + /// Force sync a mirror for a specific repo to a specific target forge. + let forceSyncMirror = ( + repoName: string, + targetForge: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_force_sync_mirror", {"repo_name": repoName, "target_forge": targetForge}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to sync ${repoName} to ${targetForge}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Branch protection + // ============================================================================ + + /// Get branch protection rules for a repo on a specific forge. + let getProtectionRules = ( + forge: string, + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_get_protection", {"forge": forge, "repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to get protection rules for ${forge}/${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + /// Update branch protection for a repo on a specific forge. + let updateProtection = ( + forge: string, + repoName: string, + rulesJson: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke( + "forgeops_update_protection", + {"forge": forge, "repo_name": repoName, "rules_json": rulesJson}, + ) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to update protection on ${forge}/${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Webhooks + // ============================================================================ + + /// List webhooks for a repo on a specific forge. + let listWebhooks = ( + forge: string, + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_list_webhooks", {"forge": forge, "repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to list webhooks for ${forge}/${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + /// Delete a webhook. + let deleteWebhook = ( + forge: string, + repoName: string, + webhookId: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke( + "forgeops_delete_webhook", + {"forge": forge, "repo_name": repoName, "webhook_id": webhookId}, + ) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to delete webhook ${webhookId}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // CI/CD + // ============================================================================ + + /// List CI/CD pipelines for a repo on a specific forge. + let listPipelines = ( + forge: string, + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_list_pipelines", {"forge": forge, "repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to list pipelines for ${forge}/${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Security + // ============================================================================ + + /// Get security alerts for a repo. + let getSecurityAlerts = ( + forge: string, + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_get_security_alerts", {"forge": forge, "repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to get security alerts for ${forge}/${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Bulk operations + // ============================================================================ + + /// Apply RSR compliance settings to a repo on all forges. + let applyCompliance = ( + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_apply_compliance", {"repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to apply compliance to ${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + + // ============================================================================ + // Offline config + // ============================================================================ + + /// Download offline configuration for a repo (settings + protection + mirrors). + let downloadConfig = ( + repoName: string, + tagger: result => 'msg, + ): Tea_Cmd.t<'msg> => { + Tea_Cmd.call(callbacks => { + invoke("forgeops_download_config", {"repo_name": repoName}) + ->Promise.then(result => { + callbacks.enqueue(tagger(Ok(result))) + Promise.resolve() + }) + ->Promise.catch(_err => { + callbacks.enqueue(tagger(Error(`Failed to download config for ${repoName}`))) + Promise.resolve() + }) + ->ignore + }) + } + +*/ diff --git a/migration/affinescript/forge-ops/src/components/ForgeOps.affine b/migration/affinescript/forge-ops/src/components/ForgeOps.affine new file mode 100644 index 00000000..555d6a42 --- /dev/null +++ b/migration/affinescript/forge-ops/src/components/ForgeOps.affine @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/components/ForgeOps.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOps; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps — Main git forge management panel (Panel-W composition root). + /// + /// Full-screen overlay panel that provides the Panel-W dashboard for managing + /// git forges (GitHub, GitLab, Bitbucket). Contains the repo selector ribbon, + /// category tab bar, settings toggle grid / mirror panel / protection editor, + /// audit side panel, and action bar. + /// + /// Layout: + /// +-------------------------------------------------------+ + /// | ForgeOps — Git Forge Management [GH:ok GL:ok BB:!] x | + /// +-------------------------------------------------------+ + /// | [Repo Ribbon: chips with GH+GL+BB badges] | + /// | [Select All] [None] [All|GH|GL|BB] [Filter: ___] | + /// +-------------------------------------------------------+ + /// | Repos|Mirror|Protect|CI/CD|Secrets|...|GH|GL|BB | <-- Category tabs + /// +---------------------------+---------------------------+ + /// | Settings Grid / Mirror | Compliance Audit / | + /// | Panel / Protection Editor | Cross-Forge Diff | + /// | (depends on active tab) | (side panel) | + /// +---------------------------+---------------------------+ + /// | [Apply All] [Push] [Download] [Audit] [Compare] | + /// | Progress: 3/265 repos processed | + /// +-------------------------------------------------------+ + + open ForgeOpsModel + open Tea.Html + + // ============================================================================ + // Message type — ForgeOps-local TEA messages + // ============================================================================ + + /// All messages the ForgeOps panel can produce. + type msg = + // Panel visibility + | TogglePanel + // Repo selection + | ToggleRepo(string) + | SelectAllRepos + | DeselectAllRepos + | SetRepoFilter(string) + | SetForgeFilter(option) + // Category tabs + | SetCategory(forgeCategory) + | SetSettingFilter(string) + // Settings + | ToggleSetting(string) + | UpdateSettingValue(string, string) + // Mirror operations + | ForceSync(string, string) + | ForceSyncAll + | RefreshMirrors + // Bulk actions + | ApplyCompliance + | PushChanges + | DownloadConfig + | RunAudit + | CompareCrossForge + // Side panels + | ToggleAuditPanel + | ToggleDiffPanel + // API results + | ReposLoaded(result) + | SettingsLoaded(result) + | MirrorStatusLoaded(result) + | ProtectionLoaded(result) + | AuditCompleted(result) + | ComplianceApplied(result) + | ConfigDownloaded(result) + | TokensVerified(result) + + // ============================================================================ + // Category tab bar + // ============================================================================ + + /// All setting categories in display order. + let allCategories: array = [ + Repos, + Mirroring, + Protection, + CiCd, + Secrets, + Webhooks, + Releases, + Security, + GitHubSpecific, + GitLabSpecific, + BitbucketSpecific, + ] + + /// Render a single category tab button. + let renderCategoryTab = ( + cat: forgeCategory, + isActive: bool, + onSetCategory: forgeCategory => msg, + ): Tea_Vdom.t => { + let activeClass = isActive + ? "border-indigo-500 text-indigo-300 bg-gray-800/50" + : "border-transparent text-gray-500 hover:text-gray-300 hover:border-gray-600" + + // Forge-specific tabs get a subtle forge colour accent + let accentClass = switch cat { + | GitHubSpecific => if isActive { "" } else { " hover:text-gray-300" } + | GitLabSpecific => if isActive { "" } else { " hover:text-orange-300" } + | BitbucketSpecific => if isActive { "" } else { " hover:text-blue-300" } + | _ => "" + } + + button( + list{ + Attrs.class_( + `px-3 py-2 text-sm font-medium border-b-2 cursor-pointer transition-colors ${activeClass}${accentClass}`, + ), + Attrs.ariaSelected(isActive), + Attrs.role("tab"), + Events.onClick(onSetCategory(cat)), + }, + list{text(ForgeOpsCatalog.categoryLabel(cat))}, + ) + } + + /// Render the full category tab bar. + let renderCategoryTabBar = ( + activeCategory: forgeCategory, + onSetCategory: forgeCategory => msg, + ): Tea_Vdom.t => { + div( + list{ + Attrs.class_("flex border-b border-gray-800 overflow-x-auto"), + Attrs.role("tablist"), + Attrs.ariaLabel("Setting categories"), + }, + allCategories + ->Array.map(cat => renderCategoryTab(cat, cat === activeCategory, onSetCategory)) + ->List.fromArray, + ) + } + + // ============================================================================ + // Connection status bar — shows status for all three forges + // ============================================================================ + + /// Render a single forge connection indicator dot + label. + let renderForgeConnection = ( + forge: forgeId, + status: forgeConnectionStatus, + ): Tea_Vdom.t => { + let label = switch forge { + | GitHub => "GH" + | GitLab => "GL" + | Bitbucket => "BB" + } + + let (dotClass, statusText) = switch status { + | Disconnected => ("bg-gray-500", "off") + | Connecting => ("bg-yellow-400 animate-pulse", "...") + | Connected(_info) => ("bg-green-400", "ok") + | ConnectionError(_err) => ("bg-red-400", "err") + } + + div( + list{Attrs.class_("flex items-center gap-1")}, + list{ + span( + list{Attrs.class_(`w-2 h-2 rounded-full ${dotClass}`)}, + list{}, + ), + span( + list{Attrs.class_("text-xs text-gray-400")}, + list{text(`${label}:${statusText}`)}, + ), + }, + ) + } + + /// Render the connection status for all three forges. + let renderConnectionBar = (connections: forgeConnections): Tea_Vdom.t => { + div( + list{Attrs.class_("flex items-center gap-3 px-3 py-1.5")}, + list{ + renderForgeConnection(GitHub, connections.gitHub), + renderForgeConnection(GitLab, connections.gitLab), + renderForgeConnection(Bitbucket, connections.bitbucket), + }, + ) + } + + // ============================================================================ + // Audit side panel + // ============================================================================ + + /// Render the audit results summary in the right side panel. + let renderAuditPanel = ( + auditResult: option, + loading: bool, + ): Tea_Vdom.t => { + div( + list{Attrs.class_("w-72 border-l border-gray-800 p-3 overflow-y-auto")}, + list{ + div( + list{Attrs.class_("text-xs text-gray-500 mb-3 font-medium")}, + list{text("RSR COMPLIANCE AUDIT")}, + ), + switch auditResult { + | None => + if loading { + div( + list{Attrs.class_("text-sm text-gray-500 italic")}, + list{text("Running audit...")}, + ) + } else { + div( + list{Attrs.class_("text-sm text-gray-600 italic")}, + list{text("Click 'Audit' to check RSR compliance")}, + ) + } + | Some(result) => + div( + list{}, + list{ + // Score summary + div( + list{Attrs.class_("flex items-center gap-3 mb-3")}, + list{ + div( + list{Attrs.class_("text-2xl font-bold text-indigo-300")}, + list{text(`${Float.toFixed(result.score *. 100.0, ~digits=0)}%`)}, + ), + div( + list{}, + list{ + div( + list{Attrs.class_("text-xs text-green-400")}, + list{text(`${Int.toString(result.passed)} passed`)}, + ), + div( + list{Attrs.class_("text-xs text-red-400")}, + list{text(`${Int.toString(result.failed)} failed`)}, + ), + div( + list{Attrs.class_("text-xs text-yellow-400")}, + list{text(`${Int.toString(result.warnings)} warnings`)}, + ), + }, + ), + }, + ), + // Repos audited + div( + list{Attrs.class_("text-xs text-gray-500 mb-2")}, + list{text(`${Int.toString(Array.length(result.repos))} repos audited`)}, + ), + // Findings list + div( + list{Attrs.class_("space-y-2")}, + result.findings + ->ForgeOpsEngine.sortFindingsBySeverity + ->Array.map(finding => + div( + list{Attrs.class_("text-xs p-2 bg-gray-800/50 rounded")}, + list{ + div( + list{Attrs.class_("flex items-center gap-1.5 mb-1")}, + list{ + span( + list{Attrs.class_(`font-bold ${ForgeOpsEngine.severityColour(finding.severity)}`)}, + list{text(ForgeOpsEngine.severityLabel(finding.severity))}, + ), + span( + list{Attrs.class_("text-gray-300 font-mono")}, + list{text(finding.settingId)}, + ), + }, + ), + div( + list{Attrs.class_("text-gray-500 mb-0.5")}, + list{text(finding.repoName)}, + ), + div( + list{Attrs.class_("text-gray-400")}, + list{text(finding.message)}, + ), + if finding.autoFixable { + span( + list{Attrs.class_("text-xs text-indigo-400 mt-1")}, + list{text("Auto-fixable")}, + ) + } else { + noNode + }, + }, + ) + ) + ->List.fromArray, + ), + }, + ) + }, + }, + ) + } + + // ============================================================================ + // Action bar + // ============================================================================ + + /// Render the bottom action bar with Apply, Push, Download, Audit, Compare buttons. + let renderActionBar = ( + selectedCount: int, + totalCount: int, + loading: bool, + bulkProgress: option, + ): Tea_Vdom.t => { + let buttonClass = "px-3 py-1.5 text-sm font-medium rounded cursor-pointer transition-colors" + let primaryClass = `${buttonClass} bg-indigo-600 hover:bg-indigo-500 text-white` + let secondaryClass = `${buttonClass} bg-gray-700 hover:bg-gray-600 text-gray-200` + let disabledClass = `${buttonClass} bg-gray-800 text-gray-600 cursor-not-allowed` + + div( + list{Attrs.class_("border-t border-gray-800 px-4 py-3 flex items-center justify-between")}, + list{ + // Action buttons + div( + list{Attrs.class_("flex items-center gap-2")}, + list{ + button( + list{ + Attrs.class_(if selectedCount > 0 && !loading { primaryClass } else { disabledClass }), + Attrs.ariaLabel("Apply RSR compliance to selected repos"), + if selectedCount > 0 && !loading { + Events.onClick(ApplyCompliance) + } else { + Attrs.noProp + }, + }, + list{text("Apply RSR")}, + ), + button( + list{ + Attrs.class_(if !loading { secondaryClass } else { disabledClass }), + Attrs.ariaLabel("Push local changes to forges"), + if !loading { + Events.onClick(PushChanges) + } else { + Attrs.noProp + }, + }, + list{text("Push Changes")}, + ), + button( + list{ + Attrs.class_(if !loading { secondaryClass } else { disabledClass }), + Attrs.ariaLabel("Download offline config"), + if !loading { + Events.onClick(DownloadConfig) + } else { + Attrs.noProp + }, + }, + list{text("Download")}, + ), + button( + list{ + Attrs.class_(if selectedCount > 0 && !loading { secondaryClass } else { disabledClass }), + Attrs.ariaLabel("Run RSR compliance audit"), + if selectedCount > 0 && !loading { + Events.onClick(RunAudit) + } else { + Attrs.noProp + }, + }, + list{text("Audit")}, + ), + button( + list{ + Attrs.class_(if selectedCount > 0 && !loading { secondaryClass } else { disabledClass }), + Attrs.ariaLabel("Compare settings across forges"), + if selectedCount > 0 && !loading { + Events.onClick(CompareCrossForge) + } else { + Attrs.noProp + }, + }, + list{text("Compare")}, + ), + }, + ), + // Progress indicator + switch bulkProgress { + | None => + div( + list{Attrs.class_("text-xs text-gray-500")}, + list{ + text(`${Int.toString(selectedCount)}/${Int.toString(totalCount)} repos selected`), + }, + ) + | Some(progress) => + div( + list{Attrs.class_("flex items-center gap-2")}, + list{ + // Progress bar + div( + list{Attrs.class_("w-40 h-2 bg-gray-800 rounded-full overflow-hidden")}, + list{ + div( + list{ + Attrs.class_("h-full bg-indigo-500 transition-all"), + Attrs.style( + "width", + `${Float.toFixed( + Int.toFloat(progress.completed) /. Int.toFloat(if progress.total > 0 { progress.total } else { 1 }) *. 100.0, + ~digits=0, + )}%`, + ), + }, + list{}, + ), + }, + ), + div( + list{Attrs.class_("text-xs text-gray-400")}, + list{ + text( + `${Int.toString(progress.completed)}/${Int.toString(progress.total)} repos`, + ), + }, + ), + switch progress.currentRepo { + | Some(repo) => + span( + list{Attrs.class_("text-xs text-gray-500 font-mono")}, + list{text(repo)}, + ) + | None => noNode + }, + if progress.failed > 0 { + span( + list{Attrs.class_("text-xs text-red-400")}, + list{text(`${Int.toString(progress.failed)} failed`)}, + ) + } else { + noNode + }, + }, + ) + }, + }, + ) + } + + // ============================================================================ + // Main panel view + // ============================================================================ + + /// Render the complete ForgeOps panel as a full-screen overlay. + /// This is the Panel-W component for the ForgeOps module. + let view = (state: forgeOpsState): Tea_Vdom.t => { + div( + list{ + Attrs.class_("fixed inset-0 z-50 bg-gray-950 flex flex-col"), + Attrs.role("dialog"), + Attrs.ariaLabel("ForgeOps — Git Forge Management"), + }, + list{ + // Header bar with title, connection status, and close button + div( + list{Attrs.class_("flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900/80")}, + list{ + div( + list{Attrs.class_("flex items-center gap-3")}, + list{ + div( + list{Attrs.class_("text-lg font-semibold text-gray-200")}, + list{text("ForgeOps")}, + ), + div( + list{Attrs.class_("text-xs text-gray-500")}, + list{text("Git Forge Management")}, + ), + }, + ), + div( + list{Attrs.class_("flex items-center gap-3")}, + list{ + renderConnectionBar(state.connections), + button( + list{ + Attrs.class_("text-gray-500 hover:text-gray-300 cursor-pointer text-lg px-2"), + Attrs.ariaLabel("Close ForgeOps"), + Events.onClick(TogglePanel), + }, + list{text("x")}, + ), + }, + ), + }, + ), + + // Repo selector ribbon + div( + list{Attrs.class_("px-4 py-2")}, + list{ + ForgeOpsRepoList.view( + state.repos, + state.selectedRepoNames, + state.filterText, + state.activeForgeFilter, + name => ToggleRepo(name), + SelectAllRepos, + DeselectAllRepos, + text => SetRepoFilter(text), + forge => SetForgeFilter(forge), + ), + }, + ), + + // Category tab bar + div( + list{Attrs.class_("px-4")}, + list{renderCategoryTabBar(state.activeCategory, cat => SetCategory(cat))}, + ), + + // Main content area: content (left) + optional side panel (right) + div( + list{Attrs.class_("flex-1 flex overflow-hidden")}, + list{ + // Main content (left) — depends on active category + div( + list{Attrs.class_("flex-1 overflow-y-auto px-4 py-2")}, + list{ + { + let currentRepoName = Array.get(state.selectedRepoNames, 0) + switch state.activeCategory { + | Mirroring => + // Mirror tab shows the dedicated mirror management panel + ForgeOpsMirrorPanel.view( + state.mirrorLinks, + state.repos, + state.loading, + (repo, target) => ForceSync(repo, target), + ForceSyncAll, + RefreshMirrors, + ) + | Protection => + // Protection tab shows the branch protection comparison editor + ForgeOpsProtectionEditor.view( + state.protectionRules, + currentRepoName, + state.loading, + ) + | _ => + // All other tabs show the settings toggle grid + ForgeOpsSettingsGrid.view( + state.settings, + state.activeCategory, + state.settingFilter, + state.exceptions, + currentRepoName, + id => ToggleSetting(id), + (id, value) => UpdateSettingValue(id, value), + ) + } + }, + }, + ), + // Side panel (right) — audit results or diff viewer + if state.showAudit { + renderAuditPanel(state.auditResult, state.loading) + } else if state.showDiff { + ForgeOpsDiffViewer.view(state.forgeDiff, state.loading) + } else { + noNode + }, + }, + ), + + // Action bar (bottom) + renderActionBar( + Array.length(state.selectedRepoNames), + Array.length(state.repos), + state.loading, + state.bulkProgress, + ), + }, + ) + } + +*/ diff --git a/migration/affinescript/forge-ops/src/components/ForgeOpsDiffViewer.affine b/migration/affinescript/forge-ops/src/components/ForgeOpsDiffViewer.affine new file mode 100644 index 00000000..e2896331 --- /dev/null +++ b/migration/affinescript/forge-ops/src/components/ForgeOpsDiffViewer.affine @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/components/ForgeOpsDiffViewer.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsDiffViewer; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Diff Viewer — Cross-forge settings comparison display. + /// + /// Shows differences between GitHub, GitLab, Bitbucket, and RSR policy + /// values for shared repo settings. Each diff entry displays the four + /// values side by side with colour-coded consistency indicators. + /// + /// Layout: + /// +-------------+--------+--------+--------+--------+----------+ + /// | Setting | GitHub | GitLab | BB | Policy | Status | + /// +-------------+--------+--------+--------+--------+----------+ + /// | visibility | public | public | public | public | OK | + /// | has_issues | true | true | false | true | DRIFT | + /// | has_wiki | false | -- | false | false | OK | + /// +-------------+--------+--------+--------+--------+----------+ + /// | 2 inconsistent | 3 missing | 42 settings match | + /// +------------------------------------------------------------+ + + open ForgeOpsModel + open Tea.Html + + // ============================================================================ + // Diff entry rendering + // ============================================================================ + + /// CSS class for a value based on whether it matches the policy. + let valueClass = (value: option, policyValue: option): string => { + switch (value, policyValue) { + | (Some(v), Some(p)) => + if v === p { "text-green-400" } else { "text-red-400" } + | (None, _) => "text-gray-600 italic" + | (_, None) => "text-gray-400" + } + } + + /// Render a single diff entry row. + let renderDiffEntry = (entry: forgeDiffEntry): Tea_Vdom.t<'msg> => { + let ghDisplay = switch entry.gitHubValue { + | Some(v) => v + | None => "--" + } + let glDisplay = switch entry.gitLabValue { + | Some(v) => v + | None => "--" + } + let bbDisplay = switch entry.bitbucketValue { + | Some(v) => v + | None => "--" + } + let policyDisplay = switch entry.policyValue { + | Some(v) => v + | None => "--" + } + + let hasMissing = + Option.isNone(entry.gitHubValue) + || Option.isNone(entry.gitLabValue) + || Option.isNone(entry.bitbucketValue) + + let rowBg = if !entry.consistent && hasMissing { + " bg-red-950/20" + } else if !entry.consistent { + " bg-yellow-950/20" + } else { + "" + } + + div( + list{Attrs.class_(`flex hover:bg-gray-800/30${rowBg}`)}, + list{ + // Setting ID + div( + list{Attrs.class_("py-1.5 px-2 text-sm text-gray-300 font-mono flex-1")}, + list{text(entry.settingId)}, + ), + // GitHub value + div( + list{Attrs.class_(`py-1.5 px-2 text-sm font-mono w-24 ${valueClass(entry.gitHubValue, entry.policyValue)}`)}, + list{text(ghDisplay)}, + ), + // GitLab value + div( + list{Attrs.class_(`py-1.5 px-2 text-sm font-mono w-24 ${valueClass(entry.gitLabValue, entry.policyValue)}`)}, + list{text(glDisplay)}, + ), + // Bitbucket value + div( + list{Attrs.class_(`py-1.5 px-2 text-sm font-mono w-24 ${valueClass(entry.bitbucketValue, entry.policyValue)}`)}, + list{text(bbDisplay)}, + ), + // Policy value + div( + list{Attrs.class_("py-1.5 px-2 text-sm font-mono w-24 text-gray-500")}, + list{text(policyDisplay)}, + ), + // Status indicator + div( + list{Attrs.class_("py-1.5 px-2 w-24")}, + list{ + if !entry.consistent { + span( + list{Attrs.class_("text-xs text-yellow-400 font-medium")}, + list{text("DRIFT")}, + ) + } else if hasMissing { + span( + list{Attrs.class_("text-xs text-gray-500 font-medium")}, + list{text("PARTIAL")}, + ) + } else { + span( + list{Attrs.class_("text-xs text-green-400")}, + list{text("OK")}, + ) + }, + }, + ), + }, + ) + } + + // ============================================================================ + // Table header + // ============================================================================ + + /// Render the diff table header. + let renderDiffHeader = (): Tea_Vdom.t<'msg> => { + let headerCell = (label: string, extraClass: string) => + div( + list{Attrs.class_(`text-left text-xs text-gray-500 font-medium py-2 px-2 ${extraClass}`)}, + list{text(label)}, + ) + + div( + list{Attrs.class_("flex border-b border-gray-800")}, + list{ + headerCell("Setting", "flex-1"), + headerCell("GitHub", "w-24"), + headerCell("GitLab", "w-24"), + headerCell("Bitbucket", "w-24"), + headerCell("Policy", "w-24"), + headerCell("Status", "w-24"), + }, + ) + } + + // ============================================================================ + // Compact side-panel view + // ============================================================================ + + /// Render the diff viewer as a compact side panel. + /// Shows only inconsistent entries with a summary bar. + let view = ( + forgeDiff: option, + _loading: bool, + ): Tea_Vdom.t<'msg> => { + div( + list{ + Attrs.class_("w-72 border-l border-gray-800 p-3 overflow-y-auto"), + Attrs.role("region"), + Attrs.ariaLabel("Cross-Forge Diff Viewer"), + }, + list{ + div( + list{Attrs.class_("text-xs text-gray-500 mb-3 font-medium")}, + list{text("CROSS-FORGE DIFF")}, + ), + switch forgeDiff { + | None => + div( + list{Attrs.class_("text-sm text-gray-600 italic")}, + list{text("Select repos and click 'Compare' to see cross-forge differences.")}, + ) + | Some(diff) => + div( + list{}, + list{ + // Summary bar + div( + list{Attrs.class_("flex items-center gap-3 mb-3 text-xs")}, + list{ + span( + list{Attrs.class_("text-yellow-400")}, + list{text(`${Int.toString(diff.inconsistentCount)} inconsistent`)}, + ), + span( + list{Attrs.class_("text-gray-500")}, + list{text(`${Int.toString(diff.missingCount)} missing`)}, + ), + span( + list{Attrs.class_("text-green-400")}, + list{ + text( + `${Int.toString( + Array.length(diff.entries) - diff.inconsistentCount, + )} OK`, + ), + }, + ), + }, + ), + // Inconsistent entries only (compact for side panel) + div( + list{Attrs.class_("space-y-1")}, + diff.entries + ->Array.filter(e => !e.consistent) + ->Array.map(entry => { + let ghDisplay = switch entry.gitHubValue { + | Some(v) => v + | None => "--" + } + let glDisplay = switch entry.gitLabValue { + | Some(v) => v + | None => "--" + } + let bbDisplay = switch entry.bitbucketValue { + | Some(v) => v + | None => "--" + } + div( + list{Attrs.class_("text-xs p-2 bg-gray-800/50 rounded")}, + list{ + div( + list{Attrs.class_("font-mono text-gray-300 mb-1")}, + list{text(entry.settingId)}, + ), + div( + list{Attrs.class_("flex items-center gap-1 flex-wrap")}, + list{ + span( + list{Attrs.class_("text-gray-400")}, + list{text(`GH:${ghDisplay}`)}, + ), + span( + list{Attrs.class_("text-orange-400")}, + list{text(`GL:${glDisplay}`)}, + ), + span( + list{Attrs.class_("text-blue-400")}, + list{text(`BB:${bbDisplay}`)}, + ), + }, + ), + }, + ) + }) + ->List.fromArray, + ), + }, + ) + }, + }, + ) + } + + /// Render the diff viewer as a full-width table (for main content area). + let viewExpanded = ( + forgeDiff: option, + ): Tea_Vdom.t<'msg> => { + switch forgeDiff { + | None => + div( + list{Attrs.class_("text-sm text-gray-600 italic px-3 py-4")}, + list{text("No cross-forge diff available. Select repos and compare.")}, + ) + | Some(diff) => + div( + list{Attrs.class_("flex-1 overflow-y-auto")}, + list{ + div( + list{Attrs.class_("w-full text-left")}, + list{ + renderDiffHeader(), + div( + list{}, + diff.entries + ->Array.map(renderDiffEntry) + ->List.fromArray, + ), + }, + ), + }, + ) + } + } + +*/ diff --git a/migration/affinescript/forge-ops/src/components/ForgeOpsMirrorPanel.affine b/migration/affinescript/forge-ops/src/components/ForgeOpsMirrorPanel.affine new file mode 100644 index 00000000..0956ed26 --- /dev/null +++ b/migration/affinescript/forge-ops/src/components/ForgeOpsMirrorPanel.affine @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/components/ForgeOpsMirrorPanel.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsMirrorPanel; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Mirror Panel — Dedicated mirror management dashboard. + /// + /// Shows mirror relationships between forges as a status table with + /// sync indicators, force-sync buttons, and mirror configuration. + /// This is a dedicated panel (not the settings grid) that appears when + /// the Mirroring tab is active. + /// + /// Layout: + /// +-------------------------------------------------------------+ + /// | MIRROR STATUS [Force Sync All] [Refresh] | + /// +-------------------------------------------------------------+ + /// | Repo | GH->GL | GH->BB | Method | Action | + /// +-------------------------------------------------------------+ + /// | proven-servers | In Sync | In Sync | Actions | [Sync] | + /// | panll | 2 behind| In Sync | Actions | [Sync] | + /// | ats2-tui | Failed! | -- | Manual | [Sync] | + /// +-------------------------------------------------------------+ + /// | Summary: 245/265 fully mirrored | 15 behind | 5 failed | + /// +-------------------------------------------------------------+ + + open ForgeOpsModel + open Tea.Html + + // ============================================================================ + // Mirror status badge + // ============================================================================ + + /// Render a coloured status badge for a mirror sync status. + let renderStatusBadge = (status: mirrorSyncStatus): Tea_Vdom.t<'msg> => { + let colourClass = ForgeOpsEngine.mirrorStatusColour(status) + let label = ForgeOpsEngine.mirrorStatusLabel(status) + + span( + list{Attrs.class_(`text-xs font-mono ${colourClass}`)}, + list{text(label)}, + ) + } + + /// Render the mirror method badge. + let renderMethodBadge = (method: mirrorMethod): Tea_Vdom.t<'msg> => { + let (label, colourClass) = switch method { + | GitHubAction => ("Actions", "text-gray-200 bg-gray-700/60") + | GitLabPullMirror => ("GL Pull", "text-orange-300 bg-orange-900/40") + | GitLabPushMirror => ("GL Push", "text-orange-300 bg-orange-900/40") + | BitbucketPipeline => ("BB Pipe", "text-blue-300 bg-blue-900/40") + | ManualPush => ("Manual", "text-yellow-300 bg-yellow-900/40") + | WebhookTrigger => ("Webhook", "text-purple-300 bg-purple-900/40") + } + + span( + list{Attrs.class_(`text-xs font-mono px-1.5 py-0.5 rounded ${colourClass}`)}, + list{text(label)}, + ) + } + + // ============================================================================ + // Mirror table row + // ============================================================================ + + /// Render a single mirror link row in the table. + let renderMirrorRow = ( + link: mirrorLink, + onForceSync: (string, string) => 'msg, + ): Tea_Vdom.t<'msg> => { + let targetLabel = ForgeOpsCatalog.forgeLabel(link.target) + + div( + list{Attrs.class_("flex items-center hover:bg-gray-800/30 py-2 px-3 border-b border-gray-800/50")}, + list{ + // Repo name + div( + list{Attrs.class_("text-sm text-gray-200 font-mono flex-1")}, + list{text(link.repoName)}, + ), + // Source -> Target label + div( + list{Attrs.class_("text-xs text-gray-500 w-24")}, + list{ + text(`${ForgeOpsCatalog.forgeLabel(link.source)}->${switch link.target { + | GitHub => "GH" + | GitLab => "GL" + | Bitbucket => "BB" + }}`), + }, + ), + // Status + div( + list{Attrs.class_("w-28")}, + list{renderStatusBadge(link.status)}, + ), + // Method + div( + list{Attrs.class_("w-24")}, + list{renderMethodBadge(link.method)}, + ), + // Auto-sync indicator + div( + list{Attrs.class_("w-16 text-center")}, + list{ + if link.autoSync { + span( + list{Attrs.class_("text-xs text-green-400"), Attrs.title("Auto-sync enabled")}, + list{text("Auto")}, + ) + } else { + span( + list{Attrs.class_("text-xs text-gray-600"), Attrs.title("Manual sync only")}, + list{text("Manual")}, + ) + }, + }, + ), + // Last sync time + div( + list{Attrs.class_("text-xs text-gray-500 w-28")}, + list{ + text(switch link.lastSuccess { + | Some(ts) => ts + | None => "Never" + }), + }, + ), + // Force sync button + div( + list{Attrs.class_("w-16")}, + list{ + button( + list{ + Attrs.class_("text-xs text-indigo-400 hover:text-indigo-300 cursor-pointer font-medium"), + Attrs.ariaLabel(`Force sync ${link.repoName} to ${targetLabel}`), + Events.onClick(onForceSync(link.repoName, targetLabel)), + }, + list{text("Sync")}, + ), + }, + ), + }, + ) + } + + // ============================================================================ + // Table header + // ============================================================================ + + /// Render the mirror table header. + let renderTableHeader = (): Tea_Vdom.t<'msg> => { + let headerCell = (label: string, extraClass: string) => + div( + list{Attrs.class_(`text-left text-xs text-gray-500 font-medium py-2 px-3 ${extraClass}`)}, + list{text(label)}, + ) + + div( + list{Attrs.class_("flex border-b border-gray-700")}, + list{ + headerCell("Repository", "flex-1"), + headerCell("Direction", "w-24"), + headerCell("Status", "w-28"), + headerCell("Method", "w-24"), + headerCell("Sync", "w-16 text-center"), + headerCell("Last Sync", "w-28"), + headerCell("Action", "w-16"), + }, + ) + } + + // ============================================================================ + // Summary bar + // ============================================================================ + + /// Render the mirror status summary bar. + let renderSummary = (links: array): Tea_Vdom.t<'msg> => { + let total = Array.length(links) + let inSync = links->Array.filter(l => l.status === InSync)->Array.length + let behind = links->Array.filter(l => + switch l.status { + | Behind(_) => true + | _ => false + } + )->Array.length + let failed = links->Array.filter(l => + switch l.status { + | SyncFailed(_) => true + | _ => false + } + )->Array.length + let neverSynced = links->Array.filter(l => l.status === NeverSynced)->Array.length + + div( + list{Attrs.class_("flex items-center gap-4 px-3 py-2 border-t border-gray-800 text-xs")}, + list{ + span( + list{Attrs.class_("text-green-400")}, + list{text(`${Int.toString(inSync)}/${Int.toString(total)} in sync`)}, + ), + if behind > 0 { + span( + list{Attrs.class_("text-yellow-400")}, + list{text(`${Int.toString(behind)} behind`)}, + ) + } else { + noNode + }, + if failed > 0 { + span( + list{Attrs.class_("text-red-400")}, + list{text(`${Int.toString(failed)} failed`)}, + ) + } else { + noNode + }, + if neverSynced > 0 { + span( + list{Attrs.class_("text-gray-500")}, + list{text(`${Int.toString(neverSynced)} never synced`)}, + ) + } else { + noNode + }, + }, + ) + } + + // ============================================================================ + // Unmirrored repos section + // ============================================================================ + + /// Render a compact list of repos missing from one or more forges. + let renderUnmirroredRepos = (repos: array): Tea_Vdom.t<'msg> => { + let unmirrored = ForgeOpsEngine.unmirroredRepos(repos) + + if Array.length(unmirrored) === 0 { + noNode + } else { + div( + list{Attrs.class_("mt-3 border-t border-gray-800 pt-3")}, + list{ + div( + list{Attrs.class_("text-xs text-gray-500 font-medium mb-2")}, + list{text(`MISSING MIRRORS (${Int.toString(Array.length(unmirrored))})`)}, + ), + div( + list{Attrs.class_("space-y-1 max-h-32 overflow-y-auto")}, + unmirrored + ->Array.map(repo => { + let badge = ForgeOpsEngine.forgePresenceBadge(repo) + div( + list{Attrs.class_("text-xs flex items-center gap-2 px-3 py-1 bg-red-950/20 rounded")}, + list{ + span( + list{Attrs.class_("text-gray-300 font-mono")}, + list{text(repo.name)}, + ), + span( + list{Attrs.class_("text-gray-500")}, + list{text(`(${badge})`)}, + ), + if Option.isNone(repo.gitLab) { + span( + list{Attrs.class_("text-red-400")}, + list{text("Missing GL")}, + ) + } else { + noNode + }, + if Option.isNone(repo.bitbucket) { + span( + list{Attrs.class_("text-red-400")}, + list{text("Missing BB")}, + ) + } else { + noNode + }, + }, + ) + }) + ->List.fromArray, + ), + }, + ) + } + } + + // ============================================================================ + // Main mirror panel view + // ============================================================================ + + /// Render the complete mirror management panel. + let view = ( + mirrorLinks: array, + repos: array, + loading: bool, + onForceSync: (string, string) => 'msg, + onForceSyncAll: 'msg, + onRefresh: 'msg, + ): Tea_Vdom.t<'msg> => { + let buttonClass = "text-xs font-medium px-2.5 py-1 rounded cursor-pointer transition-colors" + let primaryClass = `${buttonClass} bg-indigo-600 hover:bg-indigo-500 text-white` + let secondaryClass = `${buttonClass} bg-gray-700 hover:bg-gray-600 text-gray-200` + let disabledClass = `${buttonClass} bg-gray-800 text-gray-600 cursor-not-allowed` + + div( + list{ + Attrs.class_("flex-1 overflow-y-auto"), + Attrs.role("region"), + Attrs.ariaLabel("Mirror Management"), + }, + list{ + // Header with action buttons + div( + list{Attrs.class_("flex items-center justify-between px-3 py-2 border-b border-gray-800")}, + list{ + div( + list{Attrs.class_("text-xs text-gray-500 font-medium")}, + list{text("MIRROR STATUS")}, + ), + div( + list{Attrs.class_("flex items-center gap-2")}, + list{ + button( + list{ + Attrs.class_(if !loading { primaryClass } else { disabledClass }), + Attrs.ariaLabel("Force sync all mirrors"), + if !loading { + Events.onClick(onForceSyncAll) + } else { + Attrs.noProp + }, + }, + list{text("Force Sync All")}, + ), + button( + list{ + Attrs.class_(if !loading { secondaryClass } else { disabledClass }), + Attrs.ariaLabel("Refresh mirror status"), + if !loading { + Events.onClick(onRefresh) + } else { + Attrs.noProp + }, + }, + list{text("Refresh")}, + ), + }, + ), + }, + ), + // Mirror table + if Array.length(mirrorLinks) === 0 { + div( + list{Attrs.class_("text-sm text-gray-600 italic py-4 px-3")}, + list{text(if loading { "Loading mirror status..." } else { "No mirror links found. Set up mirroring in the settings." })}, + ) + } else { + div( + list{}, + list{ + renderTableHeader(), + div( + list{}, + mirrorLinks + ->Array.map(link => renderMirrorRow(link, onForceSync)) + ->List.fromArray, + ), + renderSummary(mirrorLinks), + }, + ) + }, + // Unmirrored repos section + renderUnmirroredRepos(repos), + }, + ) + } + +*/ diff --git a/migration/affinescript/forge-ops/src/components/ForgeOpsProtectionEditor.affine b/migration/affinescript/forge-ops/src/components/ForgeOpsProtectionEditor.affine new file mode 100644 index 00000000..d13f6d1c --- /dev/null +++ b/migration/affinescript/forge-ops/src/components/ForgeOpsProtectionEditor.affine @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/components/ForgeOpsProtectionEditor.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsProtectionEditor; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Protection Editor — Branch protection rule viewer/editor. + /// + /// Shows branch protection rules for the selected repo across all three + /// forges side by side. Each rule displays its configuration as a grid + /// of toggles, with indicators for which forges have the rule and whether + /// they agree. + /// + /// Layout: + /// +------------------------------------------------------------+ + /// | Branch: main [Add Rule] | + /// +------------------------------------------------------------+ + /// | | GitHub | GitLab | Bitbucket | + /// +------------------------------------------------------------+ + /// | Require PR | ON | ON | ON | Match | + /// | Required Approvals | 1 | 1 | 0 | DRIFT | + /// | Require Status Chk | ON | ON | -- | PARTIAL| + /// | Signed Commits | OFF | -- | -- | OK | + /// | Force Push | OFF | OFF | OFF | Match | + /// | Allow Deletion | OFF | OFF | OFF | Match | + /// +------------------------------------------------------------+ + + open ForgeOpsModel + open Tea.Html + + // ============================================================================ + // Protection rule comparison row + // ============================================================================ + + /// Render the value of a protection field for a specific forge. + let renderProtectionValue = ( + rules: array, + forgeId: forgeId, + getter: branchProtection => string, + ): Tea_Vdom.t<'msg> => { + let rule = rules->Array.find(r => r.forgeId === forgeId) + switch rule { + | Some(r) => + let value = getter(r) + let colourClass = if value === "ON" || value === "true" { + "text-green-400" + } else if value === "OFF" || value === "false" { + "text-gray-400" + } else { + "text-gray-200" + } + span( + list{Attrs.class_(`text-sm font-mono ${colourClass}`)}, + list{text(value)}, + ) + | None => + span( + list{Attrs.class_("text-sm font-mono text-gray-600 italic")}, + list{text("--")}, + ) + } + } + + /// Check if a protection field is consistent across all present forges. + let fieldConsistent = ( + rules: array, + getter: branchProtection => string, + ): bool => { + let values = rules->Array.map(getter) + switch Array.get(values, 0) { + | Some(first) => values->Array.every(v => v === first) + | None => true + } + } + + /// Render a single protection comparison row. + let renderComparisonRow = ( + label: string, + rules: array, + getter: branchProtection => string, + ): Tea_Vdom.t<'msg> => { + let consistent = fieldConsistent(rules, getter) + let allPresent = Array.length(rules) === 3 + + div( + list{ + Attrs.class_( + `flex items-center py-1.5 px-3 hover:bg-gray-800/30${if !consistent { " bg-yellow-950/20" } else { "" }}`, + ), + }, + list{ + // Field label + div( + list{Attrs.class_("text-sm text-gray-300 flex-1")}, + list{text(label)}, + ), + // GitHub value + div( + list{Attrs.class_("w-24 text-center")}, + list{renderProtectionValue(rules, GitHub, getter)}, + ), + // GitLab value + div( + list{Attrs.class_("w-24 text-center")}, + list{renderProtectionValue(rules, GitLab, getter)}, + ), + // Bitbucket value + div( + list{Attrs.class_("w-24 text-center")}, + list{renderProtectionValue(rules, Bitbucket, getter)}, + ), + // Status + div( + list{Attrs.class_("w-20 text-center")}, + list{ + if !consistent { + span( + list{Attrs.class_("text-xs text-yellow-400 font-medium")}, + list{text("DRIFT")}, + ) + } else if !allPresent { + span( + list{Attrs.class_("text-xs text-gray-500")}, + list{text("PARTIAL")}, + ) + } else { + span( + list{Attrs.class_("text-xs text-green-400")}, + list{text("Match")}, + ) + }, + }, + ), + }, + ) + } + + // ============================================================================ + // Table header + // ============================================================================ + + /// Render the protection comparison table header. + let renderHeader = (): Tea_Vdom.t<'msg> => { + let headerCell = (label: string, extraClass: string) => + div( + list{Attrs.class_(`text-xs text-gray-500 font-medium py-2 px-2 ${extraClass}`)}, + list{text(label)}, + ) + + div( + list{Attrs.class_("flex border-b border-gray-700")}, + list{ + headerCell("Rule", "flex-1 text-left"), + headerCell("GitHub", "w-24 text-center"), + headerCell("GitLab", "w-24 text-center"), + headerCell("Bitbucket", "w-24 text-center"), + headerCell("Status", "w-20 text-center"), + }, + ) + } + + // ============================================================================ + // Branch group + // ============================================================================ + + /// Render all protection rules for a single branch pattern, compared across forges. + let renderBranchGroup = ( + pattern: string, + rules: array, + ): Tea_Vdom.t<'msg> => { + div( + list{Attrs.class_("mb-4")}, + list{ + // Branch header + div( + list{Attrs.class_("flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-t")}, + list{ + span( + list{Attrs.class_("text-sm font-mono text-indigo-300")}, + list{text(pattern)}, + ), + span( + list{Attrs.class_("text-xs text-gray-500")}, + list{text(`(${Int.toString(Array.length(rules))} forges)`)}, + ), + }, + ), + // Comparison table + renderHeader(), + div( + list{Attrs.class_("divide-y divide-gray-800/30")}, + list{ + renderComparisonRow("Require PR", rules, r => + if r.requirePullRequest { "ON" } else { "OFF" } + ), + renderComparisonRow("Required Approvals", rules, r => + Int.toString(r.requiredApprovals) + ), + renderComparisonRow("Require Status Checks", rules, r => + if r.requireStatusChecks { "ON" } else { "OFF" } + ), + renderComparisonRow("Signed Commits", rules, r => + if r.requireSignedCommits { "ON" } else { "OFF" } + ), + renderComparisonRow("Linear History", rules, r => + if r.requireLinearHistory { "ON" } else { "OFF" } + ), + renderComparisonRow("Allow Force Push", rules, r => + if r.allowForcePush { "ON" } else { "OFF" } + ), + renderComparisonRow("Allow Deletion", rules, r => + if r.allowDeletion { "ON" } else { "OFF" } + ), + renderComparisonRow("Enforce Admins", rules, r => + if r.enforceAdmins { "ON" } else { "OFF" } + ), + }, + ), + }, + ) + } + + // ============================================================================ + // Main view + // ============================================================================ + + /// Render the complete branch protection editor for a repo. + /// Groups protection rules by branch pattern and compares across forges. + let view = ( + protectionRules: array, + selectedRepoName: option, + _loading: bool, + ): Tea_Vdom.t<'msg> => { + // Filter to selected repo + let repoRules = switch selectedRepoName { + | Some(name) => protectionRules->Array.filter(r => r.repoName === name) + | None => protectionRules + } + + // Group by branch pattern + let patterns: Dict.t> = Dict.make() + Array.forEach(repoRules, rule => { + let existing = switch Dict.get(patterns, rule.pattern) { + | Some(arr) => arr + | None => [] + } + Dict.set(patterns, rule.pattern, Array.concat(existing, [rule])) + }) + + div( + list{ + Attrs.class_("flex-1 overflow-y-auto"), + Attrs.role("region"), + Attrs.ariaLabel("Branch Protection Editor"), + }, + list{ + // Header + div( + list{Attrs.class_("flex items-center justify-between px-3 py-2 mb-2")}, + list{ + div( + list{Attrs.class_("text-xs text-gray-500 font-medium")}, + list{text("BRANCH PROTECTION RULES")}, + ), + switch selectedRepoName { + | Some(name) => + span( + list{Attrs.class_("text-xs text-gray-400 font-mono")}, + list{text(name)}, + ) + | None => + span( + list{Attrs.class_("text-xs text-gray-600 italic")}, + list{text("Select a repo")}, + ) + }, + }, + ), + // Branch groups + if Array.length(repoRules) === 0 { + div( + list{Attrs.class_("text-sm text-gray-600 italic py-4 px-3")}, + list{ + text( + switch selectedRepoName { + | Some(_) => "No branch protection rules found for this repo." + | None => "Select a repo to view branch protection rules." + }, + ), + }, + ) + } else { + div( + list{Attrs.class_("space-y-4")}, + Dict.keysToArray(patterns) + ->Array.map(pattern => { + let rules = switch Dict.get(patterns, pattern) { + | Some(arr) => arr + | None => [] + } + renderBranchGroup(pattern, rules) + }) + ->List.fromArray, + ) + }, + }, + ) + } + +*/ diff --git a/migration/affinescript/forge-ops/src/components/ForgeOpsRepoList.affine b/migration/affinescript/forge-ops/src/components/ForgeOpsRepoList.affine new file mode 100644 index 00000000..06889e7a --- /dev/null +++ b/migration/affinescript/forge-ops/src/components/ForgeOpsRepoList.affine @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/components/ForgeOpsRepoList.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsRepoList; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Repo List — Horizontal repo selector ribbon. + /// + /// Renders a scrollable horizontal ribbon of repo chips at the top of + /// the ForgeOps panel. Each chip shows the repo name plus forge presence + /// badges (GH, GL, BB). Users can select/deselect, filter, and use + /// Select All / None shortcuts. + /// + /// Layout: + /// [Select All] [None] [All|GH|GL|BB] [Filter: ________] 2/265 selected + /// [x proven-servers GH+GL+BB] [ ats2-tui GH+GL] [x panll GH+GL+BB] ... + + open ForgeOpsModel + open Tea.Html + + /// Render a single forge presence badge (small coloured tag). + let renderForgeBadge = (forge: forgeId, present: bool): Tea_Vdom.t<'msg> => { + if present { + let (label, colourClass) = switch forge { + | GitHub => ("GH", "text-gray-200 bg-gray-700/60") + | GitLab => ("GL", "text-orange-300 bg-orange-900/40") + | Bitbucket => ("BB", "text-blue-300 bg-blue-900/40") + } + span( + list{Attrs.class_(`text-xs font-mono px-1 py-0.5 rounded ${colourClass}`)}, + list{text(label)}, + ) + } else { + noNode + } + } + + /// Render a single repo chip in the ribbon. + let renderRepoChip = ( + repo: forgeRepo, + isSelected: bool, + onToggle: string => 'msg, + ): Tea_Vdom.t<'msg> => { + let borderClass = isSelected + ? "border-indigo-500 bg-indigo-950/30" + : "border-gray-700 bg-gray-800/30" + + let archiveIndicator = if repo.archived { + span( + list{Attrs.class_("text-xs text-gray-600 ml-1")}, + list{text("(archived)")}, + ) + } else { + noNode + } + + button( + list{ + Attrs.class_( + `inline-flex items-center gap-1 px-3 py-1.5 rounded border text-sm font-mono cursor-pointer transition-colors ${borderClass} hover:border-indigo-400`, + ), + Attrs.ariaPressed(isSelected), + Attrs.ariaLabel(`${isSelected ? "Deselect" : "Select"} ${repo.name}`), + Events.onClick(onToggle(repo.name)), + }, + list{ + span(list{Attrs.class_("text-gray-200")}, list{text(repo.name)}), + renderForgeBadge(GitHub, Option.isSome(repo.gitHub)), + renderForgeBadge(GitLab, Option.isSome(repo.gitLab)), + renderForgeBadge(Bitbucket, Option.isSome(repo.bitbucket)), + archiveIndicator, + }, + ) + } + + /// Render the forge filter buttons (All | GH | GL | BB). + let renderForgeFilter = ( + activeFilter: option, + onSetFilter: option => 'msg, + ): Tea_Vdom.t<'msg> => { + let buttonClass = (isActive: bool) => + if isActive { + "text-xs font-medium text-indigo-300 border-b-2 border-indigo-500 px-2 py-1 cursor-pointer" + } else { + "text-xs text-gray-500 hover:text-gray-300 px-2 py-1 cursor-pointer" + } + + div( + list{Attrs.class_("flex items-center gap-0.5")}, + list{ + button( + list{ + Attrs.class_(buttonClass(Option.isNone(activeFilter))), + Events.onClick(onSetFilter(None)), + }, + list{text("All")}, + ), + button( + list{ + Attrs.class_(buttonClass(activeFilter === Some(GitHub))), + Events.onClick(onSetFilter(Some(GitHub))), + }, + list{text("GH")}, + ), + button( + list{ + Attrs.class_(buttonClass(activeFilter === Some(GitLab))), + Events.onClick(onSetFilter(Some(GitLab))), + }, + list{text("GL")}, + ), + button( + list{ + Attrs.class_(buttonClass(activeFilter === Some(Bitbucket))), + Events.onClick(onSetFilter(Some(Bitbucket))), + }, + list{text("BB")}, + ), + }, + ) + } + + /// Render the domain filter input. + let renderFilterInput = ( + filterText: string, + onInput: string => 'msg, + ): Tea_Vdom.t<'msg> => { + div( + list{Attrs.class_("flex items-center gap-2")}, + list{ + span( + list{Attrs.class_("text-xs text-gray-500")}, + list{text("Filter:")}, + ), + input( + list{ + Attrs.class_("bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300 w-40 focus:border-indigo-500 focus:outline-none"), + Attrs.type_("text"), + Attrs.value(filterText), + Attrs.placeholder("repo name..."), + Attrs.ariaLabel("Filter repos"), + Events.onInput(text => onInput(text)), + }, + list{}, + ), + }, + ) + } + + /// Render the selection control buttons. + let renderSelectionControls = ( + onSelectAll: 'msg, + onDeselectAll: 'msg, + ): Tea_Vdom.t<'msg> => { + div( + list{Attrs.class_("flex items-center gap-2")}, + list{ + button( + list{ + Attrs.class_("text-xs text-indigo-400 hover:text-indigo-300 cursor-pointer font-medium"), + Events.onClick(onSelectAll), + }, + list{text("Select All")}, + ), + span(list{Attrs.class_("text-gray-600")}, list{text("|")}), + button( + list{ + Attrs.class_("text-xs text-gray-400 hover:text-gray-300 cursor-pointer font-medium"), + Events.onClick(onDeselectAll), + }, + list{text("None")}, + ), + }, + ) + } + + /// Render the complete repo ribbon. + let view = ( + repos: array, + selectedRepoNames: array, + filterText: string, + activeForgeFilter: option, + onToggle: string => 'msg, + onSelectAll: 'msg, + onDeselectAll: 'msg, + onSetFilter: string => 'msg, + onSetForgeFilter: option => 'msg, + ): Tea_Vdom.t<'msg> => { + // Apply forge filter + let forgeFiltered = switch activeForgeFilter { + | None => repos + | Some(forge) => ForgeOpsEngine.filterByForge(repos, forge) + } + + // Apply text filter + let filteredRepos = ForgeOpsEngine.filterRepos(forgeFiltered, filterText) + + div( + list{ + Attrs.class_("border-b border-gray-800 pb-3"), + Attrs.role("region"), + Attrs.ariaLabel("Repo selector"), + }, + list{ + // Controls row + div( + list{Attrs.class_("flex items-center justify-between mb-2")}, + list{ + div( + list{Attrs.class_("flex items-center gap-3")}, + list{ + renderSelectionControls(onSelectAll, onDeselectAll), + renderForgeFilter(activeForgeFilter, onSetForgeFilter), + }, + ), + div( + list{Attrs.class_("flex items-center gap-3")}, + list{ + span( + list{Attrs.class_("text-xs text-gray-500")}, + list{ + text( + `${Int.toString(Array.length(selectedRepoNames))}/${Int.toString(Array.length(repos))} selected`, + ), + }, + ), + renderFilterInput(filterText, onSetFilter), + }, + ), + }, + ), + // Repo chips ribbon (scrollable) + div( + list{ + Attrs.class_("flex flex-wrap gap-1.5 max-h-24 overflow-y-auto"), + Attrs.role("listbox"), + Attrs.ariaLabel("Repositories"), + }, + filteredRepos + ->Array.map(repo => { + let isSelected = Array.includes(selectedRepoNames, repo.name) + renderRepoChip(repo, isSelected, onToggle) + }) + ->List.fromArray, + ), + }, + ) + } + +*/ diff --git a/migration/affinescript/forge-ops/src/components/ForgeOpsSettingsGrid.affine b/migration/affinescript/forge-ops/src/components/ForgeOpsSettingsGrid.affine new file mode 100644 index 00000000..118ffa92 --- /dev/null +++ b/migration/affinescript/forge-ops/src/components/ForgeOpsSettingsGrid.affine @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/components/ForgeOpsSettingsGrid.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsSettingsGrid; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Settings Grid — Toggle/switch/dropdown grid for forge settings. + /// + /// Renders forge settings as an interactive grid of toggles, dropdowns, + /// and number inputs, grouped by category. Settings unavailable on the + /// user's forge tier are greyed out with a "Requires Pro/Team/Enterprise" badge. + /// Forge-only settings show a forge badge (GH/GL/BB). + /// + /// Modified settings (different from last-synced state) show an orange dot. + /// Settings that differ from RSR policy show a yellow warning. + /// + /// Layout (within a category tab): + /// +-----------------------------------------+ + /// | Visibility [public v] | + /// | Issues Enabled [================ON] | + /// | Wiki Enabled [OFF===============] | + /// | Default Branch [main v] | + /// | License [PMPL-1.0-or-later v] | + /// +-----------------------------------------+ + + open ForgeOpsModel + open Tea.Html + + /// Render a toggle switch for on/off settings. + let renderToggle = ( + setting: forgeSetting, + onToggle: string => 'msg, + ): Tea_Vdom.t<'msg> => { + let isOn = ForgeOpsEngine.isSettingEnabled(setting.value) + let bgClass = isOn ? "bg-indigo-600" : "bg-gray-600" + let translateClass = isOn ? "translate-x-5" : "translate-x-0" + + div( + list{Attrs.class_("flex items-center justify-between py-2 px-3 hover:bg-gray-800/50 rounded")}, + list{ + // Label + description + div( + list{Attrs.class_("flex-1 mr-4")}, + list{ + div( + list{Attrs.class_("text-sm text-gray-200 font-medium flex items-center gap-2")}, + list{ + text(setting.label), + // Forge badge if forge-specific + switch setting.forgeId { + | Some(forge) => + span( + list{Attrs.class_(`text-xs font-mono px-1 py-0.5 rounded ${ForgeOpsEngine.forgeBadgeColour(forge)}`)}, + list{text(ForgeOpsCatalog.forgeLabel(forge))}, + ) + | None => noNode + }, + }, + ), + div( + list{Attrs.class_("text-xs text-gray-500 mt-0.5")}, + list{text(setting.description)}, + ), + }, + ), + // Toggle switch + button( + list{ + Attrs.class_( + `relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${bgClass}`, + ), + Attrs.role("switch"), + Attrs.ariaChecked(isOn), + Attrs.ariaLabel(`Toggle ${setting.label}`), + Events.onClick(onToggle(setting.id)), + }, + list{ + span( + list{ + Attrs.class_( + `inline-block h-4 w-4 rounded-full bg-white transition-transform ${translateClass}`, + ), + Attrs.style("margin-left", "2px"), + }, + list{}, + ), + }, + ), + // Modified indicator + if setting.modified { + span( + list{ + Attrs.class_("w-2 h-2 rounded-full bg-orange-400 ml-2"), + Attrs.title("Setting has been modified"), + }, + list{}, + ) + } else { + noNode + }, + }, + ) + } + + /// Render a dropdown select for enum settings. + let renderSelect = ( + setting: forgeSetting, + options: array, + onUpdate: (string, string) => 'msg, + ): Tea_Vdom.t<'msg> => { + let currentValue = ForgeOpsEngine.settingValueToString(setting.value) + + div( + list{Attrs.class_("flex items-center justify-between py-2 px-3 hover:bg-gray-800/50 rounded")}, + list{ + div( + list{Attrs.class_("flex-1 mr-4")}, + list{ + div( + list{Attrs.class_("text-sm text-gray-200 font-medium flex items-center gap-2")}, + list{ + text(setting.label), + switch setting.forgeId { + | Some(forge) => + span( + list{Attrs.class_(`text-xs font-mono px-1 py-0.5 rounded ${ForgeOpsEngine.forgeBadgeColour(forge)}`)}, + list{text(ForgeOpsCatalog.forgeLabel(forge))}, + ) + | None => noNode + }, + }, + ), + div( + list{Attrs.class_("text-xs text-gray-500 mt-0.5")}, + list{text(setting.description)}, + ), + }, + ), + select( + list{ + Attrs.class_( + "bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-gray-200 cursor-pointer focus:border-indigo-500 focus:outline-none", + ), + Attrs.value(currentValue), + Attrs.ariaLabel(`Select ${setting.label}`), + Events.onChange(value => onUpdate(setting.id, value)), + }, + options + ->Array.map(opt => { + option'( + list{ + Attrs.value(opt), + if opt === currentValue { + Attrs.selected(true) + } else { + Attrs.noProp + }, + }, + list{text(opt)}, + ) + }) + ->List.fromArray, + ), + if setting.modified { + span( + list{ + Attrs.class_("w-2 h-2 rounded-full bg-orange-400 ml-2"), + Attrs.title("Setting has been modified"), + }, + list{}, + ) + } else { + noNode + }, + }, + ) + } + + /// Render a number input for numeric settings. + let renderNumberInput = ( + setting: forgeSetting, + onUpdate: (string, string) => 'msg, + ): Tea_Vdom.t<'msg> => { + let currentValue = ForgeOpsEngine.settingValueToString(setting.value) + + div( + list{Attrs.class_("flex items-center justify-between py-2 px-3 hover:bg-gray-800/50 rounded")}, + list{ + div( + list{Attrs.class_("flex-1 mr-4")}, + list{ + div( + list{Attrs.class_("text-sm text-gray-200 font-medium flex items-center gap-2")}, + list{ + text(setting.label), + switch setting.forgeId { + | Some(forge) => + span( + list{Attrs.class_(`text-xs font-mono px-1 py-0.5 rounded ${ForgeOpsEngine.forgeBadgeColour(forge)}`)}, + list{text(ForgeOpsCatalog.forgeLabel(forge))}, + ) + | None => noNode + }, + }, + ), + div( + list{Attrs.class_("text-xs text-gray-500 mt-0.5")}, + list{text(setting.description)}, + ), + }, + ), + input( + list{ + Attrs.class_( + "bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-gray-200 w-24 focus:border-indigo-500 focus:outline-none", + ), + Attrs.type_("number"), + Attrs.value(currentValue), + Attrs.ariaLabel(`Set ${setting.label}`), + Events.onInput(value => onUpdate(setting.id, value)), + }, + list{}, + ), + }, + ) + } + + /// Render an exception indicator badge for per-repo overrides. + let renderExceptionBadge = ( + exc: repoException, + ): Tea_Vdom.t<'msg> => { + span( + list{ + Attrs.class_("text-xs text-yellow-400 font-medium px-1.5 py-0.5 border border-yellow-500/30 rounded ml-2"), + Attrs.title(`Exception: ${exc.reason}`), + }, + list{text("EXC")}, + ) + } + + /// Render a single setting row, choosing the appropriate input type. + /// Unavailable settings show a tier badge. Forge-only settings show a forge badge. + let renderSettingRow = ( + setting: forgeSetting, + repoExc: option, + onToggle: string => 'msg, + onUpdate: (string, string) => 'msg, + ): Tea_Vdom.t<'msg> => { + let catalogEntry = ForgeOpsCatalog.findById(setting.id) + + let rowContent = switch catalogEntry { + | None => renderToggle(setting, onToggle) + | Some(entry) => + switch entry.availability { + | Unavailable(tier) => + // Greyed-out setting with tier badge + div( + list{Attrs.class_("flex items-center justify-between py-2 px-3 opacity-40 cursor-not-allowed")}, + list{ + div( + list{Attrs.class_("flex-1 mr-4")}, + list{ + div( + list{Attrs.class_("text-sm text-gray-400 font-medium")}, + list{text(setting.label)}, + ), + div( + list{Attrs.class_("text-xs text-gray-600 mt-0.5")}, + list{text(setting.description)}, + ), + }, + ), + span( + list{Attrs.class_("text-xs text-amber-500/60 font-medium px-2 py-0.5 border border-amber-500/30 rounded")}, + list{text(`Requires ${ForgeOpsCatalog.tierLabel(tier)}`)}, + ), + }, + ) + | Available | ForgeOnly(_) | Limited(_) => + switch entry.valueType { + | "toggle" => renderToggle(setting, onToggle) + | "select" => + switch entry.options { + | Some(opts) => renderSelect(setting, opts, onUpdate) + | None => renderToggle(setting, onToggle) + } + | "number" => renderNumberInput(setting, onUpdate) + | _ => renderToggle(setting, onToggle) + } + } + } + + switch repoExc { + | None => rowContent + | Some(exc) => + div( + list{Attrs.class_("relative")}, + list{ + rowContent, + renderExceptionBadge(exc), + }, + ) + } + } + + /// Render the settings grid for a given category. + /// Shows all settings in the category, filtered by search text. + let view = ( + settings: array, + activeCategory: forgeCategory, + settingFilter: string, + exceptions: array, + currentRepoName: option, + onToggle: string => 'msg, + onUpdate: (string, string) => 'msg, + ): Tea_Vdom.t<'msg> => { + let categorySettings = settings->Array.filter(s => s.category === activeCategory) + + let filteredSettings = if String.length(settingFilter) > 0 { + let lower = String.toLowerCase(settingFilter) + categorySettings->Array.filter(s => + String.includes(String.toLowerCase(s.label), lower) + || String.includes(String.toLowerCase(s.id), lower) + ) + } else { + categorySettings + } + + div( + list{ + Attrs.class_("flex-1 overflow-y-auto"), + Attrs.role("list"), + Attrs.ariaLabel(`${ForgeOpsCatalog.categoryLabel(activeCategory)} settings`), + }, + list{ + if Array.length(filteredSettings) === 0 { + div( + list{Attrs.class_("text-gray-600 text-sm italic py-4 px-3")}, + list{text(`No ${ForgeOpsCatalog.categoryLabel(activeCategory)} settings found`)}, + ) + } else { + div( + list{Attrs.class_("divide-y divide-gray-800/50")}, + filteredSettings + ->Array.map(setting => { + let repoExc = switch currentRepoName { + | Some(repo) => + ForgeOpsEngine.findException(exceptions, repo, setting.id) + | None => None + } + renderSettingRow(setting, repoExc, onToggle, onUpdate) + }) + ->List.fromArray, + ) + }, + }, + ) + } + +*/ diff --git a/migration/affinescript/forge-ops/src/core/ForgeOpsCatalog.affine b/migration/affinescript/forge-ops/src/core/ForgeOpsCatalog.affine new file mode 100644 index 00000000..add7ce71 --- /dev/null +++ b/migration/affinescript/forge-ops/src/core/ForgeOpsCatalog.affine @@ -0,0 +1,992 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/core/ForgeOpsCatalog.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsCatalog; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Catalog — Complete git forge settings reference. + /// + /// Hardcoded catalog of all forge settings, organised by category. + /// Each entry declares the setting ID, display label, description, + /// forge/tier requirement, value type, and RSR default value. + /// + /// This is the single source of truth for what settings exist, what forge + /// and tier they require, and what value the RSR policy expects. The catalog + /// drives the settings grid UI and the compliance engine. + /// + /// Category structure: + /// Common (all forges): Repos, Mirroring, Protection, CI/CD, Secrets, + /// Webhooks, Releases, Security + /// Forge-specific: GitHub, GitLab, Bitbucket + + open ForgeOpsModel + + // ============================================================================ + // Catalog entry — metadata about a setting (NOT live data) + // ============================================================================ + + /// A catalog entry describing one forge setting. + type catalogEntry = { + id: string, // Setting ID + label: string, // Human-readable display label + description: string, // Tooltip/help text + category: forgeCategory, // Which tab + availability: settingAvailability, // Forge/tier gating + valueType: string, // "toggle" | "select" | "number" | "object" + options: option>, // Valid options for "select" type + defaultValue: settingValue, // RSR policy default + } + + // ============================================================================ + // Repos — common settings across all forges + // ============================================================================ + + let repoSettings: array = [ + { + id: "visibility", + label: "Repository Visibility", + description: "Public repos are visible to everyone. Private repos require explicit access.", + category: Repos, + availability: Available, + valueType: "select", + options: Some(["public", "private", "internal"]), + defaultValue: StringValue("public"), + }, + { + id: "description", + label: "Repository Description", + description: "Short description displayed on the repo page and in search results.", + category: Repos, + availability: Available, + valueType: "object", + options: None, + defaultValue: StringValue(""), + }, + { + id: "default_branch", + label: "Default Branch", + description: "The branch that PRs/MRs target by default. RSR standard is 'main'.", + category: Repos, + availability: Available, + valueType: "select", + options: Some(["main", "master", "develop", "trunk"]), + defaultValue: StringValue("main"), + }, + { + id: "has_issues", + label: "Issues Enabled", + description: "Enable the issue tracker for bug reports and feature requests.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "has_wiki", + label: "Wiki Enabled", + description: "Enable the built-in wiki. RSR prefers docs/ in-repo over wiki.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "has_projects", + label: "Projects Enabled", + description: "Enable the project boards feature (GitHub/GitLab).", + category: Repos, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "archived", + label: "Archived", + description: "Archived repos are read-only. No new issues, PRs, or pushes.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "is_template", + label: "Template Repository", + description: "Mark as a template repo that others can generate from. RSR template = rsr-template-repo.", + category: Repos, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "allow_forking", + label: "Allow Forking", + description: "Whether others can fork this repository.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "license", + label: "License", + description: "SPDX license identifier. RSR standard is PMPL-1.0-or-later.", + category: Repos, + availability: Available, + valueType: "select", + options: Some(["PMPL-1.0-or-later", "MPL-2.0", "MIT", "Apache-2.0", "GPL-3.0-or-later", "NONE"]), + defaultValue: StringValue("PMPL-1.0-or-later"), + }, + { + id: "topics", + label: "Topics/Tags", + description: "Repository topics for discoverability. RSR repos should include 'rsr' topic.", + category: Repos, + availability: Available, + valueType: "object", + options: None, + defaultValue: ObjectValue("[]"), + }, + { + id: "delete_branch_on_merge", + label: "Auto-Delete Head Branch", + description: "Automatically delete the head branch after a PR/MR is merged.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "squash_merge", + label: "Squash Merge Allowed", + description: "Allow squash-merging pull requests.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "merge_commit", + label: "Merge Commit Allowed", + description: "Allow creating merge commits for pull requests.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "rebase_merge", + label: "Rebase Merge Allowed", + description: "Allow rebase-merging pull requests.", + category: Repos, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + ] + + // ============================================================================ + // Mirroring — dedicated mirror management section + // ============================================================================ + + let mirrorSettings: array = [ + { + id: "mirror_to_gitlab", + label: "Mirror to GitLab", + description: "Push-mirror this repo to GitLab (hyperpolymath account). RSR requires all repos mirrored.", + category: Mirroring, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "mirror_to_bitbucket", + label: "Mirror to Bitbucket", + description: "Push-mirror this repo to Bitbucket (hyperpolymath account). RSR requires all repos mirrored.", + category: Mirroring, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "mirror_method", + label: "Mirror Method", + description: "How mirroring is implemented. GitHub Actions (mirror.yml) is the RSR standard.", + category: Mirroring, + availability: Available, + valueType: "select", + options: Some(["github_action", "gitlab_pull", "gitlab_push", "bitbucket_pipeline", "manual", "webhook"]), + defaultValue: StringValue("github_action"), + }, + { + id: "mirror_auto_sync", + label: "Auto-Sync Enabled", + description: "Automatically sync mirrors on push to the source repo.", + category: Mirroring, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "mirror_instant_sync", + label: "Instant Sync (instant-sync.yml)", + description: "Use the instant-sync.yml workflow for immediate propagation on every push.", + category: Mirroring, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "mirror_include_tags", + label: "Mirror Tags", + description: "Include git tags in mirror sync.", + category: Mirroring, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "mirror_include_releases", + label: "Mirror Releases", + description: "Create releases on mirror targets when releases are published on source.", + category: Mirroring, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + ] + + // ============================================================================ + // Protection — branch protection rules + // ============================================================================ + + let protectionSettings: array = [ + { + id: "protect_main", + label: "Protect Main Branch", + description: "Enable branch protection on the default branch. RSR requires this.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "require_pull_request", + label: "Require Pull Request", + description: "Require a pull request before merging to protected branches. No direct pushes.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "required_approvals", + label: "Required Approvals", + description: "Minimum number of approving reviews before merge. 0 = no reviews required.", + category: Protection, + availability: Available, + valueType: "number", + options: None, + defaultValue: IntValue(1), + }, + { + id: "require_status_checks", + label: "Require Status Checks", + description: "Require CI status checks to pass before merge.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "require_signed_commits", + label: "Require Signed Commits", + description: "Require GPG or SSH signed commits on protected branches.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "require_linear_history", + label: "Require Linear History", + description: "Prevent merge commits on protected branches. Forces rebase or squash.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "allow_force_push", + label: "Allow Force Push", + description: "Allow force pushes to protected branches. Should be OFF for main.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "allow_deletion", + label: "Allow Branch Deletion", + description: "Allow deleting protected branches. Should be OFF for main.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "enforce_admins", + label: "Enforce for Admins", + description: "Apply protection rules to repository admins too, not just contributors.", + category: Protection, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + ] + + // ============================================================================ + // CI/CD — pipeline and workflow settings + // ============================================================================ + + let ciCdSettings: array = [ + { + id: "actions_enabled", + label: "GitHub Actions Enabled", + description: "Enable GitHub Actions workflows for this repository.", + category: CiCd, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "actions_permissions", + label: "Actions Permissions", + description: "Which actions are allowed to run. RSR standard: selected (pinned SHA only).", + category: CiCd, + availability: ForgeOnly(GitHub), + valueType: "select", + options: Some(["all", "local_only", "selected", "disabled"]), + defaultValue: StringValue("selected"), + }, + { + id: "gitlab_ci_enabled", + label: "GitLab CI/CD Enabled", + description: "Enable GitLab CI/CD pipelines for this project.", + category: CiCd, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "gitlab_auto_devops", + label: "Auto DevOps", + description: "Enable GitLab Auto DevOps (automatic CI/CD pipeline).", + category: CiCd, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "bitbucket_pipelines_enabled", + label: "Bitbucket Pipelines Enabled", + description: "Enable Bitbucket Pipelines for this repository.", + category: CiCd, + availability: ForgeOnly(Bitbucket), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "hypatia_scan", + label: "Hypatia Scan Workflow", + description: "RSR requires hypatia-scan.yml workflow for neurosymbolic CI intelligence.", + category: CiCd, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "codeql_enabled", + label: "CodeQL Analysis", + description: "RSR requires codeql.yml workflow for code analysis.", + category: CiCd, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "scorecard_enabled", + label: "OpenSSF Scorecard", + description: "RSR requires scorecard.yml for OpenSSF Scorecard checks.", + category: CiCd, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + ] + + // ============================================================================ + // Secrets — repository secrets and variables + // ============================================================================ + + let secretsSettings: array = [ + { + id: "has_gitlab_token", + label: "GITLAB_TOKEN Secret", + description: "GitLab personal access token for mirror.yml workflow. Required for mirroring.", + category: Secrets, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "has_bitbucket_token", + label: "BITBUCKET_TOKEN Secret", + description: "Bitbucket API token for mirror.yml workflow. Required for mirroring.", + category: Secrets, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "has_bitbucket_user", + label: "BITBUCKET_USER Secret", + description: "Bitbucket username/email for mirror authentication.", + category: Secrets, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "dependabot_secrets", + label: "Dependabot Secrets", + description: "Secrets available to Dependabot for private registry access.", + category: Secrets, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + ] + + // ============================================================================ + // Webhooks — webhook configuration + // ============================================================================ + + let webhookSettings: array = [ + { + id: "webhook_ssl_verify", + label: "SSL Verification", + description: "Verify SSL certificates for webhook deliveries. Must be ON.", + category: Webhooks, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "webhook_content_type", + label: "Content Type", + description: "Payload format for webhook deliveries.", + category: Webhooks, + availability: Available, + valueType: "select", + options: Some(["application/json", "application/x-www-form-urlencoded"]), + defaultValue: StringValue("application/json"), + }, + ] + + // ============================================================================ + // Releases — release management + // ============================================================================ + + let releaseSettings: array = [ + { + id: "generate_release_notes", + label: "Auto-Generate Release Notes", + description: "Automatically generate release notes from commit messages and PRs.", + category: Releases, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "tag_protection", + label: "Tag Protection Rules", + description: "Protect tags matching patterns from being created/deleted by non-admins.", + category: Releases, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + ] + + // ============================================================================ + // Security — security features + // ============================================================================ + + let securitySettings: array = [ + { + id: "dependabot_alerts", + label: "Dependabot Alerts", + description: "Enable Dependabot vulnerability alerts for dependencies.", + category: Security, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "dependabot_updates", + label: "Dependabot Security Updates", + description: "Enable automatic security update PRs from Dependabot.", + category: Security, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "secret_scanning", + label: "Secret Scanning", + description: "Scan for accidentally committed secrets (API keys, tokens, etc.).", + category: Security, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "secret_scanning_push_protection", + label: "Secret Push Protection", + description: "Block pushes that contain known secret patterns.", + category: Security, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "code_scanning", + label: "Code Scanning", + description: "Enable code scanning (CodeQL or third-party) for vulnerability detection.", + category: Security, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "vulnerability_alerts", + label: "Vulnerability Alerts", + description: "Receive alerts when dependencies have known vulnerabilities.", + category: Security, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "security_policy", + label: "SECURITY.md Present", + description: "RSR requires a SECURITY.md file with vulnerability disclosure instructions.", + category: Security, + availability: Available, + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + ] + + // ============================================================================ + // GitHub-specific settings + // ============================================================================ + + let gitHubSpecificSettings: array = [ + { + id: "gh_discussions", + label: "Discussions Enabled", + description: "Enable GitHub Discussions for community Q&A and announcements.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gh_sponsorship", + label: "Sponsors Enabled", + description: "Enable GitHub Sponsors for this repository.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gh_pages", + label: "GitHub Pages", + description: "Enable GitHub Pages for this repository.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gh_pages_source", + label: "Pages Source", + description: "GitHub Pages source branch and directory.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "select", + options: Some(["gh-pages", "main", "main/docs", "none"]), + defaultValue: StringValue("gh-pages"), + }, + { + id: "gh_environments", + label: "Deployment Environments", + description: "Configure deployment environments with protection rules.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gh_codespaces", + label: "Codespaces Enabled", + description: "Allow GitHub Codespaces for this repository.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gh_copilot", + label: "Copilot Access", + description: "Allow GitHub Copilot to access this repository's code.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gh_vulnerability_db", + label: "Security Advisories", + description: "Enable private security advisory creation for responsible disclosure.", + category: GitHubSpecific, + availability: ForgeOnly(GitHub), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + ] + + // ============================================================================ + // GitLab-specific settings + // ============================================================================ + + let gitLabSpecificSettings: array = [ + { + id: "gl_container_registry", + label: "Container Registry", + description: "Enable GitLab Container Registry for Docker/OCI images.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gl_package_registry", + label: "Package Registry", + description: "Enable GitLab Package Registry for npm, Maven, NuGet, etc.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gl_merge_method", + label: "Merge Method", + description: "Default merge method for merge requests.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "select", + options: Some(["merge", "rebase_merge", "ff"]), + defaultValue: StringValue("merge"), + }, + { + id: "gl_squash_option", + label: "Squash Commits", + description: "Squash commit behaviour for merge requests.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "select", + options: Some(["never", "always", "default_on", "default_off"]), + defaultValue: StringValue("default_off"), + }, + { + id: "gl_pages", + label: "GitLab Pages", + description: "Enable GitLab Pages for static site hosting.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gl_snippets", + label: "Snippets Enabled", + description: "Enable GitLab Snippets for sharing code fragments.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "gl_service_desk", + label: "Service Desk", + description: "Enable GitLab Service Desk for email-based issue creation.", + category: GitLabSpecific, + availability: ForgeOnly(GitLab), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + ] + + // ============================================================================ + // Bitbucket-specific settings + // ============================================================================ + + let bitbucketSpecificSettings: array = [ + { + id: "bb_jira_integration", + label: "Jira Integration", + description: "Link this repository to a Jira project for issue tracking.", + category: BitbucketSpecific, + availability: ForgeOnly(Bitbucket), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "bb_branch_restrictions", + label: "Branch Restrictions", + description: "Bitbucket-specific branch restrictions (separate from protection rules).", + category: BitbucketSpecific, + availability: ForgeOnly(Bitbucket), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "bb_merge_checks", + label: "Merge Checks", + description: "Require passing builds and minimum approvals before merge.", + category: BitbucketSpecific, + availability: ForgeOnly(Bitbucket), + valueType: "toggle", + options: None, + defaultValue: BoolValue(true), + }, + { + id: "bb_large_files", + label: "Git LFS Enabled", + description: "Enable Git Large File Storage for this repository.", + category: BitbucketSpecific, + availability: ForgeOnly(Bitbucket), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + { + id: "bb_default_reviewers", + label: "Default Reviewers", + description: "Automatically add default reviewers to pull requests.", + category: BitbucketSpecific, + availability: ForgeOnly(Bitbucket), + valueType: "toggle", + options: None, + defaultValue: BoolValue(false), + }, + ] + + // ============================================================================ + // Aggregate catalog + // ============================================================================ + + /// All settings in the catalog, combined from all categories. + let allSettings: array = Array.flat([ + repoSettings, + mirrorSettings, + protectionSettings, + ciCdSettings, + secretsSettings, + webhookSettings, + releaseSettings, + securitySettings, + gitHubSpecificSettings, + gitLabSpecificSettings, + bitbucketSpecificSettings, + ]) + + /// Find a catalog entry by its setting ID. + let findById = (id: string): option => { + allSettings->Array.find(entry => entry.id === id) + } + + /// Filter catalog entries by category. + let byCategory = (cat: forgeCategory): array => { + allSettings->Array.filter(entry => entry.category === cat) + } + + /// Get all catalog entries available on a given forge. + let availableOnForge = (forge: forgeId): array => { + allSettings->Array.filter(entry => { + switch entry.availability { + | Available => true + | Limited(_) => true + | ForgeOnly(f) => f === forge + | Unavailable(_) => false + } + }) + } + + /// Get all catalog entries available on a given tier. + let availableOnTier = (tier: forgeTier): array => { + allSettings->Array.filter(entry => { + switch entry.availability { + | Available => true + | Limited(_) => true + | ForgeOnly(_) => true + | Unavailable(required) => + switch (tier, required) { + | (EnterpriseTier, _) => true + | (TeamTier, EnterpriseTier) => false + | (TeamTier, _) => true + | (ProTier, EnterpriseTier) => false + | (ProTier, TeamTier) => false + | (ProTier, _) => true + | (FreeTier, FreeTier) => true + | (FreeTier, _) => false + } + } + }) + } + + /// Get the number of settings in each category. + let categoryCounts = (): array<(forgeCategory, int)> => { + [ + (Repos, Array.length(repoSettings)), + (Mirroring, Array.length(mirrorSettings)), + (Protection, Array.length(protectionSettings)), + (CiCd, Array.length(ciCdSettings)), + (Secrets, Array.length(secretsSettings)), + (Webhooks, Array.length(webhookSettings)), + (Releases, Array.length(releaseSettings)), + (Security, Array.length(securitySettings)), + (GitHubSpecific, Array.length(gitHubSpecificSettings)), + (GitLabSpecific, Array.length(gitLabSpecificSettings)), + (BitbucketSpecific, Array.length(bitbucketSpecificSettings)), + ] + } + + /// Human-readable label for a setting category. + let categoryLabel = (cat: forgeCategory): string => { + switch cat { + | Repos => "Repos" + | Mirroring => "Mirroring" + | Protection => "Protection" + | CiCd => "CI/CD" + | Secrets => "Secrets" + | Webhooks => "Webhooks" + | Releases => "Releases" + | Security => "Security" + | GitHubSpecific => "GitHub" + | GitLabSpecific => "GitLab" + | BitbucketSpecific => "Bitbucket" + } + } + + /// Human-readable label for a forge ID. + let forgeLabel = (forge: forgeId): string => { + switch forge { + | GitHub => "GitHub" + | GitLab => "GitLab" + | Bitbucket => "Bitbucket" + } + } + + /// Human-readable label for a forge tier. + let tierLabel = (tier: forgeTier): string => { + switch tier { + | FreeTier => "Free" + | ProTier => "Pro" + | TeamTier => "Team" + | EnterpriseTier => "Enterprise" + } + } + +*/ diff --git a/migration/affinescript/forge-ops/src/core/ForgeOpsEngine.affine b/migration/affinescript/forge-ops/src/core/ForgeOpsEngine.affine new file mode 100644 index 00000000..91b15270 --- /dev/null +++ b/migration/affinescript/forge-ops/src/core/ForgeOpsEngine.affine @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/core/ForgeOpsEngine.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 1 migration consideration detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 148: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { + +module ForgeOpsEngine; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Engine — Pure computation for compliance evaluation and diff. + /// + /// All functions are pure (no side effects, no API calls). They operate on + /// model data to produce audit findings, compliance scores, cross-forge diffs, + /// and repo filter results. Mirrors CloudGuardEngine's pattern exactly. + + open ForgeOpsModel + + // ============================================================================ + // JSON parsing helpers + // ============================================================================ + + /// Helper: extract a string field from a JSON object dict, with a default. + let jsonStr = (obj: Dict.t, key: string, default: string): string => { + switch Dict.get(obj, key) { + | Some(v) => + switch JSON.Classify.classify(v) { + | String(s) => s + | _ => default + } + | None => default + } + } + + /// Helper: extract a bool field from a JSON object dict, with a default. + let jsonBool = (obj: Dict.t, key: string, default: bool): bool => { + switch Dict.get(obj, key) { + | Some(v) => + switch JSON.Classify.classify(v) { + | Bool(b) => b + | _ => default + } + | None => default + } + } + + /// Helper: extract an int field from a JSON object dict, with a default. + let jsonInt = (obj: Dict.t, key: string, default: int): int => { + switch Dict.get(obj, key) { + | Some(v) => + switch JSON.Classify.classify(v) { + | Number(n) => Float.toInt(n) + | _ => default + } + | None => default + } + } + + /// Helper: extract a string array field from a JSON object dict. + let jsonStrArray = (obj: Dict.t, key: string): array => { + switch Dict.get(obj, key) { + | Some(v) => + switch JSON.Classify.classify(v) { + | Array(arr) => + arr->Array.filterMap(item => + switch JSON.Classify.classify(item) { + | String(s) => Some(s) + | _ => None + } + ) + | _ => [] + } + | None => [] + } + } + + // ============================================================================ + // JSON → forgeRepo parsing (GitHub API shape) + // ============================================================================ + + /// Parse a GitHub repo JSON object into a forgeRepo record. + let parseGitHubRepo = (json: JSON.t): option => { + switch JSON.Classify.classify(json) { + | Object(obj) => { + let name = jsonStr(obj, "name", "") + let fullName = jsonStr(obj, "full_name", "") + if name === "" || fullName === "" { + None + } else { + let visibility = switch jsonStr(obj, "visibility", "public") { + | "private" => Private + | "internal" => Internal + | _ => Public + } + + let sshUrl = jsonStr(obj, "ssh_url", "") + let htmlUrl = jsonStr(obj, "html_url", "") + let cloneUrl = jsonStr(obj, "clone_url", "") + + let license = switch Dict.get(obj, "license") { + | Some(licJson) => + switch JSON.Classify.classify(licJson) { + | Object(licObj) => + let spdx = jsonStr(licObj, "spdx_id", "") + if spdx !== "" && spdx !== "NOASSERTION" { Some(spdx) } else { None } + | Null => None + | _ => None + } + | None => None + } + + let language = switch Dict.get(obj, "language") { + | Some(langJson) => + switch JSON.Classify.classify(langJson) { + | String(s) => Some(s) + | _ => None + } + | None => None + } + + Some({ + name, + fullName, + description: jsonStr(obj, "description", ""), + visibility, + defaultBranch: jsonStr(obj, "default_branch", "main"), + archived: jsonBool(obj, "archived", false), + fork: jsonBool(obj, "fork", false), + template: jsonBool(obj, "is_template", false), + language, + topics: jsonStrArray(obj, "topics"), + license, + createdAt: jsonStr(obj, "created_at", ""), + updatedAt: jsonStr(obj, "updated_at", ""), + pushedAt: jsonStr(obj, "pushed_at", ""), + gitHub: Some({ + forgeId: GitHub, + remoteId: jsonStr(obj, "id", ""), + url: cloneUrl, + sshUrl, + webUrl: htmlUrl, + isMirror: jsonBool(obj, "mirror_url", false), + lastSyncedAt: None, + }), + gitLab: None, + bitbucket: None, + }) + } + } + | _ => None + } + } + + /// Parse a JSON string (array of repo objects) into forgeRepo records. + let parseGitHubReposJson = (jsonString: string): array => { + try { + let parsed = JSON.parseExn(jsonString) + switch JSON.Classify.classify(parsed) { + | Array(arr) => arr->Array.filterMap(parseGitHubRepo) + | _ => [] + } + } catch { + | _ => [] + } + } + + // ============================================================================ + // Setting value helpers + // ============================================================================ + + /// Stringify a settingValue for display. + let settingValueToString = (v: settingValue): string => { + switch v { + | BoolValue(b) => b ? "On" : "Off" + | StringValue(s) => s + | IntValue(n) => Int.toString(n) + | ObjectValue(json) => json + } + } + + /// Check if a setting value represents "on" / "enabled" / true. + let isSettingEnabled = (v: settingValue): bool => { + switch v { + | BoolValue(b) => b + | StringValue(s) => s === "on" || s === "true" || s === "1" + | IntValue(n) => n > 0 + | ObjectValue(_) => true + } + } + + /// Serialise modified settings into a JSON string for batch updates. + let serialiseModifiedSettings = (settings: array): string => { + let modified = settings->Array.filter(s => s.modified) + let items = modified->Array.map(s => { + let valueJson = switch s.value { + | BoolValue(b) => if b { "true" } else { "false" } + | StringValue(str) => `"${str}"` + | IntValue(n) => Int.toString(n) + | ObjectValue(json) => json + } + `{"id":"${s.id}","value":${valueJson}}` + }) + `[${Array.join(items, ",")}]` + } + + // ============================================================================ + // Compliance evaluation + // ============================================================================ + + /// Evaluate a single setting against its policy constraint. + let evaluateSetting = ( + repoName: string, + setting: forgeSetting, + rule: policyConstraint, + ): option => { + let currentStr = settingValueToString(setting.value) + let expectedStr = settingValueToString(setting.defaultValue) + + let matches = switch (setting.value, setting.defaultValue) { + | (BoolValue(a), BoolValue(b)) => a === b + | (StringValue(a), StringValue(b)) => a === b + | (IntValue(a), IntValue(b)) => a === b + | _ => currentStr === expectedStr + } + + if matches { + None + } else { + Some({ + repoName, + settingId: setting.id, + category: setting.category, + forgeId: setting.forgeId, + severity: rule.severity, + message: `${rule.expression}: expected ${expectedStr}, got ${currentStr}`, + currentValue: currentStr, + expectedValue: expectedStr, + autoFixable: setting.editable, + }) + } + } + + /// Compute the compliance score for settings against constraints. + let computeComplianceScore = ( + settings: array, + constraints: array, + ): (int, int, float) => { + let passed = ref(0) + let failed = ref(0) + + Array.forEach(constraints, rule => { + let matchingSetting = Array.find(settings, s => s.id === rule.id) + switch matchingSetting { + | Some(setting) => { + let currentStr = settingValueToString(setting.value) + let expectedStr = settingValueToString(setting.defaultValue) + if currentStr === expectedStr { + passed := passed.contents + 1 + } else { + failed := failed.contents + 1 + } + } + | None => failed := failed.contents + 1 + } + }) + + let total = Int.toFloat(passed.contents + failed.contents) + let score = if total > 0.0 { Int.toFloat(passed.contents) /. total } else { 0.0 } + (passed.contents, failed.contents, score) + } + + // ============================================================================ + // Repo filtering and sorting + // ============================================================================ + + /// Filter repos by search text (matches repo name). + let filterRepos = (repos: array, searchText: string): array => { + if String.length(searchText) === 0 { + repos + } else { + let lower = String.toLowerCase(searchText) + repos->Array.filter(repo => String.includes(String.toLowerCase(repo.name), lower)) + } + } + + /// Filter repos by forge presence. + let filterByForge = (repos: array, forge: forgeId): array => { + repos->Array.filter(repo => { + switch forge { + | GitHub => Option.isSome(repo.gitHub) + | GitLab => Option.isSome(repo.gitLab) + | Bitbucket => Option.isSome(repo.bitbucket) + } + }) + } + + /// Sort repos alphabetically by name. + let sortReposByName = (repos: array): array => { + let copy = Array.copy(repos) + Array.sort(copy, (a, b) => String.compare(a.name, b.name)) + copy + } + + /// Get repos that are missing on one or more forges (not fully mirrored). + let unmirroredRepos = (repos: array): array => { + repos->Array.filter(repo => + Option.isNone(repo.gitHub) + || Option.isNone(repo.gitLab) + || Option.isNone(repo.bitbucket) + ) + } + + // ============================================================================ + // Mirror status helpers + // ============================================================================ + + /// Get the sync status label for display. + let mirrorStatusLabel = (status: mirrorSyncStatus): string => { + switch status { + | InSync => "In Sync" + | Behind(n) => `${Int.toString(n)} behind` + | Ahead(n) => `${Int.toString(n)} ahead` + | Diverged(behind, ahead) => `${Int.toString(behind)} behind, ${Int.toString(ahead)} ahead` + | SyncFailed(err) => `Failed: ${err}` + | NeverSynced => "Never synced" + | Syncing => "Syncing..." + } + } + + /// CSS colour class for mirror status. + let mirrorStatusColour = (status: mirrorSyncStatus): string => { + switch status { + | InSync => "text-green-400" + | Behind(_) => "text-yellow-400" + | Ahead(_) => "text-blue-400" + | Diverged(_, _) => "text-orange-400" + | SyncFailed(_) => "text-red-400" + | NeverSynced => "text-gray-500" + | Syncing => "text-indigo-400" + } + } + + /// Count how many forges a repo is present on. + let forgeCount = (repo: forgeRepo): int => { + let gh = if Option.isSome(repo.gitHub) { 1 } else { 0 } + let gl = if Option.isSome(repo.gitLab) { 1 } else { 0 } + let bb = if Option.isSome(repo.bitbucket) { 1 } else { 0 } + gh + gl + bb + } + + // ============================================================================ + // Severity helpers (same as CloudGuardEngine) + // ============================================================================ + + /// Severity label for display. + let severityLabel = (sev: auditSeverity): string => { + switch sev { + | Critical => "CRITICAL" + | High => "HIGH" + | Medium => "MEDIUM" + | Low => "LOW" + | Info => "INFO" + } + } + + /// CSS colour class for a severity level. + let severityColour = (sev: auditSeverity): string => { + switch sev { + | Critical => "text-red-400" + | High => "text-orange-400" + | Medium => "text-yellow-400" + | Low => "text-blue-400" + | Info => "text-gray-400" + } + } + + /// Sort audit findings by severity (Critical first). + let sortFindingsBySeverity = (findings: array): array => { + let severityOrder = (sev: auditSeverity): int => { + switch sev { + | Critical => 0 + | High => 1 + | Medium => 2 + | Low => 3 + | Info => 4 + } + } + let copy = Array.copy(findings) + Array.sort(copy, (a, b) => Int.compare(severityOrder(a.severity), severityOrder(b.severity))) + copy + } + + // ============================================================================ + // Per-repo exception helpers + // ============================================================================ + + /// Find the exception for a specific repo + setting, if any. + let findException = ( + exceptions: array, + repoName: string, + settingId: string, + ): option => { + exceptions->Array.find(e => e.repoName === repoName && e.settingId === settingId) + } + + /// Apply exceptions to a setting for a given repo. + let applyException = ( + setting: forgeSetting, + exceptions: array, + repoName: string, + ): forgeSetting => { + switch findException(exceptions, repoName, setting.id) { + | Some(exc) => {...setting, value: exc.overrideValue, modified: true} + | None => setting + } + } + + // ============================================================================ + // Forge presence badge helpers + // ============================================================================ + + /// Get a compact string showing which forges a repo is on. + let forgePresenceBadge = (repo: forgeRepo): string => { + let parts = [] + let parts = if Option.isSome(repo.gitHub) { Array.concat(parts, ["GH"]) } else { parts } + let parts = if Option.isSome(repo.gitLab) { Array.concat(parts, ["GL"]) } else { parts } + let parts = if Option.isSome(repo.bitbucket) { Array.concat(parts, ["BB"]) } else { parts } + Array.join(parts, "+") + } + + /// CSS colour for a forge badge. + let forgeBadgeColour = (forge: forgeId): string => { + switch forge { + | GitHub => "text-gray-200 bg-gray-800" + | GitLab => "text-orange-300 bg-orange-900/30" + | Bitbucket => "text-blue-300 bg-blue-900/30" + } + } + + // ============================================================================ + // CI/CD status helpers + // ============================================================================ + + /// Label for a CI run status. + let ciStatusLabel = (status: ciRunStatus): string => { + switch status { + | CiSuccess => "Success" + | CiFailure => "Failed" + | CiPending => "Pending" + | CiRunning => "Running" + | CiCancelled => "Cancelled" + | CiSkipped => "Skipped" + | CiUnknown => "Unknown" + } + } + + /// CSS colour for a CI run status. + let ciStatusColour = (status: ciRunStatus): string => { + switch status { + | CiSuccess => "text-green-400" + | CiFailure => "text-red-400" + | CiPending => "text-yellow-400" + | CiRunning => "text-indigo-400" + | CiCancelled => "text-gray-500" + | CiSkipped => "text-gray-600" + | CiUnknown => "text-gray-600" + } + } + + // ============================================================================ + // Cross-forge diff computation + // ============================================================================ + + /// Compute a cross-forge diff for a set of settings. + /// Compares values of the same setting across GitHub, GitLab, and Bitbucket. + let computeForgeDiff = ( + repoName: string, + ghSettings: array, + glSettings: array, + bbSettings: array, + ): forgeDiff => { + // Collect all unique setting IDs across all forges + let allIds: Dict.t = Dict.make() + Array.forEach(ghSettings, s => Dict.set(allIds, s.id, true)) + Array.forEach(glSettings, s => Dict.set(allIds, s.id, true)) + Array.forEach(bbSettings, s => Dict.set(allIds, s.id, true)) + + let entries = Dict.keysToArray(allIds)->Array.map(id => { + let ghVal = ghSettings->Array.find(s => s.id === id)->Option.map(s => settingValueToString(s.value)) + let glVal = glSettings->Array.find(s => s.id === id)->Option.map(s => settingValueToString(s.value)) + let bbVal = bbSettings->Array.find(s => s.id === id)->Option.map(s => settingValueToString(s.value)) + + let catalogEntry = ForgeOpsCatalog.findById(id) + let policyVal = catalogEntry->Option.map(e => settingValueToString(e.defaultValue)) + let cat = switch catalogEntry { + | Some(e) => e.category + | None => Repos + } + + // Check consistency: all present values must match + let presentValues = [ghVal, glVal, bbVal]->Array.filterMap(v => v) + let consistent = switch Array.get(presentValues, 0) { + | Some(first) => presentValues->Array.every(v => v === first) + | None => true + } + + { + settingId: id, + repoName, + category: cat, + gitHubValue: ghVal, + gitLabValue: glVal, + bitbucketValue: bbVal, + policyValue: policyVal, + consistent, + } + }) + + let inconsistentCount = entries->Array.filter(e => !e.consistent)->Array.length + let missingCount = entries->Array.filter(e => + Option.isNone(e.gitHubValue) || Option.isNone(e.gitLabValue) || Option.isNone(e.bitbucketValue) + )->Array.length + + { + timestamp: "now", // TODO: use Date.now() ISO 8601 + entries, + inconsistentCount, + missingCount, + } + } + +*/ diff --git a/migration/affinescript/forge-ops/src/core/ForgeOpsPolicy.affine b/migration/affinescript/forge-ops/src/core/ForgeOpsPolicy.affine new file mode 100644 index 00000000..10ce51e4 --- /dev/null +++ b/migration/affinescript/forge-ops/src/core/ForgeOpsPolicy.affine @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/core/ForgeOpsPolicy.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsPolicy; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Policy — RSR compliance constraint definitions. + /// + /// Defines the security and compliance policy that all repos should meet. + /// These represent the RSR (Rhodium Standard Repository) requirements plus + /// hyperpolymath-specific rules (mirroring, Hypatia, workflow standards). + /// + /// In the future, constraints will load from Nickel (.k9.ncl) policy files + /// and/or A2ML Trustfile definitions. Currently hardcoded. + + open ForgeOpsModel + + // ============================================================================ + // Default RSR policy constraints + // ============================================================================ + + let defaultConstraints: array = [ + // --- Repos --- + { + id: "default_branch", + expression: "default_branch == \"main\"", + category: Repos, + enabled: true, + severity: High, + description: "RSR requires 'main' as the default branch name.", + appliesTo: None, + }, + { + id: "has_issues", + expression: "has_issues == true", + category: Repos, + enabled: true, + severity: Medium, + description: "Issue tracker should be enabled for bug reports and feature requests.", + appliesTo: None, + }, + { + id: "has_wiki", + expression: "has_wiki == false", + category: Repos, + enabled: true, + severity: Low, + description: "RSR prefers docs/ in-repo over wiki. Wiki should be disabled.", + appliesTo: None, + }, + { + id: "license", + expression: "license == \"PMPL-1.0-or-later\"", + category: Repos, + enabled: true, + severity: Critical, + description: "All hyperpolymath repos must use PMPL-1.0-or-later (or MPL-2.0 fallback with reason).", + appliesTo: None, + }, + { + id: "delete_branch_on_merge", + expression: "delete_branch_on_merge == true", + category: Repos, + enabled: true, + severity: Low, + description: "Auto-delete head branches after merge to keep the repo clean.", + appliesTo: None, + }, + + // --- Mirroring --- + { + id: "mirror_to_gitlab", + expression: "mirror_to_gitlab == true", + category: Mirroring, + enabled: true, + severity: Critical, + description: "RSR requires all repos mirrored to GitLab (hyperpolymath account).", + appliesTo: None, + }, + { + id: "mirror_to_bitbucket", + expression: "mirror_to_bitbucket == true", + category: Mirroring, + enabled: true, + severity: Critical, + description: "RSR requires all repos mirrored to Bitbucket (hyperpolymath account).", + appliesTo: None, + }, + { + id: "mirror_auto_sync", + expression: "mirror_auto_sync == true", + category: Mirroring, + enabled: true, + severity: High, + description: "Mirror sync should be automated, not manual.", + appliesTo: None, + }, + { + id: "mirror_instant_sync", + expression: "mirror_instant_sync == true", + category: Mirroring, + enabled: true, + severity: Medium, + description: "Instant-sync.yml workflow provides immediate propagation on every push.", + appliesTo: Some(GitHub), + }, + + // --- Protection --- + { + id: "protect_main", + expression: "protect_main == true", + category: Protection, + enabled: true, + severity: Critical, + description: "The default branch must have branch protection enabled.", + appliesTo: None, + }, + { + id: "require_pull_request", + expression: "require_pull_request == true", + category: Protection, + enabled: true, + severity: High, + description: "No direct pushes to main — all changes must go through a PR/MR.", + appliesTo: None, + }, + { + id: "allow_force_push", + expression: "allow_force_push == false", + category: Protection, + enabled: true, + severity: Critical, + description: "Force push to main must be prohibited. Prevents history rewriting.", + appliesTo: None, + }, + { + id: "allow_deletion", + expression: "allow_deletion == false", + category: Protection, + enabled: true, + severity: Critical, + description: "Deleting the main branch must be prohibited.", + appliesTo: None, + }, + + // --- CI/CD --- + { + id: "hypatia_scan", + expression: "hypatia_scan == true", + category: CiCd, + enabled: true, + severity: Critical, + description: "RSR requires hypatia-scan.yml workflow for neurosymbolic CI intelligence.", + appliesTo: Some(GitHub), + }, + { + id: "codeql_enabled", + expression: "codeql_enabled == true", + category: CiCd, + enabled: true, + severity: High, + description: "RSR requires codeql.yml for code analysis.", + appliesTo: Some(GitHub), + }, + { + id: "scorecard_enabled", + expression: "scorecard_enabled == true", + category: CiCd, + enabled: true, + severity: High, + description: "RSR requires scorecard.yml for OpenSSF Scorecard.", + appliesTo: Some(GitHub), + }, + + // --- Secrets --- + { + id: "has_gitlab_token", + expression: "has_gitlab_token == true", + category: Secrets, + enabled: true, + severity: High, + description: "GITLAB_TOKEN secret required for mirror.yml workflow.", + appliesTo: Some(GitHub), + }, + { + id: "has_bitbucket_token", + expression: "has_bitbucket_token == true", + category: Secrets, + enabled: true, + severity: High, + description: "BITBUCKET_TOKEN secret required for mirror.yml workflow.", + appliesTo: Some(GitHub), + }, + + // --- Webhooks --- + { + id: "webhook_ssl_verify", + expression: "webhook_ssl_verify == true", + category: Webhooks, + enabled: true, + severity: Critical, + description: "SSL verification must be enabled for all webhooks. Disabling allows MITM.", + appliesTo: None, + }, + + // --- Security --- + { + id: "dependabot_alerts", + expression: "dependabot_alerts == true", + category: Security, + enabled: true, + severity: High, + description: "Dependabot alerts should be enabled for vulnerability notification.", + appliesTo: Some(GitHub), + }, + { + id: "secret_scanning", + expression: "secret_scanning == true", + category: Security, + enabled: true, + severity: Critical, + description: "Secret scanning detects accidentally committed credentials.", + appliesTo: Some(GitHub), + }, + { + id: "secret_scanning_push_protection", + expression: "secret_scanning_push_protection == true", + category: Security, + enabled: true, + severity: Critical, + description: "Push protection blocks commits containing known secret patterns.", + appliesTo: Some(GitHub), + }, + { + id: "security_policy", + expression: "security_policy == true", + category: Security, + enabled: true, + severity: High, + description: "RSR requires SECURITY.md with vulnerability disclosure instructions.", + appliesTo: None, + }, + ] + + // ============================================================================ + // Policy evaluation helpers + // ============================================================================ + + /// Get all enabled constraints. + let enabledConstraints = (): array => { + defaultConstraints->Array.filter(c => c.enabled) + } + + /// Get constraints for a specific category. + let constraintsByCategory = (cat: forgeCategory): array => { + defaultConstraints->Array.filter(c => c.category === cat) + } + + /// Find a constraint by its setting ID. + let findConstraint = (id: string): option => { + defaultConstraints->Array.find(c => c.id === id) + } + + /// Run a full audit of settings against the policy for a given repo. + let auditSettings = ( + repoName: string, + settings: array, + ): auditResult => { + let enabledC = enabledConstraints() + let findings = ref([]) + + Array.forEach(settings, setting => { + switch findConstraint(setting.id) { + | None => () + | Some(rule) => + if rule.enabled { + let currentStr = ForgeOpsEngine.settingValueToString(setting.value) + let expectedStr = ForgeOpsEngine.settingValueToString(setting.defaultValue) + + let matches = switch (setting.value, setting.defaultValue) { + | (BoolValue(a), BoolValue(b)) => a === b + | (StringValue(a), StringValue(b)) => a === b + | (IntValue(a), IntValue(b)) => a === b + | _ => currentStr === expectedStr + } + + if !matches { + findings := Array.concat(findings.contents, [{ + repoName, + settingId: setting.id, + category: setting.category, + forgeId: setting.forgeId, + severity: rule.severity, + message: `${rule.expression}: expected ${expectedStr}, got ${currentStr}`, + currentValue: currentStr, + expectedValue: expectedStr, + autoFixable: setting.editable, + }]) + } + } + } + }) + + let totalConstrained = enabledC->Array.length + let failedCount = Array.length(findings.contents) + let passedCount = totalConstrained - failedCount + let warningCount = findings.contents->Array.filter(f => + switch f.severity { + | Medium | Low => true + | _ => false + } + )->Array.length + + let score = if totalConstrained > 0 { + Int.toFloat(passedCount) /. Int.toFloat(totalConstrained) + } else { + 1.0 + } + + { + timestamp: "now", // TODO: use Date.now() ISO 8601 + repos: [repoName], + findings: ForgeOpsEngine.sortFindingsBySeverity(findings.contents), + passed: passedCount, + failed: failedCount, + warnings: warningCount, + score, + } + } + + /// Run audit across multiple repos. + let auditMultipleRepos = ( + repos: array, + settingsPerRepo: array<(string, array)>, + ): auditResult => { + let allFindings = ref([]) + let totalPassed = ref(0) + let totalFailed = ref(0) + + Array.forEach(settingsPerRepo, ((repoName, settings)) => { + let result = auditSettings(repoName, settings) + allFindings := Array.concat(allFindings.contents, result.findings) + totalPassed := totalPassed.contents + result.passed + totalFailed := totalFailed.contents + result.failed + }) + + let total = totalPassed.contents + totalFailed.contents + let score = if total > 0 { + Int.toFloat(totalPassed.contents) /. Int.toFloat(total) + } else { + 1.0 + } + + { + timestamp: "now", + repos, + findings: ForgeOpsEngine.sortFindingsBySeverity(allFindings.contents), + passed: totalPassed.contents, + failed: totalFailed.contents, + warnings: allFindings.contents->Array.filter(f => + switch f.severity { + | Medium | Low => true + | _ => false + } + )->Array.length, + score, + } + } + +*/ diff --git a/migration/affinescript/forge-ops/src/model/ForgeOpsModel.affine b/migration/affinescript/forge-ops/src/model/ForgeOpsModel.affine new file mode 100644 index 00000000..6a157f8c --- /dev/null +++ b/migration/affinescript/forge-ops/src/model/ForgeOpsModel.affine @@ -0,0 +1,542 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/model/ForgeOpsModel.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsModel; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Model Types — Git forge management across GitHub, GitLab, Bitbucket. + /// + /// ForgeOps automates git forge administration: repo settings, mirroring, + /// branch protection, CI/CD, secrets, webhooks, releases, and security + /// scanning. It operates local-first — all configuration is cached and + /// editable offline with sync-on-demand to each forge. + /// + /// Three-panel model (PanLL integration): + /// Panel-L → Policy constraints (RSR compliance rules, mirror requirements) + /// Panel-N → AI gap analysis (why settings matter, anomaly detection) + /// Panel-W → Main dashboard (repo ribbon, category tabs, settings grid) + /// + /// Category tabs (11): + /// Common: Repos | Mirroring | Protection | CI/CD | Secrets | + /// Webhooks | Releases | Security + /// Forge-specific: GitHub | GitLab | Bitbucket + /// + /// This module has NO dependencies on other modules — leaf of the type + /// dependency graph, following the CloudGuard / VabModel pattern. + + // ============================================================================ + // Git Forge Identity — which forge a repo lives on + // ============================================================================ + + /// The three supported git forges. + type forgeId = + | GitHub + | GitLab + | Bitbucket + + /// Forge account tier, determines which features are available. + /// Each forge has its own tier names but they map to a common scale. + type forgeTier = + | FreeTier // GitHub Free, GitLab Free, Bitbucket Free + | ProTier // GitHub Pro, GitLab Premium, Bitbucket Standard + | TeamTier // GitHub Team, GitLab Premium (group), Bitbucket Premium + | EnterpriseTier // GitHub Enterprise, GitLab Ultimate, Bitbucket DC + + // ============================================================================ + // Setting categories — the tab bar in Panel-W + // ============================================================================ + + /// Top-level categories for the settings grid. The first 8 are common across + /// all forges; the last 3 are forge-specific feature tabs. + type forgeCategory = + | Repos // Common repo settings: visibility, description, topics, default branch + | Mirroring // Mirror sync status, last push, force sync, config + | Protection // Branch protection rules, required reviews, status checks + | CiCd // Actions / GitLab CI / Pipelines: workflow status, runs + | Secrets // Repository secrets, environment variables, deploy tokens + | Webhooks // Webhook management, delivery history + | Releases // Tags, releases, changelogs, artifacts + | Security // Dependabot, advisories, code scanning, secret scanning + | GitHubSpecific // GitHub-only: Discussions, Projects, Sponsors, Codespaces + | GitLabSpecific // GitLab-only: Container Registry, Package Registry, MR settings + | BitbucketSpecific // Bitbucket-only: Jira integration, Pipelines config + + // ============================================================================ + // Setting availability — forge-tier gating for the catalog + // ============================================================================ + + /// Whether a setting is available on the user's forge plan. + type settingAvailability = + | Available // Setting works on any plan + | ForgeOnly(forgeId) // Only available on this specific forge + | Unavailable(forgeTier) // Requires this tier or higher + | Limited(string) // Available but with limitations (description) + + // ============================================================================ + // Setting value types — same as CloudGuard pattern + // ============================================================================ + + /// The value of a single forge setting. + type settingValue = + | BoolValue(bool) // On/off toggles (e.g. issues enabled) + | StringValue(string) // Enum/string settings (e.g. visibility "public") + | IntValue(int) // Numeric settings (e.g. required approvals count) + | ObjectValue(string) // Complex nested JSON (serialised) + + /// A single forge setting with its current value and metadata. + type forgeSetting = { + id: string, // Setting ID (e.g. "visibility", "has_issues") + label: string, // Human-readable label + description: string, // Tooltip/help text + category: forgeCategory, // Which tab this appears under + value: settingValue, // Current value from forge API + defaultValue: settingValue, // Policy default (from RSR/Trustfile) + editable: bool, // Whether the user can change this + modified: bool, // Whether value differs from last-synced state + availability: settingAvailability, // Forge/tier gating + forgeId: option, // Which forge this applies to (None = all) + } + + // ============================================================================ + // Repository types — the core entity (replaces CloudGuard's cfZone) + // ============================================================================ + + /// Visibility level for a repository. + type repoVisibility = + | Public + | Private + | Internal // GitLab/GitHub Enterprise only + + /// A single repository across one or more forges. The repo ribbon shows + /// these as selectable chips with forge badges. + type forgeRepo = { + name: string, // Repository name (e.g. "proven-servers") + fullName: string, // Full name with owner (e.g. "hyperpolymath/proven-servers") + description: string, // Repository description + visibility: repoVisibility, // Public/Private/Internal + defaultBranch: string, // Default branch name (usually "main") + archived: bool, // Whether the repo is archived + fork: bool, // Whether the repo is a fork + template: bool, // Whether the repo is a template + language: option, // Primary language + topics: array, // Repository topics/tags + license: option, // SPDX license identifier + createdAt: string, // ISO 8601 timestamp + updatedAt: string, // ISO 8601 timestamp + pushedAt: string, // ISO 8601 last push timestamp + // Forge presence — which forges have this repo + gitHub: option, + gitLab: option, + bitbucket: option, + } + + /// Reference to a repo on a specific forge (ID + URL + status). + type forgeRepoRef = { + forgeId: forgeId, // Which forge + remoteId: string, // Forge-specific repo ID + url: string, // Clone URL (HTTPS) + sshUrl: string, // Clone URL (SSH) + webUrl: string, // Browser URL + isMirror: bool, // Whether this is a mirror copy + lastSyncedAt: option, // Last mirror sync time (ISO 8601) + } + + // ============================================================================ + // Mirror types — dedicated mirroring section + // ============================================================================ + + /// Mirror sync status between source and target forges. + type mirrorSyncStatus = + | InSync // All refs match + | Behind(int) // Target is N commits behind + | Ahead(int) // Target has N commits source doesn't + | Diverged(int, int) // (behind, ahead) — branches diverged + | SyncFailed(string) // Last sync failed with error + | NeverSynced // Mirror exists but never successfully synced + | Syncing // Currently syncing + + /// A mirror relationship between two forge instances of the same repo. + type mirrorLink = { + repoName: string, // Repository name + source: forgeId, // Primary/source forge (usually GitHub) + target: forgeId, // Mirror target (GitLab or Bitbucket) + status: mirrorSyncStatus, // Current sync status + lastAttempt: option, // ISO 8601 last sync attempt + lastSuccess: option, // ISO 8601 last successful sync + method: mirrorMethod, // How mirroring is configured + autoSync: bool, // Whether auto-sync is enabled + error: option, // Last error message + } + + /// How mirroring is implemented. + type mirrorMethod = + | GitHubAction // via mirror.yml / instant-sync.yml workflow + | GitLabPullMirror // GitLab's built-in pull mirroring + | GitLabPushMirror // GitLab's built-in push mirroring + | BitbucketPipeline // Bitbucket Pipelines-based mirroring + | ManualPush // Manual git push to remotes + | WebhookTrigger // Webhook-triggered sync + + // ============================================================================ + // Branch protection types — for the Protection tab + // ============================================================================ + + /// A branch protection rule on a specific forge. + type branchProtection = { + repoName: string, // Which repo + forgeId: forgeId, // Which forge + pattern: string, // Branch pattern (e.g. "main", "release/*") + requirePullRequest: bool, // Require PR before merge + requiredApprovals: int, // Minimum number of approvals + requireStatusChecks: bool, // Require CI status checks to pass + statusChecks: array, // Required status check names + requireSignedCommits: bool, // Require GPG/SSH signed commits + requireLinearHistory: bool, // No merge commits + allowForcePush: bool, // Allow force push (should be false on main) + allowDeletion: bool, // Allow branch deletion + enforceAdmins: bool, // Apply rules to admins too + enabled: bool, // Whether the protection rule is active + } + + // ============================================================================ + // Webhook types + // ============================================================================ + + /// A webhook on a specific forge. + type forgeWebhook = { + id: string, // Webhook ID + repoName: string, // Which repo + forgeId: forgeId, // Which forge + url: string, // Delivery URL + contentType: string, // "json" or "form" + events: array, // Events that trigger delivery + active: bool, // Whether the webhook is active + insecureSsl: bool, // Whether SSL verification is disabled (bad!) + createdAt: string, // ISO 8601 + lastDelivery: option, // ISO 8601 last delivery attempt + lastStatus: option, // HTTP status of last delivery + } + + // ============================================================================ + // CI/CD types + // ============================================================================ + + /// CI/CD pipeline/workflow run status. + type ciRunStatus = + | CiSuccess + | CiFailure + | CiPending + | CiRunning + | CiCancelled + | CiSkipped + | CiUnknown + + /// A CI/CD pipeline/workflow for a repo on a specific forge. + type ciPipeline = { + id: string, // Pipeline/workflow ID + repoName: string, // Which repo + forgeId: forgeId, // Which forge + name: string, // Workflow/pipeline name + path: string, // Config file path (e.g. ".github/workflows/ci.yml") + lastRun: option, // Last run details + enabled: bool, // Whether the pipeline is enabled + badge: option, // Badge URL + } + + /// A single CI/CD run. + type ciRun = { + id: string, // Run ID + status: ciRunStatus, // Run status + branch: string, // Branch that triggered the run + commit: string, // Commit SHA (short) + message: string, // Commit message (truncated) + startedAt: string, // ISO 8601 + finishedAt: option, // ISO 8601 + duration: option, // Duration in seconds + url: string, // Web URL to view the run + } + + // ============================================================================ + // Secret/variable types + // ============================================================================ + + /// A repository secret or variable on a specific forge. + /// Values are never returned by forge APIs — only metadata is available. + type forgeSecret = { + name: string, // Secret name (e.g. "GITLAB_TOKEN") + repoName: string, // Which repo + forgeId: forgeId, // Which forge + secretType: secretType, // Secret or variable + environment: option, // Environment scope (None = repo-level) + createdAt: string, // ISO 8601 + updatedAt: string, // ISO 8601 + } + + /// Whether an entry is a secret (encrypted, write-only) or a variable (visible). + type secretType = + | Secret // Encrypted, never readable + | Variable // Plaintext, readable + + // ============================================================================ + // Release types + // ============================================================================ + + /// A release/tag on a specific forge. + type forgeRelease = { + id: string, // Release ID + repoName: string, // Which repo + forgeId: forgeId, // Which forge + tagName: string, // Git tag (e.g. "v1.0.0") + name: string, // Release title + body: string, // Release notes (markdown) + draft: bool, // Whether this is a draft + prerelease: bool, // Whether this is a pre-release + createdAt: string, // ISO 8601 + publishedAt: option, // ISO 8601 + assets: array, // Attached files + } + + /// A release artifact/asset. + type releaseAsset = { + name: string, // File name + size: int, // Size in bytes + downloadCount: int, // Number of downloads + url: string, // Download URL + } + + // ============================================================================ + // Security types + // ============================================================================ + + /// Security alert severity (shared across Dependabot, code scanning, etc.). + type securitySeverity = + | SevCritical + | SevHigh + | SevMedium + | SevLow + | SevInfo + + /// A security alert/advisory from a forge. + type securityAlert = { + id: string, // Alert ID + repoName: string, // Which repo + forgeId: forgeId, // Which forge + alertType: securityAlertType, // What kind of alert + severity: securitySeverity, // How severe + title: string, // Alert title + description: string, // Alert description + state: string, // "open" | "fixed" | "dismissed" + createdAt: string, // ISO 8601 + fixedAt: option, // ISO 8601 if fixed + url: string, // Web URL to the alert + } + + /// Type of security alert. + type securityAlertType = + | DependabotAlert // Vulnerable dependency + | CodeScanningAlert // Code analysis finding + | SecretScanningAlert // Exposed secret detected + | AdvisoryAlert // Security advisory + + // ============================================================================ + // Deploy key types + // ============================================================================ + + /// A deploy key on a specific forge. + type deployKey = { + id: string, // Key ID + repoName: string, // Which repo + forgeId: forgeId, // Which forge + title: string, // Key title/label + fingerprint: string, // SSH key fingerprint + readOnly: bool, // Whether the key has write access + createdAt: string, // ISO 8601 + } + + // ============================================================================ + // Audit and compliance types — same pattern as CloudGuard + // ============================================================================ + + /// Audit severity for findings. + type auditSeverity = + | Critical + | High + | Medium + | Low + | Info + + /// A single audit finding — one setting that deviates from policy. + type auditFinding = { + repoName: string, // Which repo + settingId: string, // Setting ID + category: forgeCategory, // Which tab group + forgeId: option, // Which forge (None = cross-forge) + severity: auditSeverity, // How bad + message: string, // Human-readable finding + currentValue: string, // What the setting currently is + expectedValue: string, // What the policy says it should be + autoFixable: bool, // Whether ForgeOps can fix this automatically + } + + /// Overall audit result for repos. + type auditResult = { + timestamp: string, // ISO 8601 when the audit ran + repos: array, // Which repos were audited + findings: array, // All findings + passed: int, // Settings that matched policy + failed: int, // Settings that deviated + warnings: int, // Medium/low findings + score: float, // Compliance score (0.0 - 1.0) + } + + // ============================================================================ + // Config diff types — cross-forge comparison + // ============================================================================ + + /// A diff entry comparing a setting across forges. + type forgeDiffEntry = { + settingId: string, // Setting ID + repoName: string, // Which repo + category: forgeCategory, // Which tab group + gitHubValue: option, // Value on GitHub (None if absent) + gitLabValue: option, // Value on GitLab (None if absent) + bitbucketValue: option, // Value on Bitbucket (None if absent) + policyValue: option, // Value from RSR policy (None if not specified) + consistent: bool, // Whether all present forges agree + } + + /// Complete cross-forge diff for one or more repos. + type forgeDiff = { + timestamp: string, // When the diff was computed + entries: array, // All diff entries + inconsistentCount: int, // Settings that differ across forges + missingCount: int, // Settings present on some forges but not others + } + + // ============================================================================ + // Policy constraint types — Panel-L content + // ============================================================================ + + /// A policy constraint from RSR / Trustfile. + type policyConstraint = { + id: string, // Unique constraint ID + expression: string, // Human-readable rule + category: forgeCategory, // Which settings group + enabled: bool, // Whether active for auditing + severity: auditSeverity, // How severe a violation would be + description: string, // Explanation of why this matters + appliesTo: option, // Which forge (None = all) + } + + // ============================================================================ + // Per-repo exception types + // ============================================================================ + + /// An exception override for a specific repo. + type repoException = { + repoName: string, // Which repo + settingId: string, // Which setting + overrideValue: settingValue, // The override value + reason: string, // Why this repo differs + addedOn: string, // ISO 8601 + } + + // ============================================================================ + // Bulk operation progress + // ============================================================================ + + /// Progress state for a bulk operation (e.g. "Apply protection to all repos"). + type bulkProgress = { + total: int, // Total number of operations + completed: int, // Completed so far + failed: int, // Number that failed + currentRepo: option, // Currently processing + currentForge: option, // Currently processing forge + startedAt: string, // ISO 8601 + errors: array<(string, string)>, // (repo, error message) + } + + // ============================================================================ + // Connection state per forge + // ============================================================================ + + /// API connection status for a single forge. + type forgeConnectionStatus = + | Disconnected // No token configured + | Connecting // Token verification in progress + | Connected(string) // Connected — parameter is username/email + | ConnectionError(string) // Verification failed + + /// Connection state for all three forges. + type forgeConnections = { + gitHub: forgeConnectionStatus, + gitLab: forgeConnectionStatus, + bitbucket: forgeConnectionStatus, + } + + // ============================================================================ + // Root panel state — composed into Model.model (or standalone) + // ============================================================================ + + /// The complete ForgeOps panel state. Mirrors CloudGuard's cloudguardState. + type forgeOpsState = { + // Connections (one per forge) + connections: forgeConnections, // API connection status per forge + loading: bool, // Whether an API call is in flight + error: option, // Last error message + + // Data + repos: array, // All repos across forges (merged by name) + selectedRepoNames: array, // Currently selected repo names + settings: array, // Settings for currently viewed repo(s) + mirrorLinks: array, // Mirror relationships + protectionRules: array, // Branch protection rules + webhooks: array, // Webhooks + pipelines: array, // CI/CD pipelines + secrets: array, // Secrets/variables + releases: array, // Releases + securityAlerts: array, // Security alerts + deployKeys: array, // Deploy keys + + // Audit and compliance + auditResult: option, // Latest audit result + constraints: array, // Policy constraints for Panel-L + exceptions: array, // Per-repo exceptions + + // Cross-forge diff + forgeDiff: option, // Cross-forge comparison + + // Bulk operations + bulkProgress: option, // Current bulk operation + + // UI state + visible: bool, // Whether the ForgeOps overlay is shown + activeCategory: forgeCategory, // Currently active tab + filterText: string, // Repo filter text in the ribbon + settingFilter: string, // Setting filter within the grid + showDiff: bool, // Whether the diff viewer side panel is open + showAudit: bool, // Whether the audit results side panel is open + activeForgeFilter: option, // Filter repos by forge (None = all) + mirrorEditingId: option, // Mirror link being edited + } + +*/ diff --git a/migration/affinescript/forge-ops/src/modules/ForgeOpsModule.affine b/migration/affinescript/forge-ops/src/modules/ForgeOpsModule.affine new file mode 100644 index 00000000..cf66e683 --- /dev/null +++ b/migration/affinescript/forge-ops/src/modules/ForgeOpsModule.affine @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/modules/ForgeOpsModule.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ForgeOpsModule; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + + /// ForgeOps Module Registration — Capability-driven module protocol. + /// + /// Registers ForgeOps as a PanLL panel module with its capabilities, + /// configuration, and metadata. Follows the CloudGuardModule.res pattern. + + /// Capabilities that ForgeOps provides to the PanLL ecosystem. + type forgeOpsCapability = + | RepoInventory // List and monitor repos across all three forges + | MirrorManagement // Mirror sync status, force sync, configure mirroring + | BranchProtection // Branch protection rules across forges + | CiCdMonitoring // GitHub Actions, GitLab CI, Bitbucket Pipelines status + | SecretManagement // Repository secrets and variables + | WebhookManagement // Webhook CRUD and delivery monitoring + | ReleaseManagement // Tags, releases, artifacts across forges + | SecurityScanning // Dependabot, code scanning, secret scanning + | ComplianceAudit // RSR compliance evaluation + | CrossForgeDiff // Compare settings across GitHub/GitLab/Bitbucket + | BulkOperations // Apply settings across multiple repos at once + | OfflineConfig // Download/upload forge config with diff + + /// ForgeOps module configuration. + type forgeOpsModuleConfig = { + id: string, + name: string, + version: string, + description: string, + capabilities: array, + icon: option, + } + + /// The ForgeOps module registration. + let config: forgeOpsModuleConfig = { + id: "forgeops", + name: "ForgeOps", + version: "0.1.0", + description: "Git forge management across GitHub, GitLab, and Bitbucket. Automates repo settings, mirroring, branch protection, CI/CD, secrets, webhooks, releases, and security scanning with RSR compliance auditing.", + capabilities: [ + RepoInventory, + MirrorManagement, + BranchProtection, + CiCdMonitoring, + SecretManagement, + WebhookManagement, + ReleaseManagement, + SecurityScanning, + ComplianceAudit, + CrossForgeDiff, + BulkOperations, + OfflineConfig, + ], + icon: Some("git-branch"), + } + + /// Check if ForgeOps has a specific capability. + let hasCapability = (cap: forgeOpsCapability): bool => { + config.capabilities->Array.includes(cap) + } + + /// Human-readable label for a ForgeOps capability. + let capabilityLabel = (cap: forgeOpsCapability): string => { + switch cap { + | RepoInventory => "Repo Inventory" + | MirrorManagement => "Mirror Management" + | BranchProtection => "Branch Protection" + | CiCdMonitoring => "CI/CD Monitoring" + | SecretManagement => "Secret Management" + | WebhookManagement => "Webhook Management" + | ReleaseManagement => "Release Management" + | SecurityScanning => "Security Scanning" + | ComplianceAudit => "Compliance Audit" + | CrossForgeDiff => "Cross-Forge Diff" + | BulkOperations => "Bulk Operations" + | OfflineConfig => "Offline Config" + } + } + + /// Short description for each capability. + let capabilityDescription = (cap: forgeOpsCapability): string => { + switch cap { + | RepoInventory => "List and monitor all repos across GitHub, GitLab, and Bitbucket with forge presence badges" + | MirrorManagement => "View mirror sync status, trigger force sync, configure mirror.yml and instant-sync.yml" + | BranchProtection => "Set branch protection rules (required reviews, status checks, signed commits) across all forges" + | CiCdMonitoring => "Monitor GitHub Actions, GitLab CI, and Bitbucket Pipelines — run status, badges, workflow health" + | SecretManagement => "Audit repository secrets (GITLAB_TOKEN, BITBUCKET_TOKEN, etc.) across forges" + | WebhookManagement => "Create, edit, delete webhooks with delivery history and SSL verification enforcement" + | ReleaseManagement => "Manage tags, releases, and artifacts across all three forges" + | SecurityScanning => "Dependabot alerts, code scanning, secret scanning, security advisory management" + | ComplianceAudit => "Evaluate repos against RSR policy — license, mirroring, workflows, protection rules" + | CrossForgeDiff => "Compare settings across GitHub, GitLab, and Bitbucket to detect drift" + | BulkOperations => "Apply protection rules, enable features, or fix compliance across all repos at once" + | OfflineConfig => "Download repo configs as JSON, edit offline, upload with diff and dry-run preview" + } + } + +*/ diff --git a/migration/affinescript/forge-ops/src/modules/RuntimeBridge.affine b/migration/affinescript/forge-ops/src/modules/RuntimeBridge.affine new file mode 100644 index 00000000..b716ff9a --- /dev/null +++ b/migration/affinescript/forge-ops/src/modules/RuntimeBridge.affine @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/forge-ops/src/modules/RuntimeBridge.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [raw-js] line 19: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %%raw(` +// - [raw-js] line 27: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %%raw(` +// - [untyped-exception] line 67: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError( + +module RuntimeBridge; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + + /// RuntimeBridge — Gossamer IPC bridge for reposystem-forge-ops. + /// + /// All command modules use `RuntimeBridge.invoke` to call the Rust backend + /// through Gossamer's `window.__gossamer_invoke` IPC channel. + /// + /// Gossamer injects `__gossamer_invoke` via `gossamer_channel_open()` before + /// the frontend loads. If the function is missing, a descriptive error is + /// returned so development builds (browser-only) get a clear message. + + // --------------------------------------------------------------------------- + // Gossamer IPC binding + // --------------------------------------------------------------------------- + + /// Gossamer IPC: injected by gossamer_channel_open() into the webview. + /// Signature: (commandName: string, payload: object) => Promise + %%raw(` + function isGossamerRuntime() { + return typeof window !== 'undefined' + && typeof window.__gossamer_invoke === 'function'; + } + `) + @val external isGossamerRuntime: unit => bool = "isGossamerRuntime" + + %%raw(` + function gossamerInvoke(cmd, args) { + return window.__gossamer_invoke(cmd, args); + } + `) + @val external gossamerInvoke: (string, 'a) => promise<'b> = "gossamerInvoke" + + // --------------------------------------------------------------------------- + // Runtime type + // --------------------------------------------------------------------------- + + /// The runtime currently in use. + type runtime = + | Gossamer + | BrowserOnly + + /// Detect and return the current runtime. + let detectRuntime = (): runtime => { + if isGossamerRuntime() { + Gossamer + } else { + BrowserOnly + } + } + + // --------------------------------------------------------------------------- + // Unified invoke — primary API + // --------------------------------------------------------------------------- + + /// Invoke a backend command through the Gossamer IPC channel. + /// + /// - On Gossamer: calls `window.__gossamer_invoke(cmd, args)` + /// - On browser: rejects with a descriptive error + /// + /// This is the primary function all command modules should use. + let invoke = (cmd: string, args: 'a): promise<'b> => { + if isGossamerRuntime() { + gossamerInvoke(cmd, args) + } else { + Js.Promise.reject( + Js.Exn.raiseError( + `No desktop runtime — "${cmd}" requires Gossamer`, + ), + ) + } + } + + /// Invoke a backend command with no arguments. + let invokeNoArgs = (cmd: string): promise<'b> => { + invoke(cmd, ()) + } + + /// Check whether the Gossamer runtime is available. + let hasDesktopRuntime = (): bool => { + isGossamerRuntime() + } + + /// Get a human-readable name for the current runtime. + let runtimeName = (): string => { + switch detectRuntime() { + | Gossamer => "Gossamer" + | BrowserOnly => "Browser" + } + } + +*/ diff --git a/migration/affinescript/gui/src/App.affine b/migration/affinescript/gui/src/App.affine new file mode 100644 index 00000000..4b9dcca7 --- /dev/null +++ b/migration/affinescript/gui/src/App.affine @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/App.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module App; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // Main TEA Application + + open Tea + + // Initialize the application + let init = () => { + let model = Model.init() + // Load data on startup + (model, Cmd.msg(Msg.LoadAllData)) + } + + // Subscriptions (none for now, D3 handles its own events) + let subscriptions = (_model: Model.t): Sub.t => { + Sub.none + } + + // Mount to #app node + @val @scope("document") + external getElementById: string => Js.nullable = "getElementById" + + let main = () => { + App.standardProgram( + { + init: init, + update: Update.update, + view: View.view, + subscriptions: subscriptions, + }, + getElementById("app"), + (), + ) + } + +*/ diff --git a/migration/affinescript/gui/src/Graph.affine b/migration/affinescript/gui/src/Graph.affine new file mode 100644 index 00000000..7913a89f --- /dev/null +++ b/migration/affinescript/gui/src/Graph.affine @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/Graph.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Graph; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // D3 Force-directed graph visualization + + open D3 + + // ────────────────────────────────────────────── + // Types + // ────────────────────────────────────────────── + + // Graph state (mutable, managed by D3) + type graphState = { + mutable simulation: option>, + mutable svg: option, + mutable nodeGroup: option, + mutable linkGroup: option, + } + + // ────────────────────────────────────────────── + // Small utilities (no dependencies on later bindings) + // ────────────────────────────────────────────── + + // Node color based on kind + let nodeColor = (kind: [#repo | #slot | #provider]) => + switch kind { + | #repo => "#4299e1" + | #slot => "#ecc94b" + | #provider => "#48bb78" + } + + // ────────────────────────────────────────────── + // Module-level state and configuration + // Depends on: graphState type (above) + // ────────────────────────────────────────────── + + let state: graphState = { + simulation: None, + svg: None, + nodeGroup: None, + linkGroup: None, + } + + // Configuration + let config = { + "width": 800, + "height": 600, + "nodeRadius": 8, + "linkDistance": 100, + "chargeStrength": -200.0, + } + + // ────────────────────────────────────────────── + // Initialize the graph visualization + // Depends on: state, config, Selection, Zoom, Force + // ────────────────────────────────────────────── + + let initGraph = (container: Dom.element) => { + // Create SVG + let svg = + Selection.selectElement(container) + ->Selection.append("svg") + ->Selection.attr("width", config["width"]) + ->Selection.attr("height", config["height"]) + ->Selection.attr("viewBox", `0 0 ${config["width"]->Int.toString} ${config["height"]->Int.toString}`) + + // Create groups for links and nodes + let linkGroup = svg->Selection.append("g")->Selection.attr("class", "links") + let nodeGroup = svg->Selection.append("g")->Selection.attr("class", "nodes") + + // Create zoom behavior + let zoomBehavior = + Zoom.zoom()->Zoom.scaleExtent((0.1, 4.0))->Zoom.on("zoom", (event, _) => { + linkGroup->Selection.attr("transform", event.transform->Zoom.toString)->ignore + nodeGroup->Selection.attr("transform", event.transform->Zoom.toString)->ignore + }) + + svg->Zoom.applyTo(zoomBehavior)->ignore + + // Store references + state.svg = Some(svg) + state.nodeGroup = Some(nodeGroup) + state.linkGroup = Some(linkGroup) + + // Create simulation + let simulation = + Force.forceSimulationEmpty() + ->Force.force( + "charge", + Force.forceManyBody() + ->Force.strength(config["chargeStrength"]) + ->Force.manyBodyForceAsForce, + ) + ->Force.force( + "center", + Force.forceCenter( + config["width"]->Int.toFloat /. 2.0, + config["height"]->Int.toFloat /. 2.0, + ), + ) + ->Force.force( + "link", + Force.forceLinkEmpty() + ->Force.linkDistance(config["linkDistance"]->Int.toFloat) + ->Force.linkForceAsForce, + ) + ->Force.force("collide", Force.forceCollide(config["nodeRadius"]->Int.toFloat *. 2.0)) + + state.simulation = Some(simulation) + } + + // ────────────────────────────────────────────── + // Update the graph with new data + // Depends on: state, config, nodeColor, Force, Selection, Drag + // ────────────────────────────────────────────── + + let updateGraph = (nodes: array, links: array) => { + switch (state.simulation, state.nodeGroup, state.linkGroup) { + | (Some(simulation), Some(nodeGroup), Some(linkGroup)) => { + // Convert to D3 node format + let d3Nodes: array> = nodes->Array.map(( + n: Model.graphNode, + ): Force.node => { + Force.index: 0, + x: n.x, + y: n.y, + vx: n.vx, + vy: n.vy, + fx: n.fx->Nullable.fromOption, + fy: n.fy->Nullable.fromOption, + data: n, + }) + + // Convert links to D3 format + let d3Links: array> = links->Array.map(( + l: Model.graphLink, + ): Force.linkInput => { + Force.source: l.source, + target: l.target, + }) + + // Update simulation nodes + simulation->Force.nodes(d3Nodes)->ignore + + // Update link force + let linkForce = simulation->Force.getForce("link") + switch linkForce->Nullable.toOption { + | Some(f) => + f + ->Force.asLinkForce + ->Force.links(d3Links) + ->Force.linkId((link: Force.linkInput, _, _) => link.source) + ->ignore + | None => () + } + + // Update link visuals + let linkSelection = + linkGroup + ->Selection.selectChildren("line") + ->Selection.data(d3Links) + ->Selection.join("line") + ->Selection.attr("stroke", "#999") + ->Selection.attr("stroke-opacity", "0.6") + ->Selection.attr("stroke-width", "1.5") + + // Update node visuals + let nodeSelection = + nodeGroup + ->Selection.selectChildren("circle") + ->Selection.data(d3Nodes) + ->Selection.join("circle") + ->Selection.attr("r", config["nodeRadius"]) + ->Selection.attr("fill", (n: Force.node) => nodeColor(n.data.kind)) + ->Selection.attr("stroke", "#fff") + ->Selection.attr("stroke-width", "1.5") + + // Add drag behavior + let dragBehavior = + Drag.drag() + ->Drag.on("start", (event: Drag.dragEvent, d: Force.node) => { + if event.active == 0 { + simulation->Force.alphaTarget(0.3)->Force.restart->ignore + } + d.Force.fx = Nullable.make(event.x) + d.Force.fy = Nullable.make(event.y) + }) + ->Drag.on("drag", (event: Drag.dragEvent, d: Force.node) => { + d.Force.fx = Nullable.make(event.x) + d.Force.fy = Nullable.make(event.y) + }) + ->Drag.on("end", (event: Drag.dragEvent, d: Force.node) => { + if event.active == 0 { + simulation->Force.alphaTarget(0.0)->ignore + } + d.Force.fx = Nullable.null + d.Force.fy = Nullable.null + }) + + nodeSelection->Drag.applyTo(dragBehavior)->ignore + + // Update positions on tick + simulation->Force.on("tick", () => { + linkSelection + ->Selection.attr("x1", (l: Force.linkInput) => { + // Find source node position + switch d3Nodes->Array.find((n: Force.node) => n.data.id == l.source) { + | Some(n) => n.x + | None => 0.0 + } + }) + ->Selection.attr("y1", (l: Force.linkInput) => { + switch d3Nodes->Array.find((n: Force.node) => n.data.id == l.source) { + | Some(n) => n.y + | None => 0.0 + } + }) + ->Selection.attr("x2", (l: Force.linkInput) => { + switch d3Nodes->Array.find((n: Force.node) => n.data.id == l.target) { + | Some(n) => n.x + | None => 0.0 + } + }) + ->Selection.attr("y2", (l: Force.linkInput) => { + switch d3Nodes->Array.find((n: Force.node) => n.data.id == l.target) { + | Some(n) => n.y + | None => 0.0 + } + }) + ->ignore + + nodeSelection + ->Selection.attr("cx", (n: Force.node) => n.x) + ->Selection.attr("cy", (n: Force.node) => n.y) + ->ignore + })->ignore + + // Restart simulation + simulation->Force.alpha(1.0)->Force.restart->ignore + } + | _ => () + } + } + +*/ diff --git a/migration/affinescript/gui/src/Model.affine b/migration/affinescript/gui/src/Model.affine new file mode 100644 index 00000000..e4ab3cf9 --- /dev/null +++ b/migration/affinescript/gui/src/Model.affine @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/Model.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Model; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // TEA Model - Application state + + open Backend + + // Tab navigation + type tab = + | Dashboard + | Repos + | Edges + | Groups + | Aspects + | Slots + | Plans + + // Selected item for detail panel + type selectedItem = + | NoSelection + | SelectedRepo(repo) + | SelectedEdge(edge) + | SelectedGroup(group) + | SelectedAspect(aspectAnnotation) + | SelectedSlot(slot) + | SelectedProvider(provider) + | SelectedBinding(slotBinding) + | SelectedPlan(plan) + + // Form state types for inline creation forms + type edgeForm = { + from: string, + to_: string, + rel: string, + } + + type groupForm = { + name: string, + description: string, + } + + type aspectForm = { + target: string, + aspectId: string, + weight: string, + polarity: string, + reason: string, + } + + type slotForm = { + name: string, + category: string, + description: string, + capabilities: string, + } + + type providerForm = { + name: string, + slotId: string, + providerType: string, + repoId: string, + capabilities: string, + priority: string, + isFallback: bool, + } + + type bindingForm = { + consumerId: string, + slotId: string, + providerId: string, + } + + type openForm = + | NoForm + | EdgeForm(edgeForm) + | GroupForm(groupForm) + | AspectForm(aspectForm) + | SlotForm(slotForm) + | ProviderForm(providerForm) + | BindingForm(bindingForm) + + // Graph node for D3 visualization + type graphNode = { + id: string, + label: string, + kind: [#repo | #slot | #provider], + x: float, + y: float, + vx: float, + vy: float, + fx: option, + fy: option, + } + + // Graph link for D3 visualization + type graphLink = { + source: string, + target: string, + kind: [#edge | #binding], + label: option, + } + + // Main application model + type t = { + // Data + repos: array, + edges: array, + groups: array, + aspects: array, + slots: array, + providers: array, + bindings: array, + plans: array, + // UI state + activeTab: tab, + selectedItem: selectedItem, + searchQuery: string, + // Graph visualization state + graphNodes: array, + graphLinks: array, + // Loading state + loading: bool, + error: option, + // PanLL integration + panll: PanllBridge.panllState, + // Creation form state + openForm: openForm, + } + + // Initial model + let init = () => { + repos: [], + edges: [], + groups: [], + aspects: [], + slots: [], + providers: [], + bindings: [], + plans: [], + activeTab: Dashboard, + selectedItem: NoSelection, + searchQuery: "", + graphNodes: [], + graphLinks: [], + loading: true, + error: None, + panll: PanllBridge.init, + openForm: NoForm, + } + + // Tab helpers + let tabToString = tab => + switch tab { + | Dashboard => "Dashboard" + | Repos => "Repos" + | Edges => "Edges" + | Groups => "Groups" + | Aspects => "Aspects" + | Slots => "Slots" + | Plans => "Plans" + } + + let allTabs = [Dashboard, Repos, Edges, Groups, Aspects, Slots, Plans] + + // Build graph nodes from model data + let buildGraphNodes = (model: t): array => { + let repoNodes = + model.repos->Array.map(r => { + id: r.id, + label: r.name, + kind: #repo, + x: Math.random() *. 800.0, + y: Math.random() *. 600.0, + vx: 0.0, + vy: 0.0, + fx: None, + fy: None, + }) + + let slotNodes = + model.slots->Array.map(s => { + id: s.id, + label: s.name, + kind: #slot, + x: Math.random() *. 800.0, + y: Math.random() *. 600.0, + vx: 0.0, + vy: 0.0, + fx: None, + fy: None, + }) + + let providerNodes = + model.providers->Array.map(p => { + id: p.id, + label: p.name, + kind: #provider, + x: Math.random() *. 800.0, + y: Math.random() *. 600.0, + vx: 0.0, + vy: 0.0, + fx: None, + fy: None, + }) + + [repoNodes, slotNodes, providerNodes]->Array.flat + } + + // Build graph links from model data + let buildGraphLinks = (model: t): array => { + let edgeLinks = + model.edges->Array.map(e => { + source: e.from, + target: e.to_, + kind: #edge, + label: e.label, + }) + + let bindingLinks = + model.bindings->Array.map(b => { + source: b.consumer_id, + target: b.provider_id, + kind: #binding, + label: Some("uses"), + }) + + Array.concat(edgeLinks, bindingLinks) + } + + // Update graph data in model + let withGraphData = (model: t): t => { + ...model, + graphNodes: buildGraphNodes(model), + graphLinks: buildGraphLinks(model), + } + +*/ diff --git a/migration/affinescript/gui/src/Msg.affine b/migration/affinescript/gui/src/Msg.affine new file mode 100644 index 00000000..5231507e --- /dev/null +++ b/migration/affinescript/gui/src/Msg.affine @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/Msg.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Msg; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // TEA Messages - All application events + + open Backend + + // Message types + type rec t = + // Navigation + | SetTab(Model.tab) + | SetSearchQuery(string) + // Selection + | SelectRepo(repo) + | SelectEdge(edge) + | SelectGroup(group) + | SelectAspect(aspectAnnotation) + | SelectSlot(slot) + | SelectProvider(provider) + | SelectBinding(slotBinding) + | SelectPlan(plan) + | ClearSelection + // Data loading + | LoadAllData + | DataLoaded(result) + // Edge operations + | AddEdge(string, string, string) // from, to, rel + | EdgeAdded(result) + | RemoveEdge(string) + | EdgeRemoved(result) + // Group operations + | CreateGroup(string, option) // name, description + | GroupCreated(result) + | AddToGroup(string, string) // groupId, repoId + | RemoveFromGroup(string, string) + // Aspect operations + | TagAspect(string, string, int, string, string) // target, aspectId, weight, polarity, reason + | AspectTagged(result) + | RemoveAspect(string) + | AspectRemoved(result) + // Slot operations + | CreateSlot(string, string, string, array) // name, category, description, capabilities + | SlotCreated(result) + | CreateProvider(createProviderArgs) + | ProviderCreated(result) + | BindSlot(string, string, string) // consumerId, slotId, providerId + | SlotBound(result) + | UnbindSlot(string) + | SlotUnbound(result) + // Persistence + | SaveGraph + | GraphSaved(result) + // Graph interaction + | NodeDragStart(string) + | NodeDrag(string, float, float) + | NodeDragEnd(string) + | GraphZoom(float) + // Error handling + | DismissError + // Creation forms + | OpenEdgeForm + | OpenGroupForm + | OpenAspectForm + | OpenSlotForm + | OpenProviderForm + | OpenBindingForm + | CloseForm + | UpdateFormField(string, string) + | UpdateFormBool(string, bool) + | SubmitForm + // PanLL integration + | PanllConnect + | PanllDisconnect + | PanllConnectionChanged(PanllBridge.panllConnectionStatus) + | PanllSyncGraph + | PanllInbound(PanllBridge.panllInbound) + | PanllToggleAutoSync + + // Helper types for complex messages + and loadedData = { + repos: array, + edges: array, + groups: array, + aspects: array, + slots: array, + providers: array, + bindings: array, + plans: array, + } + + and createProviderArgs = { + name: string, + slotId: string, + providerType: string, + repoId: option, + capabilities: array, + priority: int, + isFallback: bool, + } + +*/ diff --git a/migration/affinescript/gui/src/RuntimeBridge.affine b/migration/affinescript/gui/src/RuntimeBridge.affine new file mode 100644 index 00000000..df35102e --- /dev/null +++ b/migration/affinescript/gui/src/RuntimeBridge.affine @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/RuntimeBridge.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [raw-js] line 19: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %%raw(` +// - [raw-js] line 27: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %%raw(` +// - [untyped-exception] line 67: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError( + +module RuntimeBridge; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + + /// RuntimeBridge — Gossamer IPC bridge for reposystem-gui. + /// + /// All command modules use `RuntimeBridge.invoke` to call the Rust backend + /// through Gossamer's `window.__gossamer_invoke` IPC channel. + /// + /// Gossamer injects `__gossamer_invoke` via `gossamer_channel_open()` before + /// the frontend loads. If the function is missing, a descriptive error is + /// returned so development builds (browser-only) get a clear message. + + // --------------------------------------------------------------------------- + // Gossamer IPC binding + // --------------------------------------------------------------------------- + + /// Gossamer IPC: injected by gossamer_channel_open() into the webview. + /// Signature: (commandName: string, payload: object) => Promise + %%raw(` + function isGossamerRuntime() { + return typeof window !== 'undefined' + && typeof window.__gossamer_invoke === 'function'; + } + `) + @val external isGossamerRuntime: unit => bool = "isGossamerRuntime" + + %%raw(` + function gossamerInvoke(cmd, args) { + return window.__gossamer_invoke(cmd, args); + } + `) + @val external gossamerInvoke: (string, 'a) => promise<'b> = "gossamerInvoke" + + // --------------------------------------------------------------------------- + // Runtime type + // --------------------------------------------------------------------------- + + /// The runtime currently in use. + type runtime = + | Gossamer + | BrowserOnly + + /// Detect and return the current runtime. + let detectRuntime = (): runtime => { + if isGossamerRuntime() { + Gossamer + } else { + BrowserOnly + } + } + + // --------------------------------------------------------------------------- + // Unified invoke — primary API + // --------------------------------------------------------------------------- + + /// Invoke a backend command through the Gossamer IPC channel. + /// + /// - On Gossamer: calls `window.__gossamer_invoke(cmd, args)` + /// - On browser: rejects with a descriptive error + /// + /// This is the primary function all command modules should use. + let invoke = (cmd: string, args: 'a): promise<'b> => { + if isGossamerRuntime() { + gossamerInvoke(cmd, args) + } else { + Js.Promise.reject( + Js.Exn.raiseError( + `No desktop runtime — "${cmd}" requires Gossamer`, + ), + ) + } + } + + /// Invoke a backend command with no arguments. + let invokeNoArgs = (cmd: string): promise<'b> => { + invoke(cmd, ()) + } + + /// Check whether the Gossamer runtime is available. + let hasDesktopRuntime = (): bool => { + isGossamerRuntime() + } + + /// Get a human-readable name for the current runtime. + let runtimeName = (): string => { + switch detectRuntime() { + | Gossamer => "Gossamer" + | BrowserOnly => "Browser" + } + } + +*/ diff --git a/migration/affinescript/gui/src/Update.affine b/migration/affinescript/gui/src/Update.affine new file mode 100644 index 00000000..e1155134 --- /dev/null +++ b/migration/affinescript/gui/src/Update.affine @@ -0,0 +1,719 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/Update.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 16 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [raw-js] line 13: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %%raw(` +// - [untyped-exception] line 27: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 55: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 64: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 73: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 82: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 91: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 100: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 109: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 118: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 127: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 144: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 153: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 162: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 181: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 229: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { + +module Update; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // TEA Update - State transitions + + open Tea + + // ============================================================================ + // Raw JS helpers (must be defined before use) + // ============================================================================ + + /// Post a message to the parent window (for PanLL embedded mode). + %%raw(` + function postMessageToParent(payload) { + if (typeof window !== 'undefined' && window.parent !== window) { + window.parent.postMessage(JSON.parse(payload), '*'); + } + } + `) + @val external postMessageToParent: string => unit = "postMessageToParent" + + // ============================================================================ + // Async command helpers + // ============================================================================ + + let loadAllData = async () => { + try { + let repos = await Backend.Commands.getRepos() + let edges = await Backend.Commands.getEdges() + let groups = await Backend.Commands.getGroups() + let aspects = await Backend.Commands.getAspects() + let slots = await Backend.Commands.getSlots() + let providers = await Backend.Commands.getProviders() + let bindings = await Backend.Commands.getBindings() + let plans = await Backend.Commands.getPlans() + + Msg.DataLoaded( + Ok({ + repos, + edges, + groups, + aspects, + slots, + providers, + bindings, + plans, + }), + ) + } catch { + | Exn.Error(e) => Msg.DataLoaded(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let addEdge = async (from, to_, rel) => { + try { + let edge = await Backend.Commands.addEdge(~from, ~to_, ~rel) + Msg.EdgeAdded(Ok(edge)) + } catch { + | Exn.Error(e) => Msg.EdgeAdded(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let removeEdge = async edgeId => { + try { + await Backend.Commands.removeEdge(~edgeId) + Msg.EdgeRemoved(Ok()) + } catch { + | Exn.Error(e) => Msg.EdgeRemoved(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let createGroup = async (name, description) => { + try { + let group = await Backend.Commands.createGroup(~name, ~description?) + Msg.GroupCreated(Ok(group)) + } catch { + | Exn.Error(e) => Msg.GroupCreated(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let addToGroup = async (groupId, repoId) => { + try { + await Backend.Commands.addToGroup(~groupId, ~repoId) + Msg.LoadAllData + } catch { + | Exn.Error(_) => Msg.LoadAllData + } + } + + let removeFromGroup = async (groupId, repoId) => { + try { + await Backend.Commands.removeFromGroup(~groupId, ~repoId) + Msg.LoadAllData + } catch { + | Exn.Error(_) => Msg.LoadAllData + } + } + + let tagAspect = async (target, aspectId, weight, polarity, reason) => { + try { + let aspect = await Backend.Commands.tagAspect(~target, ~aspectId, ~weight, ~polarity, ~reason) + Msg.AspectTagged(Ok(aspect)) + } catch { + | Exn.Error(e) => Msg.AspectTagged(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let removeAspect = async annotationId => { + try { + await Backend.Commands.removeAspect(~annotationId) + Msg.AspectRemoved(Ok()) + } catch { + | Exn.Error(e) => Msg.AspectRemoved(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let createSlot = async (name, category, description, capabilities) => { + try { + let slot = await Backend.Commands.createSlot(~name, ~category, ~description, ~capabilities) + Msg.SlotCreated(Ok(slot)) + } catch { + | Exn.Error(e) => Msg.SlotCreated(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let createProvider = async (args: Msg.createProviderArgs) => { + try { + let provider = await Backend.Commands.createProvider( + ~name=args.name, + ~slotId=args.slotId, + ~providerType=args.providerType, + ~repoId=?args.repoId, + ~capabilities=args.capabilities, + ~priority=args.priority, + ~isFallback=args.isFallback, + ) + Msg.ProviderCreated(Ok(provider)) + } catch { + | Exn.Error(e) => Msg.ProviderCreated(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let bindSlot = async (consumerId, slotId, providerId) => { + try { + let binding = await Backend.Commands.bindSlot(~consumerId, ~slotId, ~providerId) + Msg.SlotBound(Ok(binding)) + } catch { + | Exn.Error(e) => Msg.SlotBound(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let unbindSlot = async bindingId => { + try { + await Backend.Commands.unbindSlot(~bindingId) + Msg.SlotUnbound(Ok()) + } catch { + | Exn.Error(e) => Msg.SlotUnbound(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + let saveGraph = async () => { + try { + await Backend.Commands.saveGraph() + Msg.GraphSaved(Ok()) + } catch { + | Exn.Error(e) => Msg.GraphSaved(Error(Exn.message(e)->Option.getOr("Unknown error"))) + } + } + + // ============================================================================ + // PanLL bridge helpers + // ============================================================================ + + /// Attempt to connect to a running PanLL instance. + let connectToPanll = async () => { + if PanllBridge.isPanllHost() { + // Running inside PanLL — direct connection via shared internals + Msg.PanllConnectionChanged(PanllConnected("embedded")) + } else { + // Standalone — attempt HTTP handshake with PanLL service + try { + let _response = await Fetch.fetch( + PanllBridge.defaultEndpoint ++ "/api/v1/health", + {method: #GET}, + ) + Msg.PanllConnectionChanged(PanllConnected("standalone")) + } catch { + | Exn.Error(e) => + Msg.PanllConnectionChanged( + PanllError(Exn.message(e)->Option.getOr("Failed to reach PanLL")), + ) + } + } + } + + /// Push the current ecosystem graph to PanLL for Panel-W rendering. + /// + /// Two modes: + /// Embedded (PanLL iframe): window.parent.postMessage + /// Standalone: HTTP POST to PanLL service API + let syncGraphToPanll = async (model: Model.t) => { + if !(model.panll->PanllBridge.isConnected) { + Msg.DismissError + } else { + let graphJson = { + "repos": model.repos, + "edges": model.edges, + "groups": model.groups, + "aspects": model.aspects, + "slots": model.slots, + "providers": model.providers, + "bindings": model.bindings, + "plans": model.plans, + } + let message = { + "type": "reposystem:graph-snapshot", + "source": "reposystem-gui", + "timestamp": Date.make()->Date.toISOString, + "data": graphJson, + } + let payload = JSON.stringifyAny(message)->Option.getOr("{}") + + if PanllBridge.isPanllHost() { + // Embedded — postMessage to parent PanLL window + postMessageToParent(payload) + Msg.DismissError + } else { + // Standalone — POST to PanLL Panel-W API + try { + let _response = await Fetch.fetch( + PanllBridge.defaultEndpoint ++ "/api/v1/panel-w/graph", + {method: #POST, body: Fetch.Body.string(payload)}, + ) + Msg.DismissError + } catch { + | Exn.Error(e) => + Msg.PanllConnectionChanged( + PanllError(Exn.message(e)->Option.getOr("PanLL sync failed")), + ) + } + } + } + } + + // ============================================================================ + // Update function + // ============================================================================ + + let update = (model: Model.t, msg: Msg.t): (Model.t, Cmd.t) => { + switch msg { + // Navigation + | SetTab(tab) => ({...model, activeTab: tab}, Cmd.none) + | SetSearchQuery(query) => ({...model, searchQuery: query}, Cmd.none) + + // Selection + | SelectRepo(repo) => ({...model, selectedItem: SelectedRepo(repo)}, Cmd.none) + | SelectEdge(edge) => ({...model, selectedItem: SelectedEdge(edge)}, Cmd.none) + | SelectGroup(group) => ({...model, selectedItem: SelectedGroup(group)}, Cmd.none) + | SelectAspect(aspect) => ({...model, selectedItem: SelectedAspect(aspect)}, Cmd.none) + | SelectSlot(slot) => ({...model, selectedItem: SelectedSlot(slot)}, Cmd.none) + | SelectProvider(provider) => ({...model, selectedItem: SelectedProvider(provider)}, Cmd.none) + | SelectBinding(binding) => ({...model, selectedItem: SelectedBinding(binding)}, Cmd.none) + | SelectPlan(plan) => ({...model, selectedItem: SelectedPlan(plan)}, Cmd.none) + | ClearSelection => ({...model, selectedItem: NoSelection}, Cmd.none) + + // Data loading + | LoadAllData => ( + {...model, loading: true, error: None}, + Cmd.call(_ => { + loadAllData()->ignore + }), + ) + + | DataLoaded(Ok(data)) => ( + { + ...model, + repos: data.repos, + edges: data.edges, + groups: data.groups, + aspects: data.aspects, + slots: data.slots, + providers: data.providers, + bindings: data.bindings, + plans: data.plans, + loading: false, + error: None, + }->Model.withGraphData, + Cmd.none, + ) + + | DataLoaded(Error(err)) => ({...model, loading: false, error: Some(err)}, Cmd.none) + + // Edge operations + | AddEdge(from, to_, rel) => ( + model, + Cmd.call(_ => { + addEdge(from, to_, rel)->ignore + }), + ) + + | EdgeAdded(Ok(edge)) => ( + { + ...model, + edges: Array.concat(model.edges, [edge]), + }->Model.withGraphData, + Cmd.none, + ) + + | EdgeAdded(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + | RemoveEdge(edgeId) => ( + model, + Cmd.call(_ => { + removeEdge(edgeId)->ignore + }), + ) + + | EdgeRemoved(Ok()) => (model, Cmd.msg(Msg.LoadAllData)) + | EdgeRemoved(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + // Group operations + | CreateGroup(name, description) => ( + model, + Cmd.call(_ => { + createGroup(name, description)->ignore + }), + ) + + | GroupCreated(Ok(group)) => ( + {...model, groups: Array.concat(model.groups, [group])}, + Cmd.none, + ) + + | GroupCreated(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + | AddToGroup(groupId, repoId) => ( + model, + Cmd.call(_ => { + addToGroup(groupId, repoId)->ignore + }), + ) + + | RemoveFromGroup(groupId, repoId) => ( + model, + Cmd.call(_ => { + removeFromGroup(groupId, repoId)->ignore + }), + ) + + // Aspect operations + | TagAspect(target, aspectId, weight, polarity, reason) => ( + model, + Cmd.call(_ => { + tagAspect(target, aspectId, weight, polarity, reason)->ignore + }), + ) + + | AspectTagged(Ok(aspect)) => ( + {...model, aspects: Array.concat(model.aspects, [aspect])}, + Cmd.none, + ) + + | AspectTagged(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + | RemoveAspect(annotationId) => ( + model, + Cmd.call(_ => { + removeAspect(annotationId)->ignore + }), + ) + + | AspectRemoved(Ok()) => (model, Cmd.msg(Msg.LoadAllData)) + | AspectRemoved(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + // Slot operations + | CreateSlot(name, category, description, capabilities) => ( + model, + Cmd.call(_ => { + createSlot(name, category, description, capabilities)->ignore + }), + ) + + | SlotCreated(Ok(slot)) => ( + { + ...model, + slots: Array.concat(model.slots, [slot]), + }->Model.withGraphData, + Cmd.none, + ) + + | SlotCreated(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + | CreateProvider(args) => ( + model, + Cmd.call(_ => { + createProvider(args)->ignore + }), + ) + + | ProviderCreated(Ok(provider)) => ( + { + ...model, + providers: Array.concat(model.providers, [provider]), + }->Model.withGraphData, + Cmd.none, + ) + + | ProviderCreated(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + | BindSlot(consumerId, slotId, providerId) => ( + model, + Cmd.call(_ => { + bindSlot(consumerId, slotId, providerId)->ignore + }), + ) + + | SlotBound(Ok(binding)) => ( + { + ...model, + bindings: Array.concat(model.bindings, [binding]), + }->Model.withGraphData, + Cmd.none, + ) + + | SlotBound(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + | UnbindSlot(bindingId) => ( + model, + Cmd.call(_ => { + unbindSlot(bindingId)->ignore + }), + ) + + | SlotUnbound(Ok()) => (model, Cmd.msg(Msg.LoadAllData)) + | SlotUnbound(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + // Persistence + | SaveGraph => ( + model, + Cmd.call(_ => { + saveGraph()->ignore + }), + ) + + | GraphSaved(Ok()) => (model, Cmd.none) + | GraphSaved(Error(err)) => ({...model, error: Some(err)}, Cmd.none) + + // Graph interaction + | NodeDragStart(_nodeId) => (model, Cmd.none) + | NodeDrag(_nodeId, _x, _y) => (model, Cmd.none) // Handled by D3 + | NodeDragEnd(_nodeId) => (model, Cmd.none) + | GraphZoom(_scale) => (model, Cmd.none) // Handled by D3 + + // Error handling + | DismissError => ({...model, error: None}, Cmd.none) + + // Creation forms — open with empty defaults + | OpenEdgeForm => ({...model, openForm: EdgeForm({from: "", to_: "", rel: "uses"})}, Cmd.none) + | OpenGroupForm => ({...model, openForm: GroupForm({name: "", description: ""})}, Cmd.none) + | OpenAspectForm => ({...model, openForm: AspectForm({target: "", aspectId: "security", weight: "1", polarity: "risk", reason: ""})}, Cmd.none) + | OpenSlotForm => ({...model, openForm: SlotForm({name: "", category: "", description: "", capabilities: ""})}, Cmd.none) + | OpenProviderForm => ({...model, openForm: ProviderForm({name: "", slotId: "", providerType: "local", repoId: "", capabilities: "", priority: "100", isFallback: false})}, Cmd.none) + | OpenBindingForm => ({...model, openForm: BindingForm({consumerId: "", slotId: "", providerId: ""})}, Cmd.none) + | CloseForm => ({...model, openForm: NoForm}, Cmd.none) + + // Form field updates + | UpdateFormField(field, value) => ( + { + ...model, + openForm: switch model.openForm { + | EdgeForm(f) => + EdgeForm( + switch field { + | "from" => {...f, from: value} + | "to" => {...f, to_: value} + | "rel" => {...f, rel: value} + | _ => f + }, + ) + | GroupForm(f) => + GroupForm( + switch field { + | "name" => {...f, name: value} + | "description" => {...f, description: value} + | _ => f + }, + ) + | AspectForm(f) => + AspectForm( + switch field { + | "target" => {...f, target: value} + | "aspectId" => {...f, aspectId: value} + | "weight" => {...f, weight: value} + | "polarity" => {...f, polarity: value} + | "reason" => {...f, reason: value} + | _ => f + }, + ) + | SlotForm(f) => + SlotForm( + switch field { + | "name" => {...f, name: value} + | "category" => {...f, category: value} + | "description" => {...f, description: value} + | "capabilities" => {...f, capabilities: value} + | _ => f + }, + ) + | ProviderForm(f) => + ProviderForm( + switch field { + | "name" => {...f, name: value} + | "slotId" => {...f, slotId: value} + | "providerType" => {...f, providerType: value} + | "repoId" => {...f, repoId: value} + | "capabilities" => {...f, capabilities: value} + | "priority" => {...f, priority: value} + | _ => f + }, + ) + | BindingForm(f) => + BindingForm( + switch field { + | "consumerId" => {...f, consumerId: value} + | "slotId" => {...f, slotId: value} + | "providerId" => {...f, providerId: value} + | _ => f + }, + ) + | NoForm => NoForm + }, + }, + Cmd.none, + ) + + | UpdateFormBool(field, value) => ( + { + ...model, + openForm: switch model.openForm { + | ProviderForm(f) => + ProviderForm( + switch field { + | "isFallback" => {...f, isFallback: value} + | _ => f + }, + ) + | other => other + }, + }, + Cmd.none, + ) + + // Submit current form — dispatch to existing creation messages + | SubmitForm => + switch model.openForm { + | EdgeForm(f) => ( + {...model, openForm: NoForm}, + Cmd.msg(Msg.AddEdge(f.from, f.to_, f.rel)), + ) + | GroupForm(f) => ( + {...model, openForm: NoForm}, + Cmd.msg(Msg.CreateGroup(f.name, f.description == "" ? None : Some(f.description))), + ) + | AspectForm(f) => ( + {...model, openForm: NoForm}, + Cmd.msg( + Msg.TagAspect( + f.target, + f.aspectId, + Int.fromString(f.weight)->Option.getOr(1), + f.polarity, + f.reason, + ), + ), + ) + | SlotForm(f) => ( + {...model, openForm: NoForm}, + Cmd.msg( + Msg.CreateSlot( + f.name, + f.category, + f.description, + f.capabilities->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""), + ), + ), + ) + | ProviderForm(f) => ( + {...model, openForm: NoForm}, + Cmd.msg( + Msg.CreateProvider({ + name: f.name, + slotId: f.slotId, + providerType: f.providerType, + repoId: f.repoId == "" ? None : Some(f.repoId), + capabilities: f.capabilities + ->String.split(",") + ->Array.map(String.trim) + ->Array.filter(s => s != ""), + priority: Int.fromString(f.priority)->Option.getOr(100), + isFallback: f.isFallback, + }), + ), + ) + | BindingForm(f) => ( + {...model, openForm: NoForm}, + Cmd.msg(Msg.BindSlot(f.consumerId, f.slotId, f.providerId)), + ) + | NoForm => (model, Cmd.none) + } + + // PanLL integration + | PanllConnect => ( + {...model, panll: {...model.panll, connection: PanllConnecting}}, + Cmd.call(_ => { + connectToPanll()->ignore + }), + ) + + | PanllDisconnect => ( + {...model, panll: {...model.panll, connection: PanllDisconnected, instanceId: None}}, + Cmd.none, + ) + + | PanllConnectionChanged(status) => ( + { + ...model, + panll: { + ...model.panll, + connection: status, + instanceId: switch status { + | PanllConnected(id) => Some(id) + | _ => model.panll.instanceId + }, + }, + }, + Cmd.none, + ) + + | PanllSyncGraph => ( + model, + Cmd.call(_ => { + syncGraphToPanll(model)->ignore + }), + ) + + | PanllInbound(request) => ( + model, + switch request { + | PanllConstraintRequest => Cmd.msg(Msg.PanllSyncGraph) + | PanllScanRequest => Cmd.msg(Msg.LoadAllData) + | PanllExportRequest(_format) => Cmd.msg(Msg.PanllSyncGraph) + | PanllFilterRequest(filter) => Cmd.msg(Msg.SetSearchQuery(filter)) + | PanllScenarioRequest(_scenario) => Cmd.none // Requires scenario planning UI (P2) + }, + ) + + | PanllToggleAutoSync => ( + {...model, panll: {...model.panll, autoSync: !model.panll.autoSync}}, + Cmd.none, + ) + } + } + +*/ diff --git a/migration/affinescript/gui/src/View.affine b/migration/affinescript/gui/src/View.affine new file mode 100644 index 00000000..6dc69274 --- /dev/null +++ b/migration/affinescript/gui/src/View.affine @@ -0,0 +1,953 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/View.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module View; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // TEA View - UI rendering + + open Tea.Html + open Tea.Html.Attributes + open Tea.Html.Events + + // ============================================================================ + // Small utilities + // ============================================================================ + + /// Check whether a string matches the current search query (case-insensitive). + let matchesSearch = (query: string, text: string): bool => { + if query == "" { + true + } else { + String.toLowerCase(text)->String.includes(String.toLowerCase(query)) + } + } + + let tabIcon = (tab: Model.tab) => + switch tab { + | Dashboard => "~" + | Repos => "@" + | Edges => "->" + | Groups => "[]" + | Aspects => "#" + | Slots => "<>" + | Plans => "!" + } + + let tabCount = (model: Model.t, tab: Model.tab) => + switch tab { + | Dashboard => "" + | Repos => Int.toString(Array.length(model.repos)) + | Edges => Int.toString(Array.length(model.edges)) + | Groups => Int.toString(Array.length(model.groups)) + | Aspects => Int.toString(Array.length(model.aspects)) + | Slots => Int.toString(Array.length(model.slots)) + | Plans => Int.toString(Array.length(model.plans)) + } + + let forgeToString = (forge: Backend.forge) => + switch forge { + | GitHub => "GitHub" + | GitLab => "GitLab" + | Bitbucket => "Bitbucket" + | Codeberg => "Codeberg" + | Sourcehut => "Sourcehut" + | Local => "Local" + } + + let providerTypeToString = (pt: Backend.providerType) => + switch pt { + | Local => "local" + | Ecosystem => "ecosystem" + | External => "external" + | Stub => "stub" + } + + let planStatusToString = (status: Backend.planStatus) => + switch status { + | Draft => "Draft" + | Ready => "Ready" + | Applied => "Applied" + | RolledBack => "Rolled Back" + | Cancelled => "Cancelled" + } + + // ============================================================================ + // Form field helpers — reusable across all creation forms + // ============================================================================ + + /// Render a labeled text input field. + let formField = (label_: string, fieldName: string, value_: string, ~placeholder as placeholder_: string="") => + div( + list{class("form-field")}, + list{ + label(list{class("form-label")}, list{text(label_)}), + input'( + list{ + class("form-input"), + type'("text"), + placeholder(placeholder_), + value(value_), + onInput(v => Msg.UpdateFormField(fieldName, v)), + }, + list{}, + ), + }, + ) + + /// Render a labeled select dropdown. + let formSelect = ( + label_: string, + fieldName: string, + value_: string, + options: array<(string, string)>, + ) => + div( + list{class("form-field")}, + list{ + label(list{class("form-label")}, list{text(label_)}), + select( + list{class("form-select"), onInput(v => Msg.UpdateFormField(fieldName, v))}, + Array.concat( + [{ + let selected = value_ == "" + option(list{Tea.Html.Attributes.value(""), Tea.Html.Attributes.disabled(true), Tea.Html.Attributes.selected(selected)}, list{text(`Select ${label_}...`)}) + }], + options->Array.map(((val, lab)) => + option(list{Tea.Html.Attributes.value(val), Tea.Html.Attributes.selected(val == value_)}, list{text(lab)}) + ), + )->Belt.List.fromArray, + ), + }, + ) + + /// Render a labeled checkbox. + let formCheckbox = (label_: string, fieldName: string, checked_: bool) => + div( + list{class("form-field form-field-checkbox")}, + list{ + label( + list{class("form-label")}, + list{ + input'( + list{ + type'("checkbox"), + Tea.Html.Attributes.checked(checked_), + onClick(Msg.UpdateFormBool(fieldName, !checked_)), + }, + list{}, + ), + text(" " ++ label_), + }, + ), + }, + ) + + /// Render Create + Cancel action buttons for forms. + let formActions = () => + div( + list{class("form-actions")}, + list{ + button(list{onClick(Msg.SubmitForm), class("btn-submit")}, list{text("Create")}), + button(list{onClick(Msg.CloseForm), class("btn-cancel")}, list{text("Cancel")}), + }, + ) + + // ============================================================================ + // Detail renderers + // ============================================================================ + + let renderRepoDetail = (repo: Backend.repo) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(repo.name)}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(repo.id)}), + dt(list{}, list{text("Owner")}), + dd(list{}, list{text(repo.owner)}), + dt(list{}, list{text("Forge")}), + dd(list{}, list{text(forgeToString(repo.forge))}), + dt(list{}, list{text("Default Branch")}), + dd(list{}, list{text(repo.default_branch)}), + dt(list{}, list{text("Tags")}), + dd(list{}, list{text(repo.tags->Array.joinWith(", "))}), + }, + ), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderEdgeDetail = (edge: Backend.edge) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(edge.label->Option.getOr("Edge"))}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(edge.id)}), + dt(list{}, list{text("From")}), + dd(list{}, list{text(edge.from)}), + dt(list{}, list{text("To")}), + dd(list{}, list{text(edge.to_)}), + dt(list{}, list{text("Created By")}), + dd(list{}, list{text(edge.meta.created_by)}), + }, + ), + button(list{onClick(Msg.RemoveEdge(edge.id)), class("btn-danger")}, list{text("Remove")}), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderGroupDetail = (group: Backend.group) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(group.name)}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(group.id)}), + dt(list{}, list{text("Description")}), + dd(list{}, list{text(group.description->Option.getOr("-"))}), + dt(list{}, list{text("Members")}), + dd( + list{}, + group.members + ->Array.map(m => + div( + list{class("member-row")}, + list{ + span(list{}, list{text(m)}), + button( + list{onClick(Msg.RemoveFromGroup(group.id, m)), class("btn-danger btn-sm")}, + list{text("x")}, + ), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderAspectDetail = (aspect: Backend.aspectAnnotation) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(aspect.aspect_id)}), + dl( + list{}, + list{ + dt(list{}, list{text("Target")}), + dd(list{}, list{text(aspect.target)}), + dt(list{}, list{text("Weight")}), + dd(list{}, list{text(Int.toString(aspect.weight))}), + dt(list{}, list{text("Reason")}), + dd(list{}, list{text(aspect.reason)}), + }, + ), + button(list{onClick(Msg.RemoveAspect(aspect.id)), class("btn-danger")}, list{text("Remove")}), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderSlotDetail = (slot: Backend.slot) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(slot.name)}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(slot.id)}), + dt(list{}, list{text("Category")}), + dd(list{}, list{text(slot.category)}), + dt(list{}, list{text("Interface Version")}), + dd(list{}, list{text(slot.interface_version->Option.getOr("-"))}), + dt(list{}, list{text("Description")}), + dd(list{}, list{text(slot.description)}), + dt(list{}, list{text("Required Capabilities")}), + dd(list{}, list{text(slot.required_capabilities->Array.joinWith(", "))}), + }, + ), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderProviderDetail = (provider: Backend.provider) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(provider.name)}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(provider.id)}), + dt(list{}, list{text("Slot")}), + dd(list{}, list{text(provider.slot_id)}), + dt(list{}, list{text("Type")}), + dd(list{}, list{text(providerTypeToString(provider.provider_type))}), + dt(list{}, list{text("Repo")}), + dd(list{}, list{text(provider.repo_id->Option.getOr("-"))}), + dt(list{}, list{text("Priority")}), + dd(list{}, list{text(Int.toString(provider.priority))}), + dt(list{}, list{text("Fallback")}), + dd(list{}, list{text(provider.is_fallback ? "Yes" : "No")}), + }, + ), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderBindingDetail = (binding: Backend.slotBinding) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text("Binding")}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(binding.id)}), + dt(list{}, list{text("Consumer")}), + dd(list{}, list{text(binding.consumer_id)}), + dt(list{}, list{text("Slot")}), + dd(list{}, list{text(binding.slot_id)}), + dt(list{}, list{text("Provider")}), + dd(list{}, list{text(binding.provider_id)}), + }, + ), + button(list{onClick(Msg.UnbindSlot(binding.id)), class("btn-danger")}, list{text("Unbind")}), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + let renderPlanDetail = (plan: Backend.plan) => { + div( + list{class("detail")}, + list{ + h3(list{}, list{text(plan.name)}), + dl( + list{}, + list{ + dt(list{}, list{text("ID")}), + dd(list{}, list{text(plan.id)}), + dt(list{}, list{text("Status")}), + dd(list{}, list{text(planStatusToString(plan.status))}), + dt(list{}, list{text("Description")}), + dd(list{}, list{text(plan.description->Option.getOr("-"))}), + }, + ), + button(list{onClick(Msg.ClearSelection), class("btn-close")}, list{text("Close")}), + }, + ) + } + + // ============================================================================ + // Stat card + // ============================================================================ + + let renderStatCard = (label: string, count: int) => { + div( + list{class("stat-card")}, + list{ + span(list{class("stat-count")}, list{text(Int.toString(count))}), + span(list{class("stat-label")}, list{text(label)}), + }, + ) + } + + // ============================================================================ + // List renderers + // ============================================================================ + + // Dashboard with graph visualization + let renderDashboard = (model: Model.t) => { + div( + list{class("dashboard")}, + list{ + div( + list{class("stats-row")}, + list{ + renderStatCard("Repositories", Array.length(model.repos)), + renderStatCard("Edges", Array.length(model.edges)), + renderStatCard("Groups", Array.length(model.groups)), + renderStatCard("Slots", Array.length(model.slots)), + renderStatCard("Plans", Array.length(model.plans)), + }, + ), + div(list{id("graph-container"), class("graph-container")}, list{}), + }, + ) + } + + // Repos list + let renderReposList = (model: Model.t) => { + let q = model.searchQuery + let filtered = model.repos->Array.filter(repo => + matchesSearch(q, repo.name) || matchesSearch(q, repo.owner) + ) + div( + list{class("list-view")}, + list{ + div(list{class("list-header")}, list{h2(list{}, list{text("Repositories")})}), + ul( + list{class("item-list")}, + filtered + ->Array.map(repo => + li( + list{class("item"), onClick(Msg.SelectRepo(repo))}, + list{ + span(list{class("item-name")}, list{text(repo.name)}), + span(list{class("item-meta")}, list{text(`${repo.owner} | ${forgeToString(repo.forge)}`)}), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ) + } + + // Edges list + let renderEdgesList = (model: Model.t) => { + let q = model.searchQuery + let filtered = model.edges->Array.filter(edge => + matchesSearch(q, edge.label->Option.getOr("")) || + matchesSearch(q, edge.from) || + matchesSearch(q, edge.to_) + ) + div( + list{class("list-view")}, + list{ + div( + list{class("list-header")}, + list{ + h2(list{}, list{text("Edges")}), + button(list{onClick(Msg.OpenEdgeForm), class("btn-add")}, list{text("+")}), + }, + ), + switch model.openForm { + | EdgeForm(f) => + div( + list{class("creation-form")}, + list{ + h3(list{}, list{text("Add Edge")}), + formSelect("From", "from", f.from, model.repos->Array.map(r => (r.id, r.name))), + formSelect("To", "to", f.to_, model.repos->Array.map(r => (r.id, r.name))), + formSelect( + "Relation", + "rel", + f.rel, + [ + ("uses", "Uses"), + ("provides", "Provides"), + ("extends", "Extends"), + ("mirrors", "Mirrors"), + ("replaces", "Replaces"), + ], + ), + formActions(), + }, + ) + | _ => noNode + }, + ul( + list{class("item-list")}, + filtered + ->Array.map(edge => + li( + list{class("item"), onClick(Msg.SelectEdge(edge))}, + list{ + span(list{class("item-name")}, list{text(edge.label->Option.getOr(edge.id))}), + span(list{class("item-meta")}, list{text(`${edge.from} -> ${edge.to_}`)}), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ) + } + + // Groups list + let renderGroupsList = (model: Model.t) => { + let q = model.searchQuery + let filtered = model.groups->Array.filter(group => matchesSearch(q, group.name)) + div( + list{class("list-view")}, + list{ + div( + list{class("list-header")}, + list{ + h2(list{}, list{text("Groups")}), + button(list{onClick(Msg.OpenGroupForm), class("btn-add")}, list{text("+")}), + }, + ), + switch model.openForm { + | GroupForm(f) => + div( + list{class("creation-form")}, + list{ + h3(list{}, list{text("Create Group")}), + formField("Name", "name", f.name, ~placeholder="Group name"), + formField("Description", "description", f.description, ~placeholder="Optional description"), + formActions(), + }, + ) + | _ => noNode + }, + ul( + list{class("item-list")}, + filtered + ->Array.map(group => + li( + list{class("item"), onClick(Msg.SelectGroup(group))}, + list{ + span(list{class("item-name")}, list{text(group.name)}), + span( + list{class("item-meta")}, + list{text(`${Int.toString(Array.length(group.members))} members`)}, + ), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ) + } + + // Aspects list + let renderAspectsList = (model: Model.t) => { + let q = model.searchQuery + let filtered = model.aspects->Array.filter(aspect => + matchesSearch(q, aspect.aspect_id) || matchesSearch(q, aspect.target) + ) + div( + list{class("list-view")}, + list{ + div( + list{class("list-header")}, + list{ + h2(list{}, list{text("Aspect Annotations")}), + button(list{onClick(Msg.OpenAspectForm), class("btn-add")}, list{text("+")}), + }, + ), + switch model.openForm { + | AspectForm(f) => + div( + list{class("creation-form")}, + list{ + h3(list{}, list{text("Tag Aspect")}), + formSelect("Target", "target", f.target, model.repos->Array.map(r => (r.id, r.name))), + formSelect( + "Aspect", + "aspectId", + f.aspectId, + [ + ("security", "Security"), + ("reliability", "Reliability"), + ("maintainability", "Maintainability"), + ("performance", "Performance"), + ("supply-chain", "Supply Chain"), + ("observability", "Observability"), + ], + ), + formSelect( + "Weight", + "weight", + f.weight, + [("0", "0 - None"), ("1", "1 - Low"), ("2", "2 - Medium"), ("3", "3 - High")], + ), + formSelect( + "Polarity", + "polarity", + f.polarity, + [("risk", "Risk"), ("strength", "Strength"), ("neutral", "Neutral")], + ), + formField("Reason", "reason", f.reason, ~placeholder="Why this annotation?"), + formActions(), + }, + ) + | _ => noNode + }, + ul( + list{class("item-list")}, + filtered + ->Array.map(aspect => + li( + list{class("item"), onClick(Msg.SelectAspect(aspect))}, + list{ + span(list{class("item-name")}, list{text(aspect.aspect_id)}), + span(list{class("item-meta")}, list{text(`on ${aspect.target}`)}), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ) + } + + // Slots list + let renderSlotsList = (model: Model.t) => { + let q = model.searchQuery + let filteredSlots = model.slots->Array.filter(slot => + matchesSearch(q, slot.name) || matchesSearch(q, slot.category) + ) + let filteredProviders = model.providers->Array.filter(provider => + matchesSearch(q, provider.name) + ) + div( + list{class("list-view")}, + list{ + div( + list{class("list-header")}, + list{ + h2(list{}, list{text("Slots")}), + button(list{onClick(Msg.OpenSlotForm), class("btn-add")}, list{text("+")}), + }, + ), + switch model.openForm { + | SlotForm(f) => + div( + list{class("creation-form")}, + list{ + h3(list{}, list{text("Create Slot")}), + formField("Name", "name", f.name, ~placeholder="Slot name"), + formField("Category", "category", f.category, ~placeholder="e.g. database, auth, cache"), + formField("Description", "description", f.description, ~placeholder="What this slot provides"), + formField("Capabilities", "capabilities", f.capabilities, ~placeholder="cap1, cap2, ..."), + formActions(), + }, + ) + | _ => noNode + }, + ul( + list{class("item-list")}, + filteredSlots + ->Array.map(slot => + li( + list{class("item"), onClick(Msg.SelectSlot(slot))}, + list{ + span(list{class("item-name")}, list{text(slot.name)}), + span(list{class("item-meta")}, list{text(`[${slot.category}]`)}), + }, + ) + ) + ->Belt.List.fromArray, + ), + div( + list{class("list-header")}, + list{ + h2(list{}, list{text("Providers")}), + button(list{onClick(Msg.OpenProviderForm), class("btn-add")}, list{text("+")}), + }, + ), + switch model.openForm { + | ProviderForm(f) => + div( + list{class("creation-form")}, + list{ + h3(list{}, list{text("Create Provider")}), + formField("Name", "name", f.name, ~placeholder="Provider name"), + formSelect("Slot", "slotId", f.slotId, model.slots->Array.map(s => (s.id, s.name))), + formSelect( + "Type", + "providerType", + f.providerType, + [("local", "Local"), ("ecosystem", "Ecosystem"), ("external", "External"), ("stub", "Stub")], + ), + formSelect("Repository", "repoId", f.repoId, model.repos->Array.map(r => (r.id, r.name))), + formField("Capabilities", "capabilities", f.capabilities, ~placeholder="cap1, cap2, ..."), + formField("Priority", "priority", f.priority, ~placeholder="100"), + formCheckbox("Fallback provider", "isFallback", f.isFallback), + formActions(), + }, + ) + | _ => noNode + }, + ul( + list{class("item-list")}, + filteredProviders + ->Array.map(provider => + li( + list{class("item"), onClick(Msg.SelectProvider(provider))}, + list{ + span(list{class("item-name")}, list{text(provider.name)}), + span(list{class("item-meta")}, list{text(providerTypeToString(provider.provider_type))}), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ) + } + + // Plans list + let renderPlansList = (model: Model.t) => { + let q = model.searchQuery + let filtered = model.plans->Array.filter(plan => matchesSearch(q, plan.name)) + div( + list{class("list-view")}, + list{ + div(list{class("list-header")}, list{h2(list{}, list{text("Plans")})}), + ul( + list{class("item-list")}, + filtered + ->Array.map(plan => + li( + list{class("item"), onClick(Msg.SelectPlan(plan))}, + list{ + span(list{class("item-name")}, list{text(plan.name)}), + span(list{class("item-meta")}, list{text(planStatusToString(plan.status))}), + }, + ) + ) + ->Belt.List.fromArray, + ), + }, + ) + } + + // ============================================================================ + // Panel renderers + // ============================================================================ + + let renderMainPanel = (model: Model.t) => { + main( + list{class("main-panel")}, + list{ + // Search bar — shown on all list tabs (not Dashboard) + switch model.activeTab { + | Dashboard => noNode + | _ => + div( + list{class("search-bar")}, + list{ + input'( + list{ + class("search-input"), + type'("text"), + placeholder("Search..."), + value(model.searchQuery), + onInput(value => Msg.SetSearchQuery(value)), + }, + list{}, + ), + }, + ) + }, + if model.loading { + div(list{class("loading")}, list{text("Loading...")}) + } else { + switch model.activeTab { + | Dashboard => renderDashboard(model) + | Repos => renderReposList(model) + | Edges => renderEdgesList(model) + | Groups => renderGroupsList(model) + | Aspects => renderAspectsList(model) + | Slots => renderSlotsList(model) + | Plans => renderPlansList(model) + } + }, + }, + ) + } + + // ============================================================================ + // Detail panel - Shows selected item + // ============================================================================ + + let renderDetailPanel = (model: Model.t) => { + aside( + list{class("detail-panel")}, + list{ + switch model.selectedItem { + | NoSelection => div(list{class("no-selection")}, list{text("Select an item to view details")}) + | SelectedRepo(repo) => renderRepoDetail(repo) + | SelectedEdge(edge) => renderEdgeDetail(edge) + | SelectedGroup(group) => renderGroupDetail(group) + | SelectedAspect(aspect) => renderAspectDetail(aspect) + | SelectedSlot(slot) => renderSlotDetail(slot) + | SelectedProvider(provider) => renderProviderDetail(provider) + | SelectedBinding(binding) => renderBindingDetail(binding) + | SelectedPlan(plan) => renderPlanDetail(plan) + }, + }, + ) + } + + // ============================================================================ + // Header + // ============================================================================ + + // PanLL connection indicator in the header + let renderPanllStatus = (model: Model.t) => { + let panll = model.panll + let (statusClass, statusLabel) = switch panll.connection { + | PanllDisconnected => ("panll-disconnected", "PanLL: Off") + | PanllConnecting => ("panll-connecting", "PanLL: ...") + | PanllConnected(_) => ("panll-connected", "PanLL: On") + | PanllError(_) => ("panll-error", "PanLL: Err") + } + + div( + list{class("panll-status " ++ statusClass)}, + list{ + span(list{class("panll-indicator")}, list{text(statusLabel)}), + switch panll.connection { + | PanllDisconnected | PanllError(_) => + button( + list{onClick(Msg.PanllConnect), class("btn-panll")}, + list{text("Connect")}, + ) + | PanllConnected(_) => + div( + list{}, + list{ + button( + list{onClick(Msg.PanllSyncGraph), class("btn-panll")}, + list{text("Sync")}, + ), + button( + list{onClick(Msg.PanllDisconnect), class("btn-panll-disconnect")}, + list{text("X")}, + ), + }, + ) + | PanllConnecting => noNode + }, + }, + ) + } + + let renderHeader = (model: Model.t) => { + header( + list{class("app-header")}, + list{ + h1(list{}, list{text("Reposystem")}), + span(list{class("tagline")}, list{text("Railway yard for your repository ecosystem")}), + div( + list{class("header-actions")}, + list{ + renderPanllStatus(model), + button(list{onClick(Msg.SaveGraph), class("btn-save")}, list{text("Save")}), + button(list{onClick(Msg.LoadAllData), class("btn-refresh")}, list{text("Refresh")}), + }, + ), + }, + ) + } + + // ============================================================================ + // Sidebar - Tab navigation + // ============================================================================ + + let renderSidebar = (model: Model.t) => { + nav( + list{class("sidebar")}, + list{ + ul( + list{class("tab-list")}, + Model.allTabs->Array.map(tab => { + let isActive = model.activeTab == tab + li( + list{ + class(isActive ? "tab-item active" : "tab-item"), + onClick(Msg.SetTab(tab)), + }, + list{ + span(list{class("tab-icon")}, list{text(tabIcon(tab))}), + span(list{class("tab-label")}, list{text(Model.tabToString(tab))}), + span(list{class("tab-count")}, list{text(tabCount(model, tab))}), + }, + ) + })->Belt.List.fromArray, + ), + }, + ) + } + + // ============================================================================ + // Error display + // ============================================================================ + + let renderError = (model: Model.t) => { + switch model.error { + | None => noNode + | Some(err) => + div( + list{class("error-toast")}, + list{ + span(list{}, list{text(err)}), + button(list{onClick(Msg.DismissError), class("btn-dismiss")}, list{text("X")}), + }, + ) + } + } + + // ============================================================================ + // Main view function (at end — references all renderers above) + // ============================================================================ + + let view = (model: Model.t): Vdom.t => { + div( + list{class("app")}, + list{ + renderHeader(model), + div( + list{class("main-content")}, + list{ + renderSidebar(model), + renderMainPanel(model), + renderDetailPanel(model), + }, + ), + renderError(model), + }, + ) + } + +*/ diff --git a/migration/affinescript/gui/src/bindings/Backend.affine b/migration/affinescript/gui/src/bindings/Backend.affine new file mode 100644 index 00000000..8292c6f8 --- /dev/null +++ b/migration/affinescript/gui/src/bindings/Backend.affine @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/Backend.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Backend; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + // + // Backend IPC bindings for ReScript + // Uses RuntimeBridge for Gossamer/browser dispatch + + /// Invoke a backend command with arguments (delegates to RuntimeBridge). + let invoke = RuntimeBridge.invoke + + /// Invoke a backend command with no arguments (delegates to RuntimeBridge). + let invokeNoArgs = RuntimeBridge.invokeNoArgs + + // ============================================================================ + // Types matching Rust types + // ============================================================================ + + type forge = + | @as("gh") GitHub + | @as("gl") GitLab + | @as("bb") Bitbucket + | @as("cb") Codeberg + | @as("sr") Sourcehut + | @as("local") Local + + type visibility = + | @as("public") Public + | @as("private") Private + | @as("internal") Internal + + type importMeta = { + source: string, + path_hint: option, + imported_at: string, + } + + type repo = { + kind: string, + id: string, + forge: forge, + owner: string, + name: string, + default_branch: string, + visibility: visibility, + tags: array, + imports: importMeta, + } + + type relationType = + | @as("uses") Uses + | @as("provides") Provides + | @as("extends") Extends + | @as("mirrors") Mirrors + | @as("replaces") Replaces + + type channel = + | @as("api") Api + | @as("artifact") Artifact + | @as("config") Config + | @as("runtime") Runtime + | @as("human") Human + | @as("unknown") Unknown + + type edgeMeta = { + created_by: string, + created_at: string, + } + + type edge = { + kind: string, + id: string, + from: string, + @as("to") to_: string, + rel: relationType, + channel: channel, + label: option, + meta: edgeMeta, + } + + type group = { + kind: string, + id: string, + name: string, + description: option, + members: array, + } + + type polarity = + | @as("risk") Risk + | @as("strength") Strength + | @as("neutral") Neutral + + type annotationSource = { + mode: string, + who: string, + @as("when") when_: string, + rule_id: option, + } + + type aspectAnnotation = { + kind: string, + id: string, + target: string, + aspect_id: string, + weight: int, + polarity: polarity, + reason: string, + source: annotationSource, + } + + type slot = { + kind: string, + id: string, + name: string, + category: string, + interface_version: option, + description: string, + required_capabilities: array, + created_at: string, + } + + type providerType = + | @as("local") Local + | @as("ecosystem") Ecosystem + | @as("external") External + | @as("stub") Stub + + type provider = { + kind: string, + id: string, + name: string, + slot_id: string, + provider_type: providerType, + repo_id: option, + external_uri: option, + interface_version: option, + capabilities: array, + priority: int, + is_fallback: bool, + created_at: string, + } + + type bindingMode = + | @as("manual") Manual + | @as("auto") Auto + | @as("scenario") Scenario + | @as("default") Default + + type slotBinding = { + kind: string, + id: string, + consumer_id: string, + slot_id: string, + provider_id: string, + mode: bindingMode, + created_at: string, + created_by: string, + } + + type riskLevel = + | @as("low") Low + | @as("medium") Medium + | @as("high") High + | @as("critical") Critical + + type planStatus = + | @as("draft") Draft + | @as("ready") Ready + | @as("applied") Applied + | @as("rolled_back") RolledBack + | @as("cancelled") Cancelled + + /// Plan operation — tagged union matching Rust's PlanOp enum. + /// Represented as generic JSON for display; the GUI doesn't create plans yet. + type planOp = { + op: string, + } + + type plan = { + kind: string, + id: string, + name: string, + scenario_id: string, + description: option, + operations: array, + overall_risk: riskLevel, + status: planStatus, + created_at: string, + created_by: string, + applied_at: option, + rollback_plan_id: option, + } + + // ============================================================================ + // Commands + // ============================================================================ + + module Commands = { + // Read operations + let getRepos = (): promise> => invokeNoArgs("get_repos") + let getEdges = (): promise> => invokeNoArgs("get_edges") + let getGroups = (): promise> => invokeNoArgs("get_groups") + let getAspects = (): promise> => invokeNoArgs("get_aspects") + let getSlots = (): promise> => invokeNoArgs("get_slots") + let getProviders = (): promise> => invokeNoArgs("get_providers") + let getBindings = (): promise> => invokeNoArgs("get_bindings") + let getPlans = (): promise> => invokeNoArgs("get_plans") + + // Edge operations + let addEdge = (~from: string, ~to_: string, ~rel: string, ~label: option=?): promise => + invoke("add_edge", {"from": from, "to": to_, "rel": rel, "label": label}) + + let removeEdge = (~edgeId: string): promise => + invoke("remove_edge", {"edge_id": edgeId}) + + // Group operations + let createGroup = (~name: string, ~description: option=?): promise => + invoke("create_group", {"name": name, "description": description}) + + let addToGroup = (~groupId: string, ~repoId: string): promise => + invoke("add_to_group", {"group_id": groupId, "repo_id": repoId}) + + let removeFromGroup = (~groupId: string, ~repoId: string): promise => + invoke("remove_from_group", {"group_id": groupId, "repo_id": repoId}) + + // Aspect operations + let tagAspect = ( + ~target: string, + ~aspectId: string, + ~weight: int, + ~polarity: string, + ~reason: string, + ): promise => + invoke("tag_aspect", { + "target": target, + "aspect_id": aspectId, + "weight": weight, + "polarity": polarity, + "reason": reason, + }) + + let removeAspect = (~annotationId: string): promise => + invoke("remove_aspect", {"annotation_id": annotationId}) + + // Slot operations + let createSlot = ( + ~name: string, + ~category: string, + ~interfaceVersion: option=?, + ~description: string, + ~capabilities: array, + ): promise => + invoke("create_slot", { + "name": name, + "category": category, + "interface_version": interfaceVersion, + "description": description, + "capabilities": capabilities, + }) + + let createProvider = ( + ~name: string, + ~slotId: string, + ~providerType: string, + ~repoId: option=?, + ~externalUri: option=?, + ~interfaceVersion: option=?, + ~capabilities: array, + ~priority: int, + ~isFallback: bool, + ): promise => + invoke("create_provider", { + "name": name, + "slot_id": slotId, + "provider_type": providerType, + "repo_id": repoId, + "external_uri": externalUri, + "interface_version": interfaceVersion, + "capabilities": capabilities, + "priority": priority, + "is_fallback": isFallback, + }) + + let bindSlot = (~consumerId: string, ~slotId: string, ~providerId: string): promise => + invoke("bind_slot", { + "consumer_id": consumerId, + "slot_id": slotId, + "provider_id": providerId, + }) + + let unbindSlot = (~bindingId: string): promise => + invoke("unbind_slot", {"binding_id": bindingId}) + + // Persistence + let saveGraph = (): promise => invokeNoArgs("save_graph") + } + +*/ diff --git a/migration/affinescript/gui/src/bindings/D3.affine b/migration/affinescript/gui/src/bindings/D3.affine new file mode 100644 index 00000000..4466b95b --- /dev/null +++ b/migration/affinescript/gui/src/bindings/D3.affine @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/D3.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module D3; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // ReScript bindings for D3.js — minimal surface for force-directed graph + + module Selection = D3_Selection + module Force = D3_Force + module Zoom = D3_Zoom + module Drag = D3_Drag + +*/ diff --git a/migration/affinescript/gui/src/bindings/D3_Drag.affine b/migration/affinescript/gui/src/bindings/D3_Drag.affine new file mode 100644 index 00000000..98152664 --- /dev/null +++ b/migration/affinescript/gui/src/bindings/D3_Drag.affine @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/D3_Drag.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module D3_Drag; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // D3 Drag bindings + + type dragBehavior<'a> + + type dragEvent = { + active: int, + x: float, + y: float, + } + + @module("d3") external drag: unit => dragBehavior<'a> = "drag" + @send external on: (dragBehavior<'a>, string, (dragEvent, 'a) => unit) => dragBehavior<'a> = "on" + @send external applyTo: (D3_Selection.t, dragBehavior<'a>) => D3_Selection.t = "call" + +*/ diff --git a/migration/affinescript/gui/src/bindings/D3_Force.affine b/migration/affinescript/gui/src/bindings/D3_Force.affine new file mode 100644 index 00000000..074bcd3f --- /dev/null +++ b/migration/affinescript/gui/src/bindings/D3_Force.affine @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/D3_Force.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module D3_Force; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // D3 Force simulation bindings + + type simulation<'a> + type force + type manyBodyForce + type linkForce<'a> + + type node<'a> = { + mutable index: int, + mutable x: float, + mutable y: float, + mutable vx: float, + mutable vy: float, + mutable fx: Js.Nullable.t, + mutable fy: Js.Nullable.t, + data: 'a, + } + + type linkInput<'a> = { + source: string, + target: string, + } + + // Simulation + @module("d3") external forceSimulationEmpty: unit => simulation<'a> = "forceSimulation" + @send external force: (simulation<'a>, string, 'f) => simulation<'a> = "force" + @send external nodes: (simulation<'a>, array>) => simulation<'a> = "nodes" + @send external alpha: (simulation<'a>, float) => simulation<'a> = "alpha" + @send external alphaTarget: (simulation<'a>, float) => simulation<'a> = "alphaTarget" + @send external restart: simulation<'a> => simulation<'a> = "restart" + @send external on: (simulation<'a>, string, unit => unit) => simulation<'a> = "on" + @send external getForce: (simulation<'a>, string) => Js.Nullable.t = "force" + + // Forces + @module("d3") external forceManyBody: unit => manyBodyForce = "forceManyBody" + @module("d3") external forceCenter: (float, float) => force = "forceCenter" + @module("d3") external forceCollide: float => force = "forceCollide" + @module("d3") external forceLinkEmpty: unit => linkForce<'a> = "forceLink" + + // Many-body force + external asManyBodyForce: force => manyBodyForce = "%identity" + external manyBodyForceAsForce: manyBodyForce => force = "%identity" + @send external strength: (manyBodyForce, float) => manyBodyForce = "strength" + + // Link force + external asLinkForce: force => linkForce<'a> = "%identity" + external linkForceAsForce: linkForce<'a> => force = "%identity" + @send external linkDistance: (linkForce<'a>, float) => linkForce<'a> = "distance" + @send external links: (linkForce<'a>, array>) => linkForce<'a> = "links" + @send external linkId: (linkForce<'a>, (linkInput<'a>, int, array>) => string) => linkForce<'a> = "id" + +*/ diff --git a/migration/affinescript/gui/src/bindings/D3_Selection.affine b/migration/affinescript/gui/src/bindings/D3_Selection.affine new file mode 100644 index 00000000..ce276807 --- /dev/null +++ b/migration/affinescript/gui/src/bindings/D3_Selection.affine @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/D3_Selection.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module D3_Selection; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // D3 Selection bindings + + type t + + @module("d3") external select: string => t = "select" + @module("d3") external selectElement: Dom.element => t = "select" + @send external append: (t, string) => t = "append" + @send external attr: (t, string, 'a) => t = "attr" + @send external selectChildren: (t, string) => t = "selectAll" + @send external data: (t, array<'a>) => t = "data" + @send external join: (t, string) => t = "join" + @send external on: (t, string, 'a) => t = "on" + +*/ diff --git a/migration/affinescript/gui/src/bindings/D3_Zoom.affine b/migration/affinescript/gui/src/bindings/D3_Zoom.affine new file mode 100644 index 00000000..59e60b8e --- /dev/null +++ b/migration/affinescript/gui/src/bindings/D3_Zoom.affine @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/D3_Zoom.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module D3_Zoom; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // D3 Zoom bindings + + type zoomBehavior<'a> + type zoomTransform + + type zoomEvent = { + transform: zoomTransform, + } + + @module("d3") external zoom: unit => zoomBehavior<'a> = "zoom" + @send external scaleExtent: (zoomBehavior<'a>, (float, float)) => zoomBehavior<'a> = "scaleExtent" + @send external on: (zoomBehavior<'a>, string, (zoomEvent, 'a) => unit) => zoomBehavior<'a> = "on" + @send external applyTo: (D3_Selection.t, zoomBehavior<'a>) => D3_Selection.t = "call" + external toString: zoomTransform => string = "String" + +*/ diff --git a/migration/affinescript/gui/src/bindings/Fetch.affine b/migration/affinescript/gui/src/bindings/Fetch.affine new file mode 100644 index 00000000..20a3d1b7 --- /dev/null +++ b/migration/affinescript/gui/src/bindings/Fetch.affine @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/bindings/Fetch.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Fetch; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Minimal Fetch API bindings for PanLL bridge HTTP calls. + + type response + + type method = [#GET | #POST | #PUT | #DELETE] + + module Body = { + /// Wrap a string as a fetch body (identity — fetch accepts string bodies). + let string = (s: string): string => s + } + + type requestInit = { + method: method, + body?: string, + } + + @val external fetch: (string, requestInit) => promise = "fetch" + +*/ diff --git a/migration/affinescript/gui/src/modules/PanllBridge.affine b/migration/affinescript/gui/src/modules/PanllBridge.affine new file mode 100644 index 00000000..21eaa682 --- /dev/null +++ b/migration/affinescript/gui/src/modules/PanllBridge.affine @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/modules/PanllBridge.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module PanllBridge; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + + /// PanLL Bridge — Communication layer between Reposystem GUI and PanLL. + /// + /// Reposystem can operate standalone (Gossamer desktop, browser) or as an + /// embedded panel inside PanLL. This bridge handles both modes: + /// + /// Standalone mode: Reposystem runs its own TEA loop; PanLL bridge is + /// dormant. Graph data stays local. + /// + /// Embedded mode: Reposystem pushes ecosystem state to PanLL panels: + /// Panel-L receives governance constraints and slot policies + /// Panel-N receives health events for AI reasoning + /// Panel-W receives graph snapshots for barycentre rendering + /// + /// Communication uses window.postMessage when in-browser (PanLL iframe or + /// same-origin embed) or Gossamer invoke when running as a Gossamer sub-window. + + /// PanLL connection state. + type panllConnectionStatus = + | PanllDisconnected // Not connected to any PanLL instance + | PanllConnecting // Handshake in progress + | PanllConnected(string) // Connected — parameter is PanLL instance ID + | PanllError(string) // Connection failed + + /// Messages sent TO PanLL (outbound). + type panllOutbound = + | PanllGraphSnapshot(string) // JSON-serialised ecosystem graph + | PanllConstraintUpdate(string) // Governance constraint changes + | PanllHealthEvent(string) // Ecosystem health event + | PanllSlotCoverage(string) // Slot binding coverage report + | PanllAspectSummary(string) // Aggregated aspect scores + | PanllScenarioResult(string) // Scenario planning output + + /// Messages received FROM PanLL (inbound). + type panllInbound = + | PanllConstraintRequest // Panel-L wants current constraints + | PanllScanRequest // Panel-N wants a fresh scan + | PanllExportRequest(string) // Panel-W wants export in given format + | PanllFilterRequest(string) // Panel-W wants to filter by aspect/group + | PanllScenarioRequest(string) // Panel-N wants to run a scenario + + /// PanLL bridge state, composed into the main model. + type panllState = { + connection: panllConnectionStatus, + lastSentAt: option, // ISO 8601 timestamp of last outbound message + lastReceivedAt: option, // ISO 8601 timestamp of last inbound message + autoSync: bool, // Whether to push graph changes automatically + instanceId: option, // PanLL instance we're connected to + } + + /// Initial PanLL bridge state. + let init: panllState = { + connection: PanllDisconnected, + lastSentAt: None, + lastReceivedAt: None, + autoSync: false, + instanceId: None, + } + + /// PanLL service endpoint (default, overridable via Gossamer config). + let defaultEndpoint = "http://localhost:1430" + + /// Detect whether we're running inside a PanLL host. + /// Checks for PanLL's presence marker on the window object. + @val @scope("window") + external panllInternals: Js.Nullable.t<{..}> = "__PANLL_INTERNALS__" + + let isPanllHost = (): bool => { + panllInternals->Js.Nullable.toOption->Belt.Option.isSome + } + + /// Connection status as a human-readable label. + let connectionLabel = (status: panllConnectionStatus): string => { + switch status { + | PanllDisconnected => "Disconnected" + | PanllConnecting => "Connecting..." + | PanllConnected(id) => `Connected (${id})` + | PanllError(err) => `Error: ${err}` + } + } + + /// Whether the bridge is in a connected state. + let isConnected = (state: panllState): bool => { + switch state.connection { + | PanllConnected(_) => true + | _ => false + } + } + +*/ diff --git a/migration/affinescript/gui/src/modules/ReposystemModule.affine b/migration/affinescript/gui/src/modules/ReposystemModule.affine new file mode 100644 index 00000000..fd26de69 --- /dev/null +++ b/migration/affinescript/gui/src/modules/ReposystemModule.affine @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/gui/src/modules/ReposystemModule.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ReposystemModule; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + + /// Reposystem Module Registration — Capability-driven module protocol. + /// + /// Registers Reposystem as a PanLL panel module with its capabilities, + /// configuration, and metadata. Follows the ForgeOpsModule.res pattern. + /// + /// Three-panel model (PanLL integration): + /// Panel-L → Ecosystem constraints (slot policies, edge cardinality limits, + /// aspect rules, governance invariants) + /// Panel-N → Ecosystem health reasoning (dependency analysis, vulnerability + /// propagation, slot coverage gaps, orphan detection) + /// Panel-W → Ecosystem graph visualization, scan results, health dashboard, + /// scenario planning output + + /// Capabilities that Reposystem provides to the PanLL ecosystem. + type reposystemCapability = + | GraphVisualization // Force-directed, hierarchical, circular, grid layouts + | EcosystemScanning // Discover repos from filesystem or forges + | EdgeManagement // Create, remove, annotate relationships between repos + | GroupManagement // Organize repos into named collections + | AspectAnnotation // Tag repos/edges with weighted aspect scores + | SlotBindingSystem // Declare required capabilities and bind providers + | ScenarioPlanning // Multi-repo change coordination with risk assessment + | ExportMultiFormat // Export to JSON, YAML, TOML, DOT + | ConstraintEvaluation // Evaluate ecosystem against governance rules + | HealthDashboard // Aggregate health metrics across the ecosystem + + /// Reposystem module configuration. + type reposystemModuleConfig = { + id: string, + name: string, + version: string, + description: string, + capabilities: array, + icon: option, + } + + /// The Reposystem module registration. + let config: reposystemModuleConfig = { + id: "reposystem", + name: "Reposystem", + version: "0.1.0", + description: "Railway yard for repository ecosystems. Visualises repo relationships, manages slots and providers, annotates aspects, and coordinates multi-repo changes with scenario planning.", + capabilities: [ + GraphVisualization, + EcosystemScanning, + EdgeManagement, + GroupManagement, + AspectAnnotation, + SlotBindingSystem, + ScenarioPlanning, + ExportMultiFormat, + ConstraintEvaluation, + HealthDashboard, + ], + icon: Some("railway"), + } + + /// Check if Reposystem has a specific capability. + let hasCapability = (cap: reposystemCapability): bool => { + config.capabilities->Js.Array2.includes(cap) + } + + /// Human-readable label for a Reposystem capability. + let capabilityLabel = (cap: reposystemCapability): string => { + switch cap { + | GraphVisualization => "Graph Visualization" + | EcosystemScanning => "Ecosystem Scanning" + | EdgeManagement => "Edge Management" + | GroupManagement => "Group Management" + | AspectAnnotation => "Aspect Annotation" + | SlotBindingSystem => "Slot Binding System" + | ScenarioPlanning => "Scenario Planning" + | ExportMultiFormat => "Multi-Format Export" + | ConstraintEvaluation => "Constraint Evaluation" + | HealthDashboard => "Health Dashboard" + } + } + + /// Short description for each capability. + let capabilityDescription = (cap: reposystemCapability): string => { + switch cap { + | GraphVisualization => "Force-directed, hierarchical, circular, and grid graph layouts with D3 rendering" + | EcosystemScanning => "Discover repos from local filesystem or forge APIs, build initial graph" + | EdgeManagement => "Create and remove typed, channelled relationships between repos with metadata" + | GroupManagement => "Organize repos into named groups for filtering and batch operations" + | AspectAnnotation => "Tag repos and edges with weighted aspect scores (security, reliability, etc.)" + | SlotBindingSystem => "Declare required capabilities as slots and bind provider repos to fulfil them" + | ScenarioPlanning => "Coordinate multi-repo changes with risk assessment and rollback planning" + | ExportMultiFormat => "Export ecosystem graph to JSON, YAML, TOML, and Graphviz DOT formats" + | ConstraintEvaluation => "Evaluate the ecosystem against governance rules and RSR policy" + | HealthDashboard => "Aggregate health metrics, slot coverage, aspect scores across the ecosystem" + } + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/ArangoClient.affine b/migration/affinescript/recon-silly-ation/src/ArangoClient.affine new file mode 100644 index 00000000..69ed18c7 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/ArangoClient.affine @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/ArangoClient.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 17 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 55: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 84: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | exn => Error(`Failed to initialize ArangoDB: ${Js.Exn.asJsExn(exn)->Belt.Op... +// - [untyped-exception] line 130: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 137: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to insert document: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn... +// - [untyped-exception] line 174: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 180: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Error(`Failed to insert edge: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.E... +// - [untyped-exception] line 213: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 235: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to store conflict: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.... +// - [untyped-exception] line 245: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 273: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to store resolution: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Ex... +// - [untyped-exception] line 283: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 293: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 306: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to find duplicates: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn... +// - [untyped-exception] line 316: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 328: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to find related documents: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap... +// - [untyped-exception] line 335: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 342: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Health check failed: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.messa... + +module ArangoClient; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Type-safe ArangoDB client for graph operations + // Multi-model database: document storage + graph relationships + + open Types + + // ArangoDB bindings + type database + type collection + type graph + type aqlQuery + + @module("arangojs") @new + external createDatabase: {..} => database = "Database" + + @send external db: database => database = "db" + @send external useDatabase: (database, string) => database = "useDatabase" + @send external collection: (database, string) => collection = "collection" + @send external graph: (database, string) => graph = "graph" + // Typed externals: arangojs accepts and returns plain JSON, so Js.Json.t is the + // correct ReScript type. Using `{..}` here previously forced Obj.magic at every + // call site, which panic-attack flagged as unsafe_blocks. + @send external query: (database, string, Js.Json.t) => promise = "query" + + @send external save: (collection, Js.Json.t) => promise = "save" + @send external document: (collection, string) => promise = "document" + @send external update: (collection, string, Js.Json.t) => promise = "update" + @send external remove: (collection, string) => promise = "remove" + + @send external all: aqlQuery => promise> = "all" + + // Client state + // ReScript does not allow inline nested record types in record field + // positions, so the collection groupings are declared as named record + // types first. + type collections = { + documents: collection, + conflicts: collection, + resolutions: collection, + } + + type edges = { + relationships: collection, + } + + type client = { + db: database, + config: config, + collections: collections, + edges: edges, + } + + // Initialize ArangoDB client + let initialize = async (config: config): result => { + try { + let db = createDatabase({ + "url": config.arangoUrl, + "databaseName": config.arangoDatabase, + "auth": { + "username": config.arangoUsername, + "password": config.arangoPassword, + }, + }) + + // Get or create collections + let documentsCol = db->collection("documents") + let conflictsCol = db->collection("conflicts") + let resolutionsCol = db->collection("resolutions") + let relationshipsCol = db->collection("relationships") + + Ok({ + db: db, + config: config, + collections: { + documents: documentsCol, + conflicts: conflictsCol, + resolutions: resolutionsCol, + }, + edges: { + relationships: relationshipsCol, + }, + }) + } catch { + | exn => Error(`Failed to initialize ArangoDB: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`) + } + } + + // Document serialization + let documentToJson = (doc: document): Js.Json.t => { + Js.Json.object_( + Js.Dict.fromArray([ + ("_key", Js.Json.string(doc.hash)), + ("hash", Js.Json.string(doc.hash)), + ("content", Js.Json.string(doc.content)), + ("path", Js.Json.string(doc.metadata.path)), + ( + "documentType", + Js.Json.string(documentTypeToString(doc.metadata.documentType)), + ), + ("lastModified", Js.Json.number(doc.metadata.lastModified)), + ( + "version", + switch doc.metadata.version { + | None => Js.Json.null + | Some(v) => Js.Json.string(versionToString(v)) + }, + ), + ("repository", Js.Json.string(doc.metadata.repository)), + ("branch", Js.Json.string(doc.metadata.branch)), + ("createdAt", Js.Json.number(doc.createdAt)), + ]), + ) + } + + // Edge serialization + let edgeToJson = (edge: edge): Js.Json.t => { + Js.Json.object_( + Js.Dict.fromArray([ + ("_from", Js.Json.string("documents/" ++ edge.from)), + ("_to", Js.Json.string("documents/" ++ edge.to)), + ("type", Js.Json.string(edgeTypeToString(edge.edgeType))), + ("confidence", Js.Json.number(edge.confidence)), + ("metadata", edge.metadata), + ]), + ) + } + + // Insert document + let insertDocument = async (client: client, doc: document): result => { + try { + let json = documentToJson(doc) + let _ = await client.collections.documents->save(json) + Ok() + } catch { + | exn => + Error( + `Failed to insert document: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Batch insert documents + let insertDocuments = async ( + client: client, + documents: array, + ): result => { + let results = [] + for i in 0 to Belt.Array.length(documents) - 1 { + switch Belt.Array.get(documents, i) { + | Some(doc) => + let result = await insertDocument(client, doc) + results->Js.Array2.push(result)->ignore + | None => () + } + } + + let errors = + results->Belt.Array.keepMap(r => + switch r { + | Error(msg) => Some(msg) + | Ok() => None + } + ) + + if Belt.Array.length(errors) > 0 { + Error(`Failed to insert ${errors->Belt.Array.length->Belt.Int.toString} documents`) + } else { + Ok() + } + } + + // Insert edge + let insertEdge = async (client: client, edge: edge): result => { + try { + let json = edgeToJson(edge) + let _ = await client.edges.relationships->save(json) + Ok() + } catch { + | exn => + Error(`Failed to insert edge: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`) + } + } + + // Batch insert edges + let insertEdges = async (client: client, edges: array): result => { + let results = [] + for i in 0 to Belt.Array.length(edges) - 1 { + switch Belt.Array.get(edges, i) { + | Some(edge) => + let result = await insertEdge(client, edge) + results->Js.Array2.push(result)->ignore + | None => () + } + } + + let errors = + results->Belt.Array.keepMap(r => + switch r { + | Error(msg) => Some(msg) + | Ok() => None + } + ) + + if Belt.Array.length(errors) > 0 { + Error(`Failed to insert ${errors->Belt.Array.length->Belt.Int.toString} edges`) + } else { + Ok() + } + } + + // Store conflict + let storeConflict = async (client: client, conflict: conflict): result => { + try { + let docHashes = conflict.documents->Belt.Array.map(d => Js.Json.string(d.hash)) + + let json = Js.Json.object_( + Js.Dict.fromArray([ + ("_key", Js.Json.string(conflict.id)), + ("id", Js.Json.string(conflict.id)), + ("documents", Js.Json.array(docHashes)), + ("detectedAt", Js.Json.number(conflict.detectedAt)), + ("confidence", Js.Json.number(conflict.confidence)), + ( + "suggestedStrategy", + Js.Json.string(resolutionStrategyToString(conflict.suggestedStrategy)), + ), + ]), + ) + + let _ = await client.collections.conflicts->save(json) + Ok() + } catch { + | exn => + Error( + `Failed to store conflict: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Store resolution + let storeResolution = async ( + client: client, + resolution: resolutionResult, + ): result => { + try { + let json = Js.Json.object_( + Js.Dict.fromArray([ + ("_key", Js.Json.string(resolution.conflictId ++ "_resolution")), + ("conflictId", Js.Json.string(resolution.conflictId)), + ( + "strategy", + Js.Json.string(resolutionStrategyToString(resolution.strategy)), + ), + ( + "selectedDocument", + switch resolution.selectedDocument { + | None => Js.Json.null + | Some(doc) => Js.Json.string(doc.hash) + }, + ), + ("confidence", Js.Json.number(resolution.confidence)), + ("requiresApproval", Js.Json.boolean(resolution.requiresApproval)), + ("reasoning", Js.Json.string(resolution.reasoning)), + ("timestamp", Js.Json.number(resolution.timestamp)), + ]), + ) + + let _ = await client.collections.resolutions->save(json) + Ok() + } catch { + | exn => + Error( + `Failed to store resolution: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Query documents by hash + let findDocumentByHash = async ( + client: client, + hash: contentHash, + ): result, string> => { + try { + let result = await client.collections.documents->document(hash) + Ok(Some(result)) + } catch { + | _ => Ok(None) // Document not found + } + } + + // Query for duplicates + let findDuplicates = async (client: client): result, string> => { + try { + let aql = ` + FOR doc IN documents + COLLECT hash = doc.hash WITH COUNT INTO count + FILTER count > 1 + RETURN { hash, count } + ` + let result = await client.db->query(aql, Js.Json.object_(Js.Dict.empty())) + let data = await result->all + Ok(data) + } catch { + | exn => + Error( + `Failed to find duplicates: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Graph traversal: Find all related documents + let findRelatedDocuments = async ( + client: client, + hash: contentHash, + ): result, string> => { + try { + let aql = ` + FOR v, e, p IN 1..3 OUTBOUND @start relationships + RETURN { vertex: v, edge: e, path: p } + ` + let bindVars = Js.Dict.fromArray([("start", Js.Json.string("documents/" ++ hash))]) + let result = await client.db->query(aql, Js.Json.object_(bindVars)) + let data = await result->all + Ok(data) + } catch { + | exn => + Error( + `Failed to find related documents: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Health check + let healthCheck = async (client: client): result => { + try { + let aql = "RETURN 1" + let _ = await client.db->query(aql, Js.Json.object_(Js.Dict.empty())) + Ok(true) + } catch { + | exn => + Error( + `Health check failed: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/CCCPCompliance.affine b/migration/affinescript/recon-silly-ation/src/CCCPCompliance.affine new file mode 100644 index 00000000..49770d7e --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/CCCPCompliance.affine @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/CCCPCompliance.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 2 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 130: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 166: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { + +module CCCPCompliance; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // CCCP Compliance Checker + // Detects Python files and recommends ReScript/Deno migrations + // Issues "Patrojisign/insulti" warnings for Python usage + + open Types + + @module("fs") @val + external readFileSync: (string, string) => string = "readFileSync" + + @module("fs") @val + external existsSync: string => bool = "existsSync" + + @module("path") @val + external extname: string => string = "extname" + + @module("path") @val + external basename: string => string = "basename" + + // Detect Python files and patterns + let isPythonFile = (path: string): bool => { + let ext = extname(path) + ext == ".py" || ext == ".pyw" || basename(path) == "setup.py" + } + + let detectPythonImports = (content: string): array => { + let imports = [] + let lines = Js.String2.split(content, "\n") + + lines->Belt.Array.forEach(line => { + let trimmed = Js.String2.trim(line) + + // Match import statements + if Js.String2.startsWith(trimmed, "import ") || Js.String2.startsWith(trimmed, "from ") { + imports->Js.Array2.push(trimmed)->ignore + } + }) + + imports + } + + // Python anti-patterns to detect + let checkPythonAntiPatterns = (content: string): array => { + let antiPatterns = [] + + if Js.String2.includes(content, "eval(") { + antiPatterns->Js.Array2.push("Uses eval() - dangerous code execution")->ignore + } + + if Js.String2.includes(content, "exec(") { + antiPatterns->Js.Array2.push("Uses exec() - dangerous code execution")->ignore + } + + if Js.String2.includes(content, "pickle") { + antiPatterns->Js.Array2.push("Uses pickle - insecure serialization")->ignore + } + + if Js.String2.includes(content, "__import__") { + antiPatterns->Js.Array2.push("Uses dynamic imports - potential security risk")->ignore + } + + if Js.String2.includes(content, "os.system") { + antiPatterns->Js.Array2.push("Uses os.system - command injection risk")->ignore + } + + antiPatterns + } + + // Generate migration suggestion + // Defined before scanFile because ReScript `let` bindings can't + // forward-reference; scanFile calls this helper. + let generateMigrationSuggestion = (imports: array): string => { + let hasDataScience = + imports->Belt.Array.some(imp => { + Js.String2.includes(imp, "numpy") || + Js.String2.includes(imp, "pandas") || + Js.String2.includes(imp, "sklearn") + }) + + let hasWeb = + imports->Belt.Array.some(imp => { + Js.String2.includes(imp, "flask") || + Js.String2.includes(imp, "django") || + Js.String2.includes(imp, "fastapi") + }) + + let hasAsync = + imports->Belt.Array.some(imp => { + Js.String2.includes(imp, "asyncio") || Js.String2.includes(imp, "aiohttp") + }) + + let suggestions = [] + + if hasWeb { + suggestions + ->Js.Array2.push("Consider migrating to ReScript with Melange for web applications") + ->ignore + suggestions->Js.Array2.push("Or use Deno for a secure TypeScript runtime")->ignore + } + + if hasAsync { + suggestions + ->Js.Array2.push("ReScript has excellent async support with promises") + ->ignore + suggestions->Js.Array2.push("Deno provides native async/await with Web APIs")->ignore + } + + if hasDataScience { + suggestions + ->Js.Array2.push("For data science, consider R or Julia instead of Python") + ->ignore + suggestions->Js.Array2.push("Or use ReScript with bindings to WebAssembly modules")->ignore + } + + if Belt.Array.length(suggestions) == 0 { + suggestions + ->Js.Array2.push("Migrate to ReScript for type safety and compile-time guarantees") + ->ignore + suggestions->Js.Array2.push("Or use Deno for a secure, modern JavaScript/TypeScript runtime")->ignore + } + + suggestions->Js.Array2.joinWith("\n") + } + + // Generate CCCP violation report + let scanFile = (path: string): option => { + if !isPythonFile(path) { + None + } else { + try { + let content = readFileSync(path, "utf8") + let antiPatterns = checkPythonAntiPatterns(content) + let imports = detectPythonImports(content) + + let severity = if Belt.Array.length(antiPatterns) > 0 { + "error" + } else { + "warning" + } + + let message = if Belt.Array.length(antiPatterns) > 0 { + `Patrojisign/insulti: Python file with security issues detected:\n${antiPatterns->Js.Array2.joinWith("\n - ")}` + } else { + `Patrojisign/insulti: Python file detected (${imports->Belt.Array.length->Belt.Int.toString} imports)` + } + + Some({ + file: path, + violationType: "python-usage", + severity: severity, + message: message, + suggestedFix: Some(generateMigrationSuggestion(imports)), + }) + } catch { + | _ => None + } + } + } + + // Scan entire repository for CCCP violations + let scanRepository = (repoPath: string): array => { + let violations = [] + + let scanDir = (path: string) => { + if existsSync(path) { + try { + // Note: Would need proper directory traversal in real implementation + switch scanFile(path) { + | None => () + | Some(violation) => violations->Js.Array2.push(violation)->ignore + } + } catch { + | _ => () + } + } + } + + scanDir(repoPath) + violations + } + + // Generate CCCP compliance report + let generateReport = (violations: array): string => { + let lines = [] + + lines->Js.Array2.push("=== CCCP Compliance Report ===")->ignore + lines->Js.Array2.push("Patrojisign/insulti: Python Usage Detection")->ignore + lines->Js.Array2.push("")->ignore + + if Belt.Array.length(violations) == 0 { + lines->Js.Array2.push("✓ No Python files detected - repository is CCCP compliant")->ignore + } else { + let errors = violations->Belt.Array.keep(v => v.severity == "error")->Belt.Array.length + let warnings = violations->Belt.Array.keep(v => v.severity == "warning")->Belt.Array.length + + lines + ->Js.Array2.push(`Found ${violations->Belt.Array.length->Belt.Int.toString} violations:`) + ->ignore + lines->Js.Array2.push(` Errors: ${errors->Belt.Int.toString}`)->ignore + lines->Js.Array2.push(` Warnings: ${warnings->Belt.Int.toString}`)->ignore + lines->Js.Array2.push("")->ignore + + violations->Belt.Array.forEach(violation => { + let marker = violation.severity == "error" ? "❌" : "⚠️" + lines->Js.Array2.push(`${marker} ${violation.file}`)->ignore + lines->Js.Array2.push(` ${violation.message}`)->ignore + + switch violation.suggestedFix { + | None => () + | Some(fix) => { + lines->Js.Array2.push(" Suggested migrations:")->ignore + fix + ->Js.String2.split("\n") + ->Belt.Array.forEach(line => { + lines->Js.Array2.push(` - ${line}`)->ignore + }) + } + } + + lines->Js.Array2.push("")->ignore + }) + + lines->Js.Array2.push("Recommended Actions:")->ignore + lines->Js.Array2.push(" 1. Migrate Python code to ReScript for type safety")->ignore + lines->Js.Array2.push(" 2. Or use Deno for secure TypeScript/JavaScript runtime")->ignore + lines->Js.Array2.push(" 3. Remove Python dependencies from repository")->ignore + lines->Js.Array2.push(" 4. Update CI/CD to prevent Python code introduction")->ignore + } + + lines->Js.Array2.joinWith("\n") + } + + // Create GitHub issue template for Python removal + let generateIssueTemplate = (violations: array): string => { + let fileList = + violations->Belt.Array.map(v => `- [ ] ${v.file}`)->Js.Array2.joinWith("\n") + + `## Python Removal Task + + **Patrojisign/insulti Warning**: Python files detected in repository + + ### Files to migrate or remove: + ${fileList} + + ### Migration Strategy: + 1. **Assess each Python file's purpose** + 2. **Choose migration target:** + - ReScript: For type-safe, functional programming + - Deno: For secure TypeScript/JavaScript runtime + - Alternative: R/Julia for data science + 3. **Implement migration** + 4. **Add tests for migrated code** + 5. **Remove Python files** + 6. **Update documentation** + + ### CCCP Compliance Checklist: + - [ ] All Python files identified + - [ ] Migration plan approved + - [ ] Code migrated to ReScript/Deno + - [ ] Tests passing + - [ ] Python files removed + - [ ] CI/CD updated to prevent Python + - [ ] Documentation updated + + ### Resources: + - [ReScript Documentation](https://rescript-lang.org/) + - [Deno Manual](https://deno.land/manual) + - [Migration Guide](./docs/python-migration.md) + ` + } + + // Export compliance status as JSON + let exportComplianceJSON = (violations: array): Js.Json.t => { + Js.Json.object_( + Js.Dict.fromArray([ + ( + "compliant", + Js.Json.boolean(Belt.Array.length(violations) == 0), + ), + ( + "violations", + Js.Json.array( + violations->Belt.Array.map(v => { + Js.Json.object_( + Js.Dict.fromArray([ + ("file", Js.Json.string(v.file)), + ("type", Js.Json.string(v.violationType)), + ("severity", Js.Json.string(v.severity)), + ("message", Js.Json.string(v.message)), + ( + "suggestedFix", + switch v.suggestedFix { + | None => Js.Json.null + | Some(fix) => Js.Json.string(fix) + }, + ), + ]), + ) + }), + ), + ), + ("timestamp", Js.Json.number(Js.Date.now())), + ]), + ) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/CLI.affine b/migration/affinescript/recon-silly-ation/src/CLI.affine new file mode 100644 index 00000000..4388e9bc --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/CLI.affine @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/CLI.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module CLI; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Command-line interface for documentation reconciliation + // Usage: node lib/js/src/CLI.bs.js [options] + + open Types + + @module("process") @val + external argv: array = "argv" + + @module("process") @val + external env: Js.Dict.t = "env" + + @module("process") @val + external exit: int => unit = "exit" + + // Parse command line arguments + type cliArgs = { + repositories: array, + arangoUrl: option, + arangoDb: option, + arangoUser: option, + arangoPassword: option, + threshold: option, + daemon: bool, + interval: option, + help: bool, + } + + let parseArgs = (): cliArgs => { + // ReScript arrays don't support `[head, ...rest]` destructuring patterns. + // Convert the raw argv array to an immutable list once so the recursive + // parser can use `list{head, ...rest}` pattern matching throughout. + let args = argv->Belt.Array.sliceToEnd(2)->Belt.List.fromArray + + let rec parse = (args: list, acc: cliArgs): cliArgs => { + switch args { + | list{} => acc + | list{arg, ...rest} => + switch arg { + | "--help" | "-h" => {...acc, help: true} + | "--daemon" | "-d" => parse(rest, {...acc, daemon: true}) + | "--repo" | "-r" => + switch rest { + | list{path, ...remaining} => + parse(remaining, { + ...acc, + repositories: Belt.Array.concat(acc.repositories, [path]), + }) + | list{} => acc + } + | "--arango-url" => + switch rest { + | list{url, ...remaining} => parse(remaining, {...acc, arangoUrl: Some(url)}) + | list{} => acc + } + | "--arango-db" => + switch rest { + | list{db, ...remaining} => parse(remaining, {...acc, arangoDb: Some(db)}) + | list{} => acc + } + | "--arango-user" => + switch rest { + | list{user, ...remaining} => parse(remaining, {...acc, arangoUser: Some(user)}) + | list{} => acc + } + | "--arango-password" => + switch rest { + | list{pass, ...remaining} => parse(remaining, {...acc, arangoPassword: Some(pass)}) + | list{} => acc + } + | "--threshold" | "-t" => + switch rest { + | list{thresh, ...remaining} => + switch Belt.Float.fromString(thresh) { + | Some(value) => parse(remaining, {...acc, threshold: Some(value)}) + | None => parse(remaining, acc) + } + | list{} => acc + } + | "--interval" | "-i" => + switch rest { + | list{interval, ...remaining} => + switch Belt.Int.fromString(interval) { + | Some(value) => parse(remaining, {...acc, interval: Some(value)}) + | None => parse(remaining, acc) + } + | list{} => acc + } + | _ => parse(rest, acc) // Skip unknown args + } + } + } + + parse(args, { + repositories: [], + arangoUrl: None, + arangoDb: None, + arangoUser: None, + arangoPassword: None, + threshold: None, + daemon: false, + interval: None, + help: false, + }) + } + + // Load configuration from environment variables + let loadFromEnv = (args: cliArgs): cliArgs => { + { + ...args, + arangoUrl: switch args.arangoUrl { + | Some(_) => args.arangoUrl + | None => env->Js.Dict.get("ARANGO_URL") + }, + arangoDb: switch args.arangoDb { + | Some(_) => args.arangoDb + | None => env->Js.Dict.get("ARANGO_DATABASE") + }, + arangoUser: switch args.arangoUser { + | Some(_) => args.arangoUser + | None => env->Js.Dict.get("ARANGO_USERNAME") + }, + arangoPassword: switch args.arangoPassword { + | Some(_) => args.arangoPassword + | None => env->Js.Dict.get("ARANGO_PASSWORD") + }, + } + } + + // Create config from CLI args + let createConfig = (args: cliArgs): result => { + if Belt.Array.length(args.repositories) == 0 { + Error("No repositories specified. Use --repo to specify repositories.") + } else { + let arangoUrl = args.arangoUrl->Belt.Option.getWithDefault("http://localhost:8529") + let arangoDb = args.arangoDb->Belt.Option.getWithDefault("reconciliation") + let arangoUser = args.arangoUser->Belt.Option.getWithDefault("root") + let arangoPassword = args.arangoPassword->Belt.Option.getWithDefault("") + let threshold = args.threshold->Belt.Option.getWithDefault(0.9) + + Ok({ + arangoUrl: arangoUrl, + arangoDatabase: arangoDb, + arangoUsername: arangoUser, + arangoPassword: arangoPassword, + autoResolveThreshold: threshold, + repositoryPaths: args.repositories, + scanInterval: args.interval, + }) + } + } + + // Print help message + let printHelp = (): unit => { + Js.Console.log(" + Documentation Reconciliation System + ===================================== + + Usage: node lib/js/src/CLI.bs.js [options] + + Options: + -r, --repo Repository path to scan (can be specified multiple times) + -d, --daemon Run in daemon mode (continuous scanning) + -i, --interval Scan interval in daemon mode (default: 300) + -t, --threshold Auto-resolve confidence threshold (default: 0.9) + + --arango-url ArangoDB URL (default: http://localhost:8529) + --arango-db ArangoDB database name (default: reconciliation) + --arango-user ArangoDB username (default: root) + --arango-password ArangoDB password (default: empty) + + -h, --help Show this help message + + Environment Variables: + ARANGO_URL ArangoDB URL + ARANGO_DATABASE ArangoDB database name + ARANGO_USERNAME ArangoDB username + ARANGO_PASSWORD ArangoDB password + + Examples: + # Scan a single repository + node lib/js/src/CLI.bs.js --repo /path/to/repo + + # Scan multiple repositories with custom threshold + node lib/js/src/CLI.bs.js --repo /repo1 --repo /repo2 --threshold 0.95 + + # Run in daemon mode, scanning every 5 minutes + node lib/js/src/CLI.bs.js --repo /path/to/repo --daemon --interval 300 + + # Use custom ArangoDB instance + node lib/js/src/CLI.bs.js --repo /repo --arango-url http://arango:8529 --arango-db mydb + ") + } + + // Main entry point + let main = async (): unit => { + let args = parseArgs() + + if args.help { + printHelp() + exit(0) + } + + let argsWithEnv = loadFromEnv(args) + + switch createConfig(argsWithEnv) { + | Error(msg) => { + Js.Console.error(`Error: ${msg}`) + Js.Console.log("\nUse --help for usage information") + exit(1) + } + | Ok(config) => { + if args.daemon { + Js.Console.log("Starting in daemon mode...") + await Pipeline.runContinuous(config) + } else { + let result = await Pipeline.run(config) + switch result { + | Ok(_) => exit(0) + | Error(msg) => { + Js.Console.error(`Pipeline failed: ${msg}`) + exit(1) + } + } + } + } + } + } + + // Auto-run if executed directly + let _ = main() + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/ConflictResolver.affine b/migration/affinescript/recon-silly-ation/src/ConflictResolver.affine new file mode 100644 index 00000000..019e4cff --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/ConflictResolver.affine @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/ConflictResolver.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ConflictResolver; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Conflict resolution with confidence scoring and rule-based strategies + // Auto-resolve when confidence > 0.9, escalate otherwise + + open Types + + // Resolution rule with priority and confidence + type resolutionRule = { + name: string, + priority: int, + confidence: confidence, + strategy: resolutionStrategy, + applies: conflict => bool, + resolve: conflict => option, + } + + // Built-in resolution rules (from highest to lowest priority) + let builtInRules: array = [ + // Rule 1: Exact duplicates - keep latest (100% confidence) + { + name: "duplicate-keep-latest", + priority: 100, + confidence: 1.0, + strategy: KeepLatest, + applies: conflict => conflict.conflictType == DuplicateContent, + resolve: conflict => { + Deduplicator.findLatest(conflict.documents) + }, + }, + // Rule 2: LICENSE file is canonical (95% confidence) + { + name: "license-file-canonical", + priority: 95, + confidence: 0.95, + strategy: KeepCanonical, + applies: conflict => { + conflict.documents->Belt.Array.some(doc => { + doc.metadata.documentType == LICENSE && + doc.metadata.canonicalSource == LicenseFile + }) + }, + resolve: conflict => { + conflict.documents->Belt.Array.getBy(doc => { + doc.metadata.documentType == LICENSE && + doc.metadata.canonicalSource == LicenseFile + }) + }, + }, + // Rule 3: FUNDING.yml is canonical (98% confidence) + { + name: "funding-yaml-canonical", + priority: 98, + confidence: 0.98, + strategy: KeepCanonical, + applies: conflict => { + conflict.documents->Belt.Array.some(doc => { + doc.metadata.documentType == FUNDING && + doc.metadata.canonicalSource == FundingYaml + }) + }, + resolve: conflict => { + conflict.documents->Belt.Array.getBy(doc => { + doc.metadata.documentType == FUNDING && + doc.metadata.canonicalSource == FundingYaml + }) + }, + }, + // Rule 4: Keep highest semantic version (85% confidence) + { + name: "keep-highest-semver", + priority: 85, + confidence: 0.85, + strategy: KeepHighestVersion, + applies: conflict => { + conflict.documents->Belt.Array.every(doc => { + doc.metadata.version->Belt.Option.isSome + }) + }, + resolve: conflict => { + conflict.documents->Belt.Array.reduce(None, (highest, doc) => { + switch (highest, doc.metadata.version) { + | (None, Some(_)) => Some(doc) + | (Some(current), Some(version)) => { + switch current.metadata.version { + | Some(currentVersion) => + if compareVersions(version, currentVersion) > 0 { + Some(doc) + } else { + highest + } + | None => Some(doc) + } + } + | _ => highest + } + }) + }, + }, + // Rule 5: Explicit canonical source wins (100% confidence) + { + name: "explicit-canonical", + priority: 100, + confidence: 1.0, + strategy: KeepCanonical, + applies: conflict => { + conflict.documents->Belt.Array.some(doc => { + switch doc.metadata.canonicalSource { + | Explicit(_) => true + | _ => false + } + }) + }, + resolve: conflict => { + conflict.documents->Belt.Array.getBy(doc => { + switch doc.metadata.canonicalSource { + | Explicit(_) => true + | _ => false + } + }) + }, + }, + // Rule 6: Prefer canonical source over inferred (80% confidence) + { + name: "canonical-over-inferred", + priority: 80, + confidence: 0.80, + strategy: KeepCanonical, + applies: conflict => { + conflict.documents->Belt.Array.some(doc => { + doc.metadata.canonicalSource != Inferred + }) + }, + resolve: conflict => { + Deduplicator.findCanonical(conflict.documents) + }, + }, + ] + + // Find applicable rule for conflict + let findApplicableRule = ( + conflict: conflict, + rules: array, + ): option => { + rules + ->Belt.Array.keep(rule => rule.applies(conflict)) + ->Belt.SortArray.stableSortBy((r1, r2) => r2.priority - r1.priority) + ->Belt.Array.get(0) + } + + // Resolve conflict using rules + let resolveConflict = ( + conflict: conflict, + autoResolveThreshold: float, + ): resolutionResult => { + let applicableRule = findApplicableRule(conflict, builtInRules) + + switch applicableRule { + | None => { + // No rule applies - require manual resolution + { + conflictId: conflict.id, + strategy: RequireManual, + selectedDocument: None, + confidence: 0.0, + requiresApproval: true, + reasoning: "No automatic resolution rule applies to this conflict", + timestamp: Js.Date.now(), + } + } + | Some(rule) => { + let selectedDoc = rule.resolve(conflict) + let requiresApproval = rule.confidence < autoResolveThreshold + + { + conflictId: conflict.id, + strategy: rule.strategy, + selectedDocument: selectedDoc, + confidence: rule.confidence, + requiresApproval: requiresApproval, + reasoning: `Applied rule: ${rule.name} (confidence: ${rule.confidence + ->Belt.Float.toString})`, + timestamp: Js.Date.now(), + } + } + } + } + + // Batch resolve multiple conflicts + let resolveConflicts = ( + conflicts: array, + autoResolveThreshold: float, + ): array => { + conflicts->Belt.Array.map(conflict => { + resolveConflict(conflict, autoResolveThreshold) + }) + } + + // Detect conflicts between documents + let detectConflicts = (documents: array): array => { + let conflicts = [] + let grouped = Deduplicator.groupByHash(documents) + + // Detect duplicate content conflicts + grouped->Belt.Map.String.forEach((hash, docs) => { + if Belt.Array.length(docs) > 1 { + // Multiple documents with same hash but different paths + let paths = docs->Belt.Array.map(d => d.metadata.path) + let allSamePath = switch Belt.Array.get(paths, 0) { + | None => true // vacuously — no paths means no divergence + | Some(first) => paths->Belt.Array.every(p => p == first) + } + + if !allSamePath { + conflicts + ->Js.Array2.push({ + id: hash ++ "_duplicate", + conflictType: DuplicateContent, + documents: docs, + detectedAt: Js.Date.now(), + confidence: 1.0, + suggestedStrategy: KeepLatest, + }) + ->ignore + } + } + }) + + // Detect version conflicts (same type, different versions) + // Build the per-type grouping via an explicit ref since ReScript's + // `let` bindings are immutable; the old code tried to reassign + // `byType` in-place, which never compiled. + let byType = ref(Belt.Map.String.empty) + documents->Belt.Array.forEach(doc => { + let typeStr = documentTypeToString(doc.metadata.documentType) + let existing = byType.contents->Belt.Map.String.get(typeStr) + byType := + switch existing { + | None => byType.contents->Belt.Map.String.set(typeStr, [doc]) + | Some(docs) => + byType.contents->Belt.Map.String.set(typeStr, Belt.Array.concat(docs, [doc])) + } + }) + + byType.contents->Belt.Map.String.forEach((typeStr, docs) => { + if Belt.Array.length(docs) > 1 { + // Check if versions differ + let versions = docs->Belt.Array.keepMap(d => d.metadata.version) + if Belt.Array.length(versions) > 1 { + let allSame = switch Belt.Array.get(versions, 0) { + | None => true + | Some(first) => versions->Belt.Array.every(v => compareVersions(v, first) == 0) + } + + if !allSame { + conflicts + ->Js.Array2.push({ + id: typeStr ++ "_version_conflict", + conflictType: VersionMismatch, + documents: docs, + detectedAt: Js.Date.now(), + confidence: 0.8, + suggestedStrategy: KeepHighestVersion, + }) + ->ignore + } + } + } + }) + + // Detect canonical conflicts (multiple canonical sources for same type) + byType.contents->Belt.Map.String.forEach((typeStr, docs) => { + let canonicals = docs->Belt.Array.keep(doc => { + switch doc.metadata.canonicalSource { + | Inferred => false + | _ => true + } + }) + + if Belt.Array.length(canonicals) > 1 { + conflicts + ->Js.Array2.push({ + id: typeStr ++ "_canonical_conflict", + conflictType: CanonicalConflict, + documents: canonicals, + detectedAt: Js.Date.now(), + confidence: 0.7, + suggestedStrategy: KeepCanonical, + }) + ->ignore + } + }) + + conflicts + } + + // Generate resolution report + let generateReport = ( + resolutions: array, + conflicts: array, + ): string => { + let lines = [] + lines->Js.Array2.push("=== Conflict Resolution Report ===")->ignore + lines->Js.Array2.push(`Total conflicts: ${conflicts->Belt.Array.length->Belt.Int.toString}`)->ignore + lines + ->Js.Array2.push(`Resolutions: ${resolutions->Belt.Array.length->Belt.Int.toString}`) + ->ignore + + let autoResolved = + resolutions->Belt.Array.keep(r => !r.requiresApproval)->Belt.Array.length + let requireApproval = + resolutions->Belt.Array.keep(r => r.requiresApproval)->Belt.Array.length + + lines + ->Js.Array2.push(`Auto-resolved: ${autoResolved->Belt.Int.toString} (confidence > threshold)`) + ->ignore + lines + ->Js.Array2.push(`Require approval: ${requireApproval->Belt.Int.toString} (confidence < threshold)`) + ->ignore + lines->Js.Array2.push("")->ignore + + resolutions->Belt.Array.forEach(resolution => { + let status = resolution.requiresApproval ? "[MANUAL]" : "[AUTO]" + let strategy = resolutionStrategyToString(resolution.strategy) + lines + ->Js.Array2.push( + `${status} ${resolution.conflictId}: ${strategy} (confidence: ${resolution.confidence->Belt.Float.toString})`, + ) + ->ignore + lines->Js.Array2.push(` Reasoning: ${resolution.reasoning}`)->ignore + }) + + lines->Js.Array2.joinWith("\n") + } + + // Create superseded-by edges for resolved conflicts + let createSupersededEdges = (resolutions: array): array => { + resolutions + ->Belt.Array.keepMap(resolution => { + switch resolution.selectedDocument { + | None => None + | Some(selected) => { + Some({ + from: resolution.conflictId, + to: selected.hash, + edgeType: SupersededBy, + confidence: resolution.confidence, + metadata: Js.Json.object_( + Js.Dict.fromArray([ + ("strategy", Js.Json.string(resolutionStrategyToString(resolution.strategy))), + ("reasoning", Js.Json.string(resolution.reasoning)), + ("timestamp", Js.Json.number(resolution.timestamp)), + ]), + ), + }) + } + } + }) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/Deduplicator.affine b/migration/affinescript/recon-silly-ation/src/Deduplicator.affine new file mode 100644 index 00000000..f5f9f220 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/Deduplicator.affine @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/Deduplicator.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 1 migration consideration detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 15: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { + +module Deduplicator; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Content-addressable deduplication using cryptographic hashing + // Guarantees: Same hash = same content = single entry + + open Types + + // Hash content using SHA-256 + @module("crypto") @scope("default") + external createHash: string => 'a = "createHash" + + @send external update: ('a, string) => 'a = "update" + @send external digest: ('a, string) => string = "digest" + + let hashContent = (content: string): contentHash => { + try { + let hash = createHash("sha256") + hash->update(content)->digest("hex") + } catch { + | _ => { + // Fallback to simple hash if crypto not available + Js.String2.length(content)->Belt.Int.toString ++ "_" ++ Js.String2.slice(content, ~from=0, ~to_=10) + } + } + } + + // Normalize content before hashing to catch semantic duplicates + let normalizeContent = (content: string): string => { + content + ->Js.String2.trim + ->Js.String2.replaceByRe(%re("/\r\n/g"), "\n") // Normalize line endings + ->Js.String2.replaceByRe(%re("/\s+$/gm"), "") // Remove trailing whitespace + ->Js.String2.replaceByRe(%re("/\n{3,}/g"), "\n\n") // Normalize multiple blank lines + } + + // Create document with hash + let createDocument = ( + content: string, + metadata: documentMetadata, + ): document => { + let normalizedContent = normalizeContent(content) + let hash = hashContent(normalizedContent) + { + hash: hash, + content: normalizedContent, + metadata: metadata, + createdAt: Js.Date.now(), + } + } + + // Deduplication result + // Inline nested record types are not allowed in ReScript field positions; + // `dedupStats` is extracted so `deduplicationResult` can reference it by name. + type dedupStats = { + totalProcessed: int, + uniqueCount: int, + duplicateCount: int, + spacesSaved: int, // Bytes + } + + type deduplicationResult = { + unique: array, + duplicates: array<(document, document)>, // (duplicate, original) + stats: dedupStats, + } + + // Deduplicate documents by content hash + let deduplicate = (documents: array): deduplicationResult => { + // `let` bindings are immutable in ReScript, so the rolling hash map is + // held in a ref. The old code tried to reassign `hashMap` in-place and + // never compiled. + let hashMap = ref(Belt.Map.String.empty) + let unique = [] + let duplicates = [] + + documents->Belt.Array.forEach(doc => { + switch hashMap.contents->Belt.Map.String.get(doc.hash) { + | None => { + // First occurrence - add to unique set + hashMap := hashMap.contents->Belt.Map.String.set(doc.hash, doc) + unique->Js.Array2.push(doc)->ignore + } + | Some(original) => { + // Duplicate found - record it + duplicates->Js.Array2.push((doc, original))->ignore + } + } + }) + + let totalSize = documents->Belt.Array.reduce(0, (acc, doc) => { + acc + Js.String2.length(doc.content) + }) + + let uniqueSize = unique->Belt.Array.reduce(0, (acc, doc) => { + acc + Js.String2.length(doc.content) + }) + + { + unique: unique, + duplicates: duplicates, + stats: { + totalProcessed: Belt.Array.length(documents), + uniqueCount: Belt.Array.length(unique), + duplicateCount: Belt.Array.length(duplicates), + spacesSaved: totalSize - uniqueSize, + }, + } + } + + // Find duplicates for a specific document + let findDuplicates = ( + target: document, + documents: array, + ): array => { + documents->Belt.Array.keep(doc => { + doc.hash == target.hash && doc.metadata.path != target.metadata.path + }) + } + + // Check if two documents are duplicates + let isDuplicate = (doc1: document, doc2: document): bool => { + doc1.hash == doc2.hash + } + + // Group documents by hash + let groupByHash = ( + documents: array, + ): Belt.Map.String.t> => { + documents->Belt.Array.reduce(Belt.Map.String.empty, (map, doc) => { + let existing = map->Belt.Map.String.get(doc.hash) + switch existing { + | None => map->Belt.Map.String.set(doc.hash, [doc]) + | Some(docs) => { + let updated = Belt.Array.concat(docs, [doc]) + map->Belt.Map.String.set(doc.hash, updated) + } + } + }) + } + + // Find latest document in a group (by modification time) + let findLatest = (documents: array): option => { + documents->Belt.Array.reduce(None, (latest, doc) => { + switch latest { + | None => Some(doc) + | Some(current) => + if doc.metadata.lastModified > current.metadata.lastModified { + Some(doc) + } else { + latest + } + } + }) + } + + // Find canonical document in a group (by canonical source priority) + let getCanonicalPriority = (source: canonicalSource): int => { + switch source { + | LicenseFile => 95 + | FundingYaml => 98 + | SecurityMd => 90 + | CitationCff => 90 + | PackageJson => 85 + | CargoToml => 85 + | Explicit(_) => 100 + | Inferred => 50 + } + } + + let findCanonical = (documents: array): option => { + documents->Belt.Array.reduce(None, (canonical, doc) => { + let docPriority = getCanonicalPriority(doc.metadata.canonicalSource) + switch canonical { + | None => Some(doc) + | Some(current) => { + let currentPriority = getCanonicalPriority(current.metadata.canonicalSource) + if docPriority > currentPriority { + Some(doc) + } else if docPriority == currentPriority { + // If same priority, prefer latest + if doc.metadata.lastModified > current.metadata.lastModified { + Some(doc) + } else { + canonical + } + } else { + canonical + } + } + } + }) + } + + // Create edges for duplicate relationships + let createDuplicateEdges = ( + duplicates: array<(document, document)>, + ): array => { + duplicates->Belt.Array.map(((duplicate, original)) => { + { + from: duplicate.hash, + to: original.hash, + edgeType: DuplicateOf, + confidence: 1.0, // 100% confidence - exact hash match + metadata: Js.Json.object_(Js.Dict.fromArray([ + ("duplicate_path", Js.Json.string(duplicate.metadata.path)), + ("original_path", Js.Json.string(original.metadata.path)), + ("detected_at", Js.Json.number(Js.Date.now())), + ])), + } + }) + } + + // Summary report + let generateReport = (result: deduplicationResult): string => { + let {totalProcessed, uniqueCount, duplicateCount, spacesSaved} = result.stats + + let lines = [] + lines->Js.Array2.push("=== Deduplication Report ===")->ignore + lines->Js.Array2.push(`Total documents processed: ${totalProcessed->Belt.Int.toString}`)->ignore + lines->Js.Array2.push(`Unique documents: ${uniqueCount->Belt.Int.toString}`)->ignore + lines->Js.Array2.push(`Duplicates found: ${duplicateCount->Belt.Int.toString}`)->ignore + lines->Js.Array2.push(`Space saved: ${spacesSaved->Belt.Int.toString} bytes`)->ignore + lines->Js.Array2.push("")->ignore + + if duplicateCount > 0 { + lines->Js.Array2.push("Duplicate pairs:")->ignore + result.duplicates->Belt.Array.forEach(((dup, orig)) => { + lines->Js.Array2.push(` ${dup.metadata.path} -> ${orig.metadata.path}`)->ignore + }) + } + + lines->Js.Array2.joinWith("\n") + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/EnforcementBot.affine b/migration/affinescript/recon-silly-ation/src/EnforcementBot.affine new file mode 100644 index 00000000..1e2c8192 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/EnforcementBot.affine @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/EnforcementBot.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module EnforcementBot; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // + // EnforcementBot - Automated document bundle enforcement and cleanup + // + // This module provides scheduled enforcement of document policies using + // ReconForth rules. It integrates with the reconciliation pipeline to + // automatically detect and report violations. + + // ============================================================================ + // Types + // ============================================================================ + + // Enforcement rule with schedule + type enforcementRule = { + name: string, + description: string, + reconforthCode: string, + severity: string, // "error" | "warning" | "info" + autoFix: bool, + fixAction: option, // ReconForth code to apply fix + } + + // Enforcement schedule + type schedule = + | Immediate + | Interval(int) // seconds + | Cron(string) // cron expression + | OnPush // git push hook + | OnPR // pull request check + + // Enforcement job + type enforcementJob = { + id: string, + rule: enforcementRule, + schedule: schedule, + repository: string, + branch: option, + lastRun: option, + nextRun: option, + enabled: bool, + } + + // Enforcement result + type enforcementResult = { + jobId: string, + ruleName: string, + repository: string, + timestamp: float, + passed: bool, + violations: array, + fixesApplied: array, + } + + // Bot state + type botState = { + jobs: array, + results: array, + running: bool, + } + + // ============================================================================ + // Standard Enforcement Rules + // ============================================================================ + + // RSR (Rhodium Standard Repositories) compliance + let rsrComplianceRule: enforcementRule = { + name: "rsr-compliance", + description: "Ensure repository follows Rhodium Standard Repositories guidelines", + reconforthCode: ` + -- Check required files + "README" bundle-has-type? not + [ "Missing README file (RSR requirement)" error! ] when + + "LICENSE" bundle-has-type? not + [ "Missing LICENSE file (RSR requirement)" error! ] when + + "SECURITY" bundle-has-type? not + [ "Missing SECURITY.md (RSR requirement)" error! ] when + + -- Check for banned patterns + bundle-docs [ + dup doc-path ".ts" str-ends? + [ doc-path " is TypeScript - use ReScript per RSR" error! ] when + ] each + + -- Check SPDX headers + bundle-docs [ + dup doc-path ".res" str-ends? + over doc-path ".rs" str-ends? or + [ + dup doc-content "SPDX-License-Identifier" str-contains? not + [ doc-path " missing SPDX header" error! ] when + ] when + ] each + `, + severity: "error", + autoFix: false, + fixAction: None, + } + + // License compliance + let licenseComplianceRule: enforcementRule = { + name: "license-pmpl", + description: "Ensure PMPL-1.0-or-later (Palimpsest) license is used", + reconforthCode: ` + "LICENSE" bundle-get-type nil <> + [ + "LICENSE" bundle-get-type doc-content + dup "Palimpsest" str-contains? not + [ drop "License must be PMPL-1.0-or-later (Palimpsest)" error! ] + [ + "1.0" str-contains? not + [ "License should specify version 1.0" warn! ] when + ] + if + ] + [ "Missing LICENSE file" error! ] + if + `, + severity: "error", + autoFix: false, + fixAction: None, + } + + // Documentation quality + let docQualityRule: enforcementRule = { + name: "doc-quality", + description: "Check documentation quality and completeness", + reconforthCode: ` + "README" bundle-get-type nil <> + [ + "README" bundle-get-type doc-content + + -- Check minimum length + dup str-len 200 < + [ "README is too short (< 200 chars)" warn! ] when + + -- Check for sections + dup "## " str-contains? not + [ "README should have sections" suggest! ] when + + -- Check for badges + dup "![" str-contains? not + [ "Consider adding badges to README" suggest! ] when + + drop + ] + [ "Missing README" error! ] + if + `, + severity: "warning", + autoFix: false, + fixAction: None, + } + + // Security policy check + let securityPolicyRule: enforcementRule = { + name: "security-policy", + description: "Verify security policy exists and is complete", + reconforthCode: ` + "SECURITY" bundle-get-type nil <> + [ + "SECURITY" bundle-get-type doc-content + + -- Check for vulnerability reporting section + dup "vulnerabilit" str-lower str-contains? not + [ "SECURITY.md should describe vulnerability reporting" warn! ] when + + -- Check for contact info + dup "@" str-contains? not + over "email" str-lower str-contains? not and + [ "SECURITY.md should include contact information" warn! ] when + + drop + ] + [ "Missing SECURITY.md" error! ] + if + `, + severity: "error", + autoFix: false, + fixAction: None, + } + + // SCM files check (STATE.scm, META.scm, ECOSYSTEM.scm) + let scmFilesRule: enforcementRule = { + name: "scm-files", + description: "Check for Guile Scheme checkpoint files", + reconforthCode: ` + -- Check for STATE.scm + bundle-docs [ doc-path "STATE.scm" str-ends? ] filter + list-len 0 = + [ "Missing STATE.scm checkpoint file" warn! ] when + + -- Check for META.scm + bundle-docs [ doc-path "META.scm" str-ends? ] filter + list-len 0 = + [ "Missing META.scm checkpoint file" suggest! ] when + + -- Check for ECOSYSTEM.scm + bundle-docs [ doc-path "ECOSYSTEM.scm" str-ends? ] filter + list-len 0 = + [ "Missing ECOSYSTEM.scm checkpoint file" suggest! ] when + `, + severity: "warning", + autoFix: false, + fixAction: None, + } + + // ============================================================================ + // Bot Operations + // ============================================================================ + + // Create initial bot state + let createBotState = (): botState => { + { + jobs: [], + results: [], + running: false, + } + } + + // Add a job to the bot + let addJob = ( + state: botState, + rule: enforcementRule, + schedule: schedule, + repository: string, + ~branch: option=?, + (), + ): botState => { + let job: enforcementJob = { + id: `job-${Js.Date.now()->Belt.Float.toString}`, + rule, + schedule, + repository, + branch, + lastRun: None, + nextRun: Some(Js.Date.now()), + enabled: true, + } + { + ...state, + jobs: Belt.Array.concat(state.jobs, [job]), + } + } + + // Remove a job from the bot + let removeJob = (state: botState, jobId: string): botState => { + { + ...state, + jobs: state.jobs->Belt.Array.keep(j => j.id != jobId), + } + } + + // Enable/disable a job + let setJobEnabled = (state: botState, jobId: string, enabled: bool): botState => { + { + ...state, + jobs: state.jobs->Belt.Array.map(j => + if j.id == jobId { + {...j, enabled} + } else { + j + } + ), + } + } + + // Run a single enforcement job + let runJob = (job: enforcementJob, bundle: ReconForth.bundle): enforcementResult => { + let result = ReconForth.evalBundle(job.rule.reconforthCode, bundle) + + { + jobId: job.id, + ruleName: job.rule.name, + repository: job.repository, + timestamp: Js.Date.now(), + passed: result.success, + violations: Belt.Array.concat(result.errors, result.warnings), + fixesApplied: [], + } + } + + // Run all enabled jobs + let runAllJobs = (state: botState, bundleProvider: string => ReconForth.bundle): botState => { + let results = state.jobs + ->Belt.Array.keep(j => j.enabled) + ->Belt.Array.map(job => { + let bundle = bundleProvider(job.repository) + runJob(job, bundle) + }) + + let updatedJobs = state.jobs->Belt.Array.map(j => { + if j.enabled { + { + ...j, + lastRun: Some(Js.Date.now()), + nextRun: switch j.schedule { + | Immediate => None + | Interval(seconds) => Some(Js.Date.now() +. Belt.Int.toFloat(seconds * 1000)) + | Cron(_) => Some(Js.Date.now() +. 3600000.0) // Placeholder: 1 hour + | OnPush => None + | OnPR => None + }, + } + } else { + j + } + }) + + { + ...state, + jobs: updatedJobs, + results: Belt.Array.concat(state.results, results), + } + } + + // Get all violations from recent results + let getViolations = (state: botState): array => { + state.results + ->Belt.Array.keep(r => !r.passed) + ->Belt.Array.flatMap(r => r.violations) + } + + // Generate enforcement report + let generateReport = (state: botState): string => { + let totalJobs = Belt.Array.length(state.jobs) + let enabledJobs = state.jobs->Belt.Array.keep(j => j.enabled)->Belt.Array.length + let passedResults = state.results->Belt.Array.keep(r => r.passed)->Belt.Array.length + let failedResults = state.results->Belt.Array.keep(r => !r.passed)->Belt.Array.length + let totalViolations = getViolations(state)->Belt.Array.length + + `# Enforcement Report + + ## Summary + - Total Jobs: ${Belt.Int.toString(totalJobs)} + - Enabled Jobs: ${Belt.Int.toString(enabledJobs)} + - Passed: ${Belt.Int.toString(passedResults)} + - Failed: ${Belt.Int.toString(failedResults)} + - Total Violations: ${Belt.Int.toString(totalViolations)} + + ## Job Details + ${state.jobs + ->Belt.Array.map(j => + `- ${j.rule.name} (${j.enabled ? "enabled" : "disabled"}) + Repository: ${j.repository} + Last Run: ${j.lastRun->Belt.Option.mapWithDefault("never", f => Belt.Float.toString(f))}` + ) + ->Js.Array2.joinWith("\n")} + + ## Recent Violations + ${state.results + ->Belt.Array.keep(r => !r.passed) + ->Belt.Array.flatMap(r => r.violations) + ->Belt.Array.map(v => `- ${v.message}`) + ->Js.Array2.joinWith("\n")} + ` + } + + // ============================================================================ + // Preset Configurations + // ============================================================================ + + // Create bot with all standard RSR rules + let createRsrBot = (): botState => { + let state = createBotState() + let state = addJob(state, rsrComplianceRule, Interval(3600), "*", ()) + let state = addJob(state, licenseComplianceRule, OnPR, "*", ()) + let state = addJob(state, docQualityRule, Interval(86400), "*", ()) + let state = addJob(state, securityPolicyRule, OnPR, "*", ()) + let state = addJob(state, scmFilesRule, Interval(86400), "*", ()) + state + } + + // Create minimal enforcement bot + let createMinimalBot = (): botState => { + let state = createBotState() + let state = addJob(state, rsrComplianceRule, OnPR, "*", ()) + let state = addJob(state, licenseComplianceRule, OnPR, "*", ()) + state + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/GraphVisualizer.affine b/migration/affinescript/recon-silly-ation/src/GraphVisualizer.affine new file mode 100644 index 00000000..54dbe5b7 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/GraphVisualizer.affine @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/GraphVisualizer.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 4 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 299: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 308: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to export DOT: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.mess... +// - [untyped-exception] line 319: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 326: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to export HTML: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.mes... + +module GraphVisualizer; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Graph visualization for documentation relationships + // Generates DOT format for Graphviz rendering + + open Types + + // Direct fs.writeFileSync binding. The rescript node bindings in this repo + // don't expose a UTF-8 convenience helper, so we bind the JS API directly. + @module("fs") @val + external writeFileSyncUtf8: (string, string, string) => unit = "writeFileSync" + + // DOT graph configuration + type dotConfig = { + rankdir: string, // "LR" | "TB" | "RL" | "BT" + nodeShape: string, // "box" | "circle" | "ellipse" + fontSize: int, + showHashes: bool, + } + + let defaultConfig: dotConfig = { + rankdir: "LR", + nodeShape: "box", + fontSize: 12, + showHashes: false, + } + + // Escape DOT special characters + let escapeDot = (str: string): string => { + str + ->Js.String2.replaceByRe(%re("/\"/g"), "\\\"") + ->Js.String2.replaceByRe(%re("/\n/g"), "\\n") + } + + // Generate node ID + let nodeId = (doc: document): string => { + if defaultConfig.showHashes { + Js.String2.slice(doc.hash, ~from=0, ~to_=8) + } else { + doc.metadata.path + ->Js.String2.replaceByRe(%re("/[^a-zA-Z0-9]/g"), "_") + } + } + + // Generate node label + let nodeLabel = (doc: document): string => { + let docType = documentTypeToString(doc.metadata.documentType) + let path = doc.metadata.path + let version = switch doc.metadata.version { + | None => "" + | Some(v) => ` v${versionToString(v)}` + } + + `${docType}\\n${path}${version}` + } + + // Get node color based on document type + let nodeColor = (docType: documentType): string => { + switch docType { + | README => "#4a9eff" + | LICENSE => "#ff6b6b" + | SECURITY => "#ffd93d" + | CONTRIBUTING => "#95e1d3" + | CODE_OF_CONDUCT => "#a8e6cf" + | FUNDING => "#ffaaa5" + | CITATION => "#b4b4ff" + | CHANGELOG => "#ffc3a0" + | AUTHORS => "#c7ceea" + | SUPPORT => "#e2f0cb" + | Custom(_) => "#cccccc" + } + } + + // Get edge color based on edge type + let edgeColor = (edgeType: edgeType): string => { + switch edgeType { + | ConflictsWith => "#ff6b6b" + | SupersededBy => "#4a9eff" + | DuplicateOf => "#ffd93d" + | CanonicalFor => "#51cf66" + | DerivedFrom => "#a8e6cf" + } + } + + // Get edge style based on confidence + let edgeStyle = (confidence: float): string => { + if confidence >= 0.9 { + "solid" + } else if confidence >= 0.7 { + "dashed" + } else { + "dotted" + } + } + + // Generate DOT format for documents + let generateDot = ( + documents: array, + edges: array, + config: dotConfig, + ): string => { + let lines = [] + + // Header + lines->Js.Array2.push("digraph Documentation {")->ignore + lines->Js.Array2.push(` rankdir=${config.rankdir};`)->ignore + lines->Js.Array2.push(` node [shape=${config.nodeShape}, fontsize=${config.fontSize->Belt.Int.toString}, style=filled];`)->ignore + lines->Js.Array2.push(` edge [fontsize=${(config.fontSize - 2)->Belt.Int.toString}];`)->ignore + lines->Js.Array2.push("")->ignore + + // Nodes + lines->Js.Array2.push(" // Documents")->ignore + documents->Belt.Array.forEach(doc => { + let id = nodeId(doc) + let label = nodeLabel(doc) + let color = nodeColor(doc.metadata.documentType) + + lines->Js.Array2.push( + ` "${id}" [label="${label}", fillcolor="${color}"];`, + )->ignore + }) + + lines->Js.Array2.push("")->ignore + + // Edges + lines->Js.Array2.push(" // Relationships")->ignore + edges->Belt.Array.forEach(edge => { + // Find documents for this edge + let fromDoc = documents->Belt.Array.getBy(d => d.hash == edge.from) + let toDoc = documents->Belt.Array.getBy(d => d.hash == edge.to) + + switch (fromDoc, toDoc) { + | (Some(from), Some(to)) => { + let fromId = nodeId(from) + let toId = nodeId(to) + let color = edgeColor(edge.edgeType) + let style = edgeStyle(edge.confidence) + let label = edgeTypeToString(edge.edgeType) + + lines->Js.Array2.push( + ` "${fromId}" -> "${toId}" [label="${label}", color="${color}", style=${style}];`, + )->ignore + } + | _ => () + } + }) + + // Footer + lines->Js.Array2.push("}")->ignore + + lines->Js.Array2.joinWith("\n") + } + + // Generate HTML with embedded SVG + let generateHTML = ( + documents: array, + edges: array, + title: string, + ): string => { + let dot = generateDot(documents, edges, defaultConfig) + + ` + + + + ${title} + + + +
+

${title}

+ +
+ Statistics:
+ Documents: ${documents->Belt.Array.length->Belt.Int.toString}
+ Relationships: ${edges->Belt.Array.length->Belt.Int.toString} +
+ +
+
${escapeDot(dot)}
+
+ +
+ Legend:
+
+ + Conflicts +
+
+ + Supersedes +
+
+ + Duplicates +
+
+ + Canonical +
+
+ +
+ Generated: ${Js.Date.make()->Js.Date.toISOString}
+ To render: Save DOT content and run dot -Tsvg -o output.svg +
+
+ + ` + } + + // Generate Mermaid diagram (alternative to DOT) + let generateMermaid = (documents: array, edges: array): string => { + let lines = [] + + lines->Js.Array2.push("graph LR")->ignore + + // Nodes + documents->Belt.Array.forEach(doc => { + let id = nodeId(doc) + let label = documentTypeToString(doc.metadata.documentType) + lines->Js.Array2.push(` ${id}[${label}]`)->ignore + }) + + // Edges + edges->Belt.Array.forEach(edge => { + let fromDoc = documents->Belt.Array.getBy(d => d.hash == edge.from) + let toDoc = documents->Belt.Array.getBy(d => d.hash == edge.to) + + switch (fromDoc, toDoc) { + | (Some(from), Some(to)) => { + let fromId = nodeId(from) + let toId = nodeId(to) + let label = edgeTypeToString(edge.edgeType) + lines->Js.Array2.push(` ${fromId} -->|${label}| ${toId}`)->ignore + } + | _ => () + } + }) + + lines->Js.Array2.joinWith("\n") + } + + // Export to file + let exportDot = ( + documents: array, + edges: array, + filePath: string, + ): result => { + try { + let dot = generateDot(documents, edges, defaultConfig) + // Direct fs.writeFileSync binding — the Node.Fs helpers in the current + // rescript node bindings don't include a UTF-8 convenience wrapper. + writeFileSyncUtf8(filePath, dot, "utf8") + Ok() + } catch { + | exn => + Error( + `Failed to export DOT: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + let exportHTML = ( + documents: array, + edges: array, + filePath: string, + title: string, + ): result => { + try { + let html = generateHTML(documents, edges, title) + writeFileSyncUtf8(filePath, html, "utf8") + Ok() + } catch { + | exn => + Error( + `Failed to export HTML: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/HaskellBridge.affine b/migration/affinescript/recon-silly-ation/src/HaskellBridge.affine new file mode 100644 index 00000000..087aa3bf --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/HaskellBridge.affine @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/HaskellBridge.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 4 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 45: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 110: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 124: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Haskell validator failed: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.... +// - [untyped-exception] line 179: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { + +module HaskellBridge; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // ReScript bridge to Haskell validator + // Calls Haskell validator for schema enforcement + + open Types + + // execFileSync returns a string when called with `{"encoding": "utf8"}`. + // The original binding declared the return as Buffer.t, which forced a + // Buffer.toString call that the current rescript Buffer bindings don't expose. + @module("child_process") @val + external execFileSync: (string, array, {..}) => string = "execFileSync" + + // Direct fs bindings. The rescript Node.Fs module is not available in this + // repo's bindings configuration, so we bind the JS APIs directly. + @module("fs") @val + external writeFileSyncUtf8: (string, string, string) => unit = "writeFileSync" + + @module("fs") @val + external unlinkSync: string => unit = "unlinkSync" + + @module("fs") @val + external accessSync: string => unit = "accessSync" + + // schemaViolation must be declared before validationResult because the latter + // references it in its `violations` field — non-recursive types can't + // forward-reference. + type schemaViolation = { + violationField: string, + violationMessage: string, + violationSeverity: string, + violationLine: option, + } + + type validationResult = { + isValid: bool, + violations: array, + confidence: float, + } + + // Call Haskell validator + let validateDocument = ( + doc: document, + validatorPath: string, + ): result => { + try { + let docTypeStr = documentTypeToString(doc.metadata.documentType) + + // Write content to temp file + let tempFile = "/tmp/doc_" ++ doc.hash ++ ".tmp" + // Direct fs.writeFileSync binding — the Node.Fs helpers in the current + // rescript node bindings don't include a UTF-8 convenience wrapper. + writeFileSyncUtf8(tempFile, doc.content, "utf8") + + // Call Haskell validator + let output = execFileSync( + validatorPath, + [docTypeStr, tempFile], + {"encoding": "utf8"}, + ) + + // Parse JSON output — `output` is already a UTF-8 string because + // execFileSync was called with `{"encoding": "utf8"}`. + let json = Js.Json.parseExn(output) + + // Decode validation result + let isValid = json + ->Js.Json.decodeObject + ->Belt.Option.flatMap(obj => Js.Dict.get(obj, "isValid")) + ->Belt.Option.flatMap(Js.Json.decodeBoolean) + ->Belt.Option.getWithDefault(false) + + let confidence = json + ->Js.Json.decodeObject + ->Belt.Option.flatMap(obj => Js.Dict.get(obj, "confidence")) + ->Belt.Option.flatMap(Js.Json.decodeNumber) + ->Belt.Option.getWithDefault(0.0) + + let violations = json + ->Js.Json.decodeObject + ->Belt.Option.flatMap(obj => Js.Dict.get(obj, "violations")) + ->Belt.Option.flatMap(Js.Json.decodeArray) + ->Belt.Option.getWithDefault([]) + + // Drop any array element that isn't a JSON object instead of crashing on + // the first bad one. keepMap returns Some values and skips Nones. + let parsedViolations = + violations->Belt.Array.keepMap(v => { + switch v->Js.Json.decodeObject { + | None => None + | Some(obj) => + Some({ + violationField: obj + ->Js.Dict.get("violationField") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault(""), + violationMessage: obj + ->Js.Dict.get("violationMessage") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault(""), + violationSeverity: obj + ->Js.Dict.get("violationSeverity") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault("warning"), + violationLine: None, + }) + } + }) + + // Clean up temp file + try { + unlinkSync(tempFile) + } catch { + | _ => () + } + + Ok({ + isValid: isValid, + violations: parsedViolations, + confidence: confidence, + }) + } catch { + | exn => + Error( + `Haskell validator failed: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Batch validate documents + let validateDocuments = ( + documents: array, + validatorPath: string, + ): array<(document, validationResult)> => { + documents + ->Belt.Array.map(doc => { + switch validateDocument(doc, validatorPath) { + | Ok(result) => Some((doc, result)) + | Error(_) => None + } + }) + ->Belt.Array.keepMap(x => x) + } + + // Generate validation report + let generateValidationReport = ( + results: array<(document, validationResult)>, + ): string => { + let lines = [] + + lines->Js.Array2.push("=== Schema Validation Report ===")->ignore + lines->Js.Array2.push(`Total documents validated: ${results->Belt.Array.length->Belt.Int.toString}`)->ignore + + let valid = results->Belt.Array.keep(((_, r)) => r.isValid)->Belt.Array.length + let invalid = results->Belt.Array.length - valid + + lines->Js.Array2.push(`Valid: ${valid->Belt.Int.toString}`)->ignore + lines->Js.Array2.push(`Invalid: ${invalid->Belt.Int.toString}`)->ignore + lines->Js.Array2.push("")->ignore + + results->Belt.Array.forEach(((doc, result)) => { + if !result.isValid { + lines->Js.Array2.push(`❌ ${doc.metadata.path}`)->ignore + lines->Js.Array2.push(` Confidence: ${result.confidence->Belt.Float.toString}`)->ignore + + result.violations->Belt.Array.forEach(v => { + let marker = v.violationSeverity == "error" ? "ERROR" : "WARNING" + lines->Js.Array2.push(` [${marker}] ${v.violationField}: ${v.violationMessage}`)->ignore + }) + + lines->Js.Array2.push("")->ignore + } + }) + + lines->Js.Array2.joinWith("\n") + } + + // Check if validator is available + let checkValidatorAvailable = (validatorPath: string): bool => { + try { + accessSync(validatorPath) + true + } catch { + | _ => false + } + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/LLMIntegration.affine b/migration/affinescript/recon-silly-ation/src/LLMIntegration.affine new file mode 100644 index 00000000..a3b510f2 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/LLMIntegration.affine @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/LLMIntegration.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 2 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 101: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 139: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `LLM API call failed: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.messa... + +module LLMIntegration; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // LLM integration layer with guardrails + // CRITICAL: Never auto-commit LLM output; always requiresApproval: true + + open Types + + // Anthropic API binding (simplified) + type anthropicClient = {apiKey: string} + + type message = { + role: string, + content: string, + } + + type anthropicRequest = { + model: string, + messages: array, + max_tokens: int, + temperature: float, + } + + @module("@anthropic-ai/sdk") @new + external createAnthropicClient: {..} => anthropicClient = "Anthropic" + + // LLM prompt templates + let generateSecurityMdPrompt = (repoContext: string): string => { + `You are tasked with generating a SECURITY.md file for a software project. + + Repository context: + ${repoContext} + + Generate a comprehensive SECURITY.md file that includes: + 1. Supported versions + 2. How to report a vulnerability + 3. Security update policy + 4. Contact information + + Output only the markdown content, no additional commentary.` + } + + let generateContributingPrompt = (repoContext: string): string => { + `You are tasked with generating a CONTRIBUTING.md file for a software project. + + Repository context: + ${repoContext} + + Generate a comprehensive CONTRIBUTING.md file that includes: + 1. How to contribute + 2. Development setup + 3. Code style guidelines + 4. Pull request process + 5. Code of conduct reference + + Output only the markdown content, no additional commentary.` + } + + let suggestConflictResolutionPrompt = ( + conflict: conflict, + documents: array, + ): string => { + let docContents = + documents + ->Belt.Array.mapWithIndex((idx, doc) => { + `Document ${(idx + 1)->Belt.Int.toString} (${doc.metadata.path}): + --- + ${doc.content} + ---` + }) + ->Js.Array2.joinWith("\n\n") + + `You are tasked with suggesting a resolution for a documentation conflict. + + Conflict type: ${switch conflict.conflictType { + | DuplicateContent => "Duplicate content" + | VersionMismatch => "Version mismatch" + | CanonicalConflict => "Canonical source conflict" + | StructuralConflict => "Structural conflict" + | SemanticConflict => "Semantic conflict" + }} + + Conflicting documents: + ${docContents} + + Analyze these documents and suggest which one should be kept, or if they should be merged. + Provide your reasoning and confidence level (0.0-1.0). + + Response format: + { + "selected_document_index": , + "confidence": <0.0-1.0>, + "reasoning": "" + }` + } + + // Call LLM API with guardrails + let callLLM = async ( + provider: llmProvider, + promptType: llmPromptType, + context: string, + ): result => { + try { + // Build prompt based on type + let prompt = switch promptType { + | GenerateSecurityMd => generateSecurityMdPrompt(context) + | GenerateContributing => generateContributingPrompt(context) + | GenerateSupport => `Generate a SUPPORT.md file with support resources and contact info for: ${context}` + | SuggestConflictResolution => context // Already formatted + | ImproveDocumentation => `Improve the following documentation:\n\n${context}` + } + + switch provider { + // Underscored payloads are intentional: the three provider branches + // are stubs that don't actually call the respective APIs yet. + | Anthropic(_apiKey) => { + // Call Anthropic API + // Note: This is a simplified version - real implementation would use proper SDK + let response = { + content: `[LLM GENERATED CONTENT - REQUIRES APPROVAL]\n\n${prompt}`, + confidence: 0.7, + requiresApproval: true, // ALWAYS true for LLM output + reasoning: "Generated by LLM - requires human review before use", + model: "claude-3-sonnet-20240229", + } + + Ok(response) + } + | OpenAI(_apiKey) => { + // Similar implementation for OpenAI + Error("OpenAI integration not yet implemented") + } + | Local(_modelPath) => { + // Local model integration + Error("Local model integration not yet implemented") + } + } + } catch { + | exn => + Error( + `LLM API call failed: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Generate missing documentation using LLM + let generateMissingDoc = async ( + documentType: documentType, + repoContext: string, + provider: llmProvider, + ): result => { + let promptType = switch documentType { + | SECURITY => GenerateSecurityMd + | CONTRIBUTING => GenerateContributing + | SUPPORT => GenerateSupport + | _ => ImproveDocumentation + } + + await callLLM(provider, promptType, repoContext) + } + + // LLM-assisted conflict resolution + let suggestResolution = async ( + conflict: conflict, + provider: llmProvider, + ): result => { + let prompt = suggestConflictResolutionPrompt(conflict, conflict.documents) + await callLLM(provider, SuggestConflictResolution, prompt) + } + + // Validate LLM output (basic checks) + // ReScript has no `return` keyword; the old code tried to early-return from + // two `if` bodies and never compiled. Refactored to an if/else-if chain + // where the final arm falls through to the document-specific switch. + let validateLLMOutput = (response: llmResponse, docType: documentType): result => { + if Js.String2.trim(response.content) == "" { + Error("LLM generated empty content") + } else if Js.String2.includes(response.content, "[TODO]") { + Error("LLM output contains unresolved TODOs") + } else { + // Document-specific validation + switch docType { + | SECURITY => + if ( + !Js.String2.includes(response.content, "Security") && + !Js.String2.includes(response.content, "vulnerability") + ) { + Error("SECURITY.md should mention security or vulnerabilities") + } else { + Ok() + } + | LICENSE => Error("Should never generate LICENSE files with LLM") + | _ => Ok() + } + } + } + + // Create document from LLM response with guardrails + let createDocumentFromLLM = ( + response: llmResponse, + documentType: documentType, + repository: string, + ): result => { + // CRITICAL: Validate output + switch validateLLMOutput(response, documentType) { + | Error(msg) => Error(msg) + | Ok() => { + // Create metadata with explicit warning + let metadata: documentMetadata = { + path: `[LLM_GENERATED]/${documentTypeToString(documentType)}.md`, + documentType: documentType, + lastModified: Js.Date.now(), + version: None, + canonicalSource: Inferred, + repository: repository, + branch: "llm-generated", + } + + let doc = Deduplicator.createDocument(response.content, metadata) + + Ok(doc) + } + } + } + + // Audit trail for LLM generations + type llmAuditEntry = { + timestamp: float, + promptType: llmPromptType, + model: string, + inputHash: string, + outputHash: string, + confidence: confidence, + approved: bool, + approver: option, + } + + let createAuditEntry = ( + response: llmResponse, + promptType: llmPromptType, + inputContext: string, + ): llmAuditEntry => { + { + timestamp: Js.Date.now(), + promptType: promptType, + model: response.model, + inputHash: Deduplicator.hashContent(inputContext), + outputHash: Deduplicator.hashContent(response.content), + confidence: response.confidence, + approved: false, + approver: None, + } + } + + // Export guardrails summary + let guardrailsSummary = (): string => { + ` + LLM Integration Guardrails + =========================== + + 1. NEVER auto-commit LLM output (requiresApproval: always true) + 2. Validate all LLM-generated content before use + 3. Maintain audit trail of all LLM interactions + 4. Never generate LICENSE files with LLM + 5. Require human approval for all generated documentation + 6. Tag all LLM-generated files with [LLM_GENERATED] prefix + 7. Include confidence scores with all outputs + 8. Validate output meets basic quality standards + 9. Record model, timestamp, and context for all generations + 10. Allow approval workflow before merging to main branch + ` + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/LogicEngine.affine b/migration/affinescript/recon-silly-ation/src/LogicEngine.affine new file mode 100644 index 00000000..2320168e --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/LogicEngine.affine @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/LogicEngine.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module LogicEngine; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // miniKanren/Datalog-style logical inference for documentation reconciliation + // Cross-document reasoning for complex reconciliation rules + + open Types + + // Logic variable + type logicVar = string + + // Logical term + type rec term = + | Var(logicVar) + | Atom(string) + | Compound(string, array) + | DocRef(document) + + // Logical clause (fact or rule) + type clause = { + head: term, + body: array, + } + + // Knowledge base + type knowledgeBase = { + facts: array, + rules: array, + } + + // Unification result + type substitution = Belt.Map.String.t + + // Create empty knowledge base + let createKnowledgeBase = (): knowledgeBase => { + {facts: [], rules: []} + } + + // Add fact to knowledge base + let addFact = (kb: knowledgeBase, fact: term): knowledgeBase => { + let clause = {head: fact, body: []} + { + ...kb, + facts: Belt.Array.concat(kb.facts, [clause]), + } + } + + // Add rule to knowledge base + let addRule = (kb: knowledgeBase, head: term, body: array): knowledgeBase => { + let clause = {head: head, body: body} + { + ...kb, + rules: Belt.Array.concat(kb.rules, [clause]), + } + } + + // Unification algorithm (simplified) + let rec unify = (t1: term, t2: term, sub: substitution): option => { + switch (t1, t2) { + | (Atom(a1), Atom(a2)) => + if a1 == a2 { + Some(sub) + } else { + None + } + + | (Var(v), t) | (t, Var(v)) => + switch sub->Belt.Map.String.get(v) { + | Some(bound) => unify(bound, t, sub) + | None => Some(sub->Belt.Map.String.set(v, t)) + } + + | (Compound(f1, args1), Compound(f2, args2)) => + if f1 == f2 && Belt.Array.length(args1) == Belt.Array.length(args2) { + Belt.Array.zipBy(args1, args2, (a, b) => (a, b))->Belt.Array.reduce( + Some(sub), + (acc, (a1, a2)) => { + switch acc { + | None => None + | Some(s) => unify(a1, a2, s) + } + }, + ) + } else { + None + } + + | (DocRef(d1), DocRef(d2)) => + if d1.hash == d2.hash { + Some(sub) + } else { + None + } + + | _ => None + } + } + + // Query the knowledge base + let query = (kb: knowledgeBase, goal: term): array => { + let results = [] + + // Try to unify with facts + kb.facts->Belt.Array.forEach(fact => { + switch unify(goal, fact.head, Belt.Map.String.empty) { + | None => () + | Some(sub) => results->Js.Array2.push(sub)->ignore + } + }) + + // Try to unify with rules + kb.rules->Belt.Array.forEach(rule => { + switch unify(goal, rule.head, Belt.Map.String.empty) { + | None => () + | Some(sub) => { + // Would need to recursively prove body goals + // Simplified: just check if body is empty + if Belt.Array.length(rule.body) == 0 { + results->Js.Array2.push(sub)->ignore + } + } + } + }) + + results + } + + // Document relationship rules + let defineDocumentRules = (kb: knowledgeBase): knowledgeBase => { + let kb = kb + + // Rule: If two documents have same hash, they are duplicates + // duplicate(X, Y) :- same_hash(X, Y), X != Y + let kb = addRule( + kb, + Compound("duplicate", [Var("X"), Var("Y")]), + [ + Compound("same_hash", [Var("X"), Var("Y")]), + Compound("different_path", [Var("X"), Var("Y")]), + ], + ) + + // Rule: If document has canonical source, it is authoritative + // authoritative(X) :- has_canonical_source(X) + let kb = addRule( + kb, + Compound("authoritative", [Var("X")]), + [Compound("has_canonical_source", [Var("X")])], + ) + + // Rule: Latest version supersedes older versions + // supersedes(X, Y) :- same_type(X, Y), version_greater(X, Y) + let kb = addRule( + kb, + Compound("supersedes", [Var("X"), Var("Y")]), + [ + Compound("same_type", [Var("X"), Var("Y")]), + Compound("version_greater", [Var("X"), Var("Y")]), + ], + ) + + // Rule: Conflicts require resolution + // needs_resolution(X, Y) :- conflict(X, Y), not(resolved(X, Y)) + let kb = addRule( + kb, + Compound("needs_resolution", [Var("X"), Var("Y")]), + [ + Compound("conflict", [Var("X"), Var("Y")]), + Compound("not_resolved", [Var("X"), Var("Y")]), + ], + ) + + kb + } + + // Infer document relationships + let inferRelationships = (documents: array): array<(document, document, string)> => { + // kb is rolled through addFact calls inside a forEach; `let` bindings are + // immutable in ReScript, so the knowledge base lives in a ref. + let kb = ref(defineDocumentRules(createKnowledgeBase())) + + // Add facts about documents + documents->Belt.Array.forEach(doc => { + // Add document existence fact + kb := addFact(kb.contents, Compound("document", [DocRef(doc)])) + + // Add canonical source facts + switch doc.metadata.canonicalSource { + | Inferred => () + | _ => kb := addFact(kb.contents, Compound("has_canonical_source", [DocRef(doc)])) + } + + // Add version facts + switch doc.metadata.version { + | None => () + | Some(v) => + kb := + addFact( + kb.contents, + Compound( + "has_version", + [DocRef(doc), Atom(versionToString(v))], + ), + ) + } + }) + + // Query for relationships + let relationships = [] + + // Find duplicates + documents->Belt.Array.forEach(doc1 => { + documents->Belt.Array.forEach(doc2 => { + if doc1.hash == doc2.hash && doc1.metadata.path != doc2.metadata.path { + relationships->Js.Array2.push((doc1, doc2, "duplicate_of"))->ignore + } + }) + }) + + // Find version relationships + documents->Belt.Array.forEach(doc1 => { + documents->Belt.Array.forEach(doc2 => { + if doc1.metadata.documentType == doc2.metadata.documentType { + switch (doc1.metadata.version, doc2.metadata.version) { + | (Some(v1), Some(v2)) => + if compareVersions(v1, v2) > 0 { + relationships->Js.Array2.push((doc1, doc2, "supersedes"))->ignore + } + | _ => () + } + } + }) + }) + + relationships + } + + // Datalog-style queries + type datalogQuery = { + select: array, + where: array, + } + + let executeDatalogQuery = ( + kb: knowledgeBase, + q: datalogQuery, + ): array => { + // Simplified: execute first condition. + // Pre-existing bug: the original code called `query(kb, condition)` where + // `query` was the parameter (shadowed a missing function of the same name). + // Renamed the parameter to `q` to avoid the shadow and stubbed out the + // single-condition execution — no `query` function exists in this module + // to call. Returns empty substitutions until the real query engine lands. + let _ = kb + switch q.where->Belt.Array.get(0) { + | None => [] + | Some(_condition) => [] + } + } + + // Complex inference: Find canonical document for a type + let findCanonicalForType = ( + documents: array, + docType: documentType, + ): option => { + // Same ref pattern as inferRelationships — kb is accumulated inside a + // forEach so it needs to be mutable via ref. + let kb = ref(createKnowledgeBase()) + + // Add facts about canonical priority + documents->Belt.Array.forEach(doc => { + if doc.metadata.documentType == docType { + let priority = Deduplicator.getCanonicalPriority(doc.metadata.canonicalSource) + kb := + addFact( + kb.contents, + Compound( + "document_priority", + [DocRef(doc), Atom(priority->Belt.Int.toString)], + ), + ) + } + }) + + // Find document with highest priority + documents + ->Belt.Array.keep(d => d.metadata.documentType == docType) + ->Belt.Array.reduce(None, (best, doc) => { + let docPriority = Deduplicator.getCanonicalPriority(doc.metadata.canonicalSource) + + switch best { + | None => Some(doc) + | Some(current) => { + let currentPriority = Deduplicator.getCanonicalPriority( + current.metadata.canonicalSource, + ) + if docPriority > currentPriority { + Some(doc) + } else { + best + } + } + } + }) + } + + // Logical reasoning for conflict resolution + let reasonAboutConflict = (conflict: conflict): string => { + let reasoning = [] + + reasoning->Js.Array2.push("Logical analysis:")->ignore + + // Check for duplicate content + let hashes = conflict.documents->Belt.Array.map(d => d.hash) + let uniqueHashes = Belt.Set.String.fromArray(hashes) + + if Belt.Set.String.size(uniqueHashes) == 1 { + reasoning + ->Js.Array2.push("- All documents have identical content (same hash)") + ->ignore + reasoning->Js.Array2.push("- This is a pure duplication conflict")->ignore + reasoning->Js.Array2.push("- Resolution: Keep latest or canonical")->ignore + } else { + reasoning->Js.Array2.push("- Documents have different content")->ignore + reasoning->Js.Array2.push("- This is a semantic conflict")->ignore + reasoning->Js.Array2.push("- Resolution: Requires analysis or merge")->ignore + } + + // Check for canonical sources + let canonicals = conflict.documents->Belt.Array.keep(d => { + switch d.metadata.canonicalSource { + | Inferred => false + | _ => true + } + }) + + if Belt.Array.length(canonicals) > 0 { + reasoning + ->Js.Array2.push(`- ${Belt.Array.length(canonicals)->Belt.Int.toString} canonical sources found`) + ->ignore + reasoning->Js.Array2.push("- Canonical source should take precedence")->ignore + } + + // Check for versions + let versioned = conflict.documents->Belt.Array.keepMap(d => d.metadata.version) + + if Belt.Array.length(versioned) > 0 { + reasoning + ->Js.Array2.push(`- ${Belt.Array.length(versioned)->Belt.Int.toString} documents have version info`) + ->ignore + reasoning->Js.Array2.push("- Can use version-based resolution")->ignore + } + + reasoning->Js.Array2.joinWith("\n") + } + + // Export inference results as edges + let inferenceToEdges = ( + relationships: array<(document, document, string)>, + ): array => { + relationships->Belt.Array.map(((from, to, relType)) => { + let edgeType = switch relType { + | "duplicate_of" => DuplicateOf + | "supersedes" => SupersededBy + | "derived_from" => DerivedFrom + | _ => ConflictsWith + } + + { + from: from.hash, + to: to.hash, + edgeType: edgeType, + confidence: 0.85, // Inferred, not explicit + metadata: Js.Json.object_( + Js.Dict.fromArray([ + ("inference_type", Js.Json.string(relType)), + ("timestamp", Js.Json.number(Js.Date.now())), + ]), + ), + } + }) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/PackShipper.affine b/migration/affinescript/recon-silly-ation/src/PackShipper.affine new file mode 100644 index 00000000..c44d8390 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/PackShipper.affine @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/PackShipper.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module PackShipper; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // + // PackShipper - Document bundle packaging and distribution + // + // This module handles creating, validating, and shipping document bundles + // according to pack specifications. It ensures bundles meet requirements + // before distribution. + + // ============================================================================ + // Types + // ============================================================================ + + // Pack manifest + // `type rec` required because packManifest forward-references + // documentManifestEntry and packValidation (declared with `and` below). + type rec packManifest = { + name: string, + version: string, + description: string, + author: string, + license: string, + created: float, + documents: array, + validation: packValidation, + } + + // Document entry in manifest + and documentManifestEntry = { + path: string, + docType: string, + hash: string, + size: int, + required: bool, + } + + // Validation section in manifest + and packValidation = { + packSpec: string, + validated: bool, + validatedAt: float, + errors: array, + warnings: array, + } + + // Shipping destination + type shippingDestination = + | GitRepository(string, string) // url, branch + | FileSystem(string) // path + | ArangoDb(string, string) // url, database + | Archive(string) // path to archive + + // Shipping options + type shippingOptions = { + destination: shippingDestination, + dryRun: bool, + force: bool, + createPR: bool, + prTitle: option, + prBody: option, + } + + // Shipping result + type shippingResult = { + success: bool, + destination: string, + manifest: packManifest, + timestamp: float, + errors: array, + prUrl: option, + } + + // ============================================================================ + // Pack Specifications + // ============================================================================ + + // Standard Hyperpolymath pack + let hyperpolymathPackSpec = ` + "hyperpolymath-standard" pack-new + + -- Required documents + "README" pack-require + "LICENSE" pack-require + "SECURITY" pack-require + "CONTRIBUTING" pack-require + "CODE_OF_CONDUCT" pack-require + + -- Optional documents + "FUNDING" pack-optional + "CITATION" pack-optional + "CHANGELOG" pack-optional + "AUTHORS" pack-optional + "SUPPORT" pack-optional + + -- Validation rules + "license-check" [ + "LICENSE" bundle-get-type nil <> + [ + "LICENSE" bundle-get-type doc-content + "Palimpsest" str-contains? + "License must be PMPL-1.0-or-later (Palimpsest)" require! + ] when + ] pack-rule + + "spdx-headers" [ + bundle-docs [ + dup doc-path ".res" str-ends? + over doc-path ".rs" str-ends? or + [ + dup doc-content "SPDX-License-Identifier" str-contains? not + [ doc-path " missing SPDX header" error! ] when + ] when + ] each + ] pack-rule + + bundle-validate + ` + + // Minimal pack (just essentials) + let minimalPackSpec = ` + "minimal" pack-new + "README" pack-require + "LICENSE" pack-require + bundle-validate + ` + + // Security-focused pack + let securityPackSpec = ` + "security" pack-new + "README" pack-require + "LICENSE" pack-require + "SECURITY" pack-require + "CONTRIBUTING" pack-require + + "security-content" [ + "SECURITY" bundle-get-type nil <> + [ + "SECURITY" bundle-get-type doc-content + "vulnerability" str-lower str-contains? not + [ "SECURITY.md should describe vulnerability reporting" warn! ] when + ] when + ] pack-rule + + bundle-validate + ` + + // OSS pack (open source focused) + let ossPackSpec = ` + "oss" pack-new + "README" pack-require + "LICENSE" pack-require + "CONTRIBUTING" pack-require + "CODE_OF_CONDUCT" pack-require + "CHANGELOG" pack-optional + "AUTHORS" pack-optional + + "readme-quality" [ + "README" bundle-get-type doc-content + dup "## " str-contains? not + [ "README should have sections" warn! ] when + str-len 500 < + [ "README seems too short" warn! ] when + ] pack-rule + + bundle-validate + ` + + // ============================================================================ + // Pack Building + // ============================================================================ + + // Create a manifest from a bundle + let createManifest = ( + bundle: ReconForth.bundle, + name: string, + version: string, + ~description: string="", + ~author: string="", + ~packSpec: string=hyperpolymathPackSpec, + (), + ): packManifest => { + // Validate bundle against pack spec + let validationResult = ReconForth.validateBundle(bundle, packSpec) + + let documents = bundle.documents->Belt.Array.map((doc): documentManifestEntry => { + { + path: doc.metadata.path, + docType: doc.metadata.document_type, + hash: doc.hash, + size: String.length(doc.content), + required: true, // Could be enhanced to check against pack spec + } + }) + + { + name, + version, + description, + author, + license: "PMPL-1.0-or-later", + created: Js.Date.now(), + documents, + validation: { + packSpec, + validated: validationResult.success, + validatedAt: Js.Date.now(), + errors: validationResult.errors->Belt.Array.map(e => e.message), + warnings: validationResult.warnings->Belt.Array.map(w => w.message), + }, + } + } + + // Validate a manifest + let validateManifest = (manifest: packManifest): bool => { + manifest.validation.validated && Belt.Array.length(manifest.validation.errors) == 0 + } + + // Convert manifest to JSON string + let manifestToJson = (manifest: packManifest): string => { + // Using manual JSON construction for simplicity + let docsJson = manifest.documents + ->Belt.Array.map(d => + `{"path":"${d.path}","docType":"${d.docType}","hash":"${d.hash}","size":${Belt.Int.toString(d.size)},"required":${d.required ? "true" : "false"}}` + ) + ->Js.Array2.joinWith(",") + + let errorsJson = manifest.validation.errors->Belt.Array.map(e => `"${e}"`)->Js.Array2.joinWith(",") + + let warningsJson = + manifest.validation.warnings->Belt.Array.map(w => `"${w}"`)->Js.Array2.joinWith(",") + + `{ + "name": "${manifest.name}", + "version": "${manifest.version}", + "description": "${manifest.description}", + "author": "${manifest.author}", + "license": "${manifest.license}", + "created": ${Belt.Float.toString(manifest.created)}, + "documents": [${docsJson}], + "validation": { + "validated": ${manifest.validation.validated ? "true" : "false"}, + "validatedAt": ${Belt.Float.toString(manifest.validation.validatedAt)}, + "errors": [${errorsJson}], + "warnings": [${warningsJson}] + } + }` + } + + // ============================================================================ + // Shipping Operations + // ============================================================================ + + // Ship a bundle to a destination + // `_bundle` is not yet consumed — shipping currently operates on the + // manifest only. Prefix-underscore suppresses the unused-variable warning + // while preserving the parameter position for future implementations. + let ship = ( + _bundle: ReconForth.bundle, + manifest: packManifest, + options: shippingOptions, + ): shippingResult => { + // Validate manifest first + if !validateManifest(manifest) && !options.force { + { + success: false, + destination: switch options.destination { + | GitRepository(url, _) => url + | FileSystem(path) => path + | ArangoDb(url, _) => url + | Archive(path) => path + }, + manifest, + timestamp: Js.Date.now(), + errors: Belt.Array.concat( + ["Pack validation failed"], + manifest.validation.errors, + ), + prUrl: None, + } + } else if options.dryRun { + // Dry run - just report what would happen + { + success: true, + destination: switch options.destination { + | GitRepository(url, branch) => `${url}#${branch} (dry run)` + | FileSystem(path) => `${path} (dry run)` + | ArangoDb(url, db) => `${url}/${db} (dry run)` + | Archive(path) => `${path} (dry run)` + }, + manifest, + timestamp: Js.Date.now(), + errors: [], + prUrl: None, + } + } else { + // Actual shipping would happen here + // For now, just return success + { + success: true, + destination: switch options.destination { + | GitRepository(url, branch) => `${url}#${branch}` + | FileSystem(path) => path + | ArangoDb(url, db) => `${url}/${db}` + | Archive(path) => path + }, + manifest, + timestamp: Js.Date.now(), + errors: [], + prUrl: options.createPR ? Some("https://github.com/owner/repo/pull/1") : None, + } + } + } + + // ============================================================================ + // Convenience Functions + // ============================================================================ + + // Create and ship in one operation + let createAndShip = ( + bundle: ReconForth.bundle, + name: string, + version: string, + destination: shippingDestination, + ~packSpec: string=hyperpolymathPackSpec, + ~dryRun: bool=false, + (), + ): shippingResult => { + let manifest = createManifest(bundle, name, version, ~packSpec, ()) + let options = { + destination, + dryRun, + force: false, + createPR: false, + prTitle: None, + prBody: None, + } + ship(bundle, manifest, options) + } + + // Ship to a Git repository with PR + let shipWithPR = ( + bundle: ReconForth.bundle, + manifest: packManifest, + repoUrl: string, + branch: string, + ~title: string="Update documentation bundle", + ~body: string="Automated documentation update via PackShipper", + (), + ): shippingResult => { + let options = { + destination: GitRepository(repoUrl, branch), + dryRun: false, + force: false, + createPR: true, + prTitle: Some(title), + prBody: Some(body), + } + ship(bundle, manifest, options) + } + + // Generate shipping report + let generateShippingReport = (result: shippingResult): string => { + `# Shipping Report + + ## Summary + - **Success**: ${result.success ? "Yes" : "No"} + - **Destination**: ${result.destination} + - **Timestamp**: ${Belt.Float.toString(result.timestamp)} + ${result.prUrl->Belt.Option.mapWithDefault("", url => `- **Pull Request**: ${url}`)} + + ## Manifest + - **Name**: ${result.manifest.name} + - **Version**: ${result.manifest.version} + - **Documents**: ${Belt.Int.toString(Belt.Array.length(result.manifest.documents))} + - **Validated**: ${result.manifest.validation.validated ? "Yes" : "No"} + + ## Documents + ${result.manifest.documents + ->Belt.Array.map(d => + `- ${d.path} (${d.docType}, ${Belt.Int.toString(d.size)} bytes)` + ) + ->Js.Array2.joinWith("\n")} + + ## Validation + ${if Belt.Array.length(result.manifest.validation.errors) > 0 { + `### Errors + ${result.manifest.validation.errors->Belt.Array.map(e => `- ${e}`)->Js.Array2.joinWith("\n")}` + } else { + "No errors" + }} + + ${if Belt.Array.length(result.manifest.validation.warnings) > 0 { + `### Warnings + ${result.manifest.validation.warnings->Belt.Array.map(w => `- ${w}`)->Js.Array2.joinWith("\n")}` + } else { + "No warnings" + }} + + ${if Belt.Array.length(result.errors) > 0 { + `## Shipping Errors + ${result.errors->Belt.Array.map(e => `- ${e}`)->Js.Array2.joinWith("\n")}` + } else { + "" + }} + ` + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/Pipeline.affine b/migration/affinescript/recon-silly-ation/src/Pipeline.affine new file mode 100644 index 00000000..8e62661b --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/Pipeline.affine @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/Pipeline.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 4 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 65: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 83: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 122: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 160: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// `Failed to scan repository: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn... + +module Pipeline; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Idempotent orchestration pipeline + // Scan → Normalize → Dedupe → Detect → Resolve → Ingest → Report + // Each stage is rerunnable and atomic + + open Types + + // Node.js file system bindings + @module("fs") @val + external readFileSync: (string, string) => string = "readFileSync" + + @module("fs") @val + external existsSync: string => bool = "existsSync" + + @module("fs") @val + external readdirSync: string => array = "readdirSync" + + @module("fs") @val + external statSync: string => 'a = "statSync" + + @send external isDirectory: 'a => bool = "isDirectory" + @send external isFile: 'a => bool = "isFile" + + @module("path") @val + external join: (string, string) => string = "join" + + @module("path") @val + external basename: string => string = "basename" + + @module("path") @val + external extname: string => string = "extname" + + @module("child_process") @val + external execSync: (string, 'a) => string = "execSync" + + // Parse version from content (e.g., "v1.2.3", "version 2.0.0", "Version: 3.1.4") + let parseVersionFromContent = (content: string): option => { + let re = %re("/(?:v|version[:\s]+)(\d+)\.(\d+)\.(\d+)/i") + switch re->Js.Re.exec_(content) { + | None => None + | Some(result) => { + let captures = Js.Re.captures(result) + switch ( + captures->Belt.Array.get(1)->Belt.Option.flatMap(Js.Nullable.toOption), + captures->Belt.Array.get(2)->Belt.Option.flatMap(Js.Nullable.toOption), + captures->Belt.Array.get(3)->Belt.Option.flatMap(Js.Nullable.toOption), + ) { + | (Some(major), Some(minor), Some(patch)) => + switch ( + Belt.Int.fromString(major), + Belt.Int.fromString(minor), + Belt.Int.fromString(patch), + ) { + | (Some(maj), Some(min), Some(pat)) => Some({major: maj, minor: min, patch: pat}) + | _ => None + } + | _ => None + } + } + } + } + + // Detect current git branch, fallback to "main" + let detectGitBranch = (repoPath: string): string => { + try { + let result = execSync( + "git rev-parse --abbrev-ref HEAD", + {"cwd": repoPath, "encoding": "utf8"}, + ) + let trimmed = result->Js.String2.trim + if Js.String2.length(trimmed) > 0 { + trimmed + } else { + "main" + } + } catch { + | _ => "main" + } + } + + // Stage: Scan repositories for documentation files + let scanRepository = (repoPath: string): result, string> => { + try { + let documents = [] + + let rec scanDir = (path: string) => { + if !existsSync(path) { + () + } else { + let stat = statSync(path) + if stat->isDirectory { + let entries = readdirSync(path) + entries->Belt.Array.forEach(entry => { + let fullPath = join(path, entry) + scanDir(fullPath) + }) + } else if stat->isFile { + let filename = basename(path) + // Extension isn't consulted here — filename matching is exact. + // Leave the call in place (documented surface area) but drop + // the unused binding. + let _ext = extname(path) + + // Identify documentation files + let docType = switch filename->Js.String2.toUpperCase { + | "README.MD" | "README" => Some(README) + | "LICENSE.MD" | "LICENSE" | "LICENSE.TXT" => Some(LICENSE) + | "SECURITY.MD" => Some(SECURITY) + | "CONTRIBUTING.MD" => Some(CONTRIBUTING) + | "CODE_OF_CONDUCT.MD" => Some(CODE_OF_CONDUCT) + | "FUNDING.YML" | ".GITHUB/FUNDING.YML" => Some(FUNDING) + | "CITATION.CFF" => Some(CITATION) + | "CHANGELOG.MD" => Some(CHANGELOG) + | "AUTHORS.MD" | "AUTHORS" => Some(AUTHORS) + | "SUPPORT.MD" => Some(SUPPORT) + | _ => None + } + + switch docType { + | None => () + | Some(dt) => { + try { + let content = readFileSync(path, "utf8") + + // Determine canonical source + let canonicalSource = switch dt { + | LICENSE => LicenseFile + | FUNDING => FundingYaml + | SECURITY => SecurityMd + | CITATION => CitationCff + | _ => Inferred + } + + let metadata: documentMetadata = { + path: path, + documentType: dt, + lastModified: Js.Date.now(), + version: parseVersionFromContent(content), + canonicalSource: canonicalSource, + repository: repoPath, + branch: detectGitBranch(repoPath), + } + + let doc = Deduplicator.createDocument(content, metadata) + documents->Js.Array2.push(doc)->ignore + } catch { + | _ => () // Skip unreadable files + } + } + } + } + } + } + + scanDir(repoPath) + Ok(documents) + } catch { + | exn => + Error( + `Failed to scan repository: ${Js.Exn.asJsExn(exn)->Belt.Option.flatMap(Js.Exn.message)->Belt.Option.getWithDefault("Unknown error")}`, + ) + } + } + + // Stage: Normalize documents (format standardization) + let normalizeDocuments = (documents: array): array => { + documents->Belt.Array.map(doc => { + // Content is already normalized in Deduplicator.createDocument + doc + }) + } + + // Create initial pipeline state + let createPipelineState = (): pipelineState => { + { + stage: Scan, + documents: [], + conflicts: [], + resolutions: [], + errors: [], + startedAt: Js.Date.now(), + completedAt: None, + } + } + + // Execute a single pipeline stage + let executeStage = async ( + state: pipelineState, + config: config, + client: option, + ): result => { + switch state.stage { + | Scan => { + Js.Console.log("Stage: Scan repositories") + + let allDocuments = [] + let errors = [] + + config.repositoryPaths->Belt.Array.forEach(repoPath => { + switch scanRepository(repoPath) { + | Ok(docs) => { + allDocuments->Js.Array2.pushMany(docs)->ignore + } + | Error(msg) => { + errors->Js.Array2.push(msg)->ignore + } + } + }) + + Js.Console.log(`Scanned ${allDocuments->Belt.Array.length->Belt.Int.toString} documents`) + + Ok({ + ...state, + stage: Normalize, + documents: allDocuments, + errors: errors, + }) + } + + | Normalize => { + Js.Console.log("Stage: Normalize documents") + let normalized = normalizeDocuments(state.documents) + + Ok({ + ...state, + stage: Deduplicate, + documents: normalized, + }) + } + + | Deduplicate => { + Js.Console.log("Stage: Deduplicate") + let result = Deduplicator.deduplicate(state.documents) + + Js.Console.log( + `Found ${result.stats.duplicateCount->Belt.Int.toString} duplicates, ${result.stats.uniqueCount->Belt.Int.toString} unique`, + ) + + // Create duplicate edges + let edges = Deduplicator.createDuplicateEdges(result.duplicates) + + // Store edges in database if client available + switch client { + | None => () + | Some(c) => { + let _ = await ArangoClient.insertEdges(c, edges) + () + } + } + + Ok({ + ...state, + stage: DetectConflicts, + documents: result.unique, + }) + } + + | DetectConflicts => { + Js.Console.log("Stage: Detect conflicts") + let conflicts = ConflictResolver.detectConflicts(state.documents) + + Js.Console.log(`Detected ${conflicts->Belt.Array.length->Belt.Int.toString} conflicts`) + + // Store conflicts in database if client available + switch client { + | None => () + | Some(c) => { + for i in 0 to Belt.Array.length(conflicts) - 1 { + switch Belt.Array.get(conflicts, i) { + | Some(conflict) => + let _ = await ArangoClient.storeConflict(c, conflict) + () + | None => () + } + } + } + } + + Ok({ + ...state, + stage: ResolveConflicts, + conflicts: conflicts, + }) + } + + | ResolveConflicts => { + Js.Console.log("Stage: Resolve conflicts") + let resolutions = ConflictResolver.resolveConflicts( + state.conflicts, + config.autoResolveThreshold, + ) + + let autoResolved = + resolutions->Belt.Array.keep(r => !r.requiresApproval)->Belt.Array.length + let manual = resolutions->Belt.Array.keep(r => r.requiresApproval)->Belt.Array.length + + Js.Console.log( + `Resolved: ${autoResolved->Belt.Int.toString} auto, ${manual->Belt.Int.toString} require approval`, + ) + + // Store resolutions in database if client available + switch client { + | None => () + | Some(c) => { + for i in 0 to Belt.Array.length(resolutions) - 1 { + switch Belt.Array.get(resolutions, i) { + | Some(resolution) => + let _ = await ArangoClient.storeResolution(c, resolution) + () + | None => () + } + } + + // Create superseded edges + let edges = ConflictResolver.createSupersededEdges(resolutions) + let _ = await ArangoClient.insertEdges(c, edges) + () + } + } + + Ok({ + ...state, + stage: Ingest, + resolutions: resolutions, + }) + } + + | Ingest => { + Js.Console.log("Stage: Ingest into ArangoDB") + + switch client { + | None => { + let msg = "No ArangoDB client available - skipping ingest" + Js.Console.warn(msg) + Ok({ + ...state, + stage: Report, + errors: Belt.Array.concat(state.errors, [msg]), + }) + } + | Some(c) => { + let result = await ArangoClient.insertDocuments(c, state.documents) + + switch result { + | Ok() => { + Js.Console.log( + `Ingested ${state.documents->Belt.Array.length->Belt.Int.toString} documents`, + ) + Ok({ + ...state, + stage: Report, + }) + } + | Error(msg) => + Ok({ + ...state, + stage: Report, + errors: Belt.Array.concat(state.errors, [msg]), + }) + } + } + } + } + + | Report => { + Js.Console.log("Stage: Generate report") + + let dedupeReport = Deduplicator.generateReport({ + unique: state.documents, + duplicates: [], // Already processed + stats: { + totalProcessed: state.documents->Belt.Array.length, + uniqueCount: state.documents->Belt.Array.length, + duplicateCount: 0, + spacesSaved: 0, + }, + }) + + let conflictReport = ConflictResolver.generateReport( + state.resolutions, + state.conflicts, + ) + + Js.Console.log("\n" ++ dedupeReport) + Js.Console.log("\n" ++ conflictReport) + + if Belt.Array.length(state.errors) > 0 { + Js.Console.log("\nErrors:") + state.errors->Belt.Array.forEach(err => { + Js.Console.log(` - ${err}`) + }) + } + + Ok({ + ...state, + completedAt: Some(Js.Date.now()), + }) + } + } + } + + // Execute entire pipeline + let rec executePipeline = async ( + state: pipelineState, + config: config, + client: option, + ): result => { + let result = await executeStage(state, config, client) + + switch result { + | Error(msg) => Error(msg) + | Ok(newState) => { + switch newState.stage { + | Report => + switch newState.completedAt { + | Some(_) => Ok(newState) // Pipeline complete + | None => await executePipeline(newState, config, client) // Continue + } + | _ => await executePipeline(newState, config, client) // Continue + } + } + } + } + + // Run complete reconciliation pipeline + let run = async (config: config): result => { + Js.Console.log("=== Documentation Reconciliation Pipeline ===\n") + + // Initialize ArangoDB client + let clientResult = await ArangoClient.initialize(config) + + let client = switch clientResult { + | Ok(c) => { + Js.Console.log("ArangoDB client initialized") + Some(c) + } + | Error(msg) => { + Js.Console.warn(`Failed to initialize ArangoDB: ${msg}`) + Js.Console.warn("Continuing without database persistence") + None + } + } + + // Create initial state and execute pipeline + let initialState = createPipelineState() + let result = await executePipeline(initialState, config, client) + + switch result { + | Ok(finalState) => { + let duration = switch finalState.completedAt { + | None => 0.0 + | Some(completed) => completed -. finalState.startedAt + } + + Js.Console.log(`\nPipeline completed in ${duration->Belt.Float.toString}ms`) + Ok(finalState) + } + | Error(msg) => { + Js.Console.error(`Pipeline failed: ${msg}`) + Error(msg) + } + } + } + + // Run pipeline continuously (for daemon mode) + let runContinuous = async (config: config): unit => { + let rec loop = async () => { + let _ = await run(config) + + switch config.scanInterval { + | None => () // Run once and exit + | Some(interval) => { + Js.Console.log(`\nWaiting ${interval->Belt.Int.toString} seconds until next scan...`) + // Note: In real implementation, use proper async sleep + await Js.Promise.make((~resolve, ~reject as _) => { + let _ = Js.Global.setTimeout(() => resolve(.), interval * 1000) + }) + await loop() + } + } + } + + await loop() + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/Protocol.affine b/migration/affinescript/recon-silly-ation/src/Protocol.affine new file mode 100644 index 00000000..6b54e175 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/Protocol.affine @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/Protocol.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Protocol; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Protocol - Shared types for component integration (SEAM requirements) + + // ============================================================================= + // SEAM-1: Formatrix ↔ RSA Protocol + // ============================================================================= + + // SEAM-1A: Document event protocol + type documentEventType = + | Created + | Modified + | Deleted + | Converted + + type documentEvent = { + id: string, + eventType: documentEventType, + hash: string, + oldHash: option, + path: string, + format: string, + timestamp: float, + source: string, // "formatrix-docs" | "recon-silly-ation" + } + + // SEAM-1C: AST interchange format + type inlineElement = + | Text(string) + | Emphasis(string) + | Strong(string) + | Code(string) + | Link({text: string, url: string}) + | Image({alt: string, url: string}) + + // `rec` required because blockElement's Quote case references itself. + type rec blockElement = + | Paragraph(array) + | Heading({level: int, content: array}) + | CodeBlock({language: option, content: string}) + | List({ordered: bool, items: array>}) + | Quote(array) + | ThematicBreak + | Raw(string) + + type documentAst = { + title: option, + blocks: array, + format: string, + hash: string, + } + + // SEAM-1D: Hash algorithm (SHA-256) + let hashAlgorithm = "sha256" + + // ============================================================================= + // SEAM-2: RSA ↔ Docubot Protocol + // ============================================================================= + + // SEAM-2A: Generation request protocol + // `type rec` required because generationRequest.context forward-references + // repoContext (declared with `and` below). + type rec generationRequest = { + requestId: string, + documentType: string, // "README", "SECURITY", etc. + format: string, // "md", "adoc", "org" + context: repoContext, + priority: int, + requestedBy: string, + requestedAt: float, + } + + // SEAM-2A: Repository context - aligned with Docubot.res + and repoContext = { + name: string, + description: option, + language: option, + license: option, + topics: array, + existingDocs: array, + // Additional fields for Docubot integration + dependencies: option>, + readme: option, + } + + type generationResponse = { + requestId: string, + content: string, + documentType: string, + format: string, + requiresApproval: bool, // MUST always be true + confidence: float, + generatedAt: float, + auditId: string, + warnings: array, + } + + // SEAM-2B: Approval callback mechanism + type approvalRequest = { + auditId: string, + content: string, + documentType: string, + format: string, + requestedBy: string, + expiresAt: float, + } + + type approvalResponse = { + auditId: string, + approved: bool, + approvedBy: option, + approvedAt: option, + reason: option, + } + + // SEAM-2C: Audit event format + type auditEventType = + | GenerationStarted + | GenerationCompleted + | GenerationFailed + | ApprovalRequested + | ApprovalGranted + | ApprovalRejected + | ApprovalExpired + + type auditEvent = { + id: string, + eventType: auditEventType, + auditId: string, + timestamp: float, + details: Js.Dict.t, + } + + // ============================================================================= + // SEAM-3: RSA ↔ Docudactyl Protocol + // ============================================================================= + + // SEAM-3A: Pipeline trigger protocol + type pipelineTrigger = { + triggerId: string, + pipelineId: string, + params: Js.Dict.t, + triggeredBy: string, + triggeredAt: float, + priority: int, + } + + type pipelineAck = { + triggerId: string, + executionId: string, + accepted: bool, + reason: option, + } + + // SEAM-3B: Status reporting format + type pipelineStatus = + | Queued + | Running + | Completed + | Failed + | Cancelled + + type stepStatus = + | StepPending + | StepRunning + | StepCompleted({duration: float}) + | StepFailed({error: string, duration: float}) + | StepSkipped({reason: string}) + + type statusReport = { + executionId: string, + pipelineId: string, + status: pipelineStatus, + progress: float, // 0.0 to 1.0 + currentStep: option, + stepStatuses: Js.Dict.t, + startedAt: float, + updatedAt: float, + completedAt: option, + error: option, + } + + // SEAM-3C: Enforcement result format + type violationSeverity = + | ViolationError + | ViolationWarning + | ViolationInfo + + type enforcementViolation = { + id: string, + ruleId: string, + ruleName: string, + severity: violationSeverity, + message: string, + path: option, + line: option, + suggestion: option, + } + + type enforcementResult = { + executionId: string, + packSpec: string, + bundleId: string, + success: bool, + violations: array, + checkedAt: float, + duration: float, + } + + // SEAM-3D: Pack shipping completion event + type shippingDestination = + | GitRepo({url: string, branch: string}) + | FileSystem({path: string}) + | ArangoDB({collection: string}) + | Archive({path: string, format: string}) + + type packShipmentResult = { + shipmentId: string, + packName: string, + destination: shippingDestination, + success: bool, + documentCount: int, + totalSize: int, + shippedAt: float, + error: option, + manifest: option, + } + + // ============================================================================= + // SEAM-4: Docubot ↔ Docudactyl Protocol + // ============================================================================= + + // SEAM-4A: Generation scheduling + type generationSchedule = { + scheduleId: string, + documentType: string, + format: string, + repoPath: string, + interval: option, // seconds, None = one-time + priority: int, + enabled: bool, + } + + // SEAM-4B: Approval workflow routing + type approvalWorkflow = { + workflowId: string, + auditId: string, + content: string, + documentType: string, + approvers: array, + requiredApprovals: int, + currentApprovals: int, + status: string, // "pending", "approved", "rejected", "expired" + expiresAt: float, + } + + // SEAM-4C: Cost budget integration + type costBudget = { + daily: float, + monthly: float, + dailyUsed: float, + monthlyUsed: float, + dailyRemaining: float, + monthlyRemaining: float, + } + + // SEAM-4D: Health check protocol + type healthStatus = + | Healthy + | Degraded({reason: string}) + | Unhealthy({reason: string}) + | Unknown + + type healthCheck = { + componentId: string, + status: healthStatus, + latencyMs: option, + version: option, + checkedAt: float, + } + + // ============================================================================= + // SEAM-5: All ↔ ArangoDB Protocol + // ============================================================================= + + // SEAM-5A: Document collection schema + type arangoDocument = { + _key: string, // hash + _id: option, + _rev: option, + content: string, + format: string, + path: string, + repository: string, + branch: string, + createdAt: float, + modifiedAt: float, + metadata: Js.Dict.t, + } + + // SEAM-5B: Edge collection schema + type edgeType = + | Supersedes + | Duplicates + | References + | DependsOn + | GeneratedFrom + + type arangoEdge = { + _key: option, + _id: option, + _from: string, + _to: string, + edgeType: edgeType, + confidence: float, + createdAt: float, + metadata: Js.Dict.t, + } + + // SEAM-5C: Pack manifest collection schema + // Inline nested record types are not allowed in ReScript field positions; + // extract the validation-result shape as a named type. + type packValidationResult = { + success: bool, + errors: array, + warnings: array, + } + + type packManifest = { + _key: string, + name: string, + version: string, + documents: array, // document _keys + required: array, + optional: array, + validationResult: option, + createdAt: float, + shippedTo: array, + } + + // SEAM-5D: Audit log collection schema + type auditLogEntry = { + _key: string, + component: string, + action: string, + entityType: string, + entityId: string, + userId: option, + timestamp: float, + details: Js.Dict.t, + success: bool, + errorMessage: option, + } + + // ============================================================================= + // Serialization helpers + // ============================================================================= + + let documentEventToJson = (event: documentEvent): Js.Json.t => { + Js.Json.object_(Js.Dict.fromArray([ + ("id", Js.Json.string(event.id)), + ("eventType", Js.Json.string(switch event.eventType { + | Created => "created" + | Modified => "modified" + | Deleted => "deleted" + | Converted => "converted" + })), + ("hash", Js.Json.string(event.hash)), + ("oldHash", switch event.oldHash { + | Some(h) => Js.Json.string(h) + | None => Js.Json.null + }), + ("path", Js.Json.string(event.path)), + ("format", Js.Json.string(event.format)), + ("timestamp", Js.Json.number(event.timestamp)), + ("source", Js.Json.string(event.source)), + ])) + } + + let healthCheckToJson = (check: healthCheck): Js.Json.t => { + Js.Json.object_(Js.Dict.fromArray([ + ("componentId", Js.Json.string(check.componentId)), + ("status", Js.Json.string(switch check.status { + | Healthy => "healthy" + | Degraded({reason}) => `degraded: ${reason}` + | Unhealthy({reason}) => `unhealthy: ${reason}` + | Unknown => "unknown" + })), + ("latencyMs", switch check.latencyMs { + | Some(l) => Js.Json.number(l) + | None => Js.Json.null + }), + ("version", switch check.version { + | Some(v) => Js.Json.string(v) + | None => Js.Json.null + }), + ("checkedAt", Js.Json.number(check.checkedAt)), + ])) + } + + // Message queue interface + type messageQueue = { + publish: (string, Js.Json.t) => unit, + subscribe: (string, Js.Json.t => unit) => unit, + unsubscribe: string => unit, + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/ReconForth.affine b/migration/affinescript/recon-silly-ation/src/ReconForth.affine new file mode 100644 index 00000000..98b6e68e --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/ReconForth.affine @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/ReconForth.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module ReconForth; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // + // ReconForth - ReScript bindings for the ReconForth DSL + // + // This module provides type-safe bindings to the Rust/WASM ReconForth interpreter + // for document bundle validation and reconciliation. + + // ============================================================================ + // Types + // ============================================================================ + + // Validation message from ReconForth + type validationMessage = { + message: string, + path: option, + rule: option, + } + + // Validation result from ReconForth evaluation + type validationResult = { + success: bool, + errors: array, + warnings: array, + suggestions: array, + } + + // Document metadata + type documentMetadata = { + path: string, + document_type: string, + last_modified: float, + version: option, + canonical_source: string, + repository: string, + branch: string, + } + + // A document in the reconciliation system + type document = { + hash: string, + content: string, + metadata: documentMetadata, + created_at: float, + } + + // A bundle of documents + type bundle = {documents: array} + + // ============================================================================ + // WASM Bindings (external) + // ============================================================================ + + // These will be loaded from the compiled WASM module + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmHashContent: string => string = "hash_content" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmNormalizeContent: string => string = "normalize_content" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmBatchHash: array => array = "batch_hash" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmReconforthEval: string => validationResult = "reconforth_eval" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmReconforthEvalBundle: (string, bundle) => validationResult = "reconforth_eval_bundle" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmValidateBundle: (bundle, string) => validationResult = "validate_bundle" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmCreateDocument: (string, string, string) => document = "create_document" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmCreateBundle: unit => bundle = "create_bundle" + + @module("../wasm-modules/pkg/recon_wasm.js") + external wasmBundleAddDocument: (bundle, document) => bundle = "bundle_add_document" + + // ============================================================================ + // High-level API + // ============================================================================ + + // Hash content using SHA-256 + let hashContent = wasmHashContent + + // Normalize whitespace in content + let normalizeContent = wasmNormalizeContent + + // Batch hash multiple documents + let batchHash = wasmBatchHash + + // Evaluate a ReconForth program + let eval = wasmReconforthEval + + // Evaluate a ReconForth program with a bundle + let evalBundle = wasmReconforthEvalBundle + + // Validate a bundle against a pack specification + let validateBundle = wasmValidateBundle + + // Create a document from content + let createDocument = (content: string, path: string, docType: string): document => { + wasmCreateDocument(content, path, docType) + } + + // Create an empty bundle + let createBundle = (): bundle => { + wasmCreateBundle() + } + + // Add a document to a bundle + let addDocument = (bundle: bundle, doc: document): bundle => { + wasmBundleAddDocument(bundle, doc) + } + + // ============================================================================ + // Pack Specification Builders + // ============================================================================ + + // Standard Hyperpolymath pack specification + let standardPack = ` + "hyperpolymath-standard" pack-new + + -- Required documents + "README" pack-require + "LICENSE" pack-require + "SECURITY" pack-require + "CONTRIBUTING" pack-require + "CODE_OF_CONDUCT" pack-require + + -- Optional documents + "FUNDING" pack-optional + "CITATION" pack-optional + "CHANGELOG" pack-optional + "AUTHORS" pack-optional + "SUPPORT" pack-optional + + -- Custom validation rules + "license-pmpl" [ + "LICENSE" bundle-get-type nil <> + [ "LICENSE" bundle-get-type doc-content "Palimpsest" str-contains? + "License must be PMPL-1.0-or-later (Palimpsest)" require! + ] when + ] pack-rule + + bundle-validate + ` + + // Minimal pack specification (just LICENSE and README) + let minimalPack = ` + "minimal" pack-new + "README" pack-require + "LICENSE" pack-require + bundle-validate + ` + + // Security-focused pack specification + let securityPack = ` + "security-focused" pack-new + "README" pack-require + "LICENSE" pack-require + "SECURITY" pack-require + "CONTRIBUTING" pack-require + + "has-security-policy" [ + "SECURITY" bundle-get-type nil <> + "Security policy is required" require! + ] pack-rule + + "security-content-check" [ + "SECURITY" bundle-get-type nil <> + [ + "SECURITY" bundle-get-type doc-content + "vulnerability" str-lower str-contains? + "Security policy should describe vulnerability reporting" suggest! + ] when + ] pack-rule + + bundle-validate + ` + + // ============================================================================ + // Enforcement Rules + // ============================================================================ + + // Check for SPDX headers in source files + let checkSpdxHeaders = ` + : has-spdx? ( doc -- bool ) + doc-content "SPDX-License-Identifier" str-contains? ; + + : check-source-file ( doc -- ) + dup doc-path ".res" str-ends? + over doc-path ".rs" str-ends? or + over doc-path ".ts" str-ends? or + over doc-path ".js" str-ends? or + [ + dup has-spdx? not + [ doc-path " missing SPDX header" str-concat error! ] + [ drop ] + if + ] + [ drop ] + if ; + + bundle-docs [ check-source-file ] each + ` + + // Check for banned languages (TypeScript when ReScript should be used) + let checkBannedLanguages = ` + : is-typescript? ( doc -- bool ) + doc-path ".ts" str-ends? + swap doc-path ".tsx" str-ends? or ; + + : count-typescript ( bundle -- n ) + bundle-docs [ is-typescript? ] filter list-len nip ; + + dup count-typescript 0 > + [ "TypeScript files detected - use AffineScript instead per RSR" error! ] + when + ` + + // Check for proper README structure + let checkReadmeStructure = ` + "README" bundle-get-type nil <> + [ + "README" bundle-get-type doc-content + + -- Check for required sections + dup "## " str-contains? not + [ "README should have sections (## headers)" warn! ] when + + dup "Install" str-contains? not + over "Getting Started" str-contains? not and + [ "README should have installation instructions" suggest! ] when + + drop + ] + [ "Missing README file" error! ] + if + ` + + // ============================================================================ + // Helper Functions + // ============================================================================ + + // Check if a bundle is valid according to standard pack + let isValidStandardBundle = (bundle: bundle): bool => { + let result = validateBundle(bundle, standardPack) + result.success + } + + // Get all validation errors from a bundle + let getErrors = (bundle: bundle, packSpec: string): array => { + let result = validateBundle(bundle, packSpec) + result.errors->Belt.Array.map(e => e.message) + } + + // Get all warnings from a bundle + let getWarnings = (bundle: bundle, packSpec: string): array => { + let result = validateBundle(bundle, packSpec) + result.warnings->Belt.Array.map(w => w.message) + } + + // Create a bundle from a list of file paths and contents + let bundleFromFiles = (files: array<(string, string, string)>): bundle => { + files->Belt.Array.reduce(createBundle(), (bundle, (path, content, docType)) => { + let doc = createDocument(content, path, docType) + addDocument(bundle, doc) + }) + } + + // Run all enforcement checks + let runEnforcementChecks = (bundle: bundle): validationResult => { + let checkScript = ` + ${checkSpdxHeaders} + ${checkBannedLanguages} + ${checkReadmeStructure} + ` + evalBundle(checkScript, bundle) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/SecurityScheme.affine b/migration/affinescript/recon-silly-ation/src/SecurityScheme.affine new file mode 100644 index 00000000..0ce003aa --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/SecurityScheme.affine @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/SecurityScheme.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 4 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 254: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError( +// - [untyped-exception] line 353: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError("Ed448 WASM binding not yet implemented") +// - [untyped-exception] line 355: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError("SPHINCS+ WASM binding not yet implemented") +// - [untyped-exception] line 357: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError("Unsupported hybrid signature combination") + +module SecurityScheme; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SecurityScheme - Post-quantum cryptographic security scheme + // Defines algorithms, key types, and WASM bindings for the + // recon-silly-ation document reconciliation system. + // + // Algorithm selections follow NIST PQC standards (FIPS 203/204/205) + // with classical fallbacks for transition period. + + // ============================================================================= + // Hash Algorithm Types + // ============================================================================= + + // Hash algorithms for content integrity and password storage. + // SHAKE3-512 is the general-purpose default (NIST SP 800-185). + // BLAKE3 is optimised for database content-addressable storage. + // Argon2id is the mandatory choice for password/secret hashing. + type hashAlgorithm = + | SHAKE3_512 // General-purpose (Keccak-based, 512-bit output) + | BLAKE3 // Database content hashing (parallelisable, 256-bit) + | Argon2id // Password hashing (memory-hard, side-channel resistant) + + // ============================================================================= + // Signature Algorithm Types + // ============================================================================= + + // Digital signature algorithms for document signing and verification. + // Dilithium5-AES is the primary post-quantum choice (FIPS 204 / ML-DSA-87). + // Ed448 provides classical fallback (RFC 8032, 448-bit Edwards curve). + // Hybrid combines PQ + classical for defence-in-depth. + // SPHINCS+ is the stateless hash-based backup (FIPS 205 / SLH-DSA). + // `type rec` required because Hybrid recurses on signatureAlgorithm. + type rec signatureAlgorithm = + | Dilithium5_AES // ML-DSA-87 (FIPS 204), lattice-based, AES variant + | Ed448 // Classical fallback, 224-bit security level + | Hybrid(signatureAlgorithm, signatureAlgorithm) // Combined PQ + classical + | SPHINCS_Plus // SLH-DSA (FIPS 205), stateless hash-based backup + + // ============================================================================= + // Key Exchange Algorithm Types + // ============================================================================= + + // Key encapsulation mechanisms for establishing shared secrets. + // Kyber1024 is the sole choice (FIPS 203 / ML-KEM-1024, NIST Level 5). + type keyExchangeAlgorithm = + | Kyber1024 // ML-KEM-1024 (FIPS 203), lattice-based, 256-bit shared secret + + // ============================================================================= + // Symmetric Encryption Algorithm Types + // ============================================================================= + + // Symmetric authenticated encryption for document content at rest. + // XChaCha20-Poly1305 with 256-bit keys: extended nonce (192-bit), + // immune to timing attacks, no AES-NI requirement. + type symmetricAlgorithm = + | XChaCha20Poly1305_256 // 256-bit key, 192-bit nonce, AEAD + + // ============================================================================= + // Key Derivation Function Types + // ============================================================================= + + // Key derivation functions for expanding keying material. + // HKDF with SHAKE-512 provides domain separation and key expansion. + type kdfAlgorithm = + | HKDF_SHAKE512 // HKDF (RFC 5869) with SHAKE-512 as underlying PRF + + // ============================================================================= + // Random Number Generator Types + // ============================================================================= + + // Deterministic random bit generators for key generation. + // ChaCha20-DRBG provides backtracking resistance and fast performance. + type rngAlgorithm = + | ChaCha20_DRBG // ChaCha20-based DRBG, 256-bit seed + + // ============================================================================= + // Transport Protocol Types + // ============================================================================= + + // Network transport protocols for inter-component communication. + // IPv6-only eliminates NAT traversal overhead and dual-stack complexity. + // QUIC provides multiplexed, encrypted transport (RFC 9000). + // HTTP/3 runs over QUIC for application-layer interchange. + type transportProtocol = + | IPv6Only // Mandatory IPv6, no IPv4 fallback + | QUIC // RFC 9000, UDP-based multiplexed transport + | HTTP3 // RFC 9114, HTTP over QUIC + + // ============================================================================= + // Accessibility (WCAG) Compliance Report + // ============================================================================= + + // Report structure for WCAG 2.2 AAA compliance of generated documents. + // Every reconciled document must pass accessibility validation. + type accessibilityReport = { + level: string, // Target level: "AAA" (always AAA) + score: float, // Compliance score 0.0 - 1.0 + issues: array, // List of WCAG violations found + } + + // ============================================================================= + // Security Context + // ============================================================================= + + // Aggregate record holding the active algorithm suite for a session. + // Passed through the reconciliation pipeline so every stage uses + // consistent cryptographic primitives. + type securityContext = { + hashAlgo: hashAlgorithm, + signatureAlgo: signatureAlgorithm, + keyExchangeAlgo: keyExchangeAlgorithm, + symmetricAlgo: symmetricAlgorithm, + kdfAlgo: kdfAlgorithm, + rngAlgo: rngAlgorithm, + transport: transportProtocol, + } + + // Return the strongest-defaults security context. + // Uses Hybrid(Dilithium5_AES, Ed448) for defence-in-depth signatures. + let defaultSecurityContext = (): securityContext => { + hashAlgo: SHAKE3_512, + signatureAlgo: Hybrid(Dilithium5_AES, Ed448), + keyExchangeAlgo: Kyber1024, + symmetricAlgo: XChaCha20Poly1305_256, + kdfAlgo: HKDF_SHAKE512, + rngAlgo: ChaCha20_DRBG, + transport: HTTP3, + } + + // ============================================================================= + // Signed Document + // ============================================================================= + + // A document with a cryptographic signature attached. + // Used for tamper-evident reconciliation artefacts. + type signedDocument = { + content: string, // Raw document content + signature: string, // Hex-encoded signature bytes + signerPublicKey: string, // Hex-encoded public key of signer + algorithm: signatureAlgorithm, // Algorithm used to produce the signature + timestamp: float, // Unix timestamp of signing + } + + // ============================================================================= + // Key Pair + // ============================================================================= + + // Asymmetric key pair for signature or key-exchange operations. + // Keys are represented as hex-encoded strings. + type keyPair = { + publicKey: string, // Hex-encoded public key + privateKey: string, // Hex-encoded private key (MUST be protected) + algorithm: signatureAlgorithm, // Algorithm this key pair is valid for + } + + // ============================================================================= + // WASM External Bindings + // ============================================================================= + // These map to functions exported by the Rust WASM module + // (wasm-modules/src/security.rs). The WASM module must be + // initialised before calling any of these. + + @module("../wasm-modules/pkg/recon_wasm") @val + external shake3_512_hash: string => string = "shake3_512_hash" + + @module("../wasm-modules/pkg/recon_wasm") @val + external blake3_hash: string => string = "blake3_hash" + + @module("../wasm-modules/pkg/recon_wasm") @val + external dilithium5_sign: (string, string) => string = "dilithium5_sign" + + @module("../wasm-modules/pkg/recon_wasm") @val + external dilithium5_verify: (string, string, string) => bool = "dilithium5_verify" + + @module("../wasm-modules/pkg/recon_wasm") @val + external kyber1024_keygen: unit => Js.Json.t = "kyber1024_keygen" + + // ============================================================================= + // Algorithm-to-String Conversions + // ============================================================================= + + // Convert a hashAlgorithm variant to its canonical string representation. + let hashAlgorithmToString = (algo: hashAlgorithm): string => { + switch algo { + | SHAKE3_512 => "SHAKE3-512" + | BLAKE3 => "BLAKE3" + | Argon2id => "Argon2id" + } + } + + // Convert a signatureAlgorithm variant to its canonical string representation. + // Hybrid algorithms are rendered as "Hybrid(inner + inner)". + let rec signatureAlgorithmToString = (algo: signatureAlgorithm): string => { + switch algo { + | Dilithium5_AES => "ML-DSA-87 (Dilithium5-AES)" + | Ed448 => "Ed448" + | Hybrid(primary, secondary) => + `Hybrid(${signatureAlgorithmToString(primary)} + ${signatureAlgorithmToString(secondary)})` + | SPHINCS_Plus => "SLH-DSA (SPHINCS+)" + } + } + + // Convert a keyExchangeAlgorithm variant to its canonical string representation. + let keyExchangeAlgorithmToString = (algo: keyExchangeAlgorithm): string => { + switch algo { + | Kyber1024 => "ML-KEM-1024 (Kyber1024)" + } + } + + // Convert a symmetricAlgorithm variant to its canonical string representation. + let symmetricAlgorithmToString = (algo: symmetricAlgorithm): string => { + switch algo { + | XChaCha20Poly1305_256 => "XChaCha20-Poly1305-256" + } + } + + // Convert a kdfAlgorithm variant to its canonical string representation. + let kdfAlgorithmToString = (algo: kdfAlgorithm): string => { + switch algo { + | HKDF_SHAKE512 => "HKDF-SHAKE512" + } + } + + // Convert an rngAlgorithm variant to its canonical string representation. + let rngAlgorithmToString = (algo: rngAlgorithm): string => { + switch algo { + | ChaCha20_DRBG => "ChaCha20-DRBG" + } + } + + // Convert a transportProtocol variant to its canonical string representation. + let transportProtocolToString = (proto: transportProtocol): string => { + switch proto { + | IPv6Only => "IPv6-Only" + | QUIC => "QUIC (RFC 9000)" + | HTTP3 => "HTTP/3 (RFC 9114)" + } + } + + // ============================================================================= + // Hash Dispatch Helper + // ============================================================================= + + // Hash a string using the specified algorithm. + // Routes to the appropriate WASM binding. + // NOTE: Argon2id requires a salt and is not supported here; + // use the dedicated WASM function argon2id_hash(input, salt) instead. + let hashWithAlgorithm = (algo: hashAlgorithm, input: string): string => { + switch algo { + | SHAKE3_512 => shake3_512_hash(input) + | BLAKE3 => blake3_hash(input) + | Argon2id => + // Argon2id requires a salt parameter - callers must use the + // WASM binding argon2id_hash(input, salt) directly. + Js.Exn.raiseError( + "Argon2id requires a salt parameter. Use the WASM binding argon2id_hash(input, salt) directly.", + ) + } + } + + // ============================================================================= + // User-Friendly Hash Name (Placeholder) + // ============================================================================= + + // Convert a hex-encoded hash digest to a human-readable name. + // Concept: map pairs of hex bytes to words from a curated wordlist, + // then encode in Base32 for display. This makes hashes recognisable + // in reconciliation reports without needing to compare raw hex. + // + // Current implementation is a placeholder that returns a truncated + // Base32-style representation. The real implementation will live in + // the WASM module (user_friendly_hash_name) and use a proper wordlist + // derived from the EFF large wordlist. + let userFriendlyHashName = (hexHash: string): string => { + // Placeholder: take the first 10 hex characters and prefix with "doc-" + // to give a short, somewhat-recognisable identifier. + // Real implementation: WASM-side Base32 encoding -> wordlist mapping. + let prefix = Js.String2.slice(hexHash, ~from=0, ~to_=10) + `doc-${prefix}` + } + + // ============================================================================= + // Security Context Serialisation + // ============================================================================= + + // Serialise a securityContext to JSON for transport/storage. + let securityContextToJson = (ctx: securityContext): Js.Json.t => { + Js.Json.object_( + Js.Dict.fromArray([ + ("hashAlgo", Js.Json.string(hashAlgorithmToString(ctx.hashAlgo))), + ("signatureAlgo", Js.Json.string(signatureAlgorithmToString(ctx.signatureAlgo))), + ("keyExchangeAlgo", Js.Json.string(keyExchangeAlgorithmToString(ctx.keyExchangeAlgo))), + ("symmetricAlgo", Js.Json.string(symmetricAlgorithmToString(ctx.symmetricAlgo))), + ("kdfAlgo", Js.Json.string(kdfAlgorithmToString(ctx.kdfAlgo))), + ("rngAlgo", Js.Json.string(rngAlgorithmToString(ctx.rngAlgo))), + ("transport", Js.Json.string(transportProtocolToString(ctx.transport))), + ]), + ) + } + + // Serialise a signedDocument to JSON for storage or transmission. + let signedDocumentToJson = (doc: signedDocument): Js.Json.t => { + Js.Json.object_( + Js.Dict.fromArray([ + ("content", Js.Json.string(doc.content)), + ("signature", Js.Json.string(doc.signature)), + ("signerPublicKey", Js.Json.string(doc.signerPublicKey)), + ("algorithm", Js.Json.string(signatureAlgorithmToString(doc.algorithm))), + ("timestamp", Js.Json.number(doc.timestamp)), + ]), + ) + } + + // Serialise an accessibilityReport to JSON. + let accessibilityReportToJson = (report: accessibilityReport): Js.Json.t => { + Js.Json.object_( + Js.Dict.fromArray([ + ("level", Js.Json.string(report.level)), + ("score", Js.Json.number(report.score)), + ( + "issues", + Js.Json.array(report.issues->Belt.Array.map(Js.Json.string)), + ), + ]), + ) + } + + // ============================================================================= + // Factory Helpers + // ============================================================================= + + // Create an empty accessibility report at AAA level. + let emptyAccessibilityReport = (): accessibilityReport => { + level: "AAA", + score: 1.0, + issues: [], + } + + // Create a signed document (delegates actual signing to WASM). + let createSignedDocument = ( + content: string, + privateKey: string, + publicKey: string, + algo: signatureAlgorithm, + ): signedDocument => { + let signature = switch algo { + | Dilithium5_AES => dilithium5_sign(content, privateKey) + | Hybrid(Dilithium5_AES, _secondary) => + // In hybrid mode, produce the primary (PQ) signature. + // A full implementation would concatenate both signatures. + dilithium5_sign(content, privateKey) + | Ed448 => + // Ed448 signing would call a separate WASM binding (not yet wired). + Js.Exn.raiseError("Ed448 WASM binding not yet implemented") + | SPHINCS_Plus => + Js.Exn.raiseError("SPHINCS+ WASM binding not yet implemented") + | Hybrid(_, _) => + Js.Exn.raiseError("Unsupported hybrid signature combination") + } + + { + content, + signature, + signerPublicKey: publicKey, + algorithm: algo, + timestamp: Js.Date.now(), + } + } + + // Verify a signed document (delegates actual verification to WASM). + let verifySignedDocument = (doc: signedDocument): bool => { + switch doc.algorithm { + | Dilithium5_AES => dilithium5_verify(doc.content, doc.signature, doc.signerPublicKey) + | Hybrid(Dilithium5_AES, _secondary) => + // Verify primary (PQ) signature. Full implementation would + // verify both and require both to pass. + dilithium5_verify(doc.content, doc.signature, doc.signerPublicKey) + | Ed448 => false // Not yet implemented + | SPHINCS_Plus => false // Not yet implemented + | Hybrid(_, _) => false // Unsupported combination + } + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/src/Types.affine b/migration/affinescript/recon-silly-ation/src/Types.affine new file mode 100644 index 00000000..4f6f7a9a --- /dev/null +++ b/migration/affinescript/recon-silly-ation/src/Types.affine @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/src/Types.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Types; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Core domain types for documentation reconciliation system + // Content-addressable storage with graph-based conflict resolution + + // Content hash for deduplication + type contentHash = string + + // Document types we reconcile + type documentType = + | README + | LICENSE + | SECURITY + | CONTRIBUTING + | CODE_OF_CONDUCT + | FUNDING + | CITATION + | CHANGELOG + | AUTHORS + | SUPPORT + | Custom(string) + + let documentTypeToString = (dt: documentType): string => { + switch dt { + | README => "README" + | LICENSE => "LICENSE" + | SECURITY => "SECURITY" + | CONTRIBUTING => "CONTRIBUTING" + | CODE_OF_CONDUCT => "CODE_OF_CONDUCT" + | FUNDING => "FUNDING" + | CITATION => "CITATION" + | CHANGELOG => "CHANGELOG" + | AUTHORS => "AUTHORS" + | SUPPORT => "SUPPORT" + | Custom(name) => name + } + } + + let documentTypeFromString = (s: string): documentType => { + switch s { + | "README" => README + | "LICENSE" => LICENSE + | "SECURITY" => SECURITY + | "CONTRIBUTING" => CONTRIBUTING + | "CODE_OF_CONDUCT" => CODE_OF_CONDUCT + | "FUNDING" => FUNDING + | "CITATION" => CITATION + | "CHANGELOG" => CHANGELOG + | "AUTHORS" => AUTHORS + | "SUPPORT" => SUPPORT + | custom => Custom(custom) + } + } + + // Version representation + type version = { + major: int, + minor: int, + patch: int, + } + + let versionToString = (v: version): string => { + `${v.major->Belt.Int.toString}.${v.minor->Belt.Int.toString}.${v.patch->Belt.Int.toString}` + } + + let compareVersions = (v1: version, v2: version): int => { + if v1.major != v2.major { + v1.major - v2.major + } else if v1.minor != v2.minor { + v1.minor - v2.minor + } else { + v1.patch - v2.patch + } + } + + // Source of truth for canonical resolution + type canonicalSource = + | LicenseFile // LICENSE file is canonical for license info + | FundingYaml // FUNDING.yml is canonical for funding info + | SecurityMd // SECURITY.md is canonical for security policy + | CitationCff // CITATION.cff is canonical for citations + | PackageJson // package.json for Node.js projects + | CargoToml // Cargo.toml for Rust projects + | Explicit(string) // Explicitly marked canonical + | Inferred // Inferred from context + + // Document metadata + type documentMetadata = { + path: string, + documentType: documentType, + lastModified: float, // Unix timestamp + version: option, + canonicalSource: canonicalSource, + repository: string, + branch: string, + } + + // Document with content and metadata + type document = { + hash: contentHash, + content: string, + metadata: documentMetadata, + createdAt: float, + } + + // Conflict type + type conflictType = + | DuplicateContent // Same content, different locations + | VersionMismatch // Different versions of same doc + | CanonicalConflict // Multiple canonical sources claim authority + | StructuralConflict // Structural differences in format + | SemanticConflict // Semantic differences in content + + // Confidence score (0.0 to 1.0) + type confidence = float + + // Resolution strategy + type resolutionStrategy = + | KeepLatest // Keep most recent version + | KeepCanonical // Keep canonical source + | KeepHighestVersion // Keep highest version number + | Merge // Attempt to merge + | RequireManual // Escalate to human + + let resolutionStrategyToString = (rs: resolutionStrategy): string => { + switch rs { + | KeepLatest => "keep_latest" + | KeepCanonical => "keep_canonical" + | KeepHighestVersion => "keep_highest_version" + | Merge => "merge" + | RequireManual => "require_manual" + } + } + + // Conflict between documents + type conflict = { + id: string, + conflictType: conflictType, + documents: array, + detectedAt: float, + confidence: confidence, + suggestedStrategy: resolutionStrategy, + } + + // Resolution result + type resolutionResult = { + conflictId: string, + strategy: resolutionStrategy, + selectedDocument: option, + confidence: confidence, + requiresApproval: bool, + reasoning: string, + timestamp: float, + } + + // Pipeline stage + type pipelineStage = + | Scan // Scan repositories for documents + | Normalize // Normalize document formats + | Deduplicate // Remove duplicates via content hashing + | DetectConflicts // Detect conflicts between documents + | ResolveConflicts // Resolve conflicts automatically + | Ingest // Ingest into ArangoDB + | Report // Generate reconciliation report + + let pipelineStageToString = (stage: pipelineStage): string => { + switch stage { + | Scan => "scan" + | Normalize => "normalize" + | Deduplicate => "deduplicate" + | DetectConflicts => "detect_conflicts" + | ResolveConflicts => "resolve_conflicts" + | Ingest => "ingest" + | Report => "report" + } + } + + // Pipeline state + type pipelineState = { + stage: pipelineStage, + documents: array, + conflicts: array, + resolutions: array, + errors: array, + startedAt: float, + completedAt: option, + } + + // Graph edge types for ArangoDB + type edgeType = + | ConflictsWith // Document conflicts with another + | SupersededBy // Document is superseded by another + | DuplicateOf // Document is duplicate of another + | CanonicalFor // Document is canonical for a type + | DerivedFrom // Document derived from another + + let edgeTypeToString = (et: edgeType): string => { + switch et { + | ConflictsWith => "conflicts_with" + | SupersededBy => "superseded_by" + | DuplicateOf => "duplicate_of" + | CanonicalFor => "canonical_for" + | DerivedFrom => "derived_from" + } + } + + // Graph edge + type edge = { + from: string, // Document hash + to: string, // Document hash + edgeType: edgeType, + confidence: confidence, + metadata: Js.Json.t, + } + + // Configuration + type config = { + arangoUrl: string, + arangoDatabase: string, + arangoUsername: string, + arangoPassword: string, + autoResolveThreshold: float, // Auto-resolve if confidence > this + repositoryPaths: array, + scanInterval: option, // Seconds, None = run once + } + + // LLM integration types (Phase 2) + type llmProvider = + | Anthropic(string) // API key + | OpenAI(string) // API key + | Local(string) // Local model path + + type llmPromptType = + | GenerateSecurityMd + | GenerateContributing + | GenerateSupport + | SuggestConflictResolution + | ImproveDocumentation + + type llmResponse = { + content: string, + confidence: confidence, + requiresApproval: bool, // Always true for LLM output + reasoning: string, + model: string, + } + + // CCCP compliance types + type cccpViolation = { + file: string, + violationType: string, + severity: string, // "warning" | "error" + message: string, + suggestedFix: option, + } + + // Export all types + module Export = { + type t_contentHash = contentHash + type t_documentType = documentType + type t_version = version + type t_canonicalSource = canonicalSource + type t_documentMetadata = documentMetadata + type t_document = document + type t_conflictType = conflictType + type t_confidence = confidence + type t_resolutionStrategy = resolutionStrategy + type t_conflict = conflict + type t_resolutionResult = resolutionResult + type t_pipelineStage = pipelineStage + type t_pipelineState = pipelineState + type t_edgeType = edgeType + type t_edge = edge + type t_config = config + type t_llmProvider = llmProvider + type t_llmPromptType = llmPromptType + type t_llmResponse = llmResponse + type t_cccpViolation = cccpViolation + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/ArangoClientTest.affine b/migration/affinescript/recon-silly-ation/tests/ArangoClientTest.affine new file mode 100644 index 00000000..4a673c9a --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/ArangoClientTest.affine @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/ArangoClientTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 15: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 29: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 35: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module ArangoClientTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // ArangoClientTest - Unit tests for document and edge serialization + // Tests: documentToJson shape, edgeToJson shape, field presence + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture helpers + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + ~version: option=None, + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version, + canonicalSource: Inferred, + repository: "test/repo", + branch: "main", + } + + let sampleDoc = (): document => { + Deduplicator.createDocument( + "# Sample README\n\nHello World", + makeMetadata(~path="README.md", ()), + ) + } + + let sampleVersionedDoc = (): document => { + Deduplicator.createDocument( + "# Versioned\n\nContent", + makeMetadata( + ~path="CHANGELOG.md", + ~docType=CHANGELOG, + ~version=Some({major: 2, minor: 1, patch: 0}), + (), + ), + ) + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- ArangoClientTest ---") + + // 1. documentToJson produces JSON object + test("documentToJson produces valid JSON", () => { + let doc = sampleDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.length(str) > 0, "JSON string must be non-empty") + }) + + // 2. documentToJson contains _key + test("documentToJson contains _key field", () => { + let doc = sampleDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "_key"), "must contain _key") + }) + + // 3. documentToJson contains hash + test("documentToJson contains hash field", () => { + let doc = sampleDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "\"hash\""), "must contain hash") + }) + + // 4. documentToJson contains path + test("documentToJson contains path field", () => { + let doc = sampleDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "README.md"), "must contain path value") + }) + + // 5. documentToJson contains documentType + test("documentToJson contains documentType field", () => { + let doc = sampleDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "README"), "must contain documentType value") + }) + + // 6. documentToJson version is null for None + test("documentToJson version is null when None", () => { + let doc = sampleDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "null"), "version should be null") + }) + + // 7. documentToJson version is string when Some + test("documentToJson version is string when present", () => { + let doc = sampleVersionedDoc() + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "2.1.0"), "must contain version string") + }) + + // 8. edgeToJson produces valid JSON + test("edgeToJson produces valid JSON", () => { + let edge: edge = { + from: "hash_a", + to: "hash_b", + edgeType: DuplicateOf, + confidence: 1.0, + metadata: Js.Json.object_(Js.Dict.empty()), + } + let json = ArangoClient.edgeToJson(edge) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.length(str) > 0, "edge JSON must be non-empty") + }) + + // 9. edgeToJson contains _from with documents/ prefix + test("edgeToJson _from has documents/ prefix", () => { + let edge: edge = { + from: "hash_a", + to: "hash_b", + edgeType: DuplicateOf, + confidence: 1.0, + metadata: Js.Json.object_(Js.Dict.empty()), + } + let json = ArangoClient.edgeToJson(edge) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "documents/hash_a"), "_from must have documents/ prefix") + }) + + // 10. edgeToJson contains type as string + test("edgeToJson contains edge type string", () => { + let edge: edge = { + from: "h1", + to: "h2", + edgeType: SupersededBy, + confidence: 0.95, + metadata: Js.Json.object_(Js.Dict.empty()), + } + let json = ArangoClient.edgeToJson(edge) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "superseded_by"), "must contain edge type string") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/Benchmarks.affine b/migration/affinescript/recon-silly-ation/tests/Benchmarks.affine new file mode 100644 index 00000000..cc863694 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/Benchmarks.affine @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/Benchmarks.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Benchmarks; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Benchmarks - Performance benchmark harness for recon-silly-ation + // Measures: hash throughput, normalisation throughput, batch dedup, + // conflict detection, graph generation, version comparison, logic inference + // Outputs: markdown table with timing results + + open Types + + // --------------------------------------------------------------------------- + // Benchmark harness + // --------------------------------------------------------------------------- + + type benchmarkResult = { + name: string, + iterations: int, + totalMs: float, + avgMs: float, + opsPerSec: float, + } + + let runBenchmark = (name: string, iterations: int, fn: unit => unit): benchmarkResult => { + // Warm-up phase + for _ in 1 to 10 { + fn() + } + + let start = Js.Date.now() + for _ in 1 to iterations { + fn() + } + let elapsed = Js.Date.now() -. start + + let avgMs = elapsed /. Belt.Int.toFloat(iterations) + let opsPerSec = if elapsed > 0.0 { + Belt.Int.toFloat(iterations) /. (elapsed /. 1000.0) + } else { + 0.0 + } + + {name, iterations, totalMs: elapsed, avgMs, opsPerSec} + } + + let formatResult = (r: benchmarkResult): string => { + let totalStr = r.totalMs->Js.Float.toFixedWithPrecision(~digits=2) + let avgStr = r.avgMs->Js.Float.toFixedWithPrecision(~digits=4) + let opsStr = r.opsPerSec->Js.Math.round->Belt.Float.toString + `| ${r.name} | ${r.iterations->Belt.Int.toString} | ${totalStr} | ${avgStr} | ${opsStr} |` + } + + // --------------------------------------------------------------------------- + // Test data generators + // --------------------------------------------------------------------------- + + let generateContent = (size: int): string => { + let chunk = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + let chunkLen = Js.String2.length(chunk) + let repeats = size / chunkLen + 1 + let buf = ref("") + for _ in 1 to repeats { + buf := buf.contents ++ chunk + } + Js.String2.slice(buf.contents, ~from=0, ~to_=size) + } + + let makeMetadata = (~path: string, ()): documentMetadata => { + path, + documentType: README, + lastModified: 1000.0, + version: None, + canonicalSource: Inferred, + repository: "bench/repo", + branch: "main", + } + + let generateDocs = (count: int): array => { + Belt.Array.makeBy(count, i => { + let content = if mod(i, 10) == 0 { + "duplicate content body for benchmarking" + } else { + `unique document content number ${i->Belt.Int.toString} for benchmark suite` + } + Deduplicator.createDocument(content, makeMetadata(~path=`bench-${i->Belt.Int.toString}.md`, ())) + }) + } + + // --------------------------------------------------------------------------- + // Benchmark suite + // --------------------------------------------------------------------------- + + let run = (): unit => { + Js.Console.log("\n=== Performance Benchmarks ===\n") + Js.Console.log("Running benchmarks (this may take a moment)...\n") + Js.Console.log("| Benchmark | Iterations | Total (ms) | Avg (ms) | Ops/sec |") + Js.Console.log("|-----------|-----------|------------|----------|---------|") + + let results = [] + + // 1. SHA-256 hashing - small content (100 bytes) + let smallContent = generateContent(100) + let r1 = runBenchmark("hash-100B", 10000, () => { + let _ = Deduplicator.hashContent(smallContent) + }) + results->Js.Array2.push(r1)->ignore + Js.Console.log(formatResult(r1)) + + // 2. SHA-256 hashing - 10KB content + let content10k = generateContent(10240) + let r2 = runBenchmark("hash-10KB", 1000, () => { + let _ = Deduplicator.hashContent(content10k) + }) + results->Js.Array2.push(r2)->ignore + Js.Console.log(formatResult(r2)) + + // 3. SHA-256 hashing - 100KB content + let content100k = generateContent(102400) + let r3 = runBenchmark("hash-100KB", 100, () => { + let _ = Deduplicator.hashContent(content100k) + }) + results->Js.Array2.push(r3)->ignore + Js.Console.log(formatResult(r3)) + + // 4. Normalisation - small dirty content + let dirtySmall = "line1\r\n line2 \r\n\r\n\r\n\r\nline3\t \nend" + let r4 = runBenchmark("normalise-small", 10000, () => { + let _ = Deduplicator.normalizeContent(dirtySmall) + }) + results->Js.Array2.push(r4)->ignore + Js.Console.log(formatResult(r4)) + + // 5. Normalisation - 10KB dirty content + let dirty10k = generateContent(5000) ++ "\r\n" ++ generateContent(5000) + let r5 = runBenchmark("normalise-10KB", 1000, () => { + let _ = Deduplicator.normalizeContent(dirty10k) + }) + results->Js.Array2.push(r5)->ignore + Js.Console.log(formatResult(r5)) + + // 6. Document creation + let r6 = runBenchmark("create-document", 5000, () => { + let _ = Deduplicator.createDocument(smallContent, makeMetadata(~path="bench.md", ())) + }) + results->Js.Array2.push(r6)->ignore + Js.Console.log(formatResult(r6)) + + // 7. Batch dedup - 10 docs + let docs10 = generateDocs(10) + let r7 = runBenchmark("dedup-10-docs", 1000, () => { + let _ = Deduplicator.deduplicate(docs10) + }) + results->Js.Array2.push(r7)->ignore + Js.Console.log(formatResult(r7)) + + // 8. Batch dedup - 100 docs + let docs100 = generateDocs(100) + let r8 = runBenchmark("dedup-100-docs", 100, () => { + let _ = Deduplicator.deduplicate(docs100) + }) + results->Js.Array2.push(r8)->ignore + Js.Console.log(formatResult(r8)) + + // 9. Batch dedup - 1000 docs + let docs1000 = generateDocs(1000) + let r9 = runBenchmark("dedup-1000-docs", 10, () => { + let _ = Deduplicator.deduplicate(docs1000) + }) + results->Js.Array2.push(r9)->ignore + Js.Console.log(formatResult(r9)) + + // 10. Conflict detection - 100 docs + let r10 = runBenchmark("conflicts-100", 100, () => { + let _ = ConflictResolver.detectConflicts(docs100) + }) + results->Js.Array2.push(r10)->ignore + Js.Console.log(formatResult(r10)) + + // 11. Conflict resolution - batch + let conflicts = ConflictResolver.detectConflicts(docs100) + let r11 = runBenchmark("resolve-batch", 100, () => { + let _ = ConflictResolver.resolveConflicts(conflicts, 0.9) + }) + results->Js.Array2.push(r11)->ignore + Js.Console.log(formatResult(r11)) + + // 12. Graph DOT generation - 20 nodes + let docs20 = generateDocs(20) + let emptyEdges: array = [] + let r12 = runBenchmark("dot-20-nodes", 1000, () => { + let _ = GraphVisualizer.generateDot(docs20, emptyEdges, GraphVisualizer.defaultConfig) + }) + results->Js.Array2.push(r12)->ignore + Js.Console.log(formatResult(r12)) + + // 13. Version comparison + let v1: version = {major: 1, minor: 0, patch: 0} + let v2: version = {major: 2, minor: 5, patch: 3} + let r13 = runBenchmark("version-compare", 100000, () => { + let _ = compareVersions(v1, v2) + }) + results->Js.Array2.push(r13)->ignore + Js.Console.log(formatResult(r13)) + + // 14. Logic engine inference - 10 docs + let r14 = runBenchmark("logic-infer-10", 100, () => { + let _ = LogicEngine.inferRelationships(docs10) + }) + results->Js.Array2.push(r14)->ignore + Js.Console.log(formatResult(r14)) + + // 15. Mermaid generation - 20 nodes + let r15 = runBenchmark("mermaid-20-nodes", 1000, () => { + let _ = GraphVisualizer.generateMermaid(docs20, emptyEdges) + }) + results->Js.Array2.push(r15)->ignore + Js.Console.log(formatResult(r15)) + + // 16. ArangoDB document serialization + let sampleDoc = Belt.Array.getUnsafe(docs10, 0) + let r16 = runBenchmark("arango-doc-json", 10000, () => { + let _ = ArangoClient.documentToJson(sampleDoc) + }) + results->Js.Array2.push(r16)->ignore + Js.Console.log(formatResult(r16)) + + Js.Console.log("") + Js.Console.log(`Total benchmarks: ${Belt.Array.length(results)->Belt.Int.toString}`) + Js.Console.log("Note: WASM benchmarks require wasm-pack build. Run:") + Js.Console.log(" cd wasm-modules && cargo build --release --target wasm32-unknown-unknown") + Js.Console.log(" Then compare WASM hash/normalize against JS results above.") + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/CCCPComplianceTest.affine b/migration/affinescript/recon-silly-ation/tests/CCCPComplianceTest.affine new file mode 100644 index 00000000..42e1d1da --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/CCCPComplianceTest.affine @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/CCCPComplianceTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 18: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 32: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 38: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module CCCPComplianceTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // CCCPComplianceTest - Unit tests for CCCP Python compliance checker + // Tests: isPythonFile, detectPythonImports, checkPythonAntiPatterns, + // generateMigrationSuggestion, report generation + + // Note: open Types not needed for CCCPCompliance standalone tests, + // but the module internally uses it + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- CCCPComplianceTest ---") + + // 1. isPythonFile .py + test("isPythonFile detects .py files", () => { + assertTrue(CCCPCompliance.isPythonFile("script.py"), ".py should be detected") + }) + + // 2. isPythonFile .pyw + test("isPythonFile detects .pyw files", () => { + assertTrue(CCCPCompliance.isPythonFile("gui_app.pyw"), ".pyw should be detected") + }) + + // 3. isPythonFile setup.py + test("isPythonFile detects setup.py", () => { + assertTrue(CCCPCompliance.isPythonFile("setup.py"), "setup.py should be detected") + }) + + // 4. isPythonFile rejects .js + test("isPythonFile rejects .js files", () => { + assertTrue(!CCCPCompliance.isPythonFile("app.js"), ".js should not be Python") + }) + + // 5. isPythonFile rejects .res + test("isPythonFile rejects .res files", () => { + assertTrue(!CCCPCompliance.isPythonFile("Types.res"), ".res should not be Python") + }) + + // 6. detectPythonImports finds import statements + test("detectPythonImports finds import statements", () => { + let content = "import os\nimport sys\nfrom pathlib import Path\nx = 1" + let imports = CCCPCompliance.detectPythonImports(content) + assertEqual(Belt.Array.length(imports), 3, "should find 3 imports") + }) + + // 7. detectPythonImports returns empty for no imports + test("detectPythonImports empty for no imports", () => { + let content = "x = 1\ny = 2\nprint(x + y)" + let imports = CCCPCompliance.detectPythonImports(content) + assertEqual(Belt.Array.length(imports), 0, "no imports expected") + }) + + // 8. checkPythonAntiPatterns detects eval + test("checkPythonAntiPatterns detects eval()", () => { + let content = "result = eval(user_input)" + let patterns = CCCPCompliance.checkPythonAntiPatterns(content) + assertTrue( + patterns->Belt.Array.some(p => Js.String2.includes(p, "eval")), + "should detect eval()", + ) + }) + + // 9. checkPythonAntiPatterns detects exec + test("checkPythonAntiPatterns detects exec()", () => { + let content = "exec(code_string)" + let patterns = CCCPCompliance.checkPythonAntiPatterns(content) + assertTrue( + patterns->Belt.Array.some(p => Js.String2.includes(p, "exec")), + "should detect exec()", + ) + }) + + // 10. checkPythonAntiPatterns detects pickle + test("checkPythonAntiPatterns detects pickle", () => { + let content = "import pickle\ndata = pickle.loads(raw)" + let patterns = CCCPCompliance.checkPythonAntiPatterns(content) + assertTrue( + patterns->Belt.Array.some(p => Js.String2.includes(p, "pickle")), + "should detect pickle", + ) + }) + + // 11. checkPythonAntiPatterns clean code has no warnings + test("checkPythonAntiPatterns clean code returns empty", () => { + let content = "def add(a, b):\n return a + b" + let patterns = CCCPCompliance.checkPythonAntiPatterns(content) + assertEqual(Belt.Array.length(patterns), 0, "clean code should have no anti-patterns") + }) + + // 12. generateMigrationSuggestion default suggestion + test("generateMigrationSuggestion gives default for no imports", () => { + let suggestion = CCCPCompliance.generateMigrationSuggestion([]) + assertTrue( + Js.String2.includes(suggestion, "ReScript"), + "default suggestion should mention ReScript", + ) + }) + + // 13. generateMigrationSuggestion web framework + test("generateMigrationSuggestion mentions web for flask imports", () => { + let suggestion = CCCPCompliance.generateMigrationSuggestion(["import flask"]) + assertTrue( + Js.String2.includes(suggestion, "web") || Js.String2.includes(suggestion, "Melange"), + "should suggest web migration", + ) + }) + + // 14. generateMigrationSuggestion data science + test("generateMigrationSuggestion mentions Julia for numpy imports", () => { + let suggestion = CCCPCompliance.generateMigrationSuggestion(["import numpy"]) + assertTrue( + Js.String2.includes(suggestion, "Julia") || Js.String2.includes(suggestion, "R"), + "should suggest Julia/R for data science", + ) + }) + + // 15. generateReport empty violations is compliant + test("generateReport reports compliant for empty violations", () => { + let report = CCCPCompliance.generateReport([]) + assertTrue( + Js.String2.includes(report, "compliant"), + "empty violations should report compliant", + ) + }) + + // 16. generateReport non-empty violations shows count + test("generateReport shows violation count", () => { + let violation: cccpViolation = { + file: "test.py", + violationType: "python-usage", + severity: "warning", + message: "Python file detected", + suggestedFix: None, + } + let report = CCCPCompliance.generateReport([violation]) + assertTrue( + Js.String2.includes(report, "1"), + "report should mention violation count", + ) + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/ConflictResolverTest.affine b/migration/affinescript/recon-silly-ation/tests/ConflictResolverTest.affine new file mode 100644 index 00000000..645ee202 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/ConflictResolverTest.affine @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/ConflictResolverTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 12 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 16: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 30: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 36: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 146: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected selected document") +// - [untyped-exception] line 149: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected at least one conflict") +// - [untyped-exception] line 183: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected selected document") +// - [untyped-exception] line 215: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected selected document") +// - [untyped-exception] line 246: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected selected document") +// - [untyped-exception] line 267: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected selected document") +// - [untyped-exception] line 299: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected conflict") +// - [untyped-exception] line 402: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected duplicate conflict") +// - [untyped-exception] line 425: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected conflict") + +module ConflictResolverTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // ConflictResolverTest - Unit tests for conflict detection and resolution + // Tests: detectConflicts, resolution rules, threshold auto-resolve, + // batch resolve, edge generation, report + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture helpers + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + ~version: option=None, + ~canonicalSource: canonicalSource=Inferred, + ~repository: string="test/repo", + ~branch: string="main", + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version, + canonicalSource, + repository, + branch, + } + + let makeDoc = ( + content: string, + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + ~version: option=None, + ~canonicalSource: canonicalSource=Inferred, + (), + ): document => { + Deduplicator.createDocument( + content, + makeMetadata(~path, ~docType, ~lastModified, ~version, ~canonicalSource, ()), + ) + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- ConflictResolverTest ---") + + // 1. detectConflicts - duplicate content + test("detectConflicts finds DuplicateContent", () => { + let d1 = makeDoc("same body", ~path="README.md", ()) + let d2 = makeDoc("same body", ~path="docs/README.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + assertTrue(Belt.Array.length(conflicts) > 0, "should detect duplicate conflict") + let first = Belt.Array.getUnsafe(conflicts, 0) + assertEqual(first.conflictType, DuplicateContent, "type should be DuplicateContent") + }) + + // 2. detectConflicts - version mismatch + test("detectConflicts finds VersionMismatch", () => { + let d1 = makeDoc( + "v1 content", + ~path="README.md", + ~version=Some({major: 1, minor: 0, patch: 0}), + (), + ) + let d2 = makeDoc( + "v2 content", + ~path="docs/README.md", + ~version=Some({major: 2, minor: 0, patch: 0}), + (), + ) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let versionConflicts = conflicts->Belt.Array.keep(c => c.conflictType == VersionMismatch) + assertTrue(Belt.Array.length(versionConflicts) > 0, "should detect version mismatch") + }) + + // 3. detectConflicts - canonical conflict + test("detectConflicts finds CanonicalConflict", () => { + let d1 = makeDoc("content A", ~path="LICENSE", ~docType=LICENSE, ~canonicalSource=LicenseFile, ()) + let d2 = makeDoc("content B", ~path="LICENSE.md", ~docType=LICENSE, ~canonicalSource=PackageJson, ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let canonConflicts = conflicts->Belt.Array.keep(c => c.conflictType == CanonicalConflict) + assertTrue(Belt.Array.length(canonConflicts) > 0, "should detect canonical conflict") + }) + + // 4. detectConflicts - no conflicts + test("detectConflicts returns empty for unique docs", () => { + let d1 = makeDoc("content A", ~path="README.md", ()) + let d2 = makeDoc("content B", ~path="LICENSE", ~docType=LICENSE, ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + // These docs differ in hash and type so no duplicate / version conflicts expected + let dupConflicts = conflicts->Belt.Array.keep(c => c.conflictType == DuplicateContent) + assertEqual(Belt.Array.length(dupConflicts), 0, "no duplicate conflicts expected") + }) + + // 5. Rule: duplicate-keep-latest + test("rule duplicate-keep-latest resolves to latest doc", () => { + let d1 = makeDoc("same body", ~path="a.md", ~lastModified=1000.0, ()) + let d2 = makeDoc("same body", ~path="b.md", ~lastModified=5000.0, ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + switch conflicts->Belt.Array.get(0) { + | Some(conflict) => { + let result = ConflictResolver.resolveConflict(conflict, 0.9) + assertEqual(result.strategy, KeepLatest, "strategy should be KeepLatest") + assertEqual(result.confidence, 1.0, "confidence should be 1.0") + switch result.selectedDocument { + | Some(doc) => + assertEqual(doc.metadata.path, "b.md", "latest doc should be selected") + | None => Js.Exn.raiseError("expected selected document") + } + } + | None => Js.Exn.raiseError("expected at least one conflict") + } + }) + + // 6. Rule: license-file-canonical + test("rule license-file-canonical picks LICENSE file", () => { + let licDoc = makeDoc( + "MIT License", + ~path="LICENSE", + ~docType=LICENSE, + ~canonicalSource=LicenseFile, + (), + ) + let otherLic = makeDoc( + "Other license info", + ~path="pkg-license", + ~docType=LICENSE, + ~canonicalSource=PackageJson, + (), + ) + // Construct conflict manually for this rule + let conflict: conflict = { + id: "test_canonical", + conflictType: CanonicalConflict, + documents: [licDoc, otherLic], + detectedAt: Js.Date.now(), + confidence: 0.7, + suggestedStrategy: KeepCanonical, + } + let result = ConflictResolver.resolveConflict(conflict, 0.9) + assertEqual(result.strategy, KeepCanonical, "strategy should be KeepCanonical") + switch result.selectedDocument { + | Some(doc) => + assertEqual(doc.metadata.path, "LICENSE", "LICENSE file should be canonical") + | None => Js.Exn.raiseError("expected selected document") + } + }) + + // 7. Rule: funding-yaml-canonical + test("rule funding-yaml-canonical picks FUNDING.yml", () => { + let fundDoc = makeDoc( + "github: sponsor", + ~path="FUNDING.yml", + ~docType=FUNDING, + ~canonicalSource=FundingYaml, + (), + ) + let otherFund = makeDoc( + "sponsor info", + ~path="sponsor.md", + ~docType=FUNDING, + ~canonicalSource=Inferred, + (), + ) + let conflict: conflict = { + id: "funding_test", + conflictType: CanonicalConflict, + documents: [fundDoc, otherFund], + detectedAt: Js.Date.now(), + confidence: 0.7, + suggestedStrategy: KeepCanonical, + } + let result = ConflictResolver.resolveConflict(conflict, 0.9) + switch result.selectedDocument { + | Some(doc) => + assertEqual(doc.metadata.path, "FUNDING.yml", "FUNDING.yml canonical") + | None => Js.Exn.raiseError("expected selected document") + } + }) + + // 8. Rule: keep-highest-semver + test("rule keep-highest-semver picks highest version", () => { + let d1 = makeDoc( + "v1", + ~path="a.md", + ~version=Some({major: 1, minor: 0, patch: 0}), + (), + ) + let d2 = makeDoc( + "v3", + ~path="b.md", + ~version=Some({major: 3, minor: 0, patch: 0}), + (), + ) + let conflict: conflict = { + id: "version_test", + conflictType: VersionMismatch, + documents: [d1, d2], + detectedAt: Js.Date.now(), + confidence: 0.8, + suggestedStrategy: KeepHighestVersion, + } + let result = ConflictResolver.resolveConflict(conflict, 0.8) + assertEqual(result.strategy, KeepHighestVersion, "strategy should be KeepHighestVersion") + switch result.selectedDocument { + | Some(doc) => + assertEqual(doc.metadata.path, "b.md", "highest version should win") + | None => Js.Exn.raiseError("expected selected document") + } + }) + + // 9. Rule: explicit-canonical + test("rule explicit-canonical picks Explicit source", () => { + let d1 = makeDoc("x", ~path="a.md", ~canonicalSource=Inferred, ()) + let d2 = makeDoc("x", ~path="b.md", ~canonicalSource=Explicit("admin"), ()) + let conflict: conflict = { + id: "explicit_test", + conflictType: CanonicalConflict, + documents: [d1, d2], + detectedAt: Js.Date.now(), + confidence: 0.5, + suggestedStrategy: KeepCanonical, + } + let result = ConflictResolver.resolveConflict(conflict, 0.9) + assertEqual(result.confidence, 1.0, "explicit rule has 1.0 confidence") + switch result.selectedDocument { + | Some(doc) => + assertEqual(doc.metadata.path, "b.md", "explicit source should win") + | None => Js.Exn.raiseError("expected selected document") + } + }) + + // 10. Rule: canonical-over-inferred + test("rule canonical-over-inferred prefers non-inferred", () => { + let d1 = makeDoc("c", ~path="a.md", ~canonicalSource=Inferred, ()) + let d2 = makeDoc("c", ~path="b.md", ~canonicalSource=SecurityMd, ()) + let conflict: conflict = { + id: "canon_inferred_test", + conflictType: CanonicalConflict, + documents: [d1, d2], + detectedAt: Js.Date.now(), + confidence: 0.5, + suggestedStrategy: KeepCanonical, + } + let result = ConflictResolver.resolveConflict(conflict, 0.75) + assertEqual(result.strategy, KeepCanonical, "strategy should be KeepCanonical") + assertTrue(result.confidence >= 0.8, "canonical-over-inferred confidence >= 0.80") + }) + + // 11. threshold auto-resolve + test("above threshold does not require approval", () => { + let d1 = makeDoc("same", ~path="a.md", ()) + let d2 = makeDoc("same", ~path="b.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + switch conflicts->Belt.Array.get(0) { + | Some(c) => { + let result = ConflictResolver.resolveConflict(c, 0.9) + // duplicate-keep-latest has 1.0 confidence, threshold 0.9 + assertEqual(result.requiresApproval, false, "should auto-resolve") + } + | None => Js.Exn.raiseError("expected conflict") + } + }) + + // 12. below threshold requires approval + test("below threshold requires approval", () => { + let d1 = makeDoc("c", ~path="a.md", ~canonicalSource=Inferred, ()) + let d2 = makeDoc("c", ~path="b.md", ~canonicalSource=SecurityMd, ()) + let conflict: conflict = { + id: "threshold_test", + conflictType: CanonicalConflict, + documents: [d1, d2], + detectedAt: Js.Date.now(), + confidence: 0.5, + suggestedStrategy: KeepCanonical, + } + // canonical-over-inferred has 0.80 confidence; set threshold to 0.95 + let result = ConflictResolver.resolveConflict(conflict, 0.95) + assertEqual(result.requiresApproval, true, "should require approval") + }) + + // 13. batch resolve + test("resolveConflicts batch resolves multiple", () => { + let d1 = makeDoc("dup1", ~path="a.md", ()) + let d2 = makeDoc("dup1", ~path="b.md", ()) + let d3 = makeDoc("dup2", ~path="c.md", ()) + let d4 = makeDoc("dup2", ~path="d.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2, d3, d4]) + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.9) + assertEqual( + Belt.Array.length(resolutions), + Belt.Array.length(conflicts), + "one resolution per conflict", + ) + }) + + // 14. edge generation + test("createSupersededEdges generates edges for resolved conflicts", () => { + let d1 = makeDoc("same", ~path="a.md", ()) + let d2 = makeDoc("same", ~path="b.md", ~lastModified=9000.0, ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.5) + let edges = ConflictResolver.createSupersededEdges(resolutions) + assertTrue(Belt.Array.length(edges) >= 1, "at least one edge expected") + let edge = Belt.Array.getUnsafe(edges, 0) + assertEqual(edge.edgeType, SupersededBy, "edge type should be SupersededBy") + }) + + // 15. report generation + test("generateReport produces non-empty string", () => { + let d1 = makeDoc("dup", ~path="a.md", ()) + let d2 = makeDoc("dup", ~path="b.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.9) + let report = ConflictResolver.generateReport(resolutions, conflicts) + assertTrue(Js.String2.length(report) > 0, "report must not be empty") + assertTrue( + Js.String2.includes(report, "Conflict Resolution Report"), + "report must contain header", + ) + }) + + // 16. report contains auto-resolved count + test("generateReport counts auto-resolved", () => { + let d1 = makeDoc("dup", ~path="a.md", ()) + let d2 = makeDoc("dup", ~path="b.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.5) + let report = ConflictResolver.generateReport(resolutions, conflicts) + assertTrue(Js.String2.includes(report, "Auto-resolved"), "report should mention auto-resolved") + }) + + // 17. no-rule conflict falls back to RequireManual + test("no applicable rule yields RequireManual", () => { + // StructuralConflict has no built-in rule + let d1 = makeDoc("foo", ~path="a.md", ()) + let conflict: conflict = { + id: "structural_test", + conflictType: StructuralConflict, + documents: [d1], + detectedAt: Js.Date.now(), + confidence: 0.5, + suggestedStrategy: Merge, + } + let result = ConflictResolver.resolveConflict(conflict, 0.5) + // canonical-over-inferred might apply since Inferred != Inferred is false... + // Actually with only 1 doc all rules checking for specific canonicals may not apply + // Let's just verify we get a result + assertTrue( + result.confidence >= 0.0, + "should produce a resolution", + ) + }) + + // 18. duplicate conflict IDs use hash suffix + test("duplicate conflict id contains _duplicate suffix", () => { + let d1 = makeDoc("same", ~path="a.md", ()) + let d2 = makeDoc("same", ~path="b.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let dupConflicts = conflicts->Belt.Array.keep(c => c.conflictType == DuplicateContent) + switch dupConflicts->Belt.Array.get(0) { + | Some(c) => + assertTrue(Js.String2.includes(c.id, "_duplicate"), "id should contain _duplicate") + | None => Js.Exn.raiseError("expected duplicate conflict") + } + }) + + // 19. empty document set yields no conflicts + test("detectConflicts on empty array returns empty", () => { + let conflicts = ConflictResolver.detectConflicts([]) + assertEqual(Belt.Array.length(conflicts), 0, "no conflicts for empty input") + }) + + // 20. resolution reasoning contains rule name + test("resolution reasoning mentions rule name", () => { + let d1 = makeDoc("same", ~path="a.md", ()) + let d2 = makeDoc("same", ~path="b.md", ()) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + switch conflicts->Belt.Array.get(0) { + | Some(c) => { + let result = ConflictResolver.resolveConflict(c, 0.9) + assertTrue( + Js.String2.includes(result.reasoning, "rule"), + "reasoning should mention applied rule", + ) + } + | None => Js.Exn.raiseError("expected conflict") + } + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/DeduplicatorTest.affine b/migration/affinescript/recon-silly-ation/tests/DeduplicatorTest.affine new file mode 100644 index 00000000..34950545 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/DeduplicatorTest.affine @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/DeduplicatorTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 8 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 17: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 31: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 37: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 218: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("missing group for hash") +// - [untyped-exception] line 230: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected Some") +// - [untyped-exception] line 238: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Some(_) => Js.Exn.raiseError("expected None for empty array") +// - [untyped-exception] line 250: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected Some") +// - [untyped-exception] line 262: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected Some") + +module DeduplicatorTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // DeduplicatorTest - Unit tests for content-addressable deduplication + // Tests: hashContent, normalizeContent, createDocument, deduplicate, + // findDuplicates, isDuplicate, groupByHash, findLatest, findCanonical, + // createDuplicateEdges, normalization idempotency + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture helpers + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + ~version: option=None, + ~canonicalSource: canonicalSource=Inferred, + ~repository: string="test/repo", + ~branch: string="main", + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version, + canonicalSource, + repository, + branch, + } + + let makeDoc = (content: string, path: string): document => { + Deduplicator.createDocument( + content, + makeMetadata(~path, ()), + ) + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- DeduplicatorTest ---") + + // 1. hashContent determinism + test("hashContent determinism - same input same output", () => { + let h1 = Deduplicator.hashContent("hello world") + let h2 = Deduplicator.hashContent("hello world") + assertEqual(h1, h2, "same content must produce same hash") + }) + + // 2. hash uniqueness + test("hashContent uniqueness - different inputs differ", () => { + let h1 = Deduplicator.hashContent("alpha") + let h2 = Deduplicator.hashContent("beta") + assertTrue(h1 != h2, "different content must produce different hash") + }) + + // 3. hash non-empty + test("hashContent produces non-empty string", () => { + let h = Deduplicator.hashContent("test") + assertTrue(Js.String2.length(h) > 0, "hash must be non-empty") + }) + + // 4. CRLF normalisation + test("normalizeContent CRLF to LF", () => { + let input = "line1\r\nline2\r\nline3" + let normalized = Deduplicator.normalizeContent(input) + assertTrue( + !Js.String2.includes(normalized, "\r\n"), + "normalised content should not contain CRLF", + ) + assertTrue(Js.String2.includes(normalized, "\n"), "normalised content should contain LF") + }) + + // 5. trailing whitespace stripping + test("normalizeContent trailing whitespace strip", () => { + let input = "line1 \nline2\t\nline3" + let normalized = Deduplicator.normalizeContent(input) + let lines = Js.String2.split(normalized, "\n") + lines->Belt.Array.forEach(line => { + assertTrue( + line == line->Js.String2.replaceByRe(%re("/\s+$/"), ""), + "each line must not have trailing whitespace", + ) + }) + }) + + // 6. blank line collapse + test("normalizeContent blank line collapse", () => { + let input = "first\n\n\n\n\nsecond" + let normalized = Deduplicator.normalizeContent(input) + assertTrue( + !Js.String2.includes(normalized, "\n\n\n"), + "three or more consecutive newlines should be collapsed to two", + ) + }) + + // 7. createDocument sets hash + test("createDocument sets non-empty hash", () => { + let doc = makeDoc("# README", "README.md") + assertTrue(Js.String2.length(doc.hash) > 0, "hash must be set") + }) + + // 8. createDocument normalises content + test("createDocument normalises content", () => { + let doc = Deduplicator.createDocument( + "hello\r\nworld \n\n\n\nfin", + makeMetadata(~path="test.md", ()), + ) + assertTrue(!Js.String2.includes(doc.content, "\r"), "content must be normalised") + }) + + // 9. deduplicate - all unique + test("deduplicate all unique docs", () => { + let d1 = makeDoc("content A", "a.md") + let d2 = makeDoc("content B", "b.md") + let d3 = makeDoc("content C", "c.md") + let result = Deduplicator.deduplicate([d1, d2, d3]) + assertEqual(result.stats.uniqueCount, 3, "expected 3 unique docs") + assertEqual(result.stats.duplicateCount, 0, "expected 0 duplicates") + }) + + // 10. deduplicate - finds duplicates + test("deduplicate finds duplicate content", () => { + let d1 = makeDoc("same content", "a.md") + let d2 = makeDoc("same content", "b.md") + let d3 = makeDoc("different", "c.md") + let result = Deduplicator.deduplicate([d1, d2, d3]) + assertEqual(result.stats.uniqueCount, 2, "expected 2 unique") + assertEqual(result.stats.duplicateCount, 1, "expected 1 duplicate") + }) + + // 11. deduplicate preserves original + test("deduplicate unique array has first occurrence", () => { + let d1 = makeDoc("identical", "first.md") + let d2 = makeDoc("identical", "second.md") + let result = Deduplicator.deduplicate([d1, d2]) + assertEqual(Belt.Array.length(result.unique), 1, "one unique") + assertEqual( + (Belt.Array.getUnsafe(result.unique, 0)).metadata.path, + "first.md", + "first occurrence kept", + ) + }) + + // 12. findDuplicates + test("findDuplicates locates copies", () => { + let d1 = makeDoc("shared", "a.md") + let d2 = makeDoc("shared", "b.md") + let d3 = makeDoc("other", "c.md") + let dupes = Deduplicator.findDuplicates(d1, [d1, d2, d3]) + assertEqual(Belt.Array.length(dupes), 1, "expected 1 duplicate") + assertEqual( + (Belt.Array.getUnsafe(dupes, 0)).metadata.path, + "b.md", + "duplicate is b.md", + ) + }) + + // 13. isDuplicate true + test("isDuplicate returns true for same content", () => { + let d1 = makeDoc("twin", "a.md") + let d2 = makeDoc("twin", "b.md") + assertTrue(Deduplicator.isDuplicate(d1, d2), "should be duplicates") + }) + + // 14. isDuplicate false + test("isDuplicate returns false for different content", () => { + let d1 = makeDoc("alpha", "a.md") + let d2 = makeDoc("beta", "b.md") + assertTrue(!Deduplicator.isDuplicate(d1, d2), "should not be duplicates") + }) + + // 15. groupByHash + test("groupByHash groups correctly", () => { + let d1 = makeDoc("X", "a.md") + let d2 = makeDoc("X", "b.md") + let d3 = makeDoc("Y", "c.md") + let groups = Deduplicator.groupByHash([d1, d2, d3]) + assertEqual(Belt.Map.String.size(groups), 2, "two groups") + let xGroup = groups->Belt.Map.String.get(d1.hash) + switch xGroup { + | Some(arr) => assertEqual(Belt.Array.length(arr), 2, "X group has 2 docs") + | None => Js.Exn.raiseError("missing group for hash") + } + }) + + // 16. findLatest normal + test("findLatest returns most recent", () => { + let meta1 = makeMetadata(~path="old.md", ~lastModified=1000.0, ()) + let meta2 = makeMetadata(~path="new.md", ~lastModified=5000.0, ()) + let d1 = Deduplicator.createDocument("content", meta1) + let d2 = Deduplicator.createDocument("content", meta2) + switch Deduplicator.findLatest([d1, d2]) { + | Some(latest) => assertEqual(latest.metadata.path, "new.md", "newest doc") + | None => Js.Exn.raiseError("expected Some") + } + }) + + // 17. findLatest empty + test("findLatest on empty returns None", () => { + switch Deduplicator.findLatest([]) { + | None => () + | Some(_) => Js.Exn.raiseError("expected None for empty array") + } + }) + + // 18. findCanonical priority ordering + test("findCanonical prefers LicenseFile over Inferred", () => { + let m1 = makeMetadata(~path="a.md", ~canonicalSource=Inferred, ()) + let m2 = makeMetadata(~path="b.md", ~canonicalSource=LicenseFile, ()) + let d1 = Deduplicator.createDocument("c", m1) + let d2 = Deduplicator.createDocument("c", m2) + switch Deduplicator.findCanonical([d1, d2]) { + | Some(canon) => assertEqual(canon.metadata.path, "b.md", "LicenseFile wins") + | None => Js.Exn.raiseError("expected Some") + } + }) + + // 19. findCanonical Explicit highest + test("findCanonical Explicit beats all", () => { + let m1 = makeMetadata(~path="a.md", ~canonicalSource=FundingYaml, ()) + let m2 = makeMetadata(~path="b.md", ~canonicalSource=Explicit("owner"), ()) + let d1 = Deduplicator.createDocument("c", m1) + let d2 = Deduplicator.createDocument("c", m2) + switch Deduplicator.findCanonical([d1, d2]) { + | Some(canon) => assertEqual(canon.metadata.path, "b.md", "Explicit wins") + | None => Js.Exn.raiseError("expected Some") + } + }) + + // 20. getCanonicalPriority ordering + test("getCanonicalPriority Explicit > LicenseFile > Inferred", () => { + let pExplicit = Deduplicator.getCanonicalPriority(Explicit("x")) + let pLicense = Deduplicator.getCanonicalPriority(LicenseFile) + let pInferred = Deduplicator.getCanonicalPriority(Inferred) + assertTrue(pExplicit > pLicense, "Explicit > LicenseFile") + assertTrue(pLicense > pInferred, "LicenseFile > Inferred") + }) + + // 21. createDuplicateEdges + test("createDuplicateEdges produces correct edges", () => { + let d1 = makeDoc("same", "a.md") + let d2 = makeDoc("same", "b.md") + let edges = Deduplicator.createDuplicateEdges([(d2, d1)]) + assertEqual(Belt.Array.length(edges), 1, "one edge") + let edge = Belt.Array.getUnsafe(edges, 0) + assertEqual(edge.edgeType, DuplicateOf, "edge type is DuplicateOf") + assertEqual(edge.confidence, 1.0, "confidence is 1.0") + }) + + // 22. createDuplicateEdges empty + test("createDuplicateEdges empty input yields empty output", () => { + let edges = Deduplicator.createDuplicateEdges([]) + assertEqual(Belt.Array.length(edges), 0, "no edges for empty input") + }) + + // 23. normalization idempotency + test("normalizeContent is idempotent", () => { + let input = "hello\r\n world \n\n\n\n\nfoo " + let once = Deduplicator.normalizeContent(input) + let twice = Deduplicator.normalizeContent(once) + assertEqual(once, twice, "normalising twice should equal normalising once") + }) + + // 24. hash after normalisation is identical + test("hashContent on normalised content is same as normalise+hash", () => { + let raw = "test\r\ncontent \n\n\n\nend" + let n = Deduplicator.normalizeContent(raw) + let h1 = Deduplicator.hashContent(n) + let h2 = Deduplicator.hashContent(Deduplicator.normalizeContent(raw)) + assertEqual(h1, h2, "hash of normalised must be deterministic") + }) + + // 25. deduplicate stats totalProcessed + test("deduplicate stats totalProcessed is correct", () => { + let docs = [ + makeDoc("a", "a.md"), + makeDoc("b", "b.md"), + makeDoc("a", "a2.md"), + ] + let result = Deduplicator.deduplicate(docs) + assertEqual(result.stats.totalProcessed, 3, "totalProcessed is 3") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/EnforcementBotTest.affine b/migration/affinescript/recon-silly-ation/tests/EnforcementBotTest.affine new file mode 100644 index 00000000..898e0b96 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/EnforcementBotTest.affine @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/EnforcementBotTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 15: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 29: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 35: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module EnforcementBotTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // EnforcementBotTest - Unit tests for the enforcement bot + // Tests: createBotState, addJob, removeJob, setJobEnabled, + // rule names, createRsrBot, createMinimalBot + + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- EnforcementBotTest ---") + + // 1. createBotState empty + test("createBotState has empty jobs", () => { + let state = EnforcementBot.createBotState() + assertEqual(Belt.Array.length(state.jobs), 0, "jobs should be empty") + }) + + // 2. createBotState empty results + test("createBotState has empty results", () => { + let state = EnforcementBot.createBotState() + assertEqual(Belt.Array.length(state.results), 0, "results should be empty") + }) + + // 3. createBotState not running + test("createBotState is not running", () => { + let state = EnforcementBot.createBotState() + assertEqual(state.running, false, "should not be running") + }) + + // 4. addJob increases job count + test("addJob increases job count", () => { + let state = EnforcementBot.createBotState() + let state = EnforcementBot.addJob( + state, + EnforcementBot.rsrComplianceRule, + EnforcementBot.Immediate, + "test/repo", + (), + ) + assertEqual(Belt.Array.length(state.jobs), 1, "should have 1 job") + }) + + // 5. addJob job is enabled by default + test("addJob creates enabled job", () => { + let state = EnforcementBot.createBotState() + let state = EnforcementBot.addJob( + state, + EnforcementBot.rsrComplianceRule, + EnforcementBot.Immediate, + "test/repo", + (), + ) + let job = Belt.Array.getUnsafe(state.jobs, 0) + assertEqual(job.enabled, true, "job should be enabled by default") + }) + + // 6. removeJob decreases job count + test("removeJob removes the specified job", () => { + let state = EnforcementBot.createBotState() + let state = EnforcementBot.addJob( + state, + EnforcementBot.rsrComplianceRule, + EnforcementBot.Immediate, + "test/repo", + (), + ) + let jobId = (Belt.Array.getUnsafe(state.jobs, 0)).id + let state = EnforcementBot.removeJob(state, jobId) + assertEqual(Belt.Array.length(state.jobs), 0, "job should be removed") + }) + + // 7. setJobEnabled disables job + test("setJobEnabled disables a job", () => { + let state = EnforcementBot.createBotState() + let state = EnforcementBot.addJob( + state, + EnforcementBot.rsrComplianceRule, + EnforcementBot.Immediate, + "test/repo", + (), + ) + let jobId = (Belt.Array.getUnsafe(state.jobs, 0)).id + let state = EnforcementBot.setJobEnabled(state, jobId, false) + let job = Belt.Array.getUnsafe(state.jobs, 0) + assertEqual(job.enabled, false, "job should be disabled") + }) + + // 8. setJobEnabled re-enables job + test("setJobEnabled re-enables a job", () => { + let state = EnforcementBot.createBotState() + let state = EnforcementBot.addJob( + state, + EnforcementBot.rsrComplianceRule, + EnforcementBot.Immediate, + "test/repo", + (), + ) + let jobId = (Belt.Array.getUnsafe(state.jobs, 0)).id + let state = EnforcementBot.setJobEnabled(state, jobId, false) + let state = EnforcementBot.setJobEnabled(state, jobId, true) + let job = Belt.Array.getUnsafe(state.jobs, 0) + assertEqual(job.enabled, true, "job should be re-enabled") + }) + + // 9. license-pmpl rule name + test("licenseComplianceRule has name license-pmpl", () => { + assertEqual( + EnforcementBot.licenseComplianceRule.name, + "license-pmpl", + "rule name should be license-pmpl", + ) + }) + + // 10. rsrComplianceRule name + test("rsrComplianceRule has name rsr-compliance", () => { + assertEqual( + EnforcementBot.rsrComplianceRule.name, + "rsr-compliance", + "rule name should be rsr-compliance", + ) + }) + + // 11. createRsrBot has 5 rules + test("createRsrBot has 5 jobs", () => { + let state = EnforcementBot.createRsrBot() + assertEqual(Belt.Array.length(state.jobs), 5, "RSR bot should have 5 jobs") + }) + + // 12. createMinimalBot has 2 rules + test("createMinimalBot has 2 jobs", () => { + let state = EnforcementBot.createMinimalBot() + assertEqual(Belt.Array.length(state.jobs), 2, "minimal bot should have 2 jobs") + }) + + // 13. createRsrBot all jobs are enabled + test("createRsrBot all jobs enabled", () => { + let state = EnforcementBot.createRsrBot() + let allEnabled = state.jobs->Belt.Array.every(j => j.enabled) + assertTrue(allEnabled, "all RSR bot jobs should be enabled") + }) + + // 14. removeJob non-existent id is no-op + test("removeJob with non-existent id is no-op", () => { + let state = EnforcementBot.createRsrBot() + let before = Belt.Array.length(state.jobs) + let state = EnforcementBot.removeJob(state, "nonexistent-id-xyz") + let after = Belt.Array.length(state.jobs) + assertEqual(before, after, "count should not change") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/GraphVisualizerTest.affine b/migration/affinescript/recon-silly-ation/tests/GraphVisualizerTest.affine new file mode 100644 index 00000000..b64c3584 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/GraphVisualizerTest.affine @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/GraphVisualizerTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 16: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 30: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 36: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module GraphVisualizerTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // GraphVisualizerTest - Unit tests for graph visualization output + // Tests: generateDot, generateMermaid, generateHTML, node colors, edge styles, + // empty graph, edge confidence styling + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture helpers + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version: None, + canonicalSource: Inferred, + repository: "test/repo", + branch: "main", + } + + let sampleDocs = (): (document, document) => { + let d1 = Deduplicator.createDocument( + "# README\n\nProject info", + makeMetadata(~path="README.md", ()), + ) + let d2 = Deduplicator.createDocument( + "MIT License", + makeMetadata(~path="LICENSE", ~docType=LICENSE, ()), + ) + (d1, d2) + } + + let sampleEdge = (d1: document, d2: document): edge => { + { + from: d1.hash, + to: d2.hash, + edgeType: DuplicateOf, + confidence: 1.0, + metadata: Js.Json.object_(Js.Dict.empty()), + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- GraphVisualizerTest ---") + + let (d1, d2) = sampleDocs() + let edges = [sampleEdge(d1, d2)] + + // 1. generateDot contains "digraph" + test("generateDot output contains digraph", () => { + let dot = GraphVisualizer.generateDot([d1, d2], edges, GraphVisualizer.defaultConfig) + assertTrue(Js.String2.includes(dot, "digraph"), "DOT must contain digraph keyword") + }) + + // 2. generateDot contains closing brace + test("generateDot output ends with closing brace", () => { + let dot = GraphVisualizer.generateDot([d1, d2], edges, GraphVisualizer.defaultConfig) + assertTrue(Js.String2.includes(dot, "}"), "DOT must contain closing brace") + }) + + // 3. node colors - README is blue + test("nodeColor README returns blue", () => { + let color = GraphVisualizer.nodeColor(README) + assertEqual(color, "#4a9eff", "README should be blue") + }) + + // 4. node colors - LICENSE is red + test("nodeColor LICENSE returns red", () => { + let color = GraphVisualizer.nodeColor(LICENSE) + assertEqual(color, "#ff6b6b", "LICENSE should be red") + }) + + // 5. node colors - SECURITY is yellow + test("nodeColor SECURITY returns yellow", () => { + let color = GraphVisualizer.nodeColor(SECURITY) + assertEqual(color, "#ffd93d", "SECURITY should be yellow") + }) + + // 6. node colors - Custom is grey + test("nodeColor Custom returns grey", () => { + let color = GraphVisualizer.nodeColor(Custom("something")) + assertEqual(color, "#cccccc", "Custom should be grey") + }) + + // 7. edge styles - high confidence is solid + test("edgeStyle high confidence is solid", () => { + assertEqual(GraphVisualizer.edgeStyle(0.95), "solid", ">=0.9 should be solid") + }) + + // 8. edge styles - medium confidence is dashed + test("edgeStyle medium confidence is dashed", () => { + assertEqual(GraphVisualizer.edgeStyle(0.75), "dashed", ">=0.7 should be dashed") + }) + + // 9. edge styles - low confidence is dotted + test("edgeStyle low confidence is dotted", () => { + assertEqual(GraphVisualizer.edgeStyle(0.5), "dotted", "<0.7 should be dotted") + }) + + // 10. generateMermaid contains "graph" + test("generateMermaid contains graph keyword", () => { + let mermaid = GraphVisualizer.generateMermaid([d1, d2], edges) + assertTrue(Js.String2.includes(mermaid, "graph"), "Mermaid must contain graph keyword") + }) + + // 11. generateMermaid contains LR + test("generateMermaid uses left-to-right layout", () => { + let mermaid = GraphVisualizer.generateMermaid([d1, d2], edges) + assertTrue(Js.String2.includes(mermaid, "LR"), "Mermaid should use LR layout") + }) + + // 12. generateHTML contains DOCTYPE + test("generateHTML contains DOCTYPE", () => { + let html = GraphVisualizer.generateHTML([d1, d2], edges, "Test Graph") + assertTrue(Js.String2.includes(html, ""), "HTML should start with DOCTYPE") + }) + + // 13. generateHTML contains title + test("generateHTML contains provided title", () => { + let html = GraphVisualizer.generateHTML([d1, d2], edges, "My Documentation Graph") + assertTrue( + Js.String2.includes(html, "My Documentation Graph"), + "HTML should contain the title", + ) + }) + + // 14. generateHTML contains legend + test("generateHTML contains legend section", () => { + let html = GraphVisualizer.generateHTML([d1, d2], edges, "Test") + assertTrue(Js.String2.includes(html, "Legend"), "HTML should contain legend") + }) + + // 15. empty graph produces valid DOT + test("generateDot with empty graph", () => { + let dot = GraphVisualizer.generateDot([], [], GraphVisualizer.defaultConfig) + assertTrue(Js.String2.includes(dot, "digraph"), "even empty graph has digraph") + assertTrue(Js.String2.includes(dot, "}"), "even empty graph has closing brace") + }) + + // 16. edge color for ConflictsWith + test("edgeColor ConflictsWith is red", () => { + assertEqual(GraphVisualizer.edgeColor(ConflictsWith), "#ff6b6b", "conflicts are red") + }) + + // 17. edge color for CanonicalFor + test("edgeColor CanonicalFor is green", () => { + assertEqual(GraphVisualizer.edgeColor(CanonicalFor), "#51cf66", "canonical is green") + }) + + // 18. generateDot includes edge label + test("generateDot includes edge type as label", () => { + let dot = GraphVisualizer.generateDot([d1, d2], edges, GraphVisualizer.defaultConfig) + assertTrue(Js.String2.includes(dot, "duplicate_of"), "DOT should contain edge label") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/IntegrationTest.affine b/migration/affinescript/recon-silly-ation/tests/IntegrationTest.affine new file mode 100644 index 00000000..c32cc276 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/IntegrationTest.affine @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/IntegrationTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 4 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 16: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 30: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 36: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 149: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected selected document") + +module IntegrationTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // IntegrationTest - End-to-end integration tests + // Tests: full pipeline flow (create -> dedup -> detect -> resolve -> report), + // normalisation+dedup idempotency, fixture-based scenarios + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture data + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + ~version: option=None, + ~canonicalSource: canonicalSource=Inferred, + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version, + canonicalSource, + repository: "test/repo", + branch: "main", + } + + let fixtureReadme = "# My Project\n\nA documentation reconciliation tool.\n\n## Features\n\n- Deduplication\n- Conflict resolution\n- Graph storage" + + let fixtureLicense = "Palimpsest License (PMPL-1.0-or-later)\n\nCopyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)\n\nPermission is hereby granted..." + + let fixtureSecurity = "# Security Policy\n\n## Supported Versions\n\n| Version | Supported |\n|---|---|\n| 1.x | Yes |\n\n## Reporting a Vulnerability\n\nPlease email j.d.a.jewell@open.ac.uk" + + let fixtureContributing = "# Contributing\n\nWe welcome contributions!\n\n## Process\n\n1. Fork the repository\n2. Create a branch\n3. Submit a PR" + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- IntegrationTest ---") + + // 1. End-to-end: create documents + test("e2e: create documents from fixtures", () => { + let docs = [ + Deduplicator.createDocument( + fixtureReadme, + makeMetadata(~path="README.md", ()), + ), + Deduplicator.createDocument( + fixtureLicense, + makeMetadata(~path="LICENSE", ~docType=LICENSE, ~canonicalSource=LicenseFile, ()), + ), + Deduplicator.createDocument( + fixtureSecurity, + makeMetadata(~path="SECURITY.md", ~docType=SECURITY, ~canonicalSource=SecurityMd, ()), + ), + Deduplicator.createDocument( + fixtureContributing, + makeMetadata(~path="CONTRIBUTING.md", ~docType=CONTRIBUTING, ()), + ), + ] + assertEqual(Belt.Array.length(docs), 4, "should create 4 documents") + docs->Belt.Array.forEach(doc => { + assertTrue(Js.String2.length(doc.hash) > 0, "each doc should have a hash") + }) + }) + + // 2. End-to-end: dedup unique fixture docs + test("e2e: dedup unique fixtures yields no duplicates", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="README.md", ())), + Deduplicator.createDocument(fixtureLicense, makeMetadata(~path="LICENSE", ~docType=LICENSE, ())), + Deduplicator.createDocument(fixtureSecurity, makeMetadata(~path="SECURITY.md", ~docType=SECURITY, ())), + ] + let result = Deduplicator.deduplicate(docs) + assertEqual(result.stats.duplicateCount, 0, "no duplicates in unique fixtures") + assertEqual(result.stats.uniqueCount, 3, "all 3 unique") + }) + + // 3. End-to-end: dedup with duplicate README + test("e2e: dedup detects duplicate README", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="README.md", ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="docs/README.md", ())), + Deduplicator.createDocument(fixtureLicense, makeMetadata(~path="LICENSE", ~docType=LICENSE, ())), + ] + let result = Deduplicator.deduplicate(docs) + assertEqual(result.stats.duplicateCount, 1, "one duplicate README") + assertEqual(result.stats.uniqueCount, 2, "2 unique docs") + }) + + // 4. End-to-end: detect conflicts from duplicates + test("e2e: detect conflicts from duplicate docs", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="README.md", ~lastModified=1000.0, ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="docs/README.md", ~lastModified=2000.0, ())), + ] + let conflicts = ConflictResolver.detectConflicts(docs) + assertTrue(Belt.Array.length(conflicts) > 0, "should detect duplicate conflict") + }) + + // 5. End-to-end: resolve duplicate conflict + test("e2e: resolve duplicate picks latest", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="old/README.md", ~lastModified=1000.0, ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="new/README.md", ~lastModified=5000.0, ())), + ] + let conflicts = ConflictResolver.detectConflicts(docs) + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.9) + assertTrue(Belt.Array.length(resolutions) > 0, "should have resolutions") + let res = Belt.Array.getUnsafe(resolutions, 0) + switch res.selectedDocument { + | Some(doc) => + assertEqual(doc.metadata.path, "new/README.md", "latest doc should win") + | None => Js.Exn.raiseError("expected selected document") + } + }) + + // 6. End-to-end: generate report + test("e2e: generate conflict report", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="a.md", ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="b.md", ())), + ] + let conflicts = ConflictResolver.detectConflicts(docs) + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.9) + let report = ConflictResolver.generateReport(resolutions, conflicts) + assertTrue(Js.String2.length(report) > 0, "report must not be empty") + assertTrue( + Js.String2.includes(report, "Conflict Resolution Report"), + "report must contain header", + ) + }) + + // 7. Normalisation -> dedup idempotency + test("normalise -> dedup is idempotent", () => { + let rawContent = "# Hello\r\n\r\n\r\n\r\nWorld \n trailing " + let doc1 = Deduplicator.createDocument(rawContent, makeMetadata(~path="a.md", ())) + let doc2 = Deduplicator.createDocument(rawContent, makeMetadata(~path="b.md", ())) + // Both should normalise identically + assertTrue(Deduplicator.isDuplicate(doc1, doc2), "same raw content should be duplicates") + // Normalise again and dedup + let normalized = Pipeline.normalizeDocuments([doc1, doc2]) + let result = Deduplicator.deduplicate(normalized) + assertEqual(result.stats.duplicateCount, 1, "still one duplicate after normalise") + }) + + // 8. Full pipeline: create -> dedup -> conflict -> resolve -> edge + test("e2e: full pipeline produces edges", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="a/README.md", ~lastModified=100.0, ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="b/README.md", ~lastModified=200.0, ())), + Deduplicator.createDocument(fixtureLicense, makeMetadata(~path="LICENSE", ~docType=LICENSE, ())), + ] + + // Dedup + let dedupResult = Deduplicator.deduplicate(docs) + let dupEdges = Deduplicator.createDuplicateEdges(dedupResult.duplicates) + assertTrue(Belt.Array.length(dupEdges) >= 1, "should have duplicate edges") + + // Detect conflicts on all docs + let conflicts = ConflictResolver.detectConflicts(docs) + + // Resolve + let resolutions = ConflictResolver.resolveConflicts(conflicts, 0.9) + let supersededEdges = ConflictResolver.createSupersededEdges(resolutions) + + // Total edges + let totalEdges = Belt.Array.length(dupEdges) + Belt.Array.length(supersededEdges) + assertTrue(totalEdges >= 1, "should produce at least 1 total edge") + }) + + // 9. Graph visualisation from pipeline output + test("e2e: graph visualisation from pipeline output", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="README.md", ())), + Deduplicator.createDocument(fixtureLicense, makeMetadata(~path="LICENSE", ~docType=LICENSE, ())), + ] + let edges = Deduplicator.createDuplicateEdges([]) + let dot = GraphVisualizer.generateDot(docs, edges, GraphVisualizer.defaultConfig) + assertTrue(Js.String2.includes(dot, "digraph"), "DOT output should be valid") + }) + + // 10. Logic engine integration with pipeline docs + test("e2e: logic engine infers relationships from pipeline docs", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="a.md", ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="b.md", ())), + ] + let rels = LogicEngine.inferRelationships(docs) + assertTrue(Belt.Array.length(rels) > 0, "should infer duplicate relationship") + }) + + // 11. CCCP compliance in pipeline context + test("e2e: CCCP compliance on non-Python docs", () => { + assertTrue(!CCCPCompliance.isPythonFile("README.md"), "README.md is not Python") + assertTrue(!CCCPCompliance.isPythonFile("LICENSE"), "LICENSE is not Python") + }) + + // 12. Version conflict end-to-end + test("e2e: version conflict detection and resolution", () => { + let v1 = {major: 1, minor: 0, patch: 0} + let v2 = {major: 2, minor: 0, patch: 0} + let d1 = Deduplicator.createDocument( + "Version 1 content", + makeMetadata(~path="doc-v1.md", ~version=Some(v1), ()), + ) + let d2 = Deduplicator.createDocument( + "Version 2 content", + makeMetadata(~path="doc-v2.md", ~version=Some(v2), ()), + ) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let versionConflicts = conflicts->Belt.Array.keep(c => c.conflictType == VersionMismatch) + assertTrue(Belt.Array.length(versionConflicts) > 0, "should detect version conflict") + let resolutions = ConflictResolver.resolveConflicts(versionConflicts, 0.5) + assertTrue(Belt.Array.length(resolutions) > 0, "should resolve version conflict") + }) + + // 13. Canonical conflict end-to-end + test("e2e: canonical conflict resolution", () => { + let d1 = Deduplicator.createDocument( + "License A", + makeMetadata(~path="LICENSE", ~docType=LICENSE, ~canonicalSource=LicenseFile, ()), + ) + let d2 = Deduplicator.createDocument( + "License B", + makeMetadata(~path="pkg/license", ~docType=LICENSE, ~canonicalSource=CargoToml, ()), + ) + let conflicts = ConflictResolver.detectConflicts([d1, d2]) + let canonConflicts = conflicts->Belt.Array.keep(c => c.conflictType == CanonicalConflict) + assertTrue(Belt.Array.length(canonConflicts) > 0, "should detect canonical conflict") + }) + + // 14. ArangoDB serialisation in pipeline context + test("e2e: document serialisation for ArangoDB", () => { + let doc = Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="README.md", ())) + let json = ArangoClient.documentToJson(doc) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "README.md"), "serialised doc should contain path") + assertTrue(Js.String2.includes(str, "_key"), "serialised doc should contain _key") + }) + + // 15. Dedup report in pipeline context + test("e2e: dedup report generation", () => { + let docs = [ + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="a.md", ())), + Deduplicator.createDocument(fixtureReadme, makeMetadata(~path="b.md", ())), + Deduplicator.createDocument(fixtureLicense, makeMetadata(~path="LICENSE", ~docType=LICENSE, ())), + ] + let result = Deduplicator.deduplicate(docs) + let report = Deduplicator.generateReport(result) + assertTrue(Js.String2.includes(report, "Deduplication Report"), "report header") + assertTrue(Js.String2.includes(report, "1"), "should mention duplicate count") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/LogicEngineTest.affine b/migration/affinescript/recon-silly-ation/tests/LogicEngineTest.affine new file mode 100644 index 00000000..6167bf2e --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/LogicEngineTest.affine @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/LogicEngineTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 11 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 16: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 30: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 36: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 114: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("same atoms should unify") +// - [untyped-exception] line 127: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Some(_) => Js.Exn.raiseError("different atoms should not unify") +// - [untyped-exception] line 142: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | _ => Js.Exn.raiseError("X should be bound to Atom(hello)") +// - [untyped-exception] line 145: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("variable should unify with atom") +// - [untyped-exception] line 158: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("identical compounds should unify") +// - [untyped-exception] line 171: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Some(_) => Js.Exn.raiseError("different functors should not unify") +// - [untyped-exception] line 241: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | None => Js.Exn.raiseError("expected canonical document") +// - [untyped-exception] line 250: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Some(_) => Js.Exn.raiseError("should return None for wrong type") + +module LogicEngineTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // LogicEngineTest - Unit tests for miniKanren/Datalog-style logical inference + // Tests: createKnowledgeBase, addFact, addRule, unify, query, + // defineDocumentRules, inferRelationships, findCanonicalForType, reasonAboutConflict + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture helpers + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + ~version: option=None, + ~canonicalSource: canonicalSource=Inferred, + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version, + canonicalSource, + repository: "test/repo", + branch: "main", + } + + let makeDoc = (content: string, path: string): document => { + Deduplicator.createDocument(content, makeMetadata(~path, ())) + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- LogicEngineTest ---") + + // 1. createKnowledgeBase empty + test("createKnowledgeBase has empty facts and rules", () => { + let kb = LogicEngine.createKnowledgeBase() + assertEqual(Belt.Array.length(kb.facts), 0, "facts should be empty") + assertEqual(Belt.Array.length(kb.rules), 0, "rules should be empty") + }) + + // 2. addFact increments facts count + test("addFact adds one fact", () => { + let kb = LogicEngine.createKnowledgeBase() + let kb = LogicEngine.addFact(kb, LogicEngine.Atom("hello")) + assertEqual(Belt.Array.length(kb.facts), 1, "should have 1 fact") + }) + + // 3. addFact preserves existing facts + test("addFact preserves prior facts", () => { + let kb = LogicEngine.createKnowledgeBase() + let kb = LogicEngine.addFact(kb, LogicEngine.Atom("a")) + let kb = LogicEngine.addFact(kb, LogicEngine.Atom("b")) + assertEqual(Belt.Array.length(kb.facts), 2, "should have 2 facts") + }) + + // 4. addRule increments rules count + test("addRule adds one rule", () => { + let kb = LogicEngine.createKnowledgeBase() + let kb = LogicEngine.addRule( + kb, + LogicEngine.Compound("test", [LogicEngine.Var("X")]), + [LogicEngine.Atom("premise")], + ) + assertEqual(Belt.Array.length(kb.rules), 1, "should have 1 rule") + }) + + // 5. unify same atoms succeeds + test("unify same atoms returns Some", () => { + let result = LogicEngine.unify( + LogicEngine.Atom("x"), + LogicEngine.Atom("x"), + Belt.Map.String.empty, + ) + switch result { + | Some(_) => () + | None => Js.Exn.raiseError("same atoms should unify") + } + }) + + // 6. unify different atoms fails + test("unify different atoms returns None", () => { + let result = LogicEngine.unify( + LogicEngine.Atom("x"), + LogicEngine.Atom("y"), + Belt.Map.String.empty, + ) + switch result { + | None => () + | Some(_) => Js.Exn.raiseError("different atoms should not unify") + } + }) + + // 7. unify variable binding + test("unify variable binds to term", () => { + let result = LogicEngine.unify( + LogicEngine.Var("X"), + LogicEngine.Atom("hello"), + Belt.Map.String.empty, + ) + switch result { + | Some(sub) => { + switch sub->Belt.Map.String.get("X") { + | Some(LogicEngine.Atom("hello")) => () + | _ => Js.Exn.raiseError("X should be bound to Atom(hello)") + } + } + | None => Js.Exn.raiseError("variable should unify with atom") + } + }) + + // 8. unify compound terms same functor + test("unify compound terms same functor and arity", () => { + let result = LogicEngine.unify( + LogicEngine.Compound("f", [LogicEngine.Atom("a")]), + LogicEngine.Compound("f", [LogicEngine.Atom("a")]), + Belt.Map.String.empty, + ) + switch result { + | Some(_) => () + | None => Js.Exn.raiseError("identical compounds should unify") + } + }) + + // 9. unify compound terms different functor + test("unify compound terms different functor fails", () => { + let result = LogicEngine.unify( + LogicEngine.Compound("f", [LogicEngine.Atom("a")]), + LogicEngine.Compound("g", [LogicEngine.Atom("a")]), + Belt.Map.String.empty, + ) + switch result { + | None => () + | Some(_) => Js.Exn.raiseError("different functors should not unify") + } + }) + + // 10. query finds matching facts + test("query finds matching fact", () => { + let kb = LogicEngine.createKnowledgeBase() + let kb = LogicEngine.addFact( + kb, + LogicEngine.Compound("color", [LogicEngine.Atom("red")]), + ) + let results = LogicEngine.query( + kb, + LogicEngine.Compound("color", [LogicEngine.Atom("red")]), + ) + assertTrue(Belt.Array.length(results) > 0, "should find matching fact") + }) + + // 11. query returns empty for non-matching + test("query returns empty for no match", () => { + let kb = LogicEngine.createKnowledgeBase() + let kb = LogicEngine.addFact(kb, LogicEngine.Atom("exists")) + let results = LogicEngine.query(kb, LogicEngine.Atom("missing")) + assertEqual(Belt.Array.length(results), 0, "no match expected") + }) + + // 12. defineDocumentRules adds rules + test("defineDocumentRules adds rules to kb", () => { + let kb = LogicEngine.createKnowledgeBase() + let kb = LogicEngine.defineDocumentRules(kb) + assertTrue(Belt.Array.length(kb.rules) >= 4, "should have at least 4 document rules") + }) + + // 13. inferRelationships finds duplicates + test("inferRelationships detects duplicate docs", () => { + let d1 = makeDoc("same content", "a.md") + let d2 = makeDoc("same content", "b.md") + let rels = LogicEngine.inferRelationships([d1, d2]) + assertTrue(Belt.Array.length(rels) > 0, "should infer duplicate relationship") + let (_, _, relType) = Belt.Array.getUnsafe(rels, 0) + assertEqual(relType, "duplicate_of", "relationship type should be duplicate_of") + }) + + // 14. inferRelationships finds supersedes + test("inferRelationships detects version supersedes", () => { + let m1 = makeMetadata( + ~path="a.md", + ~version=Some({major: 1, minor: 0, patch: 0}), + (), + ) + let m2 = makeMetadata( + ~path="b.md", + ~version=Some({major: 2, minor: 0, patch: 0}), + (), + ) + let d1 = Deduplicator.createDocument("version one", m1) + let d2 = Deduplicator.createDocument("version two", m2) + let rels = LogicEngine.inferRelationships([d1, d2]) + let supersedes = rels->Belt.Array.keep(((_, _, t)) => t == "supersedes") + assertTrue(Belt.Array.length(supersedes) > 0, "should detect supersedes") + }) + + // 15. findCanonicalForType selects correct doc + test("findCanonicalForType picks highest priority", () => { + let m1 = makeMetadata(~path="a.md", ~docType=LICENSE, ~canonicalSource=Inferred, ()) + let m2 = makeMetadata(~path="b.md", ~docType=LICENSE, ~canonicalSource=LicenseFile, ()) + let d1 = Deduplicator.createDocument("lic A", m1) + let d2 = Deduplicator.createDocument("lic B", m2) + switch LogicEngine.findCanonicalForType([d1, d2], LICENSE) { + | Some(doc) => assertEqual(doc.metadata.path, "b.md", "LicenseFile should win") + | None => Js.Exn.raiseError("expected canonical document") + } + }) + + // 16. findCanonicalForType None for wrong type + test("findCanonicalForType returns None for unmatched type", () => { + let d = Deduplicator.createDocument("readme", makeMetadata(~path="r.md", ~docType=README, ())) + switch LogicEngine.findCanonicalForType([d], LICENSE) { + | None => () + | Some(_) => Js.Exn.raiseError("should return None for wrong type") + } + }) + + // 17. reasonAboutConflict produces non-empty string + test("reasonAboutConflict produces reasoning text", () => { + let d1 = makeDoc("same", "a.md") + let d2 = makeDoc("same", "b.md") + let conflict: conflict = { + id: "test", + conflictType: DuplicateContent, + documents: [d1, d2], + detectedAt: Js.Date.now(), + confidence: 1.0, + suggestedStrategy: KeepLatest, + } + let reasoning = LogicEngine.reasonAboutConflict(conflict) + assertTrue(Js.String2.length(reasoning) > 0, "reasoning must be non-empty") + assertTrue(Js.String2.includes(reasoning, "identical"), "should mention identical content") + }) + + // 18. reasonAboutConflict handles different content + test("reasonAboutConflict identifies different content", () => { + let d1 = makeDoc("content A", "a.md") + let d2 = makeDoc("content B", "b.md") + let conflict: conflict = { + id: "test2", + conflictType: SemanticConflict, + documents: [d1, d2], + detectedAt: Js.Date.now(), + confidence: 0.5, + suggestedStrategy: Merge, + } + let reasoning = LogicEngine.reasonAboutConflict(conflict) + assertTrue(Js.String2.includes(reasoning, "different content"), "should note different content") + }) + + // 19. inferenceToEdges converts relationships to edges + test("inferenceToEdges produces correct edge types", () => { + let d1 = makeDoc("same", "a.md") + let d2 = makeDoc("same", "b.md") + let rels = LogicEngine.inferRelationships([d1, d2]) + let edges = LogicEngine.inferenceToEdges(rels) + assertTrue(Belt.Array.length(edges) > 0, "should produce edges") + let edge = Belt.Array.getUnsafe(edges, 0) + assertEqual(edge.edgeType, DuplicateOf, "edge type should be DuplicateOf") + assertEqual(edge.confidence, 0.85, "inferred edge confidence is 0.85") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/PackShipperTest.affine b/migration/affinescript/recon-silly-ation/tests/PackShipperTest.affine new file mode 100644 index 00000000..134c5327 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/PackShipperTest.affine @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/PackShipperTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 14: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 28: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 34: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module PackShipperTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // PackShipperTest - Unit tests for document bundle packaging and distribution + // Tests: pack spec strings, validateManifest, manifestToJson + + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- PackShipperTest ---") + + // 1. hyperpolymathPackSpec exists and is non-empty + test("hyperpolymathPackSpec is non-empty", () => { + assertTrue( + Js.String2.length(PackShipper.hyperpolymathPackSpec) > 0, + "hyperpolymath pack spec must exist", + ) + }) + + // 2. hyperpolymathPackSpec contains README requirement + test("hyperpolymathPackSpec requires README", () => { + assertTrue( + Js.String2.includes(PackShipper.hyperpolymathPackSpec, "README"), + "hyperpolymath pack should require README", + ) + }) + + // 3. minimalPackSpec exists and is non-empty + test("minimalPackSpec is non-empty", () => { + assertTrue( + Js.String2.length(PackShipper.minimalPackSpec) > 0, + "minimal pack spec must exist", + ) + }) + + // 4. minimalPackSpec contains LICENSE requirement + test("minimalPackSpec requires LICENSE", () => { + assertTrue( + Js.String2.includes(PackShipper.minimalPackSpec, "LICENSE"), + "minimal pack should require LICENSE", + ) + }) + + // 5. securityPackSpec exists and is non-empty + test("securityPackSpec is non-empty", () => { + assertTrue( + Js.String2.length(PackShipper.securityPackSpec) > 0, + "security pack spec must exist", + ) + }) + + // 6. securityPackSpec contains SECURITY requirement + test("securityPackSpec requires SECURITY", () => { + assertTrue( + Js.String2.includes(PackShipper.securityPackSpec, "SECURITY"), + "security pack should require SECURITY", + ) + }) + + // 7. ossPackSpec exists and is non-empty + test("ossPackSpec is non-empty", () => { + assertTrue( + Js.String2.length(PackShipper.ossPackSpec) > 0, + "OSS pack spec must exist", + ) + }) + + // 8. ossPackSpec contains CODE_OF_CONDUCT + test("ossPackSpec requires CODE_OF_CONDUCT", () => { + assertTrue( + Js.String2.includes(PackShipper.ossPackSpec, "CODE_OF_CONDUCT"), + "OSS pack should require CODE_OF_CONDUCT", + ) + }) + + // 9. validateManifest returns true for valid manifest + test("validateManifest returns true for valid manifest", () => { + let manifest: PackShipper.packManifest = { + name: "test-pack", + version: "1.0.0", + description: "Test", + author: "Jonathan D.A. Jewell", + license: "PMPL-1.0-or-later", + created: Js.Date.now(), + documents: [], + validation: { + packSpec: "minimal", + validated: true, + validatedAt: Js.Date.now(), + errors: [], + warnings: [], + }, + } + assertEqual(PackShipper.validateManifest(manifest), true, "valid manifest should pass") + }) + + // 10. validateManifest returns false for invalid manifest + test("validateManifest returns false when validated is false", () => { + let manifest: PackShipper.packManifest = { + name: "test-pack", + version: "1.0.0", + description: "Test", + author: "Jonathan D.A. Jewell", + license: "PMPL-1.0-or-later", + created: Js.Date.now(), + documents: [], + validation: { + packSpec: "minimal", + validated: false, + validatedAt: Js.Date.now(), + errors: ["Missing LICENSE"], + warnings: [], + }, + } + assertEqual(PackShipper.validateManifest(manifest), false, "invalid manifest should fail") + }) + + // 11. manifestToJson produces JSON with name + test("manifestToJson contains name field", () => { + let manifest: PackShipper.packManifest = { + name: "my-bundle", + version: "2.0.0", + description: "A test bundle", + author: "test", + license: "PMPL-1.0-or-later", + created: 1000.0, + documents: [], + validation: { + packSpec: "minimal", + validated: true, + validatedAt: 1000.0, + errors: [], + warnings: [], + }, + } + let json = PackShipper.manifestToJson(manifest) + assertTrue(Js.String2.includes(json, "my-bundle"), "JSON should contain name") + assertTrue(Js.String2.includes(json, "2.0.0"), "JSON should contain version") + }) + + // 12. manifestToJson contains validation section + test("manifestToJson contains validation section", () => { + let manifest: PackShipper.packManifest = { + name: "test", + version: "1.0.0", + description: "", + author: "", + license: "PMPL-1.0-or-later", + created: 1000.0, + documents: [], + validation: { + packSpec: "test", + validated: true, + validatedAt: 1000.0, + errors: [], + warnings: [], + }, + } + let json = PackShipper.manifestToJson(manifest) + assertTrue(Js.String2.includes(json, "\"validation\""), "JSON should contain validation") + assertTrue(Js.String2.includes(json, "\"validated\""), "JSON should contain validated field") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/PipelineTest.affine b/migration/affinescript/recon-silly-ation/tests/PipelineTest.affine new file mode 100644 index 00000000..2b150621 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/PipelineTest.affine @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/PipelineTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 6 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 15: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 29: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 35: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 106: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Some(_) => Js.Exn.raiseError("completedAt should be None initially") +// - [untyped-exception] line 115: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Error(msg) => Js.Exn.raiseError("expected Ok, got Error: " ++ msg) +// - [untyped-exception] line 176: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// | Error(_) => Js.Exn.raiseError("expected Ok for existing directory") + +module PipelineTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // PipelineTest - Unit tests for the orchestration pipeline + // Tests: createPipelineState, scanRepository, normalizeDocuments + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Fixture helpers + // --------------------------------------------------------------------------- + + let makeMetadata = ( + ~path: string, + ~docType: documentType=README, + ~lastModified: float=1000.0, + (), + ): documentMetadata => { + path, + documentType: docType, + lastModified, + version: None, + canonicalSource: Inferred, + repository: "test/repo", + branch: "main", + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- PipelineTest ---") + + // 1. createPipelineState initial stage + test("createPipelineState starts at Scan", () => { + let state = Pipeline.createPipelineState() + assertEqual(state.stage, Scan, "initial stage should be Scan") + }) + + // 2. createPipelineState empty docs + test("createPipelineState has empty documents", () => { + let state = Pipeline.createPipelineState() + assertEqual(Belt.Array.length(state.documents), 0, "no documents initially") + }) + + // 3. createPipelineState empty conflicts + test("createPipelineState has empty conflicts", () => { + let state = Pipeline.createPipelineState() + assertEqual(Belt.Array.length(state.conflicts), 0, "no conflicts initially") + }) + + // 4. createPipelineState empty resolutions + test("createPipelineState has empty resolutions", () => { + let state = Pipeline.createPipelineState() + assertEqual(Belt.Array.length(state.resolutions), 0, "no resolutions initially") + }) + + // 5. createPipelineState empty errors + test("createPipelineState has empty errors", () => { + let state = Pipeline.createPipelineState() + assertEqual(Belt.Array.length(state.errors), 0, "no errors initially") + }) + + // 6. createPipelineState has startedAt + test("createPipelineState has positive startedAt", () => { + let state = Pipeline.createPipelineState() + assertTrue(state.startedAt > 0.0, "startedAt should be a positive timestamp") + }) + + // 7. createPipelineState has no completedAt + test("createPipelineState completedAt is None", () => { + let state = Pipeline.createPipelineState() + switch state.completedAt { + | None => () + | Some(_) => Js.Exn.raiseError("completedAt should be None initially") + } + }) + + // 8. scanRepository on existing directory + test("scanRepository returns Ok for existing directory", () => { + // Use the project root itself, which should exist + switch Pipeline.scanRepository("/var$REPOS_DIR/recon-silly-ation") { + | Ok(_) => () // success + | Error(msg) => Js.Exn.raiseError("expected Ok, got Error: " ++ msg) + } + }) + + // 9. scanRepository on missing directory + test("scanRepository handles missing directory gracefully", () => { + // A non-existent path: scanRepository should return Ok([]) since + // the inner scanDir checks existsSync and just returns empty + switch Pipeline.scanRepository("/tmp/nonexistent_test_path_xyz_42") { + | Ok(docs) => assertEqual(Belt.Array.length(docs), 0, "no docs for missing path") + | Error(_) => () // Also acceptable + } + }) + + // 10. normalizeDocuments count preservation + test("normalizeDocuments preserves document count", () => { + let d1 = Deduplicator.createDocument("# A", makeMetadata(~path="a.md", ())) + let d2 = Deduplicator.createDocument("# B", makeMetadata(~path="b.md", ())) + let d3 = Deduplicator.createDocument("# C", makeMetadata(~path="c.md", ())) + let result = Pipeline.normalizeDocuments([d1, d2, d3]) + assertEqual(Belt.Array.length(result), 3, "normalise should not change count") + }) + + // 11. normalizeDocuments empty + test("normalizeDocuments on empty returns empty", () => { + let result = Pipeline.normalizeDocuments([]) + assertEqual(Belt.Array.length(result), 0, "empty in empty out") + }) + + // 12. normalizeDocuments preserves hashes + test("normalizeDocuments preserves existing hashes", () => { + let doc = Deduplicator.createDocument("content", makeMetadata(~path="x.md", ())) + let hashBefore = doc.hash + let result = Pipeline.normalizeDocuments([doc]) + let hashAfter = (Belt.Array.getUnsafe(result, 0)).hash + assertEqual(hashBefore, hashAfter, "hash should not change after normalise") + }) + + // 13. pipeline stage toString round-trip coverage + test("all pipeline stages have string representation", () => { + let stages: array = [ + Scan, + Normalize, + Deduplicate, + DetectConflicts, + ResolveConflicts, + Ingest, + Report, + ] + stages->Belt.Array.forEach(stage => { + let str = pipelineStageToString(stage) + assertTrue(Js.String2.length(str) > 0, "stage toString must be non-empty") + }) + }) + + // 14. scanRepository finds docs in project directory + test("scanRepository finds at least one doc in project root", () => { + switch Pipeline.scanRepository("/var$REPOS_DIR/recon-silly-ation") { + | Ok(docs) => + // The project root should have README, LICENSE, SECURITY, etc. + assertTrue(Belt.Array.length(docs) >= 1, "should find at least 1 doc file") + | Error(_) => Js.Exn.raiseError("expected Ok for existing directory") + } + }) + + // 15. createPipelineState two calls produce independent states + test("createPipelineState produces independent states", () => { + let s1 = Pipeline.createPipelineState() + let s2 = Pipeline.createPipelineState() + // They should both start at Scan and be independent objects + assertEqual(s1.stage, s2.stage, "both should start at Scan") + assertEqual(Belt.Array.length(s1.documents), 0, "s1 empty") + assertEqual(Belt.Array.length(s2.documents), 0, "s2 empty") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/PropertyTest.affine b/migration/affinescript/recon-silly-ation/tests/PropertyTest.affine new file mode 100644 index 00000000..3907ac59 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/PropertyTest.affine @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/PropertyTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 16: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 30: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 36: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module PropertyTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // PropertyTest - Property-based tests (pseudo-generative) + // Tests: hash determinism (100 iterations), normalization idempotency (100), + // version comparison transitivity, dedup count invariant + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Pseudo-random data generators + // --------------------------------------------------------------------------- + + // Simple LCG for deterministic pseudo-random integers + let seedRef = ref(42) + + let nextInt = (): int => { + // Park-Miller LCG + seedRef := mod(seedRef.contents * 48271, 2147483647) + seedRef.contents + } + + let nextString = (~len: int=20, ()): string => { + let chars = "abcdefghijklmnopqrstuvwxyz0123456789 \n\r\t" + let buf = Belt.Array.makeBy(len, _ => { + let idx = mod(nextInt(), Js.String2.length(chars)) + Js.String2.charAt(chars, idx) + }) + buf->Js.Array2.joinWith("") + } + + let makeMetadata = (~path: string, ()): documentMetadata => { + path, + documentType: README, + lastModified: 1000.0, + version: None, + canonicalSource: Inferred, + repository: "test/repo", + branch: "main", + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- PropertyTest ---") + + // Reset seed for reproducibility + seedRef := 42 + + // 1. Hash determinism: 100 iterations + test("property: hash determinism (100 iterations)", () => { + for _ in 1 to 100 { + let content = nextString() + let h1 = Deduplicator.hashContent(content) + let h2 = Deduplicator.hashContent(content) + assertEqual(h1, h2, "hash must be deterministic for same input") + } + }) + + // Reset seed + seedRef := 123 + + // 2. Normalisation idempotency: 100 iterations + test("property: normalisation idempotency (100 iterations)", () => { + for _ in 1 to 100 { + let content = nextString(~len=50, ()) + let once = Deduplicator.normalizeContent(content) + let twice = Deduplicator.normalizeContent(once) + assertEqual(once, twice, "normalise(normalise(x)) == normalise(x)") + } + }) + + // 3. Version comparison transitivity + test("property: version comparison transitivity", () => { + // Generate 50 random version triples and check transitivity + seedRef := 999 + for _ in 1 to 50 { + let a = {major: mod(nextInt(), 10), minor: mod(nextInt(), 20), patch: mod(nextInt(), 100)} + let b = {major: mod(nextInt(), 10), minor: mod(nextInt(), 20), patch: mod(nextInt(), 100)} + let c = {major: mod(nextInt(), 10), minor: mod(nextInt(), 20), patch: mod(nextInt(), 100)} + + let ab = compareVersions(a, b) + let bc = compareVersions(b, c) + let ac = compareVersions(a, c) + + // If a <= b and b <= c then a <= c + if ab <= 0 && bc <= 0 { + assertTrue(ac <= 0, "transitivity: a<=b && b<=c => a<=c") + } + // If a >= b and b >= c then a >= c + if ab >= 0 && bc >= 0 { + assertTrue(ac >= 0, "transitivity: a>=b && b>=c => a>=c") + } + } + }) + + // 4. Version comparison reflexivity + test("property: version comparison reflexivity", () => { + seedRef := 777 + for _ in 1 to 50 { + let v = {major: mod(nextInt(), 10), minor: mod(nextInt(), 20), patch: mod(nextInt(), 100)} + assertEqual(compareVersions(v, v), 0, "v == v must hold") + } + }) + + // 5. Version comparison anti-symmetry + test("property: version comparison anti-symmetry", () => { + seedRef := 333 + for _ in 1 to 50 { + let a = {major: mod(nextInt(), 10), minor: mod(nextInt(), 20), patch: mod(nextInt(), 100)} + let b = {major: mod(nextInt(), 10), minor: mod(nextInt(), 20), patch: mod(nextInt(), 100)} + let ab = compareVersions(a, b) + let ba = compareVersions(b, a) + // sign(compare(a,b)) == -sign(compare(b,a)) + assertTrue( + (ab > 0 && ba < 0) || (ab < 0 && ba > 0) || (ab == 0 && ba == 0), + "anti-symmetry must hold", + ) + } + }) + + // 6. Dedup count invariant: unique + duplicate == total + test("property: dedup count invariant (50 iterations)", () => { + seedRef := 555 + for _ in 1 to 50 { + // Create 2-5 documents, some possibly sharing content + let numDocs = 2 + mod(nextInt(), 4) + // Use a small pool of contents so duplicates are likely + let contentPool = ["alpha", "beta", "gamma"] + let docs = Belt.Array.makeBy(numDocs, i => { + let contentIdx = mod(nextInt(), 3) + let content = Belt.Array.getUnsafe(contentPool, contentIdx) + Deduplicator.createDocument( + content, + makeMetadata(~path=`doc${i->Belt.Int.toString}.md`, ()), + ) + }) + let result = Deduplicator.deduplicate(docs) + assertEqual( + result.stats.uniqueCount + result.stats.duplicateCount, + result.stats.totalProcessed, + "unique + duplicate == total", + ) + } + }) + + // 7. Hash non-empty for any input + test("property: hash is non-empty for all inputs (100 iterations)", () => { + seedRef := 888 + for _ in 1 to 100 { + let content = nextString(~len=1 + mod(nextInt(), 200), ()) + let hash = Deduplicator.hashContent(content) + assertTrue(Js.String2.length(hash) > 0, "hash must be non-empty") + } + }) + + // 8. Normalised content never has CRLF + test("property: normalised content never contains CRLF (100 iterations)", () => { + seedRef := 444 + for _ in 1 to 100 { + let content = nextString(~len=50, ()) + let normalised = Deduplicator.normalizeContent(content) + assertTrue(!Js.String2.includes(normalised, "\r\n"), "no CRLF after normalisation") + } + }) + + // 9. Document type roundtrip for all known types + test("property: documentType roundtrip for all known types", () => { + let types: array = [ + README, LICENSE, SECURITY, CONTRIBUTING, CODE_OF_CONDUCT, + FUNDING, CITATION, CHANGELOG, AUTHORS, SUPPORT, + ] + types->Belt.Array.forEach(dt => { + let str = documentTypeToString(dt) + let back = documentTypeFromString(str) + assertEqual(back, dt, "roundtrip must hold for " ++ str) + }) + }) + + // 10. Dedup of single document: unique=1, dup=0 + test("property: single document dedup has unique=1 dup=0", () => { + seedRef := 111 + for _ in 1 to 20 { + let content = nextString() + let doc = Deduplicator.createDocument(content, makeMetadata(~path="solo.md", ())) + let result = Deduplicator.deduplicate([doc]) + assertEqual(result.stats.uniqueCount, 1, "single doc is unique") + assertEqual(result.stats.duplicateCount, 0, "single doc has no duplicates") + } + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/ProtocolTest.affine b/migration/affinescript/recon-silly-ation/tests/ProtocolTest.affine new file mode 100644 index 00000000..2a95219e --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/ProtocolTest.affine @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/ProtocolTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 14: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 28: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 34: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module ProtocolTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // ProtocolTest - Unit tests for SEAM protocol types and serialization + // Tests: documentEvent construction, documentEventToJson, healthCheckToJson, + // type construction for various protocol types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- ProtocolTest ---") + + // 1. documentEvent construction + test("documentEvent can be constructed", () => { + let event: Protocol.documentEvent = { + id: "evt-001", + eventType: Protocol.Created, + hash: "abc123", + oldHash: None, + path: "README.md", + format: "md", + timestamp: 1000.0, + source: "recon-silly-ation", + } + assertEqual(event.id, "evt-001", "event id should match") + assertEqual(event.path, "README.md", "path should match") + }) + + // 2. documentEventToJson contains id + test("documentEventToJson contains id field", () => { + let event: Protocol.documentEvent = { + id: "evt-test", + eventType: Protocol.Modified, + hash: "def456", + oldHash: Some("abc123"), + path: "LICENSE", + format: "txt", + timestamp: 2000.0, + source: "formatrix-docs", + } + let json = Protocol.documentEventToJson(event) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "evt-test"), "JSON should contain event id") + }) + + // 3. documentEventToJson eventType serialization + test("documentEventToJson serializes eventType correctly", () => { + let event: Protocol.documentEvent = { + id: "evt-1", + eventType: Protocol.Created, + hash: "h1", + oldHash: None, + path: "test.md", + format: "md", + timestamp: 1000.0, + source: "test", + } + let json = Protocol.documentEventToJson(event) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "created"), "eventType should be 'created'") + }) + + // 4. documentEventToJson Modified event type + test("documentEventToJson Modified maps to modified", () => { + let event: Protocol.documentEvent = { + id: "evt-2", + eventType: Protocol.Modified, + hash: "h2", + oldHash: Some("h1"), + path: "test.md", + format: "md", + timestamp: 2000.0, + source: "test", + } + let json = Protocol.documentEventToJson(event) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "modified"), "eventType should be 'modified'") + }) + + // 5. documentEventToJson oldHash None becomes null + test("documentEventToJson oldHash None is null", () => { + let event: Protocol.documentEvent = { + id: "evt-3", + eventType: Protocol.Deleted, + hash: "h3", + oldHash: None, + path: "old.md", + format: "md", + timestamp: 3000.0, + source: "test", + } + let json = Protocol.documentEventToJson(event) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "null"), "oldHash None should serialise to null") + }) + + // 6. healthCheckToJson contains componentId + test("healthCheckToJson contains componentId", () => { + let check: Protocol.healthCheck = { + componentId: "recon-silly-ation", + status: Protocol.Healthy, + latencyMs: Some(42.0), + version: Some("1.0.0"), + checkedAt: 5000.0, + } + let json = Protocol.healthCheckToJson(check) + let str = Js.Json.stringify(json) + assertTrue( + Js.String2.includes(str, "recon-silly-ation"), + "JSON should contain componentId", + ) + }) + + // 7. healthCheckToJson Healthy status + test("healthCheckToJson Healthy status serialises correctly", () => { + let check: Protocol.healthCheck = { + componentId: "test", + status: Protocol.Healthy, + latencyMs: None, + version: None, + checkedAt: 1000.0, + } + let json = Protocol.healthCheckToJson(check) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "healthy"), "status should be 'healthy'") + }) + + // 8. healthCheckToJson Degraded status + test("healthCheckToJson Degraded status includes reason", () => { + let check: Protocol.healthCheck = { + componentId: "db", + status: Protocol.Degraded({reason: "slow queries"}), + latencyMs: Some(500.0), + version: None, + checkedAt: 2000.0, + } + let json = Protocol.healthCheckToJson(check) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "degraded"), "status should contain 'degraded'") + assertTrue(Js.String2.includes(str, "slow queries"), "should include reason text") + }) + + // 9. hashAlgorithm constant + test("hashAlgorithm is sha256", () => { + assertEqual(Protocol.hashAlgorithm, "sha256", "hash algorithm should be sha256") + }) + + // 10. repoContext construction + test("repoContext can be constructed", () => { + let ctx: Protocol.repoContext = { + name: "recon-silly-ation", + description: Some("Documentation reconciliation"), + language: Some("rescript"), + license: Some("PMPL-1.0-or-later"), + topics: ["documentation", "reconciliation"], + existingDocs: ["README.md", "LICENSE"], + dependencies: None, + readme: None, + } + assertEqual(ctx.name, "recon-silly-ation", "name should match") + assertEqual(Belt.Array.length(ctx.topics), 2, "should have 2 topics") + }) + + // 11. generationRequest construction + test("generationRequest can be constructed", () => { + let req: Protocol.generationRequest = { + requestId: "req-001", + documentType: "SECURITY", + format: "md", + context: { + name: "test", + description: None, + language: None, + license: None, + topics: [], + existingDocs: [], + dependencies: None, + readme: None, + }, + priority: 1, + requestedBy: "system", + requestedAt: 1000.0, + } + assertEqual(req.requestId, "req-001", "requestId should match") + assertEqual(req.documentType, "SECURITY", "documentType should match") + }) + + // 12. healthCheckToJson Unknown status + test("healthCheckToJson Unknown status", () => { + let check: Protocol.healthCheck = { + componentId: "unknown-svc", + status: Protocol.Unknown, + latencyMs: None, + version: None, + checkedAt: 3000.0, + } + let json = Protocol.healthCheckToJson(check) + let str = Js.Json.stringify(json) + assertTrue(Js.String2.includes(str, "unknown"), "status should be 'unknown'") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/SecuritySchemeTest.affine b/migration/affinescript/recon-silly-ation/tests/SecuritySchemeTest.affine new file mode 100644 index 00000000..83dabee7 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/SecuritySchemeTest.affine @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/SecuritySchemeTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 15: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 29: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 35: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module SecuritySchemeTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // SecuritySchemeTest - Unit tests for security scheme types + // Note: No SecurityScheme module exists yet; tests focus on type construction + // and expected security context defaults using inline types. + + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Inline types (SecurityScheme module does not exist yet) + // --------------------------------------------------------------------------- + + type hashAlgorithm = + | SHA256 + | SHA384 + | SHA512 + + type signatureScheme = + | Ed25519 + | RSA4096 + + type securityContext = { + hashAlgorithm: hashAlgorithm, + signatureScheme: signatureScheme, + requireSigned: bool, + minHashLength: int, + allowedAlgorithms: array, + } + + let defaultSecurityContext: securityContext = { + hashAlgorithm: SHA256, + signatureScheme: Ed25519, + requireSigned: true, + minHashLength: 64, + allowedAlgorithms: [SHA256, SHA384, SHA512], + } + + let algorithmToString = (alg: hashAlgorithm): string => { + switch alg { + | SHA256 => "sha256" + | SHA384 => "sha384" + | SHA512 => "sha512" + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- SecuritySchemeTest ---") + + // 1. defaultSecurityContext hashAlgorithm + test("defaultSecurityContext uses SHA256", () => { + assertEqual(defaultSecurityContext.hashAlgorithm, SHA256, "default hash should be SHA256") + }) + + // 2. defaultSecurityContext signatureScheme + test("defaultSecurityContext uses Ed25519", () => { + assertEqual( + defaultSecurityContext.signatureScheme, + Ed25519, + "default signature should be Ed25519", + ) + }) + + // 3. defaultSecurityContext requireSigned + test("defaultSecurityContext requireSigned is true", () => { + assertEqual(defaultSecurityContext.requireSigned, true, "require signed should be true") + }) + + // 4. defaultSecurityContext minHashLength + test("defaultSecurityContext minHashLength is 64", () => { + assertEqual(defaultSecurityContext.minHashLength, 64, "min hash length should be 64") + }) + + // 5. defaultSecurityContext allowedAlgorithms + test("defaultSecurityContext has 3 allowed algorithms", () => { + assertEqual( + Belt.Array.length(defaultSecurityContext.allowedAlgorithms), + 3, + "should have 3 allowed algorithms", + ) + }) + + // 6. algorithmToString SHA256 + test("algorithmToString SHA256 returns sha256", () => { + assertEqual(algorithmToString(SHA256), "sha256", "SHA256 -> sha256") + }) + + // 7. algorithmToString SHA384 + test("algorithmToString SHA384 returns sha384", () => { + assertEqual(algorithmToString(SHA384), "sha384", "SHA384 -> sha384") + }) + + // 8. algorithmToString SHA512 + test("algorithmToString SHA512 returns sha512", () => { + assertEqual(algorithmToString(SHA512), "sha512", "SHA512 -> sha512") + }) + + // 9. type construction securityContext + test("securityContext can be constructed with custom values", () => { + let ctx: securityContext = { + hashAlgorithm: SHA512, + signatureScheme: RSA4096, + requireSigned: false, + minHashLength: 128, + allowedAlgorithms: [SHA512], + } + assertEqual(ctx.hashAlgorithm, SHA512, "custom hash algorithm") + assertEqual(ctx.requireSigned, false, "custom requireSigned") + assertEqual(ctx.minHashLength, 128, "custom minHashLength") + }) + + // 10. signatureScheme Ed25519 vs RSA4096 + test("signatureScheme variants are distinct", () => { + assertTrue(Ed25519 != RSA4096, "Ed25519 and RSA4096 should be distinct") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/TestRunner.affine b/migration/affinescript/recon-silly-ation/tests/TestRunner.affine new file mode 100644 index 00000000..4ede13e6 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/TestRunner.affine @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/TestRunner.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 2 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [raw-js] line 80: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %raw(`process.exit(1)`) +// - [raw-js] line 83: %raw JS block. AffineScript has no untyped FFI — replace with a typed extern (see docs/reference/ABI-FFI.md) or wait for the matching binding. +// %raw(`process.exit(0)`) + +module TestRunner; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // TestRunner - Orchestrator for the recon-silly-ation test suite + // Imports and runs all test modules, prints summary with pass/fail counts + + // --------------------------------------------------------------------------- + // Run all test modules + // --------------------------------------------------------------------------- + + let runAllTests = (): unit => { + Js.Console.log("========================================") + Js.Console.log(" recon-silly-ation Test Suite") + Js.Console.log("========================================") + + let totalPassed = ref(0) + let totalFailed = ref(0) + + let recordResults = ((p, f): (int, int)): unit => { + totalPassed := totalPassed.contents + p + totalFailed := totalFailed.contents + f + } + + // 1. Types + TypesTest.run()->recordResults + + // 2. Deduplicator + DeduplicatorTest.run()->recordResults + + // 3. ConflictResolver + ConflictResolverTest.run()->recordResults + + // 4. Pipeline + PipelineTest.run()->recordResults + + // 5. ArangoClient + ArangoClientTest.run()->recordResults + + // 6. LogicEngine + LogicEngineTest.run()->recordResults + + // 7. GraphVisualizer + GraphVisualizerTest.run()->recordResults + + // 8. CCCPCompliance + CCCPComplianceTest.run()->recordResults + + // 9. EnforcementBot + EnforcementBotTest.run()->recordResults + + // 10. PackShipper + PackShipperTest.run()->recordResults + + // 11. Protocol + ProtocolTest.run()->recordResults + + // 12. SecurityScheme + SecuritySchemeTest.run()->recordResults + + // 13. Integration + IntegrationTest.run()->recordResults + + // 14. Property + PropertyTest.run()->recordResults + + // --------------------------------------------------------------------------- + // Summary + // --------------------------------------------------------------------------- + + let total = totalPassed.contents + totalFailed.contents + + Js.Console.log("\n========================================") + Js.Console.log(" Test Summary") + Js.Console.log("========================================") + Js.Console.log(` Total: ${total->Belt.Int.toString}`) + Js.Console.log(` Passed: ${totalPassed.contents->Belt.Int.toString}`) + Js.Console.log(` Failed: ${totalFailed.contents->Belt.Int.toString}`) + Js.Console.log("========================================") + + if totalFailed.contents > 0 { + Js.Console.log(`\n RESULT: FAILED (${totalFailed.contents->Belt.Int.toString} failures)`) + %raw(`process.exit(1)`) + } else { + Js.Console.log("\n RESULT: ALL TESTS PASSED") + %raw(`process.exit(0)`) + } + } + + // Auto-run + let _ = runAllTests() + +*/ diff --git a/migration/affinescript/recon-silly-ation/tests/TypesTest.affine b/migration/affinescript/recon-silly-ation/tests/TypesTest.affine new file mode 100644 index 00000000..0ee90722 --- /dev/null +++ b/migration/affinescript/recon-silly-ation/tests/TypesTest.affine @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/recon-silly-ation/tests/TypesTest.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: 3 migration considerations detected. Each entry below +// names the pattern, source line, and the AffineScript +// answer to consider before porting. +// - [untyped-exception] line 16: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// try { +// - [untyped-exception] line 30: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) +// - [untyped-exception] line 36: Untyped exception / Promise.catch. AffineScript prefers Result[E, A] for fail-fast paths and Validation[E, A] for accumulating errors. +// Js.Exn.raiseError(msg) + +module TypesTest; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // TypesTest - Unit tests for core domain types + // Tests: documentTypeToString/fromString roundtrips, version comparison, + // versionToString, edgeTypeToString, resolutionStrategyToString, pipelineStageToString + + open Types + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + let passed = ref(0) + let failed = ref(0) + + let test = (name: string, fn: unit => unit): unit => { + try { + fn() + passed := passed.contents + 1 + Js.Console.log(` PASS ${name}`) + } catch { + | _ => { + failed := failed.contents + 1 + Js.Console.error(` FAIL ${name}`) + } + } + } + + let assertTrue = (cond: bool, msg: string): unit => { + if !cond { + Js.Exn.raiseError(msg) + } + } + + let assertEqual = (a: 'a, b: 'a, msg: string): unit => { + if a != b { + Js.Exn.raiseError(msg) + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + let run = (): (int, int) => { + Js.Console.log("\n--- TypesTest ---") + + // 1. documentType round-trips + test("documentTypeToString README", () => { + assertEqual(documentTypeToString(README), "README", "expected README") + }) + + test("documentTypeFromString README", () => { + assertEqual(documentTypeFromString("README"), README, "expected README variant") + }) + + test("documentType round-trip LICENSE", () => { + let dt = LICENSE + assertEqual(documentTypeFromString(documentTypeToString(dt)), dt, "LICENSE round-trip") + }) + + test("documentType round-trip SECURITY", () => { + let dt = SECURITY + assertEqual(documentTypeFromString(documentTypeToString(dt)), dt, "SECURITY round-trip") + }) + + test("documentType round-trip CONTRIBUTING", () => { + assertEqual( + documentTypeFromString(documentTypeToString(CONTRIBUTING)), + CONTRIBUTING, + "CONTRIBUTING round-trip", + ) + }) + + test("documentType round-trip CODE_OF_CONDUCT", () => { + assertEqual( + documentTypeFromString(documentTypeToString(CODE_OF_CONDUCT)), + CODE_OF_CONDUCT, + "CODE_OF_CONDUCT round-trip", + ) + }) + + test("documentType round-trip FUNDING", () => { + assertEqual( + documentTypeFromString(documentTypeToString(FUNDING)), + FUNDING, + "FUNDING round-trip", + ) + }) + + test("documentType round-trip CITATION", () => { + assertEqual( + documentTypeFromString(documentTypeToString(CITATION)), + CITATION, + "CITATION round-trip", + ) + }) + + test("documentType Custom round-trip", () => { + let dt = Custom("MY_DOC") + assertEqual(documentTypeFromString(documentTypeToString(dt)), dt, "Custom round-trip") + }) + + // 2. Version comparison + test("compareVersions equal", () => { + let v = {major: 1, minor: 2, patch: 3} + assertEqual(compareVersions(v, v), 0, "equal versions should be 0") + }) + + test("compareVersions major differs", () => { + let v1 = {major: 2, minor: 0, patch: 0} + let v2 = {major: 1, minor: 9, patch: 9} + assertTrue(compareVersions(v1, v2) > 0, "2.0.0 > 1.9.9") + }) + + test("compareVersions minor differs", () => { + let v1 = {major: 1, minor: 3, patch: 0} + let v2 = {major: 1, minor: 2, patch: 9} + assertTrue(compareVersions(v1, v2) > 0, "1.3.0 > 1.2.9") + }) + + test("compareVersions patch differs", () => { + let v1 = {major: 1, minor: 0, patch: 5} + let v2 = {major: 1, minor: 0, patch: 3} + assertTrue(compareVersions(v1, v2) > 0, "1.0.5 > 1.0.3") + }) + + test("compareVersions transitivity", () => { + let a = {major: 1, minor: 0, patch: 0} + let b = {major: 1, minor: 1, patch: 0} + let c = {major: 2, minor: 0, patch: 0} + assertTrue( + compareVersions(a, b) < 0 && compareVersions(b, c) < 0 && compareVersions(a, c) < 0, + "version comparison transitivity a < b < c => a < c", + ) + }) + + // 3. versionToString + test("versionToString basic", () => { + let v = {major: 3, minor: 14, patch: 159} + assertEqual(versionToString(v), "3.14.159", "expected 3.14.159") + }) + + test("versionToString zeroes", () => { + let v = {major: 0, minor: 0, patch: 0} + assertEqual(versionToString(v), "0.0.0", "expected 0.0.0") + }) + + // 4. edgeTypeToString + test("edgeTypeToString ConflictsWith", () => { + assertEqual(edgeTypeToString(ConflictsWith), "conflicts_with", "expected conflicts_with") + }) + + test("edgeTypeToString DuplicateOf", () => { + assertEqual(edgeTypeToString(DuplicateOf), "duplicate_of", "expected duplicate_of") + }) + + test("edgeTypeToString SupersededBy", () => { + assertEqual(edgeTypeToString(SupersededBy), "superseded_by", "expected superseded_by") + }) + + // 5. resolutionStrategyToString + test("resolutionStrategyToString KeepLatest", () => { + assertEqual(resolutionStrategyToString(KeepLatest), "keep_latest", "expected keep_latest") + }) + + test("resolutionStrategyToString RequireManual", () => { + assertEqual( + resolutionStrategyToString(RequireManual), + "require_manual", + "expected require_manual", + ) + }) + + // 6. pipelineStageToString + test("pipelineStageToString Scan", () => { + assertEqual(pipelineStageToString(Scan), "scan", "expected scan") + }) + + test("pipelineStageToString Report", () => { + assertEqual(pipelineStageToString(Report), "report", "expected report") + }) + + (passed.contents, failed.contents) + } + +*/ diff --git a/migration/affinescript/tools/dispatcher/src/CLI.affine b/migration/affinescript/tools/dispatcher/src/CLI.affine new file mode 100644 index 00000000..33139d93 --- /dev/null +++ b/migration/affinescript/tools/dispatcher/src/CLI.affine @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/tools/dispatcher/src/CLI.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module CLI; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // CLI.res - Command-line interface for git-dispatcher + + open Plan + + type command = + | Execute({planPath: string, dryRun: bool}) + | Validate({planPath: string}) + | Help + | Version + + // Parse command line arguments + let parseArgs = (args: array): command => { + if Array.length(args) == 0 { + Help + } else { + switch args[0] { + | Some("execute") | Some("exec") | Some("run") => { + let planPath = Array.get(args, 1)->Option.getWithDefault("plan.json") + let dryRun = Array.some(args, arg => arg == "--dry-run" || arg == "-d") + (Execute({planPath: planPath, dryRun: dryRun}): command) + } + | Some("validate") | Some("check") => { + let planPath = Array.get(args, 1)->Option.getWithDefault("plan.json") + (Validate({planPath: planPath}): command) + } + | Some("version") | Some("-v") | Some("--version") => + (Version: command) + | Some("help") | Some("-h") | Some("--help") | _ => + (Help: command) + } + } + } + + // Display help text + let showHelp = () => { + Console.log("git-dispatcher - Execution engine for reposystem plans\n") + Console.log("USAGE:") + Console.log(" git-dispatcher [options]\n") + Console.log("COMMANDS:") + Console.log(" execute Execute a plan file") + Console.log(" --dry-run, -d Dry run mode (no changes)") + Console.log(" validate Validate a plan file") + Console.log(" help Show this help") + Console.log(" version Show version\n") + Console.log("EXAMPLES:") + Console.log(" git-dispatcher execute plan.json") + Console.log(" git-dispatcher execute plan.json --dry-run") + Console.log(" git-dispatcher validate plan.json") + } + + // Display version + let showVersion = () => { + Console.log("git-dispatcher v0.1.0-dev") + Console.log("ReScript + Deno execution engine") + } + + // Load plan from JSON file + let loadPlan = (path: string): Promise.t> => { + // TODO v0.1.0: Use Deno.readTextFile + Console.log(`[CLI] Would load plan from: ${path}`) + + // Mock plan for testing + let mockPlan: plan = { + id: "test-plan-001", + name: "Test Integration Plan", + description: "Test plan for integration operations", + scenarioId: Some("test-scenario"), + operations: [ + { + id: "op-001", + opType: UpdateMetadataFromSeo({ + repoPath: "/path/to/repo", + runAnalysis: true, + }), + risk: Medium, + description: "Update SEO metadata", + requires: [], + reversible: true, + }, + { + id: "op-002", + opType: RenderDocumentation({ + repoPath: "/path/to/repo", + templates: [], + }), + risk: Low, + description: "Render documentation", + requires: ["op-001"], + reversible: true, + }, + ], + rollbackPlan: [], + createdAt: Date.now()->Float.toString, + metadata: Js.Dict.empty(), + } + + Promise.resolve(Ok(mockPlan)) + } + + // Validate a plan + let validatePlan = (plan: plan): Result.t => { + // Basic validation + if String.length(plan.id) == 0 { + Error("Plan ID is empty") + } else if Array.length(plan.operations) == 0 { + Error("Plan has no operations") + } else { + Console.log(`✓ Plan ${plan.id} is valid`) + Console.log(` Name: ${plan.name}`) + Console.log(` Operations: ${Int.toString(Array.length(plan.operations))}`) + Ok() + } + } + + // Execute a plan + let executePlan = (planPath: string, dryRun: bool): Promise.t => { + loadPlan(planPath) + ->Promise.then(result => { + switch result { + | Error(err) => { + Console.error(`Failed to load plan: ${err}`) + Promise.resolve(1) + } + | Ok(plan) => { + let ctx: executionContext = { + planId: plan.id, + dryRun, + parallel: false, + maxRetries: 3, + timeout: 300, + auditLog: true, + requireApproval: false, + } + + Executor.executePlan(plan, ctx) + ->Promise.then(result => { + Console.log("\n=== Execution Complete ===") + Console.log(`Plan: ${result.planId}`) + Console.log(`Status: ${statusToString(result.status)}`) + Console.log(`Operations: ${Int.toString(Array.length(result.operations))}`) + + let exitCode = if isSuccessful(result.status) { 0 } else { 1 } + Promise.resolve(exitCode) + }) + } + } + }) + } + + // Main entry point + let run = (args: array): Promise.t => { + let cmd = parseArgs(args) + + switch cmd { + | Help => { + showHelp() + Promise.resolve(0) + } + | Version => { + showVersion() + Promise.resolve(0) + } + | Validate({planPath}) => { + loadPlan(planPath) + ->Promise.then(result => { + switch result { + | Error(err) => { + Console.error(`Failed to load plan: ${err}`) + Promise.resolve(1) + } + | Ok(plan) => { + switch validatePlan(plan) { + | Ok() => Promise.resolve(0) + | Error(err) => { + Console.error(`Validation failed: ${err}`) + Promise.resolve(1) + } + } + } + } + }) + } + | Execute({planPath, dryRun}) => + executePlan(planPath, dryRun) + } + } + +*/ diff --git a/migration/affinescript/tools/dispatcher/src/cli/Main.affine b/migration/affinescript/tools/dispatcher/src/cli/Main.affine new file mode 100644 index 00000000..50b96b99 --- /dev/null +++ b/migration/affinescript/tools/dispatcher/src/cli/Main.affine @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/tools/dispatcher/src/cli/Main.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Main; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Main.res - CLI entry point + + // CLI command types + type rec command = + | Plan({action: planAction}) + | Execute({planId: string, dryRun: bool}) + | Status({planId: option}) + | Audit({filter: Audit.auditFilter}) + | Help + | Version + + and planAction = + | Load({path: string}) + | Show({planId: string}) + | List + | Validate({planId: string}) + + // CLI result + type cliResult = + | Success({message: string, data: option}) + | Error({message: string, code: int}) + + // Version info + let version = "0.1.0" + let banner = ` + ┌──────────────────────────────────────────┐ + │ git-dispatcher v${version} │ + │ Git workflow dispatcher │ + │ https://github.com/hyperpolymath │ + └──────────────────────────────────────────┘ + ` + + // Help text + let helpText = ` + Usage: git-dispatcher [options] + + Commands: + plan load Load a plan from JSON file + plan show Display plan details + plan list List all loaded plans + plan validate Validate plan structure + + execute Execute a plan + execute --dry-run Dry-run execution (show operations) + + status Show execution status for all plans + status Show status for specific plan + + audit Show audit log + audit --plan Show audit log for specific plan + + help Show this help message + version Show version information + + Options: + --dry-run Execute in dry-run mode (no actual operations) + --parallel Execute operations in parallel where safe + --timeout Set operation timeout (default: 300) + --no-audit Disable audit logging + + Examples: + # Load a plan from reposystem + git-dispatcher plan load plans/scenario-123.json + + # Dry-run to see what would execute + git-dispatcher execute --dry-run plan-abc123 + + # Execute plan + git-dispatcher execute plan-abc123 + + # Check execution status + git-dispatcher status plan-abc123 + + # View audit log + git-dispatcher audit --plan plan-abc123 + ` + + // Parse command line arguments + let parseArgs = (args: array): Result.t => { + let len = Array.length(args) + + if len == 0 { + Ok(Help) + } else { + switch args[0] { + | Some("help") | Some("--help") | Some("-h") => Ok(Help) + | Some("version") | Some("--version") | Some("-v") => Ok(Version) + | Some("plan") => + switch args[1] { + | Some("load") => + switch args[2] { + | Some(path) => Ok(Plan({action: Load({path: path})})) + | None => Error("Missing path argument for 'plan load'") + } + | Some("show") => + switch args[2] { + | Some(planId) => Ok(Plan({action: Show({planId: planId})})) + | None => Error("Missing plan-id argument for 'plan show'") + } + | Some("list") => Ok(Plan({action: List})) + | Some("validate") => + switch args[2] { + | Some(planId) => Ok(Plan({action: Validate({planId: planId})})) + | None => Error("Missing plan-id argument for 'plan validate'") + } + | _ => Error("Unknown plan action. Use: load, show, list, validate") + } + | Some("execute") => + let dryRun = Array.some(args, arg => arg == "--dry-run") + switch args->Array.keep(arg => arg != "--dry-run")->Array.get(1) { + | Some(planId) => Ok(Execute({planId: planId, dryRun: dryRun})) + | None => Error("Missing plan-id argument for 'execute'") + } + | Some("status") => + switch args[1] { + | Some(planId) => Ok(Status({planId: Some(planId)})) + | None => Ok(Status({planId: None})) + } + | Some("audit") => { + let filter: Audit.auditFilter = { + planId: None, + eventType: None, + startDate: None, + endDate: None, + limit: 100, + } + Ok(Audit({filter: filter})) + } + | Some(cmd) => Error(`Unknown command: ${cmd}`) + | None => Ok(Help) + } + } + } + + // Run command + let run = (command: command): cliResult => { + switch command { + | Help => Success({message: helpText, data: None}) + | Version => Success({message: banner, data: None}) + | Plan({action}) => + switch action { + | Load({path}) => Success({ + message: `Would load plan from: ${path}`, + data: None, + }) + | Show({planId}) => Success({ + message: `Would show plan: ${planId}`, + data: None, + }) + | List => Success({ + message: "Would list all plans", + data: None, + }) + | Validate({planId}) => Success({ + message: `Would validate plan: ${planId}`, + data: None, + }) + } + | Execute({planId, dryRun}) => + if dryRun { + Success({ + message: `Dry-run execution for plan: ${planId}`, + data: None, + }) + } else { + Success({ + message: `Would execute plan: ${planId}`, + data: None, + }) + } + | Status({planId}) => + switch planId { + | Some(id) => Success({ + message: `Would show status for plan: ${id}`, + data: None, + }) + | None => Success({ + message: "Would show status for all plans", + data: None, + }) + } + | Audit({filter: _}) => Success({ + message: "Would show audit log", + data: None, + }) + } + } + + // Entry point (called from JS/Deno) + let main = (args: array): int => { + switch parseArgs(args) { + | Ok(command) => + switch run(command) { + | Success({message, data: _}) => { + Js.log(message) + 0 + } + | Error({message, code}) => { + Js.log("Error: " ++ message) + code + } + } + | Error(msg) => { + Js.log("Error: " ++ msg) + Js.log("\nUse 'git-dispatcher help' for usage information") + 1 + } + } + } + +*/ diff --git a/migration/affinescript/tools/dispatcher/src/engine/Executor.affine b/migration/affinescript/tools/dispatcher/src/engine/Executor.affine new file mode 100644 index 00000000..fdc78a2b --- /dev/null +++ b/migration/affinescript/tools/dispatcher/src/engine/Executor.affine @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/tools/dispatcher/src/engine/Executor.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module Executor; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // Executor.res - Core execution engine for operations + + open Plan + + // Execute a single operation + let executeOperation = ( + op: planOp, + ctx: executionContext, + ): Promise.t => { + let startTime = Date.now()->Float.toString + + Console.log(`[Executor] Starting operation ${op.id}: ${op.description}`) + + // Create result builder + let makeResult = (status, output, error) => { + { + opId: op.id, + status, + startedAt: Some(startTime), + completedAt: Some(Date.now()->Float.toString), + output, + error, + metadata: Js.Dict.empty(), + } + } + + // Validate before execution + let validationPromise = switch op.opType { + | UpdateMetadataFromSeo({repoPath, runAnalysis}) => + IntegrationValidator.validateUpdateMetadataFromSeo(repoPath, runAnalysis) + | RenderDocumentation({repoPath, templates}) => + IntegrationValidator.validateRenderDocumentation(repoPath, templates) + | CreateScaffold({template, destination}) => + IntegrationValidator.validateCreateScaffold(template, destination) + | RegisterInReposystem({repoPath, aspects}) => + IntegrationValidator.validateRegisterInReposystem(repoPath, aspects) + | _ => + Promise.resolve(IntegrationValidator.Valid) + } + + validationPromise + ->Promise.then(validation => { + switch validation { + | IntegrationValidator.Invalid(err) => { + Console.error(`[Executor] Validation failed: ${err.reason}`) + Promise.resolve( + makeResult( + Failed({error: `Validation failed: ${err.reason}`}), + None, + Some(`Missing prerequisite: ${err.missingPrerequisite}`), + ) + ) + } + | IntegrationValidator.Valid => { + // Execute based on operation type + let executionPromise = switch op.opType { + | UpdateMetadataFromSeo({repoPath, runAnalysis}) => + SeoUpdater.execute(repoPath, runAnalysis, ctx) + + | RenderDocumentation({repoPath, templates}) => + DocRenderer.execute(repoPath, templates, ctx) + + | CreateScaffold(_) | RegisterInReposystem(_) => + // Still using stubs from IntegrationOps + IntegrationOps.executeIntegrationOp(op.opType, ctx) + + | _ => + // Core operations not yet implemented + Promise.resolve({ + opId: op.id, + status: Skipped({reason: "Core operations not yet implemented"}), + startedAt: None, + completedAt: None, + output: None, + error: None, + metadata: Dict.make(), + }) + } + + executionPromise + ->Promise.then(result => { + // Log the result + switch result.status { + | Completed => Console.log(`[Executor] ✓ ${op.id}: Completed`) + | Failed({error}) => Console.error(`[Executor] ✗ ${op.id}: ${error}`) + | Skipped({reason}) => Console.log(`[Executor] ⊘ ${op.id}: ${reason}`) + | _ => () + } + Promise.resolve(result) + }) + } + } + }) + } + + // Execute a plan + let executePlan = ( + plan: plan, + ctx: executionContext, + ): Promise.t => { + let startTime = Date.now()->Float.toString + + Console.log(`[Executor] Starting plan ${plan.id}: ${plan.name}`) + Console.log(`[Executor] Operations: ${Int.toString(Array.length(plan.operations))}`) + + if ctx.dryRun { + Console.log(`[Executor] DRY RUN - No changes will be made`) + } + + // Execute operations sequentially (TODO: respect dependency graph) + let executeSequentially = (ops: array): Promise.t> => { + Array.reduce( + ops, + Promise.resolve([]), + (accPromise, op) => { + accPromise->Promise.then(acc => { + executeOperation(op, ctx) + ->Promise.then(result => { + Promise.resolve(Array.concat(acc, [result])) + }) + }) + }, + ) + } + + executeSequentially(plan.operations) + ->Promise.then(results => { + let completedAt = Date.now()->Float.toString + + // Check if any operations failed + let failures = Array.keep(results, r => + switch r.status { + | Failed(_) => true + | _ => false + } + ) + + let status = if Array.length(failures) > 0 { + Failed({error: `${Int.toString(Array.length(failures))} operations failed`}) + } else { + Completed + } + + Console.log(`[Executor] Plan ${plan.id} complete: ${statusToString(status)}`) + + Promise.resolve({ + planId: plan.id, + status, + operations: results, + startedAt: startTime, + completedAt: Some(completedAt), + rollbackRequired: Array.length(failures) > 0, + auditTraceId: None, + }) + }) + } + +*/ diff --git a/migration/affinescript/tools/dispatcher/src/executors/DocRenderer.affine b/migration/affinescript/tools/dispatcher/src/executors/DocRenderer.affine new file mode 100644 index 00000000..9c807287 --- /dev/null +++ b/migration/affinescript/tools/dispatcher/src/executors/DocRenderer.affine @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Generated by tools/res-to-affine from /home/user/reposystem/tools/dispatcher/src/executors/DocRenderer.res (--partial, #488) +// PARTIAL PORT (#488): module-top-level functions are rendered as `fn` +// skeletons with switch->match + best-effort expression translation. +// This output DELIBERATELY does NOT type-check — param/return types are +// `_` holes and un-translatable expressions/patterns are `() /* TODO */` +// / `_ /* TODO */`. Fill the holes (check against the quoted original) +// to finish the port. +// +// MIGRATE: scanner found no Phase-1 anti-patterns. A clean .res +// surface does not mean the port is mechanical — +// re-decomposition still applies (see PILOT.md upstream). + +module DocRenderer; + +// (no module-top-level function bindings to port here; see the +// markers above and the original below.) +// TODO: finish the port — fill the type holes and resolve the +// `() /* TODO */` / `_ /* TODO */` islands per the markers. + +/* ORIGINAL RESCRIPT — retained for reference; delete once port lands. + // SPDX-License-Identifier: PMPL-1.0-or-later + // DocRenderer.res - Real implementation of RenderDocumentation operation + + open Plan + + // Find all template files in repository + let findTemplates = (repoPath: string): Promise.t, string>> => { + // TODO v0.1.0: Use Deno.readDir recursively + // Pattern: *.template.* files (e.g., README.template.adoc) + Console.log(`[DocRenderer] Would find templates in ${repoPath}`) + + let mockTemplates = [ + "README.template.adoc", + "docs/API.template.adoc", + ] + + Promise.resolve(Ok(mockTemplates)) + } + + // Run gnosis on a single template + let renderTemplate = ( + repoPath: string, + templatePath: string, + ): Promise.t> => { + let scmDir = `${repoPath}/.machine_readable` + let outputPath = String.replace(templatePath, ".template", "") + + // TODO v0.1.0: Execute gnosis + // Expected command: + // gnosis render