@dsdenes/devboard 0.1.560 → 0.1.562
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
findOnPath,
|
|
6
|
+
originForge,
|
|
7
|
+
rootProblem,
|
|
8
|
+
} from "../repository";
|
|
9
|
+
import { configuredGiteaToken, giteaBaseUrl } from "../../lib/forge";
|
|
10
|
+
import { giteaRepository, type FetchLike } from "../../lib/gitea";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Everything Start must prove before it spawns a scout: the checkout is the
|
|
14
|
+
* canonical root of a GitHub origin, the state directory is inside it, the
|
|
15
|
+
* agent tools exist, the scan command is installed, and the credential can
|
|
16
|
+
* push. A refusal names the missing fact and spawns no peer and no scan.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ScoutPreflightResult = { ok: true; repository: string } | { ok: false; message: string };
|
|
20
|
+
|
|
21
|
+
export interface ScoutPreflightOptions {
|
|
22
|
+
repoDir: string;
|
|
23
|
+
stateDir: string;
|
|
24
|
+
environment?: Record<string, string | undefined>;
|
|
25
|
+
/** Injected for tests; defaults to the operator's `gh`. */
|
|
26
|
+
run?: (argv: string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
27
|
+
/** Injected transport for the Gitea API. */
|
|
28
|
+
giteaFetch?: FetchLike;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ScoutCommandOutcome {
|
|
32
|
+
code: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
/** The child was asked to stop at the deadline or on abort. */
|
|
36
|
+
killed?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ScoutCommandOptions {
|
|
40
|
+
/** Kill the child when this many milliseconds elapse. */
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
/** Kill the child when this signal aborts. */
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
/** Working directory; inherits the worker's when omitted. */
|
|
45
|
+
cwd?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How long a child may ignore SIGTERM before it is sent SIGKILL. */
|
|
49
|
+
const TERMINATE_GRACE_MS = 200;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs one child under an optional deadline. A child that ignores SIGTERM is
|
|
53
|
+
* killed with SIGKILL after a short grace, so a stalled command always
|
|
54
|
+
* settles and always reports a non-zero exit.
|
|
55
|
+
*/
|
|
56
|
+
export async function runCommand(
|
|
57
|
+
command: string[],
|
|
58
|
+
environment?: Record<string, string | undefined>,
|
|
59
|
+
options: ScoutCommandOptions = {},
|
|
60
|
+
): Promise<ScoutCommandOutcome> {
|
|
61
|
+
if (options.signal?.aborted === true) {
|
|
62
|
+
return { code: 1, stdout: "", stderr: "the command was cancelled", killed: true };
|
|
63
|
+
}
|
|
64
|
+
let child: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
65
|
+
try {
|
|
66
|
+
child = Bun.spawn({
|
|
67
|
+
cmd: command,
|
|
68
|
+
stdin: "ignore",
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
env: environment ?? process.env,
|
|
72
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let killed = false;
|
|
79
|
+
let grace: Timer | undefined;
|
|
80
|
+
const terminate = (): void => {
|
|
81
|
+
if (killed) return;
|
|
82
|
+
killed = true;
|
|
83
|
+
try {
|
|
84
|
+
child.kill("SIGTERM");
|
|
85
|
+
} catch {
|
|
86
|
+
// The child already exited.
|
|
87
|
+
}
|
|
88
|
+
grace = setTimeout(() => {
|
|
89
|
+
try {
|
|
90
|
+
child.kill("SIGKILL");
|
|
91
|
+
} catch {
|
|
92
|
+
// It exited inside the grace period.
|
|
93
|
+
}
|
|
94
|
+
}, TERMINATE_GRACE_MS);
|
|
95
|
+
grace.unref?.();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const timer =
|
|
99
|
+
options.timeoutMs === undefined
|
|
100
|
+
? undefined
|
|
101
|
+
: setTimeout(terminate, Math.max(1, options.timeoutMs));
|
|
102
|
+
timer?.unref?.();
|
|
103
|
+
const onAbort = (): void => terminate();
|
|
104
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
try {
|
|
106
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
107
|
+
new Response(child.stdout).text(),
|
|
108
|
+
new Response(child.stderr).text(),
|
|
109
|
+
child.exited,
|
|
110
|
+
]);
|
|
111
|
+
const diagnostic = killed
|
|
112
|
+
? `${stderr.length === 0 ? "" : `${stderr}\n`}the command did not finish before its deadline`
|
|
113
|
+
: stderr;
|
|
114
|
+
// A child killed at the deadline never counts as a successful command,
|
|
115
|
+
// even when it happened to exit 0 while the signal was being delivered.
|
|
116
|
+
return { code: killed && code === 0 ? 1 : code, stdout, stderr: diagnostic, killed };
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
clearTimeout(grace);
|
|
120
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function ghCommand(
|
|
125
|
+
argv: string[],
|
|
126
|
+
environment?: Record<string, string | undefined>,
|
|
127
|
+
options: ScoutCommandOptions = {},
|
|
128
|
+
): Promise<ScoutCommandOutcome> {
|
|
129
|
+
return await runCommand(
|
|
130
|
+
["gh", ...argv],
|
|
131
|
+
{
|
|
132
|
+
...(environment ?? process.env),
|
|
133
|
+
GH_PROMPT_DISABLED: "1",
|
|
134
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
135
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
136
|
+
NO_COLOR: "1",
|
|
137
|
+
},
|
|
138
|
+
options,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstLine(text: string): string | undefined {
|
|
143
|
+
return text
|
|
144
|
+
.split("\n")
|
|
145
|
+
.map((line) => line.trim())
|
|
146
|
+
.find((line) => line.length > 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** True when `stateDir` resolves inside `repoDir` (or is the root itself is refused). */
|
|
150
|
+
export function stateInsideRepository(repoDir: string, stateDir: string): boolean {
|
|
151
|
+
const root = resolve(repoDir);
|
|
152
|
+
const state = resolve(stateDir);
|
|
153
|
+
const path = relative(root, state);
|
|
154
|
+
if (path.length === 0) return false;
|
|
155
|
+
return !path.startsWith("..") && !isAbsolute(path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The installed `/scout-recommendations` command, in the checkout first and
|
|
160
|
+
* then the operator's omp command directories.
|
|
161
|
+
*/
|
|
162
|
+
export function scoutCommandPaths(
|
|
163
|
+
repoDir: string,
|
|
164
|
+
environment: Record<string, string | undefined> = process.env,
|
|
165
|
+
): string[] {
|
|
166
|
+
const home = environment.HOME ?? homedir();
|
|
167
|
+
return [
|
|
168
|
+
join(resolve(repoDir), ".omp", "commands", "scout-recommendations.md"),
|
|
169
|
+
join(home, ".omp", "commands", "scout-recommendations.md"),
|
|
170
|
+
join(home, ".omp", "agent", "commands", "scout-recommendations.md"),
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function preflightScout(options: ScoutPreflightOptions): Promise<ScoutPreflightResult> {
|
|
175
|
+
const environment = options.environment ?? process.env;
|
|
176
|
+
const root = rootProblem(options.repoDir);
|
|
177
|
+
if (root !== undefined) return { ok: false, message: root };
|
|
178
|
+
|
|
179
|
+
const remote = originForge(options.repoDir, environment);
|
|
180
|
+
if (remote === undefined) {
|
|
181
|
+
return { ok: false, message: "the checkout's origin does not name a supported repository" };
|
|
182
|
+
}
|
|
183
|
+
if (remote.forge === "gitlab") {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
message: `the scout supports a GitHub or Gitea origin, not a GitLab one: ${remote.host}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const repository = remote.project;
|
|
190
|
+
|
|
191
|
+
if (!stateInsideRepository(options.repoDir, options.stateDir)) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
message:
|
|
195
|
+
`STATE_DIR ${resolve(options.stateDir)} is not inside REPO_DIR ${resolve(options.repoDir)}; ` +
|
|
196
|
+
"the scout keeps its state beside the checkout",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (findOnPath("omp", environment) === undefined) {
|
|
201
|
+
return { ok: false, message: "missing prerequisite: omp is not on PATH" };
|
|
202
|
+
}
|
|
203
|
+
if (remote.forge === "github" && findOnPath("gh", environment) === undefined) {
|
|
204
|
+
return { ok: false, message: "missing prerequisite: gh is not on PATH" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const commandPaths = scoutCommandPaths(options.repoDir, environment);
|
|
208
|
+
if (!commandPaths.some((path) => existsSync(path))) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
message:
|
|
212
|
+
"missing prerequisite: the /scout-recommendations command is not installed for omp " +
|
|
213
|
+
`(looked in ${commandPaths.join(", ")})`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (remote.forge === "gitea") {
|
|
218
|
+
const baseUrl = giteaBaseUrl(remote, environment);
|
|
219
|
+
if (baseUrl === undefined) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
message:
|
|
223
|
+
"missing prerequisite: the Gitea address is not configured " +
|
|
224
|
+
"(set DEVBOARD_GITEA_URL or use an HTTP(S) origin)",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const token = configuredGiteaToken(environment);
|
|
228
|
+
if (token === undefined) {
|
|
229
|
+
return { ok: false, message: "missing prerequisite: GITEA_TOKEN is not set" };
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const document = await giteaRepository(
|
|
233
|
+
{ baseUrl, project: repository, token },
|
|
234
|
+
options.giteaFetch,
|
|
235
|
+
);
|
|
236
|
+
const permissions =
|
|
237
|
+
typeof document.permissions === "object" && document.permissions !== null
|
|
238
|
+
? document.permissions
|
|
239
|
+
: {};
|
|
240
|
+
const writable =
|
|
241
|
+
("push" in permissions && permissions.push === true) ||
|
|
242
|
+
("admin" in permissions && permissions.admin === true);
|
|
243
|
+
if (!writable) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
message:
|
|
247
|
+
`missing prerequisite: the Gitea token cannot write to ${repository} on ${baseUrl}`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
message:
|
|
254
|
+
`missing prerequisite: the Gitea repository ${repository} could not be read: ` +
|
|
255
|
+
(error instanceof Error ? error.message : String(error)),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, repository };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const execute = options.run ?? ((argv: string[]) => ghCommand(argv, environment));
|
|
262
|
+
const permission = await execute(["api", `repos/${repository}`, "--jq", ".permissions.push"]);
|
|
263
|
+
if (permission.code !== 0 || permission.stdout.trim() !== "true") {
|
|
264
|
+
const detail =
|
|
265
|
+
firstLine(permission.stderr) ??
|
|
266
|
+
firstLine(permission.stdout) ??
|
|
267
|
+
`gh exited with code ${permission.code}`;
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
message:
|
|
271
|
+
`missing prerequisite: 'gh api repos/${repository} --jq .permissions.push' ` +
|
|
272
|
+
`did not return true: ${detail}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { ok: true, repository };
|
|
277
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
4
|
+
import { isInteger } from "../guards";
|
|
5
|
+
import {
|
|
6
|
+
CATEGORIES,
|
|
7
|
+
EXCLUDED_ROOTS,
|
|
8
|
+
SIGNIFICANCE_MAX,
|
|
9
|
+
SIGNIFICANCE_MIN,
|
|
10
|
+
type ScoutCategory,
|
|
11
|
+
} from "./paths";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The scan contract's answer: every recommendation the agent produced, checked
|
|
15
|
+
* against the repository before anything is filed. Validation is atomic — one
|
|
16
|
+
* malformed item rejects the whole answer, so a partial or invented result can
|
|
17
|
+
* never become an issue.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const TITLE_MAX = 200;
|
|
21
|
+
const ID_LENGTH = 16;
|
|
22
|
+
const MAX_SUMMARY = 4000;
|
|
23
|
+
const MAX_QUOTE = 2000;
|
|
24
|
+
const MAX_SIGNIFICANCE_REASON = 2000;
|
|
25
|
+
|
|
26
|
+
export interface ScoutEvidence {
|
|
27
|
+
/** Repository-relative path, `/` separators. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** `N` or `N-M`, inside the file's line count. */
|
|
30
|
+
lines: string;
|
|
31
|
+
quote?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScoutRecommendation {
|
|
35
|
+
/** First 16 hex characters of the finding's identity hash. */
|
|
36
|
+
id: string;
|
|
37
|
+
category: ScoutCategory;
|
|
38
|
+
title: string;
|
|
39
|
+
summary: string;
|
|
40
|
+
impact: string;
|
|
41
|
+
evidence: ScoutEvidence[];
|
|
42
|
+
proposedChange: string;
|
|
43
|
+
files: string[];
|
|
44
|
+
acceptanceCriteria: string[];
|
|
45
|
+
/** 1–5; only a 4 or a 5 earns an issue. */
|
|
46
|
+
significance: number;
|
|
47
|
+
significanceReason: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ScanResult =
|
|
51
|
+
| { ok: true; items: ScoutRecommendation[]; shortfall: string | null }
|
|
52
|
+
| { ok: false; error: string };
|
|
53
|
+
|
|
54
|
+
export function isCategory(value: unknown): value is ScoutCategory {
|
|
55
|
+
return typeof value === "string" && (CATEGORIES as readonly string[]).includes(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lowercase, whitespace-collapsed, trimmed: the one title identity everywhere. */
|
|
59
|
+
export function normalizeTitle(title: string): string {
|
|
60
|
+
return title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `## Category` line of an issue body, for an issue Auto Scout adopted
|
|
65
|
+
* rather than filed. A missing heading or a blank value yields null.
|
|
66
|
+
*/
|
|
67
|
+
export function categoryFromBody(body: string): string | null {
|
|
68
|
+
const lines = body.split(/\r?\n/);
|
|
69
|
+
const heading = lines.findIndex((line) => line.trim().toLowerCase() === "## category");
|
|
70
|
+
if (heading < 0) return null;
|
|
71
|
+
for (let index = heading + 1; index < lines.length; index += 1) {
|
|
72
|
+
const value = lines[index]!.trim();
|
|
73
|
+
if (value.length === 0) continue;
|
|
74
|
+
if (value.startsWith("#")) return null;
|
|
75
|
+
return value.length <= 40 ? value : null;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The same finding produced twice keeps its id: the hash covers the category,
|
|
82
|
+
* the sorted evidence paths, and the normalized title, and nothing that a new
|
|
83
|
+
* scan may legitimately change, such as the summary or the significance score.
|
|
84
|
+
*/
|
|
85
|
+
export function recommendationId(
|
|
86
|
+
category: ScoutCategory,
|
|
87
|
+
evidencePaths: string[],
|
|
88
|
+
title: string,
|
|
89
|
+
): string {
|
|
90
|
+
const paths = [...new Set(evidencePaths)].sort().join("\n");
|
|
91
|
+
return createHash("sha256")
|
|
92
|
+
.update([category, paths, normalizeTitle(title)].join("\u0000"))
|
|
93
|
+
.digest("hex")
|
|
94
|
+
.slice(0, ID_LENGTH);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Normalizes a repository-relative path for display and comparison. */
|
|
98
|
+
export function normalizePath(value: string): string {
|
|
99
|
+
return value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function excluded(relativePath: string): boolean {
|
|
103
|
+
const segments = relativePath.split("/");
|
|
104
|
+
for (const root of EXCLUDED_ROOTS) {
|
|
105
|
+
const parts = root.split("/");
|
|
106
|
+
if (parts.length === 1) {
|
|
107
|
+
if (segments.includes(root)) return true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (let index = 0; index + parts.length <= segments.length; index += 1) {
|
|
111
|
+
let matched = true;
|
|
112
|
+
for (const [offset, part] of parts.entries()) {
|
|
113
|
+
if (segments[index + offset] !== part) {
|
|
114
|
+
matched = false;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (matched) return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface EvidenceCheck {
|
|
125
|
+
ok: boolean;
|
|
126
|
+
/** Repository-relative path when the check passed. */
|
|
127
|
+
path: string;
|
|
128
|
+
detail?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const LINE_SPAN = /^(\d+)(?:-(\d+))?$/;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolves one cited path inside the repository and checks the cited lines
|
|
135
|
+
* against the file's real length. A path escape, a missing or non-file target,
|
|
136
|
+
* an excluded tree, and an out-of-range span are all refusals.
|
|
137
|
+
*/
|
|
138
|
+
export function checkEvidencePath(repoDir: string, rawPath: string, lines: string): EvidenceCheck {
|
|
139
|
+
const path = normalizePath(rawPath);
|
|
140
|
+
if (path.length === 0) return { ok: false, path, detail: "the evidence path is empty" };
|
|
141
|
+
if (path.includes("\u0000")) return { ok: false, path, detail: "the evidence path is malformed" };
|
|
142
|
+
if (isAbsolute(path)) return { ok: false, path, detail: `${path} is absolute` };
|
|
143
|
+
const segments = path.split("/");
|
|
144
|
+
if (segments.includes("..")) return { ok: false, path, detail: `${path} escapes the repository` };
|
|
145
|
+
if (excluded(path)) {
|
|
146
|
+
return { ok: false, path, detail: `${path} is inside an excluded tree` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let root: string;
|
|
150
|
+
try {
|
|
151
|
+
root = realpathSync(repoDir);
|
|
152
|
+
} catch {
|
|
153
|
+
return { ok: false, path, detail: `${repoDir} is not readable` };
|
|
154
|
+
}
|
|
155
|
+
const absolute = resolve(repoDir, path);
|
|
156
|
+
if (absolute !== root && !absolute.startsWith(`${root}${sep}`)) {
|
|
157
|
+
return { ok: false, path, detail: `${path} escapes the repository` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let real: string;
|
|
161
|
+
try {
|
|
162
|
+
real = realpathSync(absolute);
|
|
163
|
+
} catch {
|
|
164
|
+
return { ok: false, path, detail: `${path} does not exist` };
|
|
165
|
+
}
|
|
166
|
+
if (real !== root && !real.startsWith(`${root}${sep}`)) {
|
|
167
|
+
return { ok: false, path, detail: `${path} escapes the repository` };
|
|
168
|
+
}
|
|
169
|
+
let stats;
|
|
170
|
+
try {
|
|
171
|
+
stats = lstatSync(real);
|
|
172
|
+
} catch {
|
|
173
|
+
return { ok: false, path, detail: `${path} does not exist` };
|
|
174
|
+
}
|
|
175
|
+
if (!stats.isFile()) return { ok: false, path, detail: `${path} is not a regular file` };
|
|
176
|
+
|
|
177
|
+
const span = LINE_SPAN.exec(lines.trim());
|
|
178
|
+
if (span === null) {
|
|
179
|
+
return { ok: false, path, detail: `${path}:${lines} is not a line span` };
|
|
180
|
+
}
|
|
181
|
+
const first = Number(span[1]);
|
|
182
|
+
const last = span[2] === undefined ? first : Number(span[2]);
|
|
183
|
+
let count: number;
|
|
184
|
+
try {
|
|
185
|
+
const text = readFileSync(real, "utf8");
|
|
186
|
+
count = text.length === 0 ? 0 : text.replace(/\n$/, "").split("\n").length;
|
|
187
|
+
} catch {
|
|
188
|
+
return { ok: false, path, detail: `${path} is not readable` };
|
|
189
|
+
}
|
|
190
|
+
if (first < 1 || last < first || last > count) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
path,
|
|
194
|
+
detail: `${path}:${lines} is outside the file's ${count} line(s)`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, path };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function boundedText(
|
|
201
|
+
value: unknown,
|
|
202
|
+
field: string,
|
|
203
|
+
limit: number,
|
|
204
|
+
label: string,
|
|
205
|
+
): { ok: true; value: string } | { ok: false; error: string } {
|
|
206
|
+
if (typeof value !== "string") return { ok: false, error: `${label} has no ${field}` };
|
|
207
|
+
const text = value.trim();
|
|
208
|
+
if (text.length === 0) return { ok: false, error: `${label} has an empty ${field}` };
|
|
209
|
+
if (text.length > limit) {
|
|
210
|
+
return { ok: false, error: `${label} has a ${field} longer than ${limit} characters` };
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, value: text };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stringList(
|
|
216
|
+
value: unknown,
|
|
217
|
+
field: string,
|
|
218
|
+
label: string,
|
|
219
|
+
): { ok: true; value: string[] } | { ok: false; error: string } {
|
|
220
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
221
|
+
return { ok: false, error: `${label} has no ${field}` };
|
|
222
|
+
}
|
|
223
|
+
const items: string[] = [];
|
|
224
|
+
for (const entry of value) {
|
|
225
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
226
|
+
return { ok: false, error: `${label} has a blank ${field} entry` };
|
|
227
|
+
}
|
|
228
|
+
items.push(entry.trim());
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, value: items };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateItem(
|
|
234
|
+
value: unknown,
|
|
235
|
+
index: number,
|
|
236
|
+
repoDir: string,
|
|
237
|
+
): { ok: true; item: ScoutRecommendation } | { ok: false; error: string } {
|
|
238
|
+
const fallback = `item ${index + 1}`;
|
|
239
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
240
|
+
return { ok: false, error: `${fallback} is not an object` };
|
|
241
|
+
}
|
|
242
|
+
const source = value as Record<string, unknown>;
|
|
243
|
+
const rawTitle = source.title;
|
|
244
|
+
const label =
|
|
245
|
+
typeof rawTitle === "string" && rawTitle.trim().length > 0
|
|
246
|
+
? `item ${index + 1} ("${rawTitle.trim().slice(0, TITLE_MAX)}")`
|
|
247
|
+
: fallback;
|
|
248
|
+
|
|
249
|
+
if (!isCategory(source.category)) {
|
|
250
|
+
return { ok: false, error: `${label} has no valid category` };
|
|
251
|
+
}
|
|
252
|
+
const category = source.category;
|
|
253
|
+
|
|
254
|
+
const title = boundedText(rawTitle, "title", TITLE_MAX, label);
|
|
255
|
+
if (!title.ok) return title;
|
|
256
|
+
if (/[\r\n]/.test(rawTitle as string)) {
|
|
257
|
+
return { ok: false, error: `${label} has a title that spans more than one line` };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const summary = boundedText(source.summary, "summary", MAX_SUMMARY, label);
|
|
261
|
+
if (!summary.ok) return summary;
|
|
262
|
+
const proposedChange = boundedText(source.proposedChange, "proposed change", MAX_SUMMARY, label);
|
|
263
|
+
if (!proposedChange.ok) return proposedChange;
|
|
264
|
+
|
|
265
|
+
let impact = "";
|
|
266
|
+
if (source.impact !== undefined) {
|
|
267
|
+
const checked = boundedText(source.impact, "impact", MAX_SUMMARY, label);
|
|
268
|
+
if (!checked.ok) return checked;
|
|
269
|
+
impact = checked.value;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const significance = source.significance;
|
|
273
|
+
if (
|
|
274
|
+
!isInteger(significance) ||
|
|
275
|
+
significance < SIGNIFICANCE_MIN ||
|
|
276
|
+
significance > SIGNIFICANCE_MAX
|
|
277
|
+
) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: `${label} has no significance score from ${SIGNIFICANCE_MIN} to ${SIGNIFICANCE_MAX}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const reason = boundedText(
|
|
284
|
+
source.significanceReason,
|
|
285
|
+
"significance reason",
|
|
286
|
+
MAX_SIGNIFICANCE_REASON,
|
|
287
|
+
label,
|
|
288
|
+
);
|
|
289
|
+
if (!reason.ok) return reason;
|
|
290
|
+
|
|
291
|
+
if (!Array.isArray(source.evidence) || source.evidence.length === 0) {
|
|
292
|
+
return { ok: false, error: `${label} cites no evidence` };
|
|
293
|
+
}
|
|
294
|
+
const evidence: ScoutEvidence[] = [];
|
|
295
|
+
for (const [position, entry] of source.evidence.entries()) {
|
|
296
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
297
|
+
return { ok: false, error: `${label} evidence ${position + 1} is not an object` };
|
|
298
|
+
}
|
|
299
|
+
const record = entry as Record<string, unknown>;
|
|
300
|
+
if (typeof record.path !== "string") {
|
|
301
|
+
return { ok: false, error: `${label} evidence ${position + 1} has no path` };
|
|
302
|
+
}
|
|
303
|
+
if (typeof record.lines !== "string") {
|
|
304
|
+
return { ok: false, error: `${label} evidence ${position + 1} has no line span` };
|
|
305
|
+
}
|
|
306
|
+
if (record.quote !== undefined && typeof record.quote !== "string") {
|
|
307
|
+
return { ok: false, error: `${label} evidence ${position + 1} has a non-text quote` };
|
|
308
|
+
}
|
|
309
|
+
const check = checkEvidencePath(repoDir, record.path, record.lines);
|
|
310
|
+
if (!check.ok) return { ok: false, error: `${label} evidence: ${check.detail}` };
|
|
311
|
+
const quote =
|
|
312
|
+
typeof record.quote === "string" && record.quote.trim().length > 0
|
|
313
|
+
? record.quote.trim().slice(0, MAX_QUOTE)
|
|
314
|
+
: undefined;
|
|
315
|
+
evidence.push({
|
|
316
|
+
path: check.path,
|
|
317
|
+
lines: record.lines.trim(),
|
|
318
|
+
...(quote === undefined ? {} : { quote }),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const files = stringList(source.files, "file list", label);
|
|
323
|
+
if (!files.ok) return files;
|
|
324
|
+
const cited = new Set(evidence.map((entry) => entry.path));
|
|
325
|
+
const normalizedFiles = files.value.map(normalizePath);
|
|
326
|
+
for (const file of normalizedFiles) {
|
|
327
|
+
if (!cited.has(file)) {
|
|
328
|
+
return { ok: false, error: `${label} lists ${file}, which its evidence does not cite` };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const criteria = stringList(source.acceptanceCriteria, "acceptance criteria", label);
|
|
333
|
+
if (!criteria.ok) return criteria;
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
item: {
|
|
338
|
+
id: recommendationId(category, evidence.map((entry) => entry.path), title.value),
|
|
339
|
+
category,
|
|
340
|
+
title: title.value,
|
|
341
|
+
summary: summary.value,
|
|
342
|
+
impact,
|
|
343
|
+
evidence,
|
|
344
|
+
proposedChange: proposedChange.value,
|
|
345
|
+
files: [...new Set(normalizedFiles)],
|
|
346
|
+
acceptanceCriteria: criteria.value,
|
|
347
|
+
significance,
|
|
348
|
+
significanceReason: reason.value,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Parses `<scan-dir>/recommendations.json`. A result may carry at most the
|
|
355
|
+
* count the request asked for; anything else fails the whole scan.
|
|
356
|
+
*/
|
|
357
|
+
export function parseScanResult(
|
|
358
|
+
value: unknown,
|
|
359
|
+
options: { requested: number; repoDir: string },
|
|
360
|
+
): ScanResult {
|
|
361
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
362
|
+
return { ok: false, error: "the result is not a JSON object" };
|
|
363
|
+
}
|
|
364
|
+
const source = value as Record<string, unknown>;
|
|
365
|
+
if (!Array.isArray(source.recommendations)) {
|
|
366
|
+
return { ok: false, error: "the result has no recommendations array" };
|
|
367
|
+
}
|
|
368
|
+
if (source.recommendations.length > options.requested) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error:
|
|
372
|
+
`the result carried ${source.recommendations.length} recommendations, ` +
|
|
373
|
+
`more than the ${options.requested} requested`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const items: ScoutRecommendation[] = [];
|
|
378
|
+
const ids = new Map<string, number>();
|
|
379
|
+
for (const [index, entry] of source.recommendations.entries()) {
|
|
380
|
+
const checked = validateItem(entry, index, options.repoDir);
|
|
381
|
+
if (!checked.ok) return { ok: false, error: checked.error };
|
|
382
|
+
const seen = ids.get(checked.item.id);
|
|
383
|
+
if (seen !== undefined) {
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
error:
|
|
387
|
+
`item ${index + 1} ("${checked.item.title}") repeats the id of item ${seen + 1}: ` +
|
|
388
|
+
checked.item.id,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
ids.set(checked.item.id, index);
|
|
392
|
+
items.push(checked.item);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const shortfall =
|
|
396
|
+
typeof source.shortfall === "string" && source.shortfall.trim().length > 0
|
|
397
|
+
? source.shortfall.trim()
|
|
398
|
+
: null;
|
|
399
|
+
return { ok: true, items, shortfall };
|
|
400
|
+
}
|