@dsdenes/devboard 0.1.560 → 0.1.563
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 +729 -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,1510 @@
|
|
|
1
|
+
import { fixtureRecord } from "./fixtures/adr-source";
|
|
2
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { Server } from "bun";
|
|
15
|
+
import { createApp } from "./app";
|
|
16
|
+
import { declaredCommandAliases } from "./discover";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discover Mode's deterministic contract. It drives the app over its WebSocket
|
|
20
|
+
* with the stub ACP peer and a real git fixture host under the harness `.tmp`,
|
|
21
|
+
* never outside the repository root.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
|
|
25
|
+
const TEST_ROOT = join(import.meta.dir, "..", ".tmp", "discover-tests");
|
|
26
|
+
const GOAL_TEXT = "# Fixture goal\n\nKeep the fixture buildable.\n";
|
|
27
|
+
const DISCOVER_SKILLS = [
|
|
28
|
+
"discover-testing-strategy",
|
|
29
|
+
"discover-naming-conventions",
|
|
30
|
+
"discover-coding-conventions",
|
|
31
|
+
"discover-architecture-boundaries",
|
|
32
|
+
"discover-glossary",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const cleanup: (() => void)[] = [];
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
for (const action of cleanup.reverse()) action();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function testDirectory(name: string): string {
|
|
44
|
+
const directory = join(
|
|
45
|
+
TEST_ROOT,
|
|
46
|
+
`${name}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`,
|
|
47
|
+
);
|
|
48
|
+
mkdirSync(directory, { recursive: true });
|
|
49
|
+
cleanup.push(() => {
|
|
50
|
+
try {
|
|
51
|
+
rmSync(directory, { recursive: true, force: true });
|
|
52
|
+
} catch {
|
|
53
|
+
// A locked fixture is left for the operator.
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return directory;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function git(cwd: string, args: string[]): string {
|
|
60
|
+
const result = Bun.spawnSync({
|
|
61
|
+
cmd: [
|
|
62
|
+
"git",
|
|
63
|
+
"-C",
|
|
64
|
+
cwd,
|
|
65
|
+
"-c",
|
|
66
|
+
"user.name=Discover Fixture",
|
|
67
|
+
"-c",
|
|
68
|
+
"user.email=fixture@example.test",
|
|
69
|
+
"-c",
|
|
70
|
+
"commit.gpgsign=false",
|
|
71
|
+
"-c",
|
|
72
|
+
"init.defaultBranch=main",
|
|
73
|
+
...args,
|
|
74
|
+
],
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "true" },
|
|
78
|
+
});
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`);
|
|
81
|
+
}
|
|
82
|
+
return result.stdout.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const GITIGNORE = [
|
|
86
|
+
".worktree/",
|
|
87
|
+
"node_modules/",
|
|
88
|
+
"state/",
|
|
89
|
+
"origin.git/",
|
|
90
|
+
"*.log",
|
|
91
|
+
".env",
|
|
92
|
+
"",
|
|
93
|
+
].join("\n");
|
|
94
|
+
|
|
95
|
+
interface Fixture {
|
|
96
|
+
root: string;
|
|
97
|
+
goalFile: string;
|
|
98
|
+
adrDir: string;
|
|
99
|
+
stateDir: string;
|
|
100
|
+
logPath: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createFixture(name: string, options: { goal?: boolean; dirty?: boolean } = {}): Fixture {
|
|
104
|
+
const root = testDirectory(name);
|
|
105
|
+
mkdirSync(join(root, "adrs"), { recursive: true });
|
|
106
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
107
|
+
mkdirSync(join(root, "test"), { recursive: true });
|
|
108
|
+
mkdirSync(join(root, ".github", "workflows"), { recursive: true });
|
|
109
|
+
writeFileSync(join(root, "README.md"), "# Fixture\n\nA small fixture host for Discover.\n");
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(root, "package.json"),
|
|
112
|
+
`${JSON.stringify({ name: "fixture-host", scripts: { build: "bun run build:src", test: "bun test", "test:unit": "bun test" } }, null, 2)}\n`,
|
|
113
|
+
);
|
|
114
|
+
writeFileSync(join(root, "src", "order.ts"), "export const orderTotal = (total: number): number => total;\n");
|
|
115
|
+
mkdirSync(join(root, "src", "checkout"), { recursive: true });
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(root, "src", "checkout", "flow.ts"),
|
|
118
|
+
"export { orderTotal } from \"../order\";\n",
|
|
119
|
+
);
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(root, "test", "order.test.ts"),
|
|
122
|
+
'import { test, expect } from "bun:test";\ntest("order total", () => expect(1).toBe(1));\n',
|
|
123
|
+
);
|
|
124
|
+
writeFileSync(
|
|
125
|
+
join(root, ".github", "workflows", "ci.yml"),
|
|
126
|
+
"name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n",
|
|
127
|
+
);
|
|
128
|
+
writeFileSync(join(root, "adrs", "0001-fixture.md"), fixtureRecord("0001-fixture.md", "Fixture decision"));
|
|
129
|
+
if (options.goal !== false) writeFileSync(join(root, "GOAL.md"), GOAL_TEXT);
|
|
130
|
+
writeFileSync(join(root, ".gitignore"), GITIGNORE);
|
|
131
|
+
git(root, ["init", "-b", "main"]);
|
|
132
|
+
git(root, ["add", "-A"]);
|
|
133
|
+
git(root, ["commit", "-m", "chore: fixture base"]);
|
|
134
|
+
git(root, ["init", "--bare", join(root, "origin.git")]);
|
|
135
|
+
git(root, ["remote", "add", "origin", join(root, "origin.git")]);
|
|
136
|
+
git(root, ["push", "-u", "origin", "main"]);
|
|
137
|
+
if (options.dirty === true) writeFileSync(join(root, "src", "dirty.ts"), "export const dirty = true;\n");
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
root,
|
|
141
|
+
goalFile: join(root, "GOAL.md"),
|
|
142
|
+
adrDir: join(root, "adrs"),
|
|
143
|
+
stateDir: join(root, "state"),
|
|
144
|
+
logPath: join(root, "agent.log"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface TestServer {
|
|
149
|
+
server: Server<unknown>;
|
|
150
|
+
fixture: Fixture;
|
|
151
|
+
url: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface StartOptions {
|
|
155
|
+
fixture?: Fixture;
|
|
156
|
+
stub?: string[];
|
|
157
|
+
stateDir?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function startServer(options: StartOptions = {}): TestServer {
|
|
161
|
+
const fixture = options.fixture ?? createFixture("discover");
|
|
162
|
+
const stateDir = options.stateDir ?? fixture.stateDir;
|
|
163
|
+
const command = [`bun ${STUB_AGENT}`, `--log=${fixture.logPath}`, ...(options.stub ?? ["--discover"])];
|
|
164
|
+
const server = createApp({
|
|
165
|
+
adrDir: fixture.adrDir,
|
|
166
|
+
staticDir: join(fixture.root, "static"),
|
|
167
|
+
hostname: "127.0.0.1",
|
|
168
|
+
port: 0,
|
|
169
|
+
repoDir: fixture.root,
|
|
170
|
+
stateDir,
|
|
171
|
+
goalFile: fixture.goalFile,
|
|
172
|
+
agentCommand: command.join(" "),
|
|
173
|
+
tracker: async () => undefined,
|
|
174
|
+
trackerSettle: { attempts: 1 },
|
|
175
|
+
});
|
|
176
|
+
const url = `127.0.0.1:${server.port}`;
|
|
177
|
+
fixturesByUrl.set(url, fixture);
|
|
178
|
+
return { server, fixture, url };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface TestClient {
|
|
182
|
+
socket: WebSocket;
|
|
183
|
+
events: Record<string, unknown>[];
|
|
184
|
+
send(command: unknown): void;
|
|
185
|
+
waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
|
|
186
|
+
close(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function connect(url: string): Promise<TestClient> {
|
|
190
|
+
const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
|
|
191
|
+
const socket = new WebSocket(`ws://${url}/ws`);
|
|
192
|
+
const events: Record<string, unknown>[] = [];
|
|
193
|
+
const buffered: Record<string, unknown>[] = [];
|
|
194
|
+
const waiters: {
|
|
195
|
+
predicate: (message: Record<string, unknown>) => boolean;
|
|
196
|
+
resolve: (message: Record<string, unknown>) => void;
|
|
197
|
+
reject: (error: Error) => void;
|
|
198
|
+
}[] = [];
|
|
199
|
+
|
|
200
|
+
socket.onopen = () => {
|
|
201
|
+
resolve({
|
|
202
|
+
socket,
|
|
203
|
+
events,
|
|
204
|
+
send(command) {
|
|
205
|
+
socket.send(JSON.stringify(command));
|
|
206
|
+
},
|
|
207
|
+
waitFor(predicate) {
|
|
208
|
+
const index = buffered.findIndex(predicate);
|
|
209
|
+
if (index >= 0) {
|
|
210
|
+
const message = buffered.splice(index, 1)[0];
|
|
211
|
+
if (message !== undefined) return Promise.resolve(message);
|
|
212
|
+
}
|
|
213
|
+
const { promise: waiting, resolve: settle } = Promise.withResolvers<Record<string, unknown>>();
|
|
214
|
+
waiters.push({ predicate, resolve: settle, reject: () => {} });
|
|
215
|
+
return waiting;
|
|
216
|
+
},
|
|
217
|
+
close() {
|
|
218
|
+
socket.close();
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
socket.onmessage = (event) => {
|
|
224
|
+
let message: Record<string, unknown>;
|
|
225
|
+
try {
|
|
226
|
+
message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
227
|
+
} catch {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
events.push(message);
|
|
231
|
+
const index = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
232
|
+
if (index >= 0) {
|
|
233
|
+
const waiter = waiters.splice(index, 1)[0];
|
|
234
|
+
if (waiter !== undefined) waiter.resolve(message);
|
|
235
|
+
} else {
|
|
236
|
+
buffered.push(message);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
socket.onerror = () => reject(new Error("websocket failed"));
|
|
241
|
+
return promise;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function logLines(logPath: string): string[] {
|
|
245
|
+
if (!existsSync(logPath)) return [];
|
|
246
|
+
return readFileSync(logPath, "utf8").split("\n").filter((line) => line.length > 0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function spawnCount(logPath: string): number {
|
|
250
|
+
return logLines(logPath).filter((line) => line.startsWith("spawn ")).length;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function prompts(logPath: string): string[] {
|
|
254
|
+
return logLines(logPath)
|
|
255
|
+
.filter((line) => line.startsWith("prompt "))
|
|
256
|
+
.map((line) => line.slice("prompt ".length));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function waitUntil(predicate: () => boolean, label: string, timeout = 8000) {
|
|
260
|
+
const deadline = Date.now() + timeout;
|
|
261
|
+
while (!predicate() && Date.now() < deadline) await Bun.sleep(20);
|
|
262
|
+
if (!predicate()) throw new Error(label);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface DiscoverState {
|
|
266
|
+
active: boolean;
|
|
267
|
+
runId: string | null;
|
|
268
|
+
interrupted: boolean;
|
|
269
|
+
stale: boolean;
|
|
270
|
+
warnings: string[];
|
|
271
|
+
busy: string | null;
|
|
272
|
+
error: string | null;
|
|
273
|
+
manifest: Record<string, unknown> | null;
|
|
274
|
+
freshness: Record<string, unknown> | null;
|
|
275
|
+
sheet: Record<string, unknown> | null;
|
|
276
|
+
commandAliases: Array<{ command: string; alias: string }>;
|
|
277
|
+
glossaries: Record<string, unknown>[];
|
|
278
|
+
glossaryUsage: Record<string, Record<string, { count: number; files: number; examples: string[] }>>;
|
|
279
|
+
glossarySignificance: Record<string, Record<string, { score: number; reason: string }>>;
|
|
280
|
+
decisions: Record<string, unknown>[];
|
|
281
|
+
skills: Record<string, unknown>[];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function stateOf(message: Record<string, unknown>): DiscoverState {
|
|
285
|
+
return message.state as DiscoverState;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const discoverStates = (client: TestClient) =>
|
|
289
|
+
client.events.filter((message) => message.type === "discoverState");
|
|
290
|
+
|
|
291
|
+
/** Sends one command and resolves with the first discoverState it produced. */
|
|
292
|
+
async function afterCommand(
|
|
293
|
+
client: TestClient,
|
|
294
|
+
command: unknown,
|
|
295
|
+
): Promise<DiscoverState> {
|
|
296
|
+
const before = discoverStates(client).length;
|
|
297
|
+
client.send(command);
|
|
298
|
+
await waitUntil(
|
|
299
|
+
() => discoverStates(client).length > before,
|
|
300
|
+
"discoverState after command",
|
|
301
|
+
);
|
|
302
|
+
return stateOf(discoverStates(client)[discoverStates(client).length - 1] as Record<string, unknown>);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function discoverState(client: TestClient): Promise<DiscoverState> {
|
|
306
|
+
return afterCommand(client, { type: "getDiscover" });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function runDiscover(client: TestClient): Promise<DiscoverState> {
|
|
310
|
+
client.send({ type: "discoverProject" });
|
|
311
|
+
await client.waitFor((message) => message.type === "discoverStarted");
|
|
312
|
+
const stopped = await client.waitFor((message) => message.type === "discoverStopped");
|
|
313
|
+
expect(stopped.completed).toBe(true);
|
|
314
|
+
return discoverState(client);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** File bytes of one tree, keyed by relative path, for byte-for-byte checks. */
|
|
318
|
+
function treeBytes(root: string): Map<string, string> {
|
|
319
|
+
const files = new Map<string, string>();
|
|
320
|
+
if (!existsSync(root)) return files;
|
|
321
|
+
const walk = (prefix: string) => {
|
|
322
|
+
const directory = prefix.length === 0 ? root : join(root, prefix);
|
|
323
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
324
|
+
const next = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
|
|
325
|
+
if (entry.isDirectory()) walk(next);
|
|
326
|
+
else if (entry.isFile()) files.set(next, readFileSync(join(root, next)).toString("base64"));
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
walk("");
|
|
330
|
+
return files;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function ompBytes(root: string): Map<string, string> {
|
|
334
|
+
return new Map([
|
|
335
|
+
...[...treeBytes(join(root, ".omp", "discover"))].map(([path, bytes]) => [
|
|
336
|
+
`.omp/discover/${path}`,
|
|
337
|
+
bytes,
|
|
338
|
+
] as const),
|
|
339
|
+
...[...treeBytes(join(root, ".omp", "skills"))].map(([path, bytes]) => [
|
|
340
|
+
`.omp/skills/${path}`,
|
|
341
|
+
bytes,
|
|
342
|
+
] as const),
|
|
343
|
+
]);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function hashText(text: string): string {
|
|
347
|
+
return createHash("sha256").update(text).digest("hex");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// A token-shaped secret, never a path segment: `…/task-…` or a worktree name
|
|
351
|
+
// that starts `sk-` is not a credential.
|
|
352
|
+
const SECRET_PATTERN =
|
|
353
|
+
/(?<![A-Za-z0-9_/-])(?:ghp_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})(?![A-Za-z0-9_/-])/;
|
|
354
|
+
|
|
355
|
+
function leaves(node: unknown, trail: string, out: Array<{ path: string; leaf: Record<string, unknown> }>) {
|
|
356
|
+
if (typeof node !== "object" || node === null) return;
|
|
357
|
+
if (Array.isArray(node)) {
|
|
358
|
+
for (const entry of node) leaves(entry, trail, out);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const record = node as Record<string, unknown>;
|
|
362
|
+
if ("confidence" in record && "evidence" in record) {
|
|
363
|
+
out.push({ path: trail, leaf: record });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const [key, value] of Object.entries(record)) leaves(value, `${trail}.${key}`, out);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function stringsIn(value: unknown, out: string[] = []): string[] {
|
|
370
|
+
if (typeof value === "string") out.push(value);
|
|
371
|
+
else if (Array.isArray(value)) for (const entry of value) stringsIn(entry, out);
|
|
372
|
+
else if (typeof value === "object" && value !== null) {
|
|
373
|
+
for (const entry of Object.values(value)) stringsIn(entry, out);
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
describe("discover mode", () => {
|
|
379
|
+
test(
|
|
380
|
+
"connects idle, reports an empty state, and never starts a run by itself",
|
|
381
|
+
async () => {
|
|
382
|
+
const { server, fixture, url } = startServer();
|
|
383
|
+
const client = await connect(url);
|
|
384
|
+
try {
|
|
385
|
+
const initial = await discoverState(client);
|
|
386
|
+
expect(initial).toMatchObject({
|
|
387
|
+
active: false,
|
|
388
|
+
runId: null,
|
|
389
|
+
sheet: null,
|
|
390
|
+
manifest: null,
|
|
391
|
+
glossaries: [],
|
|
392
|
+
skills: [],
|
|
393
|
+
interrupted: false,
|
|
394
|
+
busy: null,
|
|
395
|
+
});
|
|
396
|
+
await Bun.sleep(200);
|
|
397
|
+
expect(spawnCount(fixture.logPath)).toBe(0);
|
|
398
|
+
expect(client.events.some((message) => message.type === "discoverStarted")).toBe(false);
|
|
399
|
+
expect(client.events.some((message) => message.type === "activity")).toBe(false);
|
|
400
|
+
expect(client.events.some((message) => message.type === "question")).toBe(false);
|
|
401
|
+
} finally {
|
|
402
|
+
client.close();
|
|
403
|
+
server.stop(true);
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
15000,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
test(
|
|
410
|
+
"publishes one peer's staged sheet, glossaries, and five skills atomically",
|
|
411
|
+
async () => {
|
|
412
|
+
const { server, fixture, url } = startServer();
|
|
413
|
+
const client = await connect(url);
|
|
414
|
+
const goalBefore = readFileSync(fixture.goalFile);
|
|
415
|
+
const adrBefore = treeBytes(fixture.adrDir);
|
|
416
|
+
try {
|
|
417
|
+
const state = await runDiscover(client);
|
|
418
|
+
|
|
419
|
+
expect(spawnCount(fixture.logPath)).toBe(1);
|
|
420
|
+
const prompt = prompts(fixture.logPath)[0] ?? "";
|
|
421
|
+
expect(prompt).toMatch(/^\/discover .*\.omp\/discover\/staging\/run-/);
|
|
422
|
+
expect(prompt).not.toContain(".omp/discover/staging/run- \n");
|
|
423
|
+
|
|
424
|
+
const manifest = state.manifest as Record<string, unknown>;
|
|
425
|
+
expect(manifest).not.toBeNull();
|
|
426
|
+
expect(manifest.schemaVersion).toBe(1);
|
|
427
|
+
expect(typeof manifest.fingerprint).toBe("string");
|
|
428
|
+
expect(String(manifest.runId)).toMatch(/^run-/);
|
|
429
|
+
expect(typeof manifest.sheetDigest).toBe("string");
|
|
430
|
+
const digests = manifest.glossaryDigests as Record<string, string>;
|
|
431
|
+
expect(Object.keys(digests)).toEqual(["orders"]);
|
|
432
|
+
const skills = manifest.skills as Array<Record<string, unknown>>;
|
|
433
|
+
expect(skills.map((skill) => skill.name)).toEqual(DISCOVER_SKILLS);
|
|
434
|
+
for (const skill of skills) {
|
|
435
|
+
expect(skill.state).toBe("generated");
|
|
436
|
+
expect(skill.baselineHash).toBe(skill.currentHash);
|
|
437
|
+
}
|
|
438
|
+
const paths = manifest.paths as Record<string, unknown>;
|
|
439
|
+
for (const value of stringsIn(paths)) {
|
|
440
|
+
expect(value.startsWith("/")).toBe(false);
|
|
441
|
+
expect(value).not.toMatch(/[A-Za-z]:\\/);
|
|
442
|
+
}
|
|
443
|
+
expect(paths.significance).toBe(".omp/discover/significance.json");
|
|
444
|
+
|
|
445
|
+
// The published set exists exactly where the contract puts it.
|
|
446
|
+
const root = fixture.root;
|
|
447
|
+
for (const relative of [
|
|
448
|
+
".omp/discover/manifest.json",
|
|
449
|
+
".omp/discover/sheet.json",
|
|
450
|
+
".omp/discover/decisions.json",
|
|
451
|
+
".omp/discover/significance.json",
|
|
452
|
+
".omp/discover/glossary/orders.glossary.yaml",
|
|
453
|
+
...DISCOVER_SKILLS.map((name) => `.omp/discover/skills/${name}/SKILL.md`),
|
|
454
|
+
...DISCOVER_SKILLS.map((name) => `.omp/skills/${name}/SKILL.md`),
|
|
455
|
+
]) {
|
|
456
|
+
expect(existsSync(join(root, relative))).toBe(true);
|
|
457
|
+
}
|
|
458
|
+
expect(existsSync(join(root, ".omp", "discover", "staging"))).toBe(false);
|
|
459
|
+
|
|
460
|
+
// The sidecar carries one integer score and one reason per concept.
|
|
461
|
+
const sidecar = JSON.parse(
|
|
462
|
+
readFileSync(join(root, ".omp", "discover", "significance.json"), "utf8"),
|
|
463
|
+
) as Record<string, unknown>;
|
|
464
|
+
expect(Object.keys(sidecar)).toEqual(["schemaVersion", "contexts"]);
|
|
465
|
+
expect(sidecar.schemaVersion).toBe(1);
|
|
466
|
+
expect(Object.keys(sidecar.contexts as Record<string, unknown>)).toEqual(["orders"]);
|
|
467
|
+
expect(state.glossarySignificance.orders?.["order-total"]).toEqual({
|
|
468
|
+
score: 90,
|
|
469
|
+
reason: "The order total is the domain's central summed value.",
|
|
470
|
+
});
|
|
471
|
+
expect(state.glossarySignificance.orders?.option?.score).toBe(10);
|
|
472
|
+
|
|
473
|
+
// The sheet is data, not prose, and every non-unknown leaf is grounded.
|
|
474
|
+
const sheet = state.sheet as Record<string, unknown>;
|
|
475
|
+
expect(Object.keys(sheet)).toEqual([
|
|
476
|
+
"schemaVersion",
|
|
477
|
+
"freshness",
|
|
478
|
+
"definition",
|
|
479
|
+
"stack",
|
|
480
|
+
"deployment",
|
|
481
|
+
"environments",
|
|
482
|
+
"commands",
|
|
483
|
+
"branching",
|
|
484
|
+
"testing",
|
|
485
|
+
"architecture",
|
|
486
|
+
"ci",
|
|
487
|
+
"runtime",
|
|
488
|
+
"dependencyMap",
|
|
489
|
+
"delivery",
|
|
490
|
+
]);
|
|
491
|
+
const freshness = sheet.freshness as Record<string, unknown>;
|
|
492
|
+
expect(String(freshness.commit).length).toBe(7);
|
|
493
|
+
expect(freshness.dirty).toBe(false);
|
|
494
|
+
expect(freshness.fingerprint).toBe(manifest.fingerprint);
|
|
495
|
+
const definition = sheet.definition as Record<string, unknown>;
|
|
496
|
+
const definitionText = String(definition.value);
|
|
497
|
+
expect(definitionText.endsWith(".")).toBe(true);
|
|
498
|
+
expect(definitionText.match(/[.!?](\s|$)/g)?.length).toBe(1);
|
|
499
|
+
expect(definitionText.length).toBeLessThanOrEqual(240);
|
|
500
|
+
|
|
501
|
+
const allLeaves: Array<{ path: string; leaf: Record<string, unknown> }> = [];
|
|
502
|
+
leaves(sheet, "sheet", allLeaves);
|
|
503
|
+
expect(allLeaves.length).toBeGreaterThan(20);
|
|
504
|
+
for (const { path, leaf } of allLeaves) {
|
|
505
|
+
expect(["observed", "declared", "inferred", "unknown"]).toContain(String(leaf.confidence));
|
|
506
|
+
const evidence = leaf.evidence as string[];
|
|
507
|
+
if (leaf.confidence === "unknown") {
|
|
508
|
+
expect(evidence).toEqual([]);
|
|
509
|
+
expect(String(leaf.reason).trim().length).toBeGreaterThan(0);
|
|
510
|
+
expect(leaf.value).toBeNull();
|
|
511
|
+
} else {
|
|
512
|
+
expect(evidence.length).toBeGreaterThan(0);
|
|
513
|
+
expect(evidence).toEqual([...new Set(evidence)].sort());
|
|
514
|
+
for (const entry of evidence) {
|
|
515
|
+
expect(entry.startsWith("/")).toBe(false);
|
|
516
|
+
expect(entry.split("/")).not.toContain("..");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const value of stringsIn(leaf.value)) {
|
|
520
|
+
expect(value.includes("\n")).toBe(false);
|
|
521
|
+
expect(value.length).toBeLessThanOrEqual(160);
|
|
522
|
+
expect(value.startsWith("/")).toBe(false);
|
|
523
|
+
expect(value).not.toMatch(SECRET_PATTERN);
|
|
524
|
+
}
|
|
525
|
+
expect(path.length).toBeGreaterThan(0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The testing card carries a happy-path count and a declared level
|
|
529
|
+
// command, and the manifest's own invocations resolve the suite.
|
|
530
|
+
const testing = sheet.testing;
|
|
531
|
+
if (typeof testing !== "object" || testing === null || Array.isArray(testing)) {
|
|
532
|
+
throw new Error("the published sheet has no testing section");
|
|
533
|
+
}
|
|
534
|
+
const testingFields = testing as Record<string, unknown>;
|
|
535
|
+
const levelsLeaf = testingFields.levels;
|
|
536
|
+
if (typeof levelsLeaf !== "object" || levelsLeaf === null || Array.isArray(levelsLeaf)) {
|
|
537
|
+
throw new Error("the published sheet has no testing levels");
|
|
538
|
+
}
|
|
539
|
+
const levelValues = (levelsLeaf as Record<string, unknown>).value;
|
|
540
|
+
if (!Array.isArray(levelValues)) throw new Error("the testing levels carry no records");
|
|
541
|
+
const publishedLevels = levelValues as Array<Record<string, unknown>>;
|
|
542
|
+
expect(publishedLevels).toHaveLength(1);
|
|
543
|
+
expect(publishedLevels[0]?.level).toBe("unit");
|
|
544
|
+
expect(publishedLevels[0]?.happyPath).toBe("1");
|
|
545
|
+
expect(publishedLevels[0]?.command).toBe("npm run test:unit");
|
|
546
|
+
|
|
547
|
+
// A quality gate carries the one line describing what it checks.
|
|
548
|
+
const ci = sheet.ci;
|
|
549
|
+
if (typeof ci !== "object" || ci === null || Array.isArray(ci)) {
|
|
550
|
+
throw new Error("the published sheet has no ci section");
|
|
551
|
+
}
|
|
552
|
+
const gatesLeaf = (ci as Record<string, unknown>).qualityGates;
|
|
553
|
+
if (typeof gatesLeaf !== "object" || gatesLeaf === null || Array.isArray(gatesLeaf)) {
|
|
554
|
+
throw new Error("the published sheet has no quality gates");
|
|
555
|
+
}
|
|
556
|
+
expect((gatesLeaf as Record<string, unknown>).value).toEqual([
|
|
557
|
+
{ name: "bun test", checks: "Runs the whole fixture test suite in CI." },
|
|
558
|
+
]);
|
|
559
|
+
expect(state.commandAliases).toEqual([
|
|
560
|
+
{ command: "bun run build:src", alias: "npm run build" },
|
|
561
|
+
{ command: "bun test", alias: "npm test" },
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
// The glossary is a complete UbiLang document.
|
|
565
|
+
expect(state.glossaries.length).toBe(1);
|
|
566
|
+
const glossary = state.glossaries[0] as Record<string, unknown>;
|
|
567
|
+
expect(glossary.boundedContext).toBe("orders");
|
|
568
|
+
const concepts = glossary.concepts as Array<Record<string, unknown>>;
|
|
569
|
+
expect(concepts.length).toBeGreaterThanOrEqual(1);
|
|
570
|
+
const ids = new Set<string>();
|
|
571
|
+
for (const concept of concepts) {
|
|
572
|
+
const id = String(concept.id);
|
|
573
|
+
expect(id).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
574
|
+
expect(ids.has(id)).toBe(false);
|
|
575
|
+
ids.add(id);
|
|
576
|
+
expect(String(concept.status)).toBe("confirmed");
|
|
577
|
+
expect(["owner", "standard", "llm"]).toContain(String(concept.origin));
|
|
578
|
+
expect(["entity", "event", "field", "role", "actor", "value", "rule"]).toContain(
|
|
579
|
+
String(concept.kind),
|
|
580
|
+
);
|
|
581
|
+
const definitions = concept.definitions as Array<Record<string, unknown>>;
|
|
582
|
+
expect(definitions.some((entry) => entry.language === "en")).toBe(true);
|
|
583
|
+
const grounding = concept.grounding as string[];
|
|
584
|
+
expect(grounding.length).toBeGreaterThan(0);
|
|
585
|
+
for (const entry of grounding) expect(entry).toMatch(/^(code|model):/);
|
|
586
|
+
const prefLabels = concept.prefLabels as Array<Record<string, unknown>>;
|
|
587
|
+
for (const label of prefLabels) expect(["en", "de"]).toContain(String(label.language));
|
|
588
|
+
const scored = state.glossarySignificance.orders?.[id];
|
|
589
|
+
expect(scored).toBeDefined();
|
|
590
|
+
expect(Number.isInteger(scored?.score)).toBe(true);
|
|
591
|
+
expect(scored?.score).toBeGreaterThanOrEqual(0);
|
|
592
|
+
expect(scored?.score).toBeLessThanOrEqual(100);
|
|
593
|
+
expect(scored?.reason.length).toBeGreaterThan(0);
|
|
594
|
+
expect(scored?.reason).not.toContain("\n");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Usage is measured from the host's documentation and source, and the
|
|
598
|
+
// identifiers that name a concept become its examples.
|
|
599
|
+
const usage = state.glossaryUsage.orders?.["order-total"];
|
|
600
|
+
expect(usage).toBeDefined();
|
|
601
|
+
expect(usage?.count).toBeGreaterThanOrEqual(2);
|
|
602
|
+
expect(usage?.files).toBeGreaterThanOrEqual(2);
|
|
603
|
+
expect(usage?.examples).toContain("orderTotal");
|
|
604
|
+
|
|
605
|
+
// Artifacts hold no secret value and no absolute host path.
|
|
606
|
+
for (const [, bytes] of ompBytes(root)) {
|
|
607
|
+
const text = Buffer.from(bytes, "base64").toString("utf8");
|
|
608
|
+
expect(text).not.toMatch(SECRET_PATTERN);
|
|
609
|
+
expect(text).not.toMatch(/\/home\/|\/Users\/|[A-Za-z]:\\\\/);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
expect(readFileSync(fixture.goalFile)).toEqual(goalBefore);
|
|
613
|
+
expect(treeBytes(fixture.adrDir)).toEqual(adrBefore);
|
|
614
|
+
} finally {
|
|
615
|
+
client.close();
|
|
616
|
+
server.stop(true);
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
30000,
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
test(
|
|
623
|
+
"starts one peer per click, stages under .omp/discover/staging, and stops without publishing",
|
|
624
|
+
async () => {
|
|
625
|
+
const { server, fixture, url } = startServer({ stub: ["--discover", "--discover-no-exit", "--delay-ms=60000"] });
|
|
626
|
+
const client = await connect(url);
|
|
627
|
+
try {
|
|
628
|
+
client.send({ type: "discoverProject" });
|
|
629
|
+
const started = await client.waitFor((message) => message.type === "discoverStarted");
|
|
630
|
+
expect(started.runId).toMatch(/^run-/);
|
|
631
|
+
await waitUntil(
|
|
632
|
+
() => existsSync(join(fixture.root, ".omp", "discover", "staging", String(started.runId))),
|
|
633
|
+
"staging directory exists",
|
|
634
|
+
);
|
|
635
|
+
await waitUntil(() => spawnCount(fixture.logPath) === 1, "peer announced");
|
|
636
|
+
const prompt = prompts(fixture.logPath)[0] ?? "";
|
|
637
|
+
expect(prompt).toBe(
|
|
638
|
+
`/discover ${join(fixture.root, ".omp", "discover", "staging", String(started.runId))}`,
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// A second start is refused with one error and spawns nothing.
|
|
642
|
+
client.send({ type: "discoverProject" });
|
|
643
|
+
const refusal = await client.waitFor((message) => message.type === "discoverError");
|
|
644
|
+
expect(String(refusal.message)).toContain("already active");
|
|
645
|
+
expect(spawnCount(fixture.logPath)).toBe(1);
|
|
646
|
+
|
|
647
|
+
client.send({ type: "stopDiscover" });
|
|
648
|
+
const stopped = await client.waitFor((message) => message.type === "discoverStopped");
|
|
649
|
+
expect(stopped.completed).toBe(false);
|
|
650
|
+
await waitUntil(
|
|
651
|
+
() => !existsSync(join(fixture.root, ".omp", "discover", "staging", String(started.runId))),
|
|
652
|
+
"staging removed on stop",
|
|
653
|
+
);
|
|
654
|
+
const state = await discoverState(client);
|
|
655
|
+
expect(state.active).toBe(false);
|
|
656
|
+
expect(state.sheet).toBeNull();
|
|
657
|
+
expect(spawnCount(fixture.logPath)).toBe(1);
|
|
658
|
+
} finally {
|
|
659
|
+
client.close();
|
|
660
|
+
server.stop(true);
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
30000,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
test(
|
|
667
|
+
"replays state, activity, and a pending question to a reconnecting client, and answers once",
|
|
668
|
+
async () => {
|
|
669
|
+
const { server, url } = startServer({
|
|
670
|
+
stub: ["--discover", "--discover-question", "--discover-no-exit", "--delay-ms=60000"],
|
|
671
|
+
});
|
|
672
|
+
const first = await connect(url);
|
|
673
|
+
const second = await connect(url);
|
|
674
|
+
try {
|
|
675
|
+
first.send({ type: "discoverProject" });
|
|
676
|
+
await first.waitFor((message) => message.type === "discoverStarted");
|
|
677
|
+
await first.waitFor((message) => message.type === "discoverQuestion");
|
|
678
|
+
|
|
679
|
+
const replay = await connect(url);
|
|
680
|
+
try {
|
|
681
|
+
await waitUntil(() => replay.events.length >= 4, "replay delivered", 3000);
|
|
682
|
+
const types = replay.events.slice(0, 6).map((message) => String(message.type));
|
|
683
|
+
expect(types[0]).toBe("discoverState");
|
|
684
|
+
expect(types[1]).toBe("discoverStarted");
|
|
685
|
+
expect(types).toContain("discoverActivity");
|
|
686
|
+
expect(types).toContain("discoverQuestion");
|
|
687
|
+
|
|
688
|
+
const question = await replay.waitFor((message) => message.type === "discoverQuestion");
|
|
689
|
+
replay.send({ type: "answerDiscover", id: question.id, value: "Alpha" });
|
|
690
|
+
await first.waitFor(
|
|
691
|
+
(message) =>
|
|
692
|
+
message.type === "discoverActivity" &&
|
|
693
|
+
String(message.text).includes("Answer received: Alpha"),
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
replay.send({ type: "answerDiscover", id: question.id, value: "Alpha" });
|
|
697
|
+
const stale = await replay.waitFor((message) => message.type === "discoverError");
|
|
698
|
+
expect(String(stale.message)).toMatch(/pending/);
|
|
699
|
+
replay.send({ type: "answerDiscover", id: question.id, value: " " });
|
|
700
|
+
const empty = await replay.waitFor((message) => message.type === "discoverError");
|
|
701
|
+
expect(String(empty.message)).toMatch(/non-empty/);
|
|
702
|
+
|
|
703
|
+
// Discover events never borrow Goal Mode's channels.
|
|
704
|
+
for (const client of [first, second, replay]) {
|
|
705
|
+
expect(client.events.some((message) => message.type === "activity")).toBe(false);
|
|
706
|
+
expect(client.events.some((message) => message.type === "question")).toBe(false);
|
|
707
|
+
expect(client.events.some((message) => message.type === "error")).toBe(false);
|
|
708
|
+
expect(client.events.some((message) => message.type === "runStarted")).toBe(false);
|
|
709
|
+
}
|
|
710
|
+
} finally {
|
|
711
|
+
replay.close();
|
|
712
|
+
}
|
|
713
|
+
} finally {
|
|
714
|
+
first.close();
|
|
715
|
+
second.close();
|
|
716
|
+
server.stop(true);
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
30000,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
test(
|
|
723
|
+
"fails atomically for every invalid staged set and leaves the published artifacts byte-identical",
|
|
724
|
+
async () => {
|
|
725
|
+
const variants = [
|
|
726
|
+
"--discover-invalid-sheet",
|
|
727
|
+
"--discover-sheet-absolute",
|
|
728
|
+
"--discover-sheet-no-evidence",
|
|
729
|
+
"--discover-sheet-secret",
|
|
730
|
+
"--discover-sheet-two-sentences",
|
|
731
|
+
"--discover-sheet-happy-overflow",
|
|
732
|
+
"--discover-glossary-invalid",
|
|
733
|
+
"--discover-glossary-grounding",
|
|
734
|
+
"--discover-missing-artifact",
|
|
735
|
+
"--discover-extra-artifact",
|
|
736
|
+
"--discover-write-outside",
|
|
737
|
+
"--discover-error",
|
|
738
|
+
];
|
|
739
|
+
|
|
740
|
+
for (const variant of variants) {
|
|
741
|
+
const fixture = createFixture(`failure-${variant.replace(/^--/, "")}`);
|
|
742
|
+
const healthy = startServer({ fixture });
|
|
743
|
+
const client = await connect(healthy.url);
|
|
744
|
+
let before: Map<string, string>;
|
|
745
|
+
try {
|
|
746
|
+
await runDiscover(client);
|
|
747
|
+
before = ompBytes(fixture.root);
|
|
748
|
+
expect(before.size).toBeGreaterThan(0);
|
|
749
|
+
} finally {
|
|
750
|
+
client.close();
|
|
751
|
+
healthy.server.stop(true);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const failing = startServer({ fixture, stub: ["--discover", variant] });
|
|
755
|
+
const failingClient = await connect(failing.url);
|
|
756
|
+
try {
|
|
757
|
+
failingClient.send({ type: "discoverProject" });
|
|
758
|
+
await failingClient.waitFor((message) => message.type === "discoverStarted");
|
|
759
|
+
const errored = await failingClient.waitFor((message) => message.type === "discoverError");
|
|
760
|
+
expect(String(errored.message).trim().length).toBeGreaterThan(0);
|
|
761
|
+
const stopped = await failingClient.waitFor((message) => message.type === "discoverStopped");
|
|
762
|
+
expect(stopped.completed).toBe(false);
|
|
763
|
+
|
|
764
|
+
expect(ompBytes(fixture.root)).toEqual(before);
|
|
765
|
+
const stagingRoot = join(fixture.root, ".omp", "discover", "staging");
|
|
766
|
+
expect(existsSync(stagingRoot) ? readdirSync(stagingRoot).length : 0).toBe(0);
|
|
767
|
+
expect(failingClient.events.filter((message) => message.type === "discoverError").length).toBe(1);
|
|
768
|
+
} finally {
|
|
769
|
+
failingClient.close();
|
|
770
|
+
failing.server.stop(true);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
120000,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
test(
|
|
778
|
+
"refuses a generate name that collides with another family and publishes nothing",
|
|
779
|
+
async () => {
|
|
780
|
+
const fixture = createFixture("collision");
|
|
781
|
+
mkdirSync(join(fixture.root, ".omp", "skills", "discover-testing-strategy"), { recursive: true });
|
|
782
|
+
writeFileSync(
|
|
783
|
+
join(fixture.root, ".omp", "skills", "discover-testing-strategy", "SKILL.md"),
|
|
784
|
+
[
|
|
785
|
+
"---",
|
|
786
|
+
"name: discover-testing-strategy",
|
|
787
|
+
"description: A prepare-family skill that owns this name.",
|
|
788
|
+
"metadata:",
|
|
789
|
+
' devboard-managed: "true"',
|
|
790
|
+
' devboard-family: "prepare"',
|
|
791
|
+
"---",
|
|
792
|
+
"",
|
|
793
|
+
"- Always keep the prepare policy.",
|
|
794
|
+
"",
|
|
795
|
+
].join("\n"),
|
|
796
|
+
);
|
|
797
|
+
const { server, url } = startServer({ fixture });
|
|
798
|
+
const client = await connect(url);
|
|
799
|
+
try {
|
|
800
|
+
client.send({ type: "discoverProject" });
|
|
801
|
+
await client.waitFor((message) => message.type === "discoverStarted");
|
|
802
|
+
const errored = await client.waitFor((message) => message.type === "discoverError");
|
|
803
|
+
expect(String(errored.message)).toContain("discover-testing-strategy");
|
|
804
|
+
await client.waitFor((message) => message.type === "discoverStopped");
|
|
805
|
+
expect(existsSync(join(fixture.root, ".omp", "discover", "manifest.json"))).toBe(false);
|
|
806
|
+
expect(readFileSync(join(fixture.root, ".omp", "skills", "discover-testing-strategy", "SKILL.md"), "utf8")).toContain(
|
|
807
|
+
"prepare-family",
|
|
808
|
+
);
|
|
809
|
+
} finally {
|
|
810
|
+
client.close();
|
|
811
|
+
server.stop(true);
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
20000,
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
test(
|
|
818
|
+
"preserves edited skills across a re-crawl, refuses invalid saves, and restores missing skills",
|
|
819
|
+
async () => {
|
|
820
|
+
const { server, fixture, url } = startServer();
|
|
821
|
+
const client = await connect(url);
|
|
822
|
+
try {
|
|
823
|
+
await runDiscover(client);
|
|
824
|
+
const editedName = "discover-coding-conventions";
|
|
825
|
+
const livePath = join(fixture.root, ".omp", "skills", editedName, "SKILL.md");
|
|
826
|
+
const original = readFileSync(livePath, "utf8");
|
|
827
|
+
const edited = original.replace(
|
|
828
|
+
"- Handle recoverable errors at the caller",
|
|
829
|
+
"- Handle recoverable errors at the boundary",
|
|
830
|
+
);
|
|
831
|
+
expect(edited).not.toBe(original);
|
|
832
|
+
|
|
833
|
+
const saved = await afterCommand(client, {
|
|
834
|
+
type: "saveDiscoverSkill",
|
|
835
|
+
name: editedName,
|
|
836
|
+
text: edited,
|
|
837
|
+
});
|
|
838
|
+
expect(saved.skills.find((skill) => skill.name === editedName)?.state).toBe("edited");
|
|
839
|
+
expect(readFileSync(livePath, "utf8")).toBe(edited);
|
|
840
|
+
|
|
841
|
+
// A save that drops the family marker is refused and changes nothing.
|
|
842
|
+
client.send({
|
|
843
|
+
type: "saveDiscoverSkill",
|
|
844
|
+
name: editedName,
|
|
845
|
+
text: edited.replace(' devboard-family: "discover"\n', ""),
|
|
846
|
+
});
|
|
847
|
+
const refused = await client.waitFor((message) => message.type === "discoverError");
|
|
848
|
+
expect(String(refused.message)).toContain("devboard-family");
|
|
849
|
+
expect(readFileSync(livePath, "utf8")).toBe(edited);
|
|
850
|
+
|
|
851
|
+
client.send({ type: "saveDiscoverSkill", name: "prepare-only", text: edited });
|
|
852
|
+
const unknown = await client.waitFor((message) => message.type === "discoverError");
|
|
853
|
+
expect(String(unknown.message)).toContain("not one of the five");
|
|
854
|
+
} finally {
|
|
855
|
+
client.close();
|
|
856
|
+
server.stop(true);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const second = startServer({ fixture });
|
|
860
|
+
const after = await connect(second.url);
|
|
861
|
+
try {
|
|
862
|
+
const beforeReRun = readFileSync(
|
|
863
|
+
join(fixture.root, ".omp", "skills", "discover-coding-conventions", "SKILL.md"),
|
|
864
|
+
"utf8",
|
|
865
|
+
);
|
|
866
|
+
const state = await runDiscover(after);
|
|
867
|
+
expect(beforeReRun).toContain("Handle recoverable errors at the boundary");
|
|
868
|
+
expect(
|
|
869
|
+
readFileSync(
|
|
870
|
+
join(fixture.root, ".omp", "skills", "discover-coding-conventions", "SKILL.md"),
|
|
871
|
+
"utf8",
|
|
872
|
+
),
|
|
873
|
+
).toBe(beforeReRun);
|
|
874
|
+
const skill = state.skills.find((entry) => entry.name === "discover-coding-conventions");
|
|
875
|
+
expect(skill?.state).toBe("edited");
|
|
876
|
+
const baseline = readFileSync(
|
|
877
|
+
join(fixture.root, ".omp", "discover", "skills", "discover-coding-conventions", "SKILL.md"),
|
|
878
|
+
"utf8",
|
|
879
|
+
);
|
|
880
|
+
expect(skill?.baseline).toBe(baseline);
|
|
881
|
+
expect(skill?.baselineHash).toBe(hashText(baseline));
|
|
882
|
+
expect(skill?.currentHash).toBe(hashText(beforeReRun));
|
|
883
|
+
expect(state.warnings).toEqual([]);
|
|
884
|
+
|
|
885
|
+
// A deleted skill is not recreated by a crawl; Restore recreates it.
|
|
886
|
+
rmSync(join(fixture.root, ".omp", "skills", "discover-glossary"), { recursive: true });
|
|
887
|
+
const missing = await discoverState(after);
|
|
888
|
+
const glossarySkill = missing.skills.find((entry) => entry.name === "discover-glossary");
|
|
889
|
+
expect(glossarySkill?.state).toBe("missing");
|
|
890
|
+
expect(existsSync(join(fixture.root, ".omp", "skills", "discover-glossary"))).toBe(false);
|
|
891
|
+
|
|
892
|
+
const restored = await afterCommand(after, {
|
|
893
|
+
type: "restoreDiscoverSkill",
|
|
894
|
+
name: "discover-glossary",
|
|
895
|
+
});
|
|
896
|
+
expect(restored.skills.find((entry) => entry.name === "discover-glossary")?.state).toBe(
|
|
897
|
+
"generated",
|
|
898
|
+
);
|
|
899
|
+
expect(
|
|
900
|
+
readFileSync(join(fixture.root, ".omp", "skills", "discover-glossary", "SKILL.md"), "utf8"),
|
|
901
|
+
).toBe(
|
|
902
|
+
readFileSync(
|
|
903
|
+
join(fixture.root, ".omp", "discover", "skills", "discover-glossary", "SKILL.md"),
|
|
904
|
+
"utf8",
|
|
905
|
+
),
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
// Restore returns an edited skill to its generated version.
|
|
909
|
+
const rederived = await afterCommand(after, {
|
|
910
|
+
type: "restoreDiscoverSkill",
|
|
911
|
+
name: "discover-coding-conventions",
|
|
912
|
+
});
|
|
913
|
+
expect(
|
|
914
|
+
rederived.skills.find((entry) => entry.name === "discover-coding-conventions")?.state,
|
|
915
|
+
).toBe("generated");
|
|
916
|
+
} finally {
|
|
917
|
+
after.close();
|
|
918
|
+
second.server.stop(true);
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
60000,
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
test(
|
|
925
|
+
"publishes every crawled term as confirmed without an operator confirm step",
|
|
926
|
+
async () => {
|
|
927
|
+
const { server, url } = startServer();
|
|
928
|
+
const client = await connect(url);
|
|
929
|
+
try {
|
|
930
|
+
const before = await runDiscover(client);
|
|
931
|
+
const initial = before.glossaries[0] as { concepts: Array<Record<string, unknown>> };
|
|
932
|
+
expect(initial.concepts.find((concept) => concept.id === "order")?.status).toBe(
|
|
933
|
+
"confirmed",
|
|
934
|
+
);
|
|
935
|
+
expect(initial.concepts.find((concept) => concept.id === "order-total")?.status).toBe(
|
|
936
|
+
"confirmed",
|
|
937
|
+
);
|
|
938
|
+
expect(before.decisions).toHaveLength(0);
|
|
939
|
+
|
|
940
|
+
// Confirmation is a publication rule, not a manual step.
|
|
941
|
+
client.send({ type: "confirmGlossaryTerm", context: "orders", id: "order" });
|
|
942
|
+
const refusal = await client.waitFor((message) => message.type === "discoverError");
|
|
943
|
+
expect(String(refusal.message)).toContain("confirmed");
|
|
944
|
+
} finally {
|
|
945
|
+
client.close();
|
|
946
|
+
server.stop(true);
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
30000,
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
test(
|
|
953
|
+
"records a glossary rejection, persists it, and preserves it on a re-crawl",
|
|
954
|
+
async () => {
|
|
955
|
+
const { server, url } = startServer();
|
|
956
|
+
const client = await connect(url);
|
|
957
|
+
try {
|
|
958
|
+
const before = await runDiscover(client);
|
|
959
|
+
const initial = before.glossaries[0] as { concepts: Array<Record<string, unknown>> };
|
|
960
|
+
expect(initial.concepts.find((concept) => concept.id === "order-total")?.status).toBe(
|
|
961
|
+
"confirmed",
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
client.send({ type: "rejectGlossaryTerm", context: "orders", id: "order-total", reason: " " });
|
|
965
|
+
const noReason = await client.waitFor((message) => message.type === "discoverError");
|
|
966
|
+
expect(String(noReason.message)).toMatch(/non-empty reason/);
|
|
967
|
+
|
|
968
|
+
const rejected = await afterCommand(client, {
|
|
969
|
+
type: "rejectGlossaryTerm",
|
|
970
|
+
context: "orders",
|
|
971
|
+
id: "order-total",
|
|
972
|
+
reason: "It names a field; the domain uses the aggregate total.",
|
|
973
|
+
});
|
|
974
|
+
const rejectedGlossary = rejected.glossaries[0] as {
|
|
975
|
+
concepts: Array<Record<string, unknown>>;
|
|
976
|
+
};
|
|
977
|
+
expect(
|
|
978
|
+
rejectedGlossary.concepts.find((concept) => concept.id === "order-total")?.status,
|
|
979
|
+
).toBe("invalidated");
|
|
980
|
+
expect(rejected.decisions.length).toBe(1);
|
|
981
|
+
expect(rejected.decisions[0]).toMatchObject({
|
|
982
|
+
context: "orders",
|
|
983
|
+
id: "order-total",
|
|
984
|
+
action: "reject",
|
|
985
|
+
});
|
|
986
|
+
} finally {
|
|
987
|
+
client.close();
|
|
988
|
+
server.stop(true);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// A reconnect and a server restart both read the decision from disk.
|
|
992
|
+
const restarted = startServer({ fixture: fixtureFor(url) });
|
|
993
|
+
const after = await connect(restarted.url);
|
|
994
|
+
try {
|
|
995
|
+
const state = await discoverState(after);
|
|
996
|
+
const glossary = state.glossaries[0] as { concepts: Array<Record<string, unknown>> };
|
|
997
|
+
expect(glossary.concepts.find((concept) => concept.id === "order-total")?.status).toBe(
|
|
998
|
+
"invalidated",
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
// A later run preserves the human decision and the concept.
|
|
1002
|
+
const rerun = await runDiscover(after);
|
|
1003
|
+
const merged = rerun.glossaries[0] as { concepts: Array<Record<string, unknown>> };
|
|
1004
|
+
const preserved = merged.concepts.find((concept) => concept.id === "order-total");
|
|
1005
|
+
expect(preserved?.status).toBe("invalidated");
|
|
1006
|
+
expect(preserved?.prefLabels).toEqual([{ language: "en", text: "Order Total" }]);
|
|
1007
|
+
expect(rerun.decisions.length).toBe(1);
|
|
1008
|
+
expect(rerun.warnings).toEqual([]);
|
|
1009
|
+
} finally {
|
|
1010
|
+
after.close();
|
|
1011
|
+
restarted.server.stop(true);
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
60000,
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
test(
|
|
1018
|
+
"fingerprints the host, reports stale only on a readable change, and produces one structural digest",
|
|
1019
|
+
async () => {
|
|
1020
|
+
const fixture = createFixture("determinism");
|
|
1021
|
+
const first = startServer({ fixture, stub: ["--discover", "--discover-prose=First crawl definition."] });
|
|
1022
|
+
const client = await connect(first.url);
|
|
1023
|
+
let firstState: DiscoverState;
|
|
1024
|
+
try {
|
|
1025
|
+
firstState = await runDiscover(client);
|
|
1026
|
+
expect(firstState.stale).toBe(false);
|
|
1027
|
+
} finally {
|
|
1028
|
+
client.close();
|
|
1029
|
+
first.server.stop(true);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const second = startServer({
|
|
1033
|
+
fixture,
|
|
1034
|
+
stub: ["--discover", "--discover-prose=A different second crawl sentence."],
|
|
1035
|
+
});
|
|
1036
|
+
const secondClient = await connect(second.url);
|
|
1037
|
+
try {
|
|
1038
|
+
const secondState = await runDiscover(secondClient);
|
|
1039
|
+
const firstManifest = firstState.manifest as Record<string, unknown>;
|
|
1040
|
+
const secondManifest = secondState.manifest as Record<string, unknown>;
|
|
1041
|
+
expect(secondManifest.sheetDigest).toBe(firstManifest.sheetDigest);
|
|
1042
|
+
expect(secondManifest.glossaryDigests).toEqual(firstManifest.glossaryDigests);
|
|
1043
|
+
const firstDefinition = (firstState.sheet as Record<string, unknown>).definition as Record<string, unknown>;
|
|
1044
|
+
const secondDefinition = (secondState.sheet as Record<string, unknown>).definition as Record<string, unknown>;
|
|
1045
|
+
expect(secondDefinition.value).not.toBe(firstDefinition.value);
|
|
1046
|
+
|
|
1047
|
+
writeFileSync(join(fixture.root, "src", "order.ts"), "export const orderTotal = (total: number): number => total + 1;\n");
|
|
1048
|
+
client.send({ type: "getDiscover" });
|
|
1049
|
+
const after = await discoverState(secondClient);
|
|
1050
|
+
expect(after.stale).toBe(true);
|
|
1051
|
+
} finally {
|
|
1052
|
+
secondClient.close();
|
|
1053
|
+
second.server.stop(true);
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
60000,
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
test(
|
|
1060
|
+
"expands the lifecycle prerequisite, scopes preparation to its family, and refuses other work while running",
|
|
1061
|
+
async () => {
|
|
1062
|
+
const fixture = createFixture("lifecycle");
|
|
1063
|
+
const { server, url } = startServer({ fixture });
|
|
1064
|
+
const client = await connect(url);
|
|
1065
|
+
try {
|
|
1066
|
+
await runDiscover(client);
|
|
1067
|
+
|
|
1068
|
+
// Only Discover skills exist, and the lifecycle gate accepts them.
|
|
1069
|
+
client.send({ type: "getSkills" });
|
|
1070
|
+
const skillsState = await client.waitFor((message) => message.type === "skillsState");
|
|
1071
|
+
const summaries = (skillsState.skills as Array<Record<string, unknown>>).filter((skill) =>
|
|
1072
|
+
String(skill.name).startsWith("discover-"),
|
|
1073
|
+
);
|
|
1074
|
+
expect(summaries.map((skill) => skill.name).sort()).toEqual([...DISCOVER_SKILLS].sort());
|
|
1075
|
+
for (const summary of summaries) {
|
|
1076
|
+
expect(summary.family).toBe("discover");
|
|
1077
|
+
expect(summary.state).toBe("generated");
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
client.send({ type: "startLifecycle" });
|
|
1081
|
+
const rejected = client.events.find(
|
|
1082
|
+
(message) =>
|
|
1083
|
+
message.type === "error" && String(message.message).includes("No skills are prepared"),
|
|
1084
|
+
);
|
|
1085
|
+
expect(rejected).toBeUndefined();
|
|
1086
|
+
await client.waitFor((message) => message.type === "lifecycleState");
|
|
1087
|
+
client.send({ type: "stopLifecycle" });
|
|
1088
|
+
|
|
1089
|
+
} finally {
|
|
1090
|
+
client.close();
|
|
1091
|
+
server.stop(true);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Preparation replaces only its own family and ignores Discover
|
|
1095
|
+
// directives that would contradict its project-agnostic policy.
|
|
1096
|
+
const preparing = startServer({ fixture, stub: ["--discover", "--prepare-skills"] });
|
|
1097
|
+
const prepareClient = await connect(preparing.url);
|
|
1098
|
+
try {
|
|
1099
|
+
const discoverBefore = ompBytes(fixture.root);
|
|
1100
|
+
const editedPath = join(fixture.root, ".omp", "skills", "discover-naming-conventions", "SKILL.md");
|
|
1101
|
+
const edited = readFileSync(editedPath, "utf8").replace(
|
|
1102
|
+
"- Keep file names kebab-case",
|
|
1103
|
+
"- Never declare dependencies in one direction.\n- Keep file names kebab-case",
|
|
1104
|
+
);
|
|
1105
|
+
await afterCommand(prepareClient, {
|
|
1106
|
+
type: "saveDiscoverSkill",
|
|
1107
|
+
name: "discover-naming-conventions",
|
|
1108
|
+
text: edited,
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
prepareClient.send({ type: "prepareSkills" });
|
|
1112
|
+
const prepared = await prepareClient.waitFor(
|
|
1113
|
+
(message) => message.type === "skillsPreparationStopped",
|
|
1114
|
+
);
|
|
1115
|
+
expect(prepared.completed).toBe(true);
|
|
1116
|
+
expect(readFileSync(editedPath, "utf8")).toContain("Never declare dependencies in one direction.");
|
|
1117
|
+
for (const [path, bytes] of discoverBefore) {
|
|
1118
|
+
if (!path.includes(".omp/skills/discover-")) continue;
|
|
1119
|
+
const live = readFileSync(join(fixture.root, path), "utf8");
|
|
1120
|
+
if (path.endsWith("discover-naming-conventions/SKILL.md")) {
|
|
1121
|
+
expect(live).toContain("Never declare dependencies in one direction.");
|
|
1122
|
+
} else {
|
|
1123
|
+
expect(live).toBe(Buffer.from(bytes, "base64").toString("utf8"));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
prepareClient.send({ type: "getSkills" });
|
|
1127
|
+
const mixed = await prepareClient.waitFor((message) => message.type === "skillsState");
|
|
1128
|
+
const families = new Set(
|
|
1129
|
+
(mixed.skills as Array<Record<string, unknown>>).map((skill) => String(skill.family)),
|
|
1130
|
+
);
|
|
1131
|
+
expect(families).toContain("prepare");
|
|
1132
|
+
expect(families).toContain("discover");
|
|
1133
|
+
} finally {
|
|
1134
|
+
prepareClient.close();
|
|
1135
|
+
preparing.server.stop(true);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// While a Discover run is live, every other start is refused.
|
|
1139
|
+
const running = startServer({
|
|
1140
|
+
fixture,
|
|
1141
|
+
stub: ["--discover", "--discover-no-exit", "--delay-ms=60000"],
|
|
1142
|
+
});
|
|
1143
|
+
const active = await connect(running.url);
|
|
1144
|
+
try {
|
|
1145
|
+
active.send({ type: "discoverProject" });
|
|
1146
|
+
await active.waitFor((message) => message.type === "discoverStarted");
|
|
1147
|
+
const spawns = spawnCount(fixture.logPath);
|
|
1148
|
+
const commands = [
|
|
1149
|
+
{ type: "startRun", file: "0001-fixture.md" },
|
|
1150
|
+
{ type: "startLifecycle" },
|
|
1151
|
+
{ type: "saveGoal", text: "# changed\n" },
|
|
1152
|
+
{ type: "prepareSkills" },
|
|
1153
|
+
{ type: "createGoal" },
|
|
1154
|
+
{ type: "reviseGoal", request: "tighten" },
|
|
1155
|
+
{ type: "startFlowImplementation" },
|
|
1156
|
+
{ type: "createFlowGoal" },
|
|
1157
|
+
];
|
|
1158
|
+
for (const command of commands) {
|
|
1159
|
+
active.send(command);
|
|
1160
|
+
const refusal = await active.waitFor(
|
|
1161
|
+
(message) => message.type === "error" || message.type === "flowError",
|
|
1162
|
+
);
|
|
1163
|
+
expect(String(refusal.message)).toMatch(/Discover run/i);
|
|
1164
|
+
}
|
|
1165
|
+
expect(spawnCount(fixture.logPath)).toBe(spawns);
|
|
1166
|
+
active.send({ type: "discoverProject" });
|
|
1167
|
+
const second = await active.waitFor((message) => message.type === "discoverError");
|
|
1168
|
+
expect(String(second.message)).toContain("already active");
|
|
1169
|
+
expect(spawnCount(fixture.logPath)).toBe(spawns);
|
|
1170
|
+
} finally {
|
|
1171
|
+
active.close();
|
|
1172
|
+
running.server.stop(true);
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
90000,
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
test(
|
|
1179
|
+
"cancels an active run on reset and interrupts one on restart without auto-resuming",
|
|
1180
|
+
async () => {
|
|
1181
|
+
const fixture = createFixture("reset-restart");
|
|
1182
|
+
const first = startServer({ fixture });
|
|
1183
|
+
const client = await connect(first.url);
|
|
1184
|
+
try {
|
|
1185
|
+
const state = await runDiscover(client);
|
|
1186
|
+
const published = ompBytes(fixture.root);
|
|
1187
|
+
expect(state.sheet).not.toBeNull();
|
|
1188
|
+
|
|
1189
|
+
const running = startServer({
|
|
1190
|
+
fixture,
|
|
1191
|
+
stub: ["--discover", "--discover-no-exit", "--delay-ms=60000"],
|
|
1192
|
+
});
|
|
1193
|
+
const active = await connect(running.url);
|
|
1194
|
+
try {
|
|
1195
|
+
active.send({ type: "discoverProject" });
|
|
1196
|
+
const started = await active.waitFor((message) => message.type === "discoverStarted");
|
|
1197
|
+
active.send({ type: "resetImplementation" });
|
|
1198
|
+
const stopped = await active.waitFor((message) => message.type === "discoverStopped");
|
|
1199
|
+
expect(stopped.completed).toBe(false);
|
|
1200
|
+
await active.waitFor((message) => message.type === "implementationResetStopped");
|
|
1201
|
+
expect(existsSync(join(fixture.root, ".omp", "discover", "staging", String(started.runId)))).toBe(false);
|
|
1202
|
+
expect(ompBytes(fixture.root)).toEqual(published);
|
|
1203
|
+
} finally {
|
|
1204
|
+
active.close();
|
|
1205
|
+
running.server.stop(true);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// A restart while a run is live interrupts it: no resume, staging gone,
|
|
1209
|
+
// artifacts intact.
|
|
1210
|
+
const live = startServer({
|
|
1211
|
+
fixture,
|
|
1212
|
+
stub: ["--discover", "--discover-no-exit", "--delay-ms=60000"],
|
|
1213
|
+
});
|
|
1214
|
+
const before = await connect(live.url);
|
|
1215
|
+
let runId = "";
|
|
1216
|
+
try {
|
|
1217
|
+
before.send({ type: "discoverProject" });
|
|
1218
|
+
runId = String((await before.waitFor((message) => message.type === "discoverStarted")).runId);
|
|
1219
|
+
await waitUntil(
|
|
1220
|
+
() => existsSync(join(fixture.root, ".omp", "discover", "staging", runId)),
|
|
1221
|
+
"staging exists before restart",
|
|
1222
|
+
);
|
|
1223
|
+
} finally {
|
|
1224
|
+
before.close();
|
|
1225
|
+
live.server.stop(true);
|
|
1226
|
+
}
|
|
1227
|
+
const spawns = spawnCount(fixture.logPath);
|
|
1228
|
+
|
|
1229
|
+
const replacement = startServer({
|
|
1230
|
+
fixture,
|
|
1231
|
+
stub: ["--discover", "--discover-no-exit", "--delay-ms=60000"],
|
|
1232
|
+
});
|
|
1233
|
+
const after = await connect(replacement.url);
|
|
1234
|
+
try {
|
|
1235
|
+
const interruption = await after.waitFor((message) => message.type === "discoverError");
|
|
1236
|
+
expect(String(interruption.message)).toContain("interrupted");
|
|
1237
|
+
const restored = await afterCommand(after, { type: "getDiscover" });
|
|
1238
|
+
expect(restored.interrupted).toBe(true);
|
|
1239
|
+
expect(restored.active).toBe(false);
|
|
1240
|
+
await Bun.sleep(200);
|
|
1241
|
+
expect(spawnCount(fixture.logPath)).toBe(spawns);
|
|
1242
|
+
expect(existsSync(join(fixture.root, ".omp", "discover", "staging", runId))).toBe(false);
|
|
1243
|
+
expect(ompBytes(fixture.root)).toEqual(published);
|
|
1244
|
+
} finally {
|
|
1245
|
+
after.close();
|
|
1246
|
+
replacement.server.stop(true);
|
|
1247
|
+
}
|
|
1248
|
+
} finally {
|
|
1249
|
+
client.close();
|
|
1250
|
+
first.server.stop(true);
|
|
1251
|
+
}
|
|
1252
|
+
},
|
|
1253
|
+
90000,
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
test(
|
|
1257
|
+
"runs on a host without GOAL.md and with a dirty working tree",
|
|
1258
|
+
async () => {
|
|
1259
|
+
const fixture = createFixture("no-goal", { goal: false, dirty: true });
|
|
1260
|
+
const { server, url } = startServer({ fixture });
|
|
1261
|
+
const client = await connect(url);
|
|
1262
|
+
try {
|
|
1263
|
+
const state = await runDiscover(client);
|
|
1264
|
+
expect(state.sheet).not.toBeNull();
|
|
1265
|
+
expect((state.freshness as Record<string, unknown>).dirty).toBe(true);
|
|
1266
|
+
expect(existsSync(fixture.goalFile)).toBe(false);
|
|
1267
|
+
expect(existsSync(join(fixture.root, ".omp", "discover", "manifest.json"))).toBe(true);
|
|
1268
|
+
} finally {
|
|
1269
|
+
client.close();
|
|
1270
|
+
server.stop(true);
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
30000,
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
test(
|
|
1277
|
+
"rejects every invalid significance sidecar and leaves the published set byte-identical",
|
|
1278
|
+
async () => {
|
|
1279
|
+
const variants = [
|
|
1280
|
+
"missing",
|
|
1281
|
+
"invalid",
|
|
1282
|
+
"array",
|
|
1283
|
+
"unknown-context",
|
|
1284
|
+
"unknown-concept",
|
|
1285
|
+
"extra-concept",
|
|
1286
|
+
"extra-key",
|
|
1287
|
+
"schema",
|
|
1288
|
+
"score",
|
|
1289
|
+
"score-object",
|
|
1290
|
+
"reason-empty",
|
|
1291
|
+
"reason-multiline",
|
|
1292
|
+
"reason-absolute",
|
|
1293
|
+
"reason-secret",
|
|
1294
|
+
];
|
|
1295
|
+
const fixture = createFixture("failure-significance");
|
|
1296
|
+
const healthy = startServer({ fixture });
|
|
1297
|
+
const client = await connect(healthy.url);
|
|
1298
|
+
let before: Map<string, string>;
|
|
1299
|
+
try {
|
|
1300
|
+
await runDiscover(client);
|
|
1301
|
+
before = ompBytes(fixture.root);
|
|
1302
|
+
expect(before.has(".omp/discover/significance.json")).toBe(true);
|
|
1303
|
+
} finally {
|
|
1304
|
+
client.close();
|
|
1305
|
+
healthy.server.stop(true);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
for (const variant of variants) {
|
|
1309
|
+
const failing = startServer({
|
|
1310
|
+
fixture,
|
|
1311
|
+
stub: ["--discover", `--discover-significance=${variant}`],
|
|
1312
|
+
});
|
|
1313
|
+
const failingClient = await connect(failing.url);
|
|
1314
|
+
try {
|
|
1315
|
+
failingClient.send({ type: "discoverProject" });
|
|
1316
|
+
await failingClient.waitFor((message) => message.type === "discoverStarted");
|
|
1317
|
+
const errored = await failingClient.waitFor((message) => message.type === "discoverError");
|
|
1318
|
+
expect(String(errored.message)).toContain("significance.json");
|
|
1319
|
+
const stopped = await failingClient.waitFor((message) => message.type === "discoverStopped");
|
|
1320
|
+
expect(stopped.completed).toBe(false);
|
|
1321
|
+
expect(
|
|
1322
|
+
failingClient.events.filter((message) => message.type === "discoverError").length,
|
|
1323
|
+
).toBe(1);
|
|
1324
|
+
expect(ompBytes(fixture.root)).toEqual(before);
|
|
1325
|
+
const stagingRoot = join(fixture.root, ".omp", "discover", "staging");
|
|
1326
|
+
expect(existsSync(stagingRoot) ? readdirSync(stagingRoot).length : 0).toBe(0);
|
|
1327
|
+
} finally {
|
|
1328
|
+
failingClient.close();
|
|
1329
|
+
failing.server.stop(true);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
120000,
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
test(
|
|
1337
|
+
"serves a legacy manifest without the sidecar path as unscored",
|
|
1338
|
+
async () => {
|
|
1339
|
+
const { server, fixture, url } = startServer();
|
|
1340
|
+
const client = await connect(url);
|
|
1341
|
+
try {
|
|
1342
|
+
await runDiscover(client);
|
|
1343
|
+
const manifestPath = join(fixture.root, ".omp", "discover", "manifest.json");
|
|
1344
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
|
1345
|
+
paths: Record<string, unknown>;
|
|
1346
|
+
};
|
|
1347
|
+
// A publication written before the sidecar existed carries no path.
|
|
1348
|
+
delete manifest.paths.significance;
|
|
1349
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1350
|
+
rmSync(join(fixture.root, ".omp", "discover", "significance.json"));
|
|
1351
|
+
|
|
1352
|
+
const state = await discoverState(client);
|
|
1353
|
+
expect(state.error).toBeNull();
|
|
1354
|
+
expect(state.glossarySignificance).toEqual({});
|
|
1355
|
+
expect(state.glossaries.length).toBe(1);
|
|
1356
|
+
expect((state.glossaries[0]?.concepts as unknown[]).length).toBeGreaterThan(0);
|
|
1357
|
+
} finally {
|
|
1358
|
+
client.close();
|
|
1359
|
+
server.stop(true);
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
30000,
|
|
1363
|
+
);
|
|
1364
|
+
|
|
1365
|
+
test(
|
|
1366
|
+
"keeps every digest when only the significance scores change",
|
|
1367
|
+
async () => {
|
|
1368
|
+
const { server, fixture, url } = startServer();
|
|
1369
|
+
const client = await connect(url);
|
|
1370
|
+
try {
|
|
1371
|
+
const first = await runDiscover(client);
|
|
1372
|
+
const firstManifest = first.manifest as Record<string, unknown>;
|
|
1373
|
+
const sidecarPath = join(fixture.root, ".omp", "discover", "significance.json");
|
|
1374
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, "utf8")) as {
|
|
1375
|
+
schemaVersion: number;
|
|
1376
|
+
contexts: Record<string, Record<string, { score: number; reason: string }>>;
|
|
1377
|
+
};
|
|
1378
|
+
const orders = sidecar.contexts.orders ?? {};
|
|
1379
|
+
orders["order-total"] = { score: 55, reason: "Still significant, scored lower." };
|
|
1380
|
+
orders.option = { score: 5, reason: "An incidental framework option token." };
|
|
1381
|
+
sidecar.contexts.orders = orders;
|
|
1382
|
+
writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
1383
|
+
|
|
1384
|
+
const second = await discoverState(client);
|
|
1385
|
+
const secondManifest = second.manifest as Record<string, unknown>;
|
|
1386
|
+
expect(secondManifest.sheetDigest).toBe(firstManifest.sheetDigest);
|
|
1387
|
+
expect(secondManifest.glossaryDigests).toEqual(firstManifest.glossaryDigests);
|
|
1388
|
+
expect(second.glossarySignificance.orders?.["order-total"]?.score).toBe(55);
|
|
1389
|
+
expect(second.glossarySignificance.orders?.option?.score).toBe(5);
|
|
1390
|
+
} finally {
|
|
1391
|
+
client.close();
|
|
1392
|
+
server.stop(true);
|
|
1393
|
+
}
|
|
1394
|
+
},
|
|
1395
|
+
30000,
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
test(
|
|
1399
|
+
"publishes a preserved human decision unscored when the crawl stages no score for it",
|
|
1400
|
+
async () => {
|
|
1401
|
+
const fixture = createFixture("preserved-unscored");
|
|
1402
|
+
const first = startServer({ fixture });
|
|
1403
|
+
const firstClient = await connect(first.url);
|
|
1404
|
+
try {
|
|
1405
|
+
await runDiscover(firstClient);
|
|
1406
|
+
} finally {
|
|
1407
|
+
firstClient.close();
|
|
1408
|
+
first.server.stop(true);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const dropped = startServer({
|
|
1412
|
+
fixture,
|
|
1413
|
+
stub: ["--discover", "--discover-glossary-drop"],
|
|
1414
|
+
});
|
|
1415
|
+
const client = await connect(dropped.url);
|
|
1416
|
+
try {
|
|
1417
|
+
client.send({ type: "discoverProject" });
|
|
1418
|
+
await client.waitFor((message) => message.type === "discoverStarted");
|
|
1419
|
+
const stopped = await client.waitFor((message) => message.type === "discoverStopped");
|
|
1420
|
+
expect(stopped.completed).toBe(true);
|
|
1421
|
+
const state = await discoverState(client);
|
|
1422
|
+
expect(state.error).toBeNull();
|
|
1423
|
+
const glossary = state.glossaries[0] as {
|
|
1424
|
+
concepts: Array<{ id: string; status: string }>;
|
|
1425
|
+
};
|
|
1426
|
+
const preserved = glossary.concepts.find((concept) => concept.id === "order-total");
|
|
1427
|
+
expect(preserved).toBeDefined();
|
|
1428
|
+
expect(preserved?.status).toBe("confirmed");
|
|
1429
|
+
expect(state.glossarySignificance.orders?.["order-total"]).toBeUndefined();
|
|
1430
|
+
expect(state.glossarySignificance.orders?.order?.score).toBe(80);
|
|
1431
|
+
} finally {
|
|
1432
|
+
client.close();
|
|
1433
|
+
dropped.server.stop(true);
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
60000,
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
test(
|
|
1440
|
+
"accepts a sheet whose levels carry no happy-path count",
|
|
1441
|
+
async () => {
|
|
1442
|
+
const { server, url } = startServer({
|
|
1443
|
+
stub: ["--discover", "--discover-sheet-no-happy-path"],
|
|
1444
|
+
});
|
|
1445
|
+
const client = await connect(url);
|
|
1446
|
+
try {
|
|
1447
|
+
const state = await runDiscover(client);
|
|
1448
|
+
expect(state.error).toBeNull();
|
|
1449
|
+
const testing = state.sheet?.testing;
|
|
1450
|
+
if (typeof testing !== "object" || testing === null) {
|
|
1451
|
+
throw new Error("the published sheet has no testing section");
|
|
1452
|
+
}
|
|
1453
|
+
const levelsLeaf = (testing as Record<string, unknown>).levels;
|
|
1454
|
+
if (typeof levelsLeaf !== "object" || levelsLeaf === null) {
|
|
1455
|
+
throw new Error("the published sheet has no testing levels");
|
|
1456
|
+
}
|
|
1457
|
+
const values = (levelsLeaf as Record<string, unknown>).value;
|
|
1458
|
+
if (!Array.isArray(values)) throw new Error("the testing levels carry no records");
|
|
1459
|
+
const publishedLevels = values as Array<Record<string, unknown>>;
|
|
1460
|
+
expect(publishedLevels).toHaveLength(1);
|
|
1461
|
+
expect(publishedLevels[0]?.happyPath).toBeUndefined();
|
|
1462
|
+
expect(publishedLevels[0]?.tests).toBe("1");
|
|
1463
|
+
} finally {
|
|
1464
|
+
client.close();
|
|
1465
|
+
server.stop(true);
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
30000,
|
|
1469
|
+
);
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
describe("declared command aliases", () => {
|
|
1473
|
+
test("maps a script body to the command that runs it", () => {
|
|
1474
|
+
const root = testDirectory("aliases");
|
|
1475
|
+
writeFileSync(
|
|
1476
|
+
join(root, "package.json"),
|
|
1477
|
+
`${JSON.stringify({
|
|
1478
|
+
scripts: {
|
|
1479
|
+
test: "bun test",
|
|
1480
|
+
"test:unit": "bun test",
|
|
1481
|
+
build: "tsc -p .",
|
|
1482
|
+
lint: "eslint .",
|
|
1483
|
+
},
|
|
1484
|
+
})}\n`,
|
|
1485
|
+
);
|
|
1486
|
+
expect(declaredCommandAliases(root)).toEqual([
|
|
1487
|
+
{ command: "bun test", alias: "npm test" },
|
|
1488
|
+
{ command: "eslint .", alias: "npm run lint" },
|
|
1489
|
+
{ command: "tsc -p .", alias: "npm run build" },
|
|
1490
|
+
]);
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
test("ignores a missing, malformed, or scriptless manifest", () => {
|
|
1494
|
+
const root = testDirectory("aliases-missing");
|
|
1495
|
+
expect(declaredCommandAliases(root)).toEqual([]);
|
|
1496
|
+
writeFileSync(join(root, "package.json"), "{not json\n");
|
|
1497
|
+
expect(declaredCommandAliases(root)).toEqual([]);
|
|
1498
|
+
writeFileSync(join(root, "package.json"), `${JSON.stringify({ scripts: [] })}\n`);
|
|
1499
|
+
expect(declaredCommandAliases(root)).toEqual([]);
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
/** The fixture a server started from `url` serves, for a restart in one test. */
|
|
1504
|
+
const fixturesByUrl = new Map<string, Fixture>();
|
|
1505
|
+
|
|
1506
|
+
function fixtureFor(url: string): Fixture {
|
|
1507
|
+
const fixture = fixturesByUrl.get(url);
|
|
1508
|
+
if (fixture === undefined) throw new Error(`no fixture serves ${url}`);
|
|
1509
|
+
return fixture;
|
|
1510
|
+
}
|