@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,3198 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
16
|
+
import { dependencyName, normalizeRepositoryUrl } from "../lib/dependencies.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discover Mode's artifact contract: the project sheet, the UbiLang glossaries,
|
|
20
|
+
* the five project-local skills, the host fingerprint, canonical ordering, the
|
|
21
|
+
* structural digest, staged validation, and atomic publication.
|
|
22
|
+
*
|
|
23
|
+
* Everything this module writes lives under `<host>/.omp/discover/` or in a live
|
|
24
|
+
* skill under `<host>/.omp/skills/`. It never writes `GOAL.md`, `adrs/`, host
|
|
25
|
+
* source, or anything outside those two directories.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const DISCOVER_SCHEMA_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
export const DISCOVER_SKILLS = [
|
|
31
|
+
"discover-testing-strategy",
|
|
32
|
+
"discover-naming-conventions",
|
|
33
|
+
"discover-coding-conventions",
|
|
34
|
+
"discover-architecture-boundaries",
|
|
35
|
+
"discover-glossary",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type DiscoverSkillName = (typeof DISCOVER_SKILLS)[number];
|
|
39
|
+
|
|
40
|
+
const DISCOVER_SKILLS_BY_NAME: Record<string, true> = Object.fromEntries(
|
|
41
|
+
DISCOVER_SKILLS.map((name) => [name, true] as const),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function isDiscoverSkill(name: string): name is DiscoverSkillName {
|
|
45
|
+
return Object.hasOwn(DISCOVER_SKILLS_BY_NAME, name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const DISCOVER_SHEET_SECTIONS = [
|
|
49
|
+
"definition",
|
|
50
|
+
"stack",
|
|
51
|
+
"deployment",
|
|
52
|
+
"environments",
|
|
53
|
+
"commands",
|
|
54
|
+
"branching",
|
|
55
|
+
"testing",
|
|
56
|
+
"architecture",
|
|
57
|
+
"ci",
|
|
58
|
+
"runtime",
|
|
59
|
+
"dependencyMap",
|
|
60
|
+
"delivery",
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
64
|
+
export const SKILL_NAME_MAX = 64;
|
|
65
|
+
export const SKILL_DESCRIPTION_MAX = 1024;
|
|
66
|
+
const CONCEPT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
67
|
+
const CONTEXT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
68
|
+
const CELL_MAX = 160;
|
|
69
|
+
const DEFINITION_MAX = 240;
|
|
70
|
+
const UNKNOWN_REASON_MAX = 200;
|
|
71
|
+
const EVIDENCE_MAX = 32;
|
|
72
|
+
const LIST_MAX = 64;
|
|
73
|
+
|
|
74
|
+
const MANAGED_MARKER = "devboard-managed";
|
|
75
|
+
const FAMILY_MARKER = "devboard-family";
|
|
76
|
+
const SKILL_MARKER = "devboard-skill";
|
|
77
|
+
const DISCOVER_FAMILY = "discover";
|
|
78
|
+
|
|
79
|
+
const DEPLOYMENT_CLASSES = [
|
|
80
|
+
"package",
|
|
81
|
+
"container",
|
|
82
|
+
"static-site",
|
|
83
|
+
"service",
|
|
84
|
+
"cli",
|
|
85
|
+
"library",
|
|
86
|
+
"plugin",
|
|
87
|
+
"none",
|
|
88
|
+
] as const;
|
|
89
|
+
const BRANCH_MODELS = ["direct", "pull-request", "git-flow"] as const;
|
|
90
|
+
const TEST_LEVELS = ["unit", "integration", "end-to-end", "contract", "other"] as const;
|
|
91
|
+
const CONFIDENCES = ["observed", "declared", "inferred", "unknown"] as const;
|
|
92
|
+
const CONCEPT_STATUSES = [
|
|
93
|
+
"confirmed",
|
|
94
|
+
"unconfirmed",
|
|
95
|
+
"proposed",
|
|
96
|
+
"deprecated",
|
|
97
|
+
"invalidated",
|
|
98
|
+
] as const;
|
|
99
|
+
const CONCEPT_ORIGINS = ["owner", "standard", "llm"] as const;
|
|
100
|
+
const CONCEPT_KINDS = ["entity", "event", "field", "role", "actor", "value", "rule"] as const;
|
|
101
|
+
const LANGUAGES = ["de", "en"] as const;
|
|
102
|
+
|
|
103
|
+
/** Relative roots the crawl and the fingerprint never read. */
|
|
104
|
+
const EXCLUDED_ROOTS = [".devboard", ".worktree", ".omp/discover", ".omp/skills", ".git"];
|
|
105
|
+
|
|
106
|
+
/** A crawl writes only files below its staging directory; these are the only allowed paths. */
|
|
107
|
+
const STAGED_SHEET = "sheet.json";
|
|
108
|
+
const STAGED_GLOSSARY_DIR = "glossary";
|
|
109
|
+
const STAGED_SIGNIFICANCE = "glossary/significance.json";
|
|
110
|
+
const STAGED_SKILL_DIR = "skills";
|
|
111
|
+
|
|
112
|
+
/** The crawl's own significance sidecar: one score and one reason per staged concept. */
|
|
113
|
+
const SIGNIFICANCE_SCHEMA_VERSION = 1;
|
|
114
|
+
const SIGNIFICANCE_REASON_MAX = 160;
|
|
115
|
+
const SIGNIFICANCE_CONTEXT_KEYS = ["schemaVersion", "contexts"] as const;
|
|
116
|
+
const SIGNIFICANCE_ENTRY_KEYS = ["score", "reason"] as const;
|
|
117
|
+
|
|
118
|
+
/** Secret shapes that must never reach an artifact. */
|
|
119
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
120
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
121
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/,
|
|
122
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/,
|
|
123
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
124
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
|
|
125
|
+
/:\/\/[^\s/@:]+:[^\s/@:]+@/,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const ABSOLUTE_PATH_PATTERN = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
|
|
129
|
+
|
|
130
|
+
export class DiscoverValidationError extends Error {
|
|
131
|
+
constructor(message: string) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "DiscoverValidationError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function invalid(path: string, rule: string): never {
|
|
138
|
+
throw new DiscoverValidationError(`${path}: ${rule}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
142
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function singleLine(value: string): boolean {
|
|
146
|
+
return !value.includes("\n") && !value.includes("\r");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasSecret(value: string): boolean {
|
|
150
|
+
return SECRET_PATTERNS.some((pattern) => pattern.test(value));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ------------------------------------------------------------------ paths
|
|
154
|
+
|
|
155
|
+
export function discoverRoot(repoDir: string): string {
|
|
156
|
+
return join(repoDir, ".omp", "discover");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function stagingRoot(repoDir: string): string {
|
|
160
|
+
return join(discoverRoot(repoDir), "staging");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function stagingDirectory(repoDir: string, runId: string): string {
|
|
164
|
+
return join(stagingRoot(repoDir), runId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function liveSkillsRoot(repoDir: string): string {
|
|
168
|
+
return join(repoDir, ".omp", "skills");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function baselinePath(repoDir: string, name: string): string {
|
|
172
|
+
return join(discoverRoot(repoDir), "skills", name, "SKILL.md");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function liveSkillPath(repoDir: string, name: string): string {
|
|
176
|
+
return join(liveSkillsRoot(repoDir), name, "SKILL.md");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function glossaryPath(repoDir: string, context: string): string {
|
|
180
|
+
return join(discoverRoot(repoDir), "glossary", `${context}.glossary.yaml`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Host-relative artifact path, always spelled with forward slashes. */
|
|
184
|
+
export function artifactRelative(repoDir: string, path: string): string {
|
|
185
|
+
return relative(repoDir, path).split(sep).join("/");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function removeStaging(repoDir: string, runId: string): void {
|
|
189
|
+
rmSync(stagingDirectory(repoDir, runId), { recursive: true, force: true });
|
|
190
|
+
// Staging exists only while a run is active, so an empty staging root goes too.
|
|
191
|
+
try {
|
|
192
|
+
rmSync(stagingRoot(repoDir), { recursive: true, force: true });
|
|
193
|
+
} catch {
|
|
194
|
+
// A concurrent run may have created its own staging directory.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// -------------------------------------------------------- remote targets
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Discover's second target: a public GitHub repository cloned under the
|
|
202
|
+
* dashboard's state directory. The scratch root is the only place a remote
|
|
203
|
+
* clone lives; every artifact of a remote run stays inside it.
|
|
204
|
+
*/
|
|
205
|
+
export const REMOTE_SCRATCH_NAME = "discover-remote";
|
|
206
|
+
export const REMOTE_URL_MAX_BYTES = 2048;
|
|
207
|
+
export const REMOTE_CLONE_TIMEOUT_MS = 300_000;
|
|
208
|
+
|
|
209
|
+
export interface RemoteRepository {
|
|
210
|
+
/** The trimmed URL as submitted. */
|
|
211
|
+
url: string;
|
|
212
|
+
owner: string;
|
|
213
|
+
repo: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type RemoteUrlParse =
|
|
217
|
+
| { ok: true; repository: RemoteRepository }
|
|
218
|
+
| { ok: false; reason: string };
|
|
219
|
+
|
|
220
|
+
const REMOTE_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,99})$/;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Accepts exactly `https://github.com/<owner>/<repo>`, with an optional
|
|
224
|
+
* trailing slash and an optional `.git` suffix. Everything else is refused
|
|
225
|
+
* with the one reason the caller reports.
|
|
226
|
+
*/
|
|
227
|
+
export function parseRemoteUrl(raw: string): RemoteUrlParse {
|
|
228
|
+
const url = raw.trim();
|
|
229
|
+
if (url.length === 0) return { ok: false, reason: "the URL is empty" };
|
|
230
|
+
if (Buffer.byteLength(url, "utf8") > REMOTE_URL_MAX_BYTES) {
|
|
231
|
+
return { ok: false, reason: `the URL is longer than ${REMOTE_URL_MAX_BYTES} bytes` };
|
|
232
|
+
}
|
|
233
|
+
if (!url.startsWith("https://")) {
|
|
234
|
+
return { ok: false, reason: "the URL must start with https://" };
|
|
235
|
+
}
|
|
236
|
+
const rest = url.slice("https://".length);
|
|
237
|
+
if (rest.includes("?") || rest.includes("#")) {
|
|
238
|
+
return { ok: false, reason: "the URL must not carry a query or fragment" };
|
|
239
|
+
}
|
|
240
|
+
const parts = rest.split("/");
|
|
241
|
+
if (parts.length === 4 && parts[3] === "") parts.pop();
|
|
242
|
+
if (parts.length !== 3) {
|
|
243
|
+
return { ok: false, reason: "the URL must name exactly one owner and one repository" };
|
|
244
|
+
}
|
|
245
|
+
const host = parts[0] ?? "";
|
|
246
|
+
const owner = parts[1] ?? "";
|
|
247
|
+
const rawRepo = parts[2] ?? "";
|
|
248
|
+
if (host.includes("@")) {
|
|
249
|
+
return { ok: false, reason: "the URL must not carry user information" };
|
|
250
|
+
}
|
|
251
|
+
if (host.includes(":")) {
|
|
252
|
+
return { ok: false, reason: "the URL must not carry a port" };
|
|
253
|
+
}
|
|
254
|
+
if (host.toLowerCase() !== "github.com") {
|
|
255
|
+
return { ok: false, reason: "the URL must name github.com" };
|
|
256
|
+
}
|
|
257
|
+
const repo = rawRepo.endsWith(".git") ? rawRepo.slice(0, -4) : rawRepo;
|
|
258
|
+
if (!REMOTE_SEGMENT.test(owner) || !REMOTE_SEGMENT.test(repo)) {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
reason:
|
|
262
|
+
"the owner and repository must be 1-100 letters, digits, dots, underscores, or hyphens",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, repository: { url, owner, repo } };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function remoteScratchRoot(stateDir: string): string {
|
|
269
|
+
return join(resolve(stateDir), REMOTE_SCRATCH_NAME);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The one clone directory name a repository gets; always lowercase. */
|
|
273
|
+
export function remoteCloneName(owner: string, repo: string): string {
|
|
274
|
+
return `${owner}-${repo}`.toLowerCase();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The retained project's directory. A run's checkout lives beside the retained
|
|
279
|
+
* projects under a dotted `.staging-*` name, so it never displaces them.
|
|
280
|
+
*/
|
|
281
|
+
export function remoteProjectDirectory(
|
|
282
|
+
scratchRoot: string,
|
|
283
|
+
owner: string,
|
|
284
|
+
repo: string,
|
|
285
|
+
): string {
|
|
286
|
+
return join(scratchRoot, remoteCloneName(owner, repo));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The record naming one retained project; it sits beside the two `.omp` trees. */
|
|
290
|
+
export const REMOTE_RECORD_NAME = "project.json";
|
|
291
|
+
|
|
292
|
+
/** Every transient directory a run creates starts with this prefix. */
|
|
293
|
+
export const REMOTE_TRANSIENT_PREFIX = ".staging-";
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Live claims on transient directories, one `<name>.json` file per claim. A
|
|
297
|
+
* dashboard's startup hygiene never removes a transient another live dashboard
|
|
298
|
+
* still owns; without the claim, a second dashboard sharing `STATE_DIR` would
|
|
299
|
+
* delete the checkout of a crawl that is still running.
|
|
300
|
+
*/
|
|
301
|
+
const RUN_CLAIM_DIRECTORY = ".runs";
|
|
302
|
+
|
|
303
|
+
interface RunClaim {
|
|
304
|
+
/** Process that owns the transient directory. */
|
|
305
|
+
pid: number;
|
|
306
|
+
/** ISO time the claim was written, for diagnosis only. */
|
|
307
|
+
claimedAt: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function claimPath(scratchRoot: string, name: string): string {
|
|
311
|
+
return join(scratchRoot, RUN_CLAIM_DIRECTORY, `${name}.json`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** True while the claim's owner process still runs; an unreadable claim is dead. */
|
|
315
|
+
function claimAlive(scratchRoot: string, name: string): boolean {
|
|
316
|
+
let raw: unknown;
|
|
317
|
+
try {
|
|
318
|
+
raw = JSON.parse(readFileSync(claimPath(scratchRoot, name), "utf8"));
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
const pid = (raw as Partial<RunClaim> | null)?.pid;
|
|
323
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
|
|
324
|
+
if (pid === process.pid) return true;
|
|
325
|
+
try {
|
|
326
|
+
process.kill(pid, 0);
|
|
327
|
+
return true;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
// Only a vanished process means the claim is dead; any other failure is
|
|
330
|
+
// treated as live, so a checkout is never removed on a guess.
|
|
331
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Records this process as the owner of one transient directory. Returns false
|
|
337
|
+
* when the claim cannot be written; the caller then refuses to create the
|
|
338
|
+
* transient rather than leave it exposed to another dashboard's cleanup.
|
|
339
|
+
*/
|
|
340
|
+
export function claimScratchEntry(scratchRoot: string, name: string): boolean {
|
|
341
|
+
const claim: RunClaim = { pid: process.pid, claimedAt: new Date().toISOString() };
|
|
342
|
+
try {
|
|
343
|
+
mkdirSync(join(scratchRoot, RUN_CLAIM_DIRECTORY), { recursive: true });
|
|
344
|
+
writeFileSync(claimPath(scratchRoot, name), `${JSON.stringify(claim)}\n`);
|
|
345
|
+
return true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Drops one claim and the claim directory once it is empty. */
|
|
352
|
+
export function releaseScratchEntry(scratchRoot: string, name: string): void {
|
|
353
|
+
try {
|
|
354
|
+
rmSync(claimPath(scratchRoot, name), { force: true });
|
|
355
|
+
rmdirSync(join(scratchRoot, RUN_CLAIM_DIRECTORY));
|
|
356
|
+
} catch {
|
|
357
|
+
// A claim that cannot be removed is dropped by the next start, and a
|
|
358
|
+
// directory still holding another run's claim stays.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Removes claims whose owner is gone, then the empty claim directory. */
|
|
363
|
+
function pruneScratchClaims(scratchRoot: string): void {
|
|
364
|
+
const directory = join(scratchRoot, RUN_CLAIM_DIRECTORY);
|
|
365
|
+
let entries;
|
|
366
|
+
try {
|
|
367
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
368
|
+
} catch {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
373
|
+
const name = entry.name.slice(0, -".json".length);
|
|
374
|
+
if (claimAlive(scratchRoot, name)) continue;
|
|
375
|
+
try {
|
|
376
|
+
rmSync(join(directory, entry.name), { force: true });
|
|
377
|
+
} catch {
|
|
378
|
+
// A claim that cannot be removed is retried by the next start.
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
rmdirSync(directory);
|
|
383
|
+
} catch {
|
|
384
|
+
// The directory stays only while it still holds a live or stuck claim.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** What a retained project keeps after the run's checkout is gone. */
|
|
389
|
+
export interface RetainedRemoteProject {
|
|
390
|
+
url: string;
|
|
391
|
+
owner: string;
|
|
392
|
+
repo: string;
|
|
393
|
+
/** ISO time the discovery that produced the retained bytes completed. */
|
|
394
|
+
completedAt: string;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parseRetainedProject(raw: unknown): RetainedRemoteProject | null {
|
|
398
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
399
|
+
const record = raw as Record<string, unknown>;
|
|
400
|
+
const url = record.url;
|
|
401
|
+
const owner = record.owner;
|
|
402
|
+
const repo = record.repo;
|
|
403
|
+
const completedAt = record.completedAt;
|
|
404
|
+
if (typeof url !== "string" || typeof owner !== "string" || typeof repo !== "string") {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
if (typeof completedAt !== "string" || !Number.isFinite(Date.parse(completedAt))) return null;
|
|
408
|
+
const parsed = parseRemoteUrl(url);
|
|
409
|
+
if (!parsed.ok) return null;
|
|
410
|
+
if (parsed.repository.owner !== owner || parsed.repository.repo !== repo) return null;
|
|
411
|
+
return { url: parsed.repository.url, owner, repo, completedAt };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Reads the record a retained project wrote, or null when it is unusable. */
|
|
415
|
+
export function readRetainedProject(directory: string): RetainedRemoteProject | null {
|
|
416
|
+
let raw: unknown;
|
|
417
|
+
try {
|
|
418
|
+
raw = JSON.parse(readFileSync(join(directory, REMOTE_RECORD_NAME), "utf8"));
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
return parseRetainedProject(raw);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A retained project is usable only when its record names the directory and
|
|
427
|
+
* both published trees are on disk. Nothing is repaired or re-crawled here.
|
|
428
|
+
*/
|
|
429
|
+
export function retainedProjectReady(
|
|
430
|
+
directory: string,
|
|
431
|
+
record: RetainedRemoteProject,
|
|
432
|
+
): boolean {
|
|
433
|
+
if (basename(resolve(directory)) !== remoteCloneName(record.owner, record.repo)) return false;
|
|
434
|
+
return (
|
|
435
|
+
existsSync(join(directory, ".omp", "discover")) &&
|
|
436
|
+
existsSync(join(directory, ".omp", "skills"))
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Every readable retained project, newest completed discovery first. */
|
|
441
|
+
export function listRetainedProjects(scratchRoot: string): RetainedRemoteProject[] {
|
|
442
|
+
let entries;
|
|
443
|
+
try {
|
|
444
|
+
entries = readdirSync(scratchRoot, { withFileTypes: true });
|
|
445
|
+
} catch {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
const projects: RetainedRemoteProject[] = [];
|
|
449
|
+
for (const entry of entries) {
|
|
450
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
451
|
+
const directory = join(scratchRoot, entry.name);
|
|
452
|
+
const record = readRetainedProject(directory);
|
|
453
|
+
if (record === null) continue;
|
|
454
|
+
if (remoteCloneName(record.owner, record.repo) !== entry.name) continue;
|
|
455
|
+
if (!retainedProjectReady(directory, record)) continue;
|
|
456
|
+
projects.push(record);
|
|
457
|
+
}
|
|
458
|
+
projects.sort((left, right) => {
|
|
459
|
+
const newer = Date.parse(right.completedAt) - Date.parse(left.completedAt);
|
|
460
|
+
return newer !== 0 ? newer : left.url.localeCompare(right.url);
|
|
461
|
+
});
|
|
462
|
+
return projects;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Copies a finished run's published `.omp/discover` and `.omp/skills` bytes out
|
|
467
|
+
* of its checkout and records the completion time. The previous snapshot is
|
|
468
|
+
* restored when the rename fails, so a failed retention never loses bytes.
|
|
469
|
+
*/
|
|
470
|
+
export function retainRemoteProject(
|
|
471
|
+
scratchRoot: string,
|
|
472
|
+
checkoutDir: string,
|
|
473
|
+
record: RetainedRemoteProject,
|
|
474
|
+
runId: string,
|
|
475
|
+
): string | null {
|
|
476
|
+
const published = join(checkoutDir, ".omp");
|
|
477
|
+
const discoverSource = join(published, "discover");
|
|
478
|
+
const skillsSource = join(published, "skills");
|
|
479
|
+
if (!existsSync(discoverSource) || !existsSync(skillsSource)) {
|
|
480
|
+
return "the crawl published no Discover artifacts to retain";
|
|
481
|
+
}
|
|
482
|
+
const staging = join(scratchRoot, `${REMOTE_TRANSIENT_PREFIX}retain-${runId}`);
|
|
483
|
+
const previous = join(scratchRoot, `${REMOTE_TRANSIENT_PREFIX}previous-${runId}`);
|
|
484
|
+
const stagingName = basename(staging);
|
|
485
|
+
const previousName = basename(previous);
|
|
486
|
+
const destination = remoteProjectDirectory(scratchRoot, record.owner, record.repo);
|
|
487
|
+
claimScratchEntry(scratchRoot, stagingName);
|
|
488
|
+
claimScratchEntry(scratchRoot, previousName);
|
|
489
|
+
try {
|
|
490
|
+
rmSync(staging, { recursive: true, force: true });
|
|
491
|
+
mkdirSync(join(staging, ".omp"), { recursive: true });
|
|
492
|
+
cpSync(discoverSource, join(staging, ".omp", "discover"), { recursive: true });
|
|
493
|
+
cpSync(skillsSource, join(staging, ".omp", "skills"), { recursive: true });
|
|
494
|
+
writeFileSync(join(staging, REMOTE_RECORD_NAME), `${JSON.stringify(record, null, 2)}\n`);
|
|
495
|
+
rmSync(previous, { recursive: true, force: true });
|
|
496
|
+
const hadPrevious = existsSync(destination);
|
|
497
|
+
if (hadPrevious) renameSync(destination, previous);
|
|
498
|
+
try {
|
|
499
|
+
renameSync(staging, destination);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (hadPrevious) {
|
|
502
|
+
try {
|
|
503
|
+
renameSync(previous, destination);
|
|
504
|
+
} catch {
|
|
505
|
+
// The snapshot stays at the previous name; the caller reports it.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
rmSync(previous, { recursive: true, force: true });
|
|
511
|
+
return null;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
try {
|
|
514
|
+
rmSync(staging, { recursive: true, force: true });
|
|
515
|
+
} catch {
|
|
516
|
+
// A staging directory that cannot be removed is removed by the next start.
|
|
517
|
+
}
|
|
518
|
+
return error instanceof Error ? error.message : String(error);
|
|
519
|
+
} finally {
|
|
520
|
+
releaseScratchEntry(scratchRoot, stagingName);
|
|
521
|
+
releaseScratchEntry(scratchRoot, previousName);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Deletes one project's retained bytes. Every other project stays untouched. */
|
|
526
|
+
export function removeRetainedProject(
|
|
527
|
+
scratchRoot: string,
|
|
528
|
+
record: RetainedRemoteProject,
|
|
529
|
+
): string | null {
|
|
530
|
+
try {
|
|
531
|
+
rmSync(remoteProjectDirectory(scratchRoot, record.owner, record.repo), {
|
|
532
|
+
recursive: true,
|
|
533
|
+
force: true,
|
|
534
|
+
});
|
|
535
|
+
return null;
|
|
536
|
+
} catch (error) {
|
|
537
|
+
return error instanceof Error ? error.message : String(error);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** True when `path` is a strict descendant of `root`. */
|
|
542
|
+
export function isInsideRepository(repoDir: string, path: string): boolean {
|
|
543
|
+
const base = resolve(repoDir);
|
|
544
|
+
const target = resolve(path);
|
|
545
|
+
return target !== base && target.startsWith(`${base}${sep}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export interface RemoteCloneRequest {
|
|
549
|
+
/** `git clone ... -- <url> .staging-<run-id>`. */
|
|
550
|
+
argv: string[];
|
|
551
|
+
/** The scratch root the child runs in. */
|
|
552
|
+
cwd: string;
|
|
553
|
+
env: Record<string, string>;
|
|
554
|
+
timeoutMs: number;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export interface RemoteCloneResult {
|
|
558
|
+
code: number;
|
|
559
|
+
stderr: string;
|
|
560
|
+
timedOut: boolean;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Tests inject a runner through `AppOptions`; production spawns git. */
|
|
564
|
+
export type RemoteCloneRunner = (request: RemoteCloneRequest) => Promise<RemoteCloneResult>;
|
|
565
|
+
|
|
566
|
+
function cloneEnvironment(): Record<string, string> {
|
|
567
|
+
const env: Record<string, string> = {};
|
|
568
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
569
|
+
if (value !== undefined) env[key] = value;
|
|
570
|
+
}
|
|
571
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
572
|
+
env.GIT_ASKPASS = "true";
|
|
573
|
+
env.NO_COLOR = "1";
|
|
574
|
+
return env;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function remoteCloneRequest(
|
|
578
|
+
url: string,
|
|
579
|
+
scratchRoot: string,
|
|
580
|
+
runId: string,
|
|
581
|
+
): RemoteCloneRequest {
|
|
582
|
+
return {
|
|
583
|
+
argv: [
|
|
584
|
+
"git",
|
|
585
|
+
"clone",
|
|
586
|
+
"--depth",
|
|
587
|
+
"1",
|
|
588
|
+
"--single-branch",
|
|
589
|
+
"--no-tags",
|
|
590
|
+
"--",
|
|
591
|
+
url,
|
|
592
|
+
`.staging-${runId}`,
|
|
593
|
+
],
|
|
594
|
+
cwd: scratchRoot,
|
|
595
|
+
env: cloneEnvironment(),
|
|
596
|
+
timeoutMs: REMOTE_CLONE_TIMEOUT_MS,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Startup hygiene: a run's checkout, its clone staging, and its copy staging
|
|
602
|
+
* never survive a restart, and a retained project is never touched. A transient
|
|
603
|
+
* another live dashboard still owns is left alone: two dashboards can share one
|
|
604
|
+
* `STATE_DIR`, and one starting must not delete the other's running checkout.
|
|
605
|
+
*/
|
|
606
|
+
export function cleanupRemoteScratch(scratchRoot: string): void {
|
|
607
|
+
let entries;
|
|
608
|
+
try {
|
|
609
|
+
entries = readdirSync(scratchRoot, { withFileTypes: true });
|
|
610
|
+
} catch {
|
|
611
|
+
pruneScratchClaims(scratchRoot);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
for (const entry of entries) {
|
|
615
|
+
if (!entry.isDirectory() || entry.name === RUN_CLAIM_DIRECTORY) continue;
|
|
616
|
+
const directory = join(scratchRoot, entry.name);
|
|
617
|
+
const transient = entry.name.startsWith(REMOTE_TRANSIENT_PREFIX);
|
|
618
|
+
let checkout = false;
|
|
619
|
+
try {
|
|
620
|
+
checkout = existsSync(join(directory, ".git"));
|
|
621
|
+
} catch {
|
|
622
|
+
checkout = false;
|
|
623
|
+
}
|
|
624
|
+
if (!transient && !checkout) continue;
|
|
625
|
+
if (transient && claimAlive(scratchRoot, entry.name)) continue;
|
|
626
|
+
try {
|
|
627
|
+
rmSync(directory, { recursive: true, force: true });
|
|
628
|
+
} catch {
|
|
629
|
+
// A directory that cannot be removed is left for the next start.
|
|
630
|
+
}
|
|
631
|
+
releaseScratchEntry(scratchRoot, entry.name);
|
|
632
|
+
}
|
|
633
|
+
pruneScratchClaims(scratchRoot);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ------------------------------------------------------------ host facts
|
|
637
|
+
|
|
638
|
+
export interface HostFacts {
|
|
639
|
+
fingerprint: string;
|
|
640
|
+
commit: string;
|
|
641
|
+
dirty: boolean;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function gitOutput(repoDir: string, args: string[]): string | undefined {
|
|
645
|
+
try {
|
|
646
|
+
const result = Bun.spawnSync({
|
|
647
|
+
cmd: ["git", "-C", repoDir, "-c", "core.quotepath=false", ...args],
|
|
648
|
+
stdout: "pipe",
|
|
649
|
+
stderr: "pipe",
|
|
650
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "true", NO_COLOR: "1" },
|
|
651
|
+
});
|
|
652
|
+
if (result.exitCode !== 0) return undefined;
|
|
653
|
+
return result.stdout.toString();
|
|
654
|
+
} catch {
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function excluded(relativePath: string): boolean {
|
|
660
|
+
return EXCLUDED_ROOTS.some(
|
|
661
|
+
(root) => relativePath === root || relativePath.startsWith(`${root}/`),
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Every file the fingerprint covers: tracked and untracked, never ignored. */
|
|
666
|
+
function hostFiles(repoDir: string): string[] {
|
|
667
|
+
const listed = gitOutput(repoDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
668
|
+
if (listed !== undefined) {
|
|
669
|
+
return listed
|
|
670
|
+
.split("\u0000")
|
|
671
|
+
.filter((entry) => entry.length > 0 && !excluded(entry) && !ABSOLUTE_PATH_PATTERN.test(entry))
|
|
672
|
+
.sort();
|
|
673
|
+
}
|
|
674
|
+
// A host without git still has a fingerprint: walk the tree, skipping the
|
|
675
|
+
// directories the crawl would not read.
|
|
676
|
+
const files: string[] = [];
|
|
677
|
+
const walk = (directory: string) => {
|
|
678
|
+
let entries: string[];
|
|
679
|
+
try {
|
|
680
|
+
entries = readdirSync(directory);
|
|
681
|
+
} catch {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
for (const entry of entries) {
|
|
685
|
+
const absolute = join(directory, entry);
|
|
686
|
+
const relativePath = relative(repoDir, absolute).split(sep).join("/");
|
|
687
|
+
if (excluded(relativePath)) continue;
|
|
688
|
+
if (entry === "node_modules" || entry === ".git") continue;
|
|
689
|
+
let stats;
|
|
690
|
+
try {
|
|
691
|
+
stats = statSync(absolute);
|
|
692
|
+
} catch {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (stats.isDirectory()) walk(absolute);
|
|
696
|
+
else if (stats.isFile()) files.push(relativePath);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
walk(repoDir);
|
|
700
|
+
return files.sort();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function contentHash(bytes: Buffer): string {
|
|
704
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The host fingerprint: `HEAD`, plus the content of every tracked and untracked
|
|
709
|
+
* non-ignored file outside the excluded directories. Two runs on an unchanged
|
|
710
|
+
* host produce one fingerprint.
|
|
711
|
+
*/
|
|
712
|
+
export function hostFacts(repoDir: string): HostFacts {
|
|
713
|
+
const digest = createHash("sha256");
|
|
714
|
+
for (const path of hostFiles(repoDir)) {
|
|
715
|
+
digest.update(path);
|
|
716
|
+
digest.update("\u0000");
|
|
717
|
+
try {
|
|
718
|
+
digest.update(contentHash(readFileSync(join(repoDir, path))));
|
|
719
|
+
} catch {
|
|
720
|
+
digest.update("unreadable");
|
|
721
|
+
}
|
|
722
|
+
digest.update("\u0000");
|
|
723
|
+
}
|
|
724
|
+
const commit = (gitOutput(repoDir, ["rev-parse", "--short=7", "HEAD"]) ?? "").trim();
|
|
725
|
+
const status = gitOutput(repoDir, ["status", "--porcelain", "--untracked-files=all"]);
|
|
726
|
+
return {
|
|
727
|
+
fingerprint: digest.digest("hex"),
|
|
728
|
+
commit,
|
|
729
|
+
dirty: status === undefined ? false : dirtyLines(status).length > 0,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ------------------------------------------------------- glossary usage
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* How often one glossary concept is used across the crawled host, and the
|
|
737
|
+
* identifiers that name it. Derived from the published glossary and the host
|
|
738
|
+
* files; never written back into an artifact.
|
|
739
|
+
*/
|
|
740
|
+
export interface ConceptUsage {
|
|
741
|
+
/** Occurrences of the concept's preferred and alternate labels. */
|
|
742
|
+
count: number;
|
|
743
|
+
/** Distinct crawled files carrying at least one occurrence. */
|
|
744
|
+
files: number;
|
|
745
|
+
/** Representative identifiers from the host that name the concept. */
|
|
746
|
+
examples: string[];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Usage keyed by bounded context, then concept id. */
|
|
750
|
+
export type GlossaryUsage = Record<string, Record<string, ConceptUsage>>;
|
|
751
|
+
|
|
752
|
+
const USAGE_FILE_MAX = 512 * 1024;
|
|
753
|
+
const USAGE_EXAMPLES_MAX = 8;
|
|
754
|
+
const USAGE_CACHE_MAX = 8;
|
|
755
|
+
const USAGE_IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
756
|
+
|
|
757
|
+
const usageCache = new Map<string, GlossaryUsage>();
|
|
758
|
+
|
|
759
|
+
/** Lower-case words of a label, punctuation removed, for matching code names. */
|
|
760
|
+
function usageWords(text: string): string[] {
|
|
761
|
+
return text
|
|
762
|
+
.toLowerCase()
|
|
763
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
764
|
+
.trim()
|
|
765
|
+
.split(" ")
|
|
766
|
+
.filter((word) => word.length > 0);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Every spelling a label takes in prose and identifiers. */
|
|
770
|
+
function usageVariants(labels: string[]): string[] {
|
|
771
|
+
const variants = new Set<string>();
|
|
772
|
+
for (const label of labels) {
|
|
773
|
+
const words = usageWords(label);
|
|
774
|
+
if (words.length === 0) continue;
|
|
775
|
+
const phrase = words.join(" ");
|
|
776
|
+
if (phrase.length >= 2) variants.add(phrase);
|
|
777
|
+
if (words.length > 1) {
|
|
778
|
+
variants.add(words.join(""));
|
|
779
|
+
variants.add(words.join("_"));
|
|
780
|
+
variants.add(words.join("-"));
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return [...variants].sort((left, right) =>
|
|
784
|
+
left.length === right.length ? left.localeCompare(right) : right.length - left.length,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function countOccurrences(text: string, term: string): number {
|
|
789
|
+
let count = 0;
|
|
790
|
+
let index = text.indexOf(term);
|
|
791
|
+
while (index >= 0) {
|
|
792
|
+
count += 1;
|
|
793
|
+
index = text.indexOf(term, index + term.length);
|
|
794
|
+
}
|
|
795
|
+
return count;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
interface UsageFile {
|
|
799
|
+
path: string;
|
|
800
|
+
text: string;
|
|
801
|
+
identifiers: string[];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function usageFiles(repoDir: string): UsageFile[] {
|
|
805
|
+
const files: UsageFile[] = [];
|
|
806
|
+
for (const path of hostFiles(repoDir)) {
|
|
807
|
+
const absolute = join(repoDir, path);
|
|
808
|
+
let stats;
|
|
809
|
+
try {
|
|
810
|
+
stats = statSync(absolute);
|
|
811
|
+
} catch {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (!stats.isFile() || stats.size > USAGE_FILE_MAX) continue;
|
|
815
|
+
let bytes: Buffer;
|
|
816
|
+
try {
|
|
817
|
+
bytes = readFileSync(absolute);
|
|
818
|
+
} catch {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (bytes.includes(0)) continue;
|
|
822
|
+
const text = bytes.toString("utf8");
|
|
823
|
+
files.push({ path, text, identifiers: text.match(USAGE_IDENTIFIER) ?? [] });
|
|
824
|
+
}
|
|
825
|
+
return files;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function usageKey(repoDir: string, fingerprint: string, glossaries: Glossary[]): string {
|
|
829
|
+
const shape = glossaries.map((glossary) => ({
|
|
830
|
+
context: glossary.boundedContext,
|
|
831
|
+
concepts: glossary.concepts.map((concept) => ({
|
|
832
|
+
id: concept.id,
|
|
833
|
+
labels: [...concept.prefLabels, ...concept.altLabels].map((label) => label.text),
|
|
834
|
+
grounding: concept.grounding,
|
|
835
|
+
})),
|
|
836
|
+
}));
|
|
837
|
+
return createHash("sha256")
|
|
838
|
+
.update(repoDir)
|
|
839
|
+
.update("\u0000")
|
|
840
|
+
.update(fingerprint)
|
|
841
|
+
.update("\u0000")
|
|
842
|
+
.update(JSON.stringify(shape))
|
|
843
|
+
.digest("hex");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Counts each concept's usage across the host's documentation and source and
|
|
848
|
+
* collects identifiers that name it. The scan reads the same files the
|
|
849
|
+
* fingerprint covers: tracked and untracked non-ignored files, never
|
|
850
|
+
* `.devboard/`, `.worktree/`, `.git/`, dependency caches, or published
|
|
851
|
+
* Discover artifacts. Results are cached per host revision and glossary shape.
|
|
852
|
+
*/
|
|
853
|
+
export function glossaryUsage(
|
|
854
|
+
repoDir: string,
|
|
855
|
+
glossaries: Glossary[],
|
|
856
|
+
fingerprint: string,
|
|
857
|
+
): GlossaryUsage {
|
|
858
|
+
const key = usageKey(repoDir, fingerprint, glossaries);
|
|
859
|
+
const cached = usageCache.get(key);
|
|
860
|
+
if (cached !== undefined) return cached;
|
|
861
|
+
|
|
862
|
+
const files = usageFiles(repoDir);
|
|
863
|
+
const usage: GlossaryUsage = {};
|
|
864
|
+
for (const glossary of glossaries) {
|
|
865
|
+
const contexts: Record<string, ConceptUsage> = {};
|
|
866
|
+
for (const concept of glossary.concepts) {
|
|
867
|
+
const labels = [...concept.prefLabels, ...concept.altLabels].map((label) => label.text);
|
|
868
|
+
const variants = usageVariants(labels);
|
|
869
|
+
const wordSets = labels
|
|
870
|
+
.map(usageWords)
|
|
871
|
+
.filter((words) => words.length > 0)
|
|
872
|
+
.map((words) => ({ words, compact: words.join("") }));
|
|
873
|
+
const grounding = new Set(
|
|
874
|
+
concept.grounding
|
|
875
|
+
.filter((entry) => entry.startsWith("code:"))
|
|
876
|
+
.map((entry) => entry.slice("code:".length)),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
let count = 0;
|
|
880
|
+
let occurrencesInFiles = 0;
|
|
881
|
+
const examples = new Map<string, { count: number; grounded: boolean }>();
|
|
882
|
+
for (const entry of files) {
|
|
883
|
+
const text = entry.text.toLowerCase();
|
|
884
|
+
let inFile = 0;
|
|
885
|
+
for (const variant of variants) inFile += countOccurrences(text, variant);
|
|
886
|
+
if (inFile > 0) {
|
|
887
|
+
count += inFile;
|
|
888
|
+
occurrencesInFiles += 1;
|
|
889
|
+
}
|
|
890
|
+
if (wordSets.length === 0) continue;
|
|
891
|
+
const grounded = grounding.has(entry.path);
|
|
892
|
+
for (const name of entry.identifiers) {
|
|
893
|
+
if (name.length < 3 || name.length > 80) continue;
|
|
894
|
+
const lower = name.toLowerCase();
|
|
895
|
+
const matches = wordSets.some(({ words, compact }) =>
|
|
896
|
+
words.length === 1
|
|
897
|
+
? lower.includes(words[0] ?? "")
|
|
898
|
+
: words.every((word) => lower.includes(word)) || lower.includes(compact),
|
|
899
|
+
);
|
|
900
|
+
if (!matches) continue;
|
|
901
|
+
const previous = examples.get(name);
|
|
902
|
+
examples.set(name, {
|
|
903
|
+
count: (previous?.count ?? 0) + 1,
|
|
904
|
+
grounded: (previous?.grounded ?? false) || grounded,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
for (const entry of concept.grounding) {
|
|
910
|
+
if (!entry.startsWith("model:")) continue;
|
|
911
|
+
const name = entry.slice("model:".length);
|
|
912
|
+
const previous = examples.get(name);
|
|
913
|
+
examples.set(name, { count: previous?.count ?? 0, grounded: true });
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const ranked = [...examples.entries()]
|
|
917
|
+
.sort(([leftName, left], [rightName, right]) =>
|
|
918
|
+
left.grounded === right.grounded
|
|
919
|
+
? left.count === right.count
|
|
920
|
+
? leftName.localeCompare(rightName)
|
|
921
|
+
: right.count - left.count
|
|
922
|
+
: left.grounded
|
|
923
|
+
? -1
|
|
924
|
+
: 1,
|
|
925
|
+
)
|
|
926
|
+
.slice(0, USAGE_EXAMPLES_MAX)
|
|
927
|
+
.map(([name]) => name);
|
|
928
|
+
|
|
929
|
+
contexts[concept.id] = {
|
|
930
|
+
count,
|
|
931
|
+
files: occurrencesInFiles,
|
|
932
|
+
examples: ranked,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
usage[glossary.boundedContext] = contexts;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
usageCache.set(key, usage);
|
|
939
|
+
while (usageCache.size > USAGE_CACHE_MAX) {
|
|
940
|
+
const oldest = usageCache.keys().next().value;
|
|
941
|
+
if (oldest === undefined) break;
|
|
942
|
+
usageCache.delete(oldest);
|
|
943
|
+
}
|
|
944
|
+
return usage;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* A change outside the excluded directories. The crawl's own staging and the
|
|
949
|
+
* published harness artifacts are not host dirt.
|
|
950
|
+
*/
|
|
951
|
+
function dirtyLines(status: string): string[] {
|
|
952
|
+
const lines: string[] = [];
|
|
953
|
+
for (const line of status.split("\n")) {
|
|
954
|
+
if (line.trim().length === 0) continue;
|
|
955
|
+
let path = line.slice(3);
|
|
956
|
+
const rename = path.indexOf(" -> ");
|
|
957
|
+
if (rename >= 0) path = path.slice(rename + 4);
|
|
958
|
+
if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
|
|
959
|
+
if (excluded(path)) continue;
|
|
960
|
+
lines.push(line);
|
|
961
|
+
}
|
|
962
|
+
return lines;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// -------------------------------------------------------------- strings
|
|
966
|
+
|
|
967
|
+
function textValue(value: unknown, path: string, max = CELL_MAX): string {
|
|
968
|
+
if (typeof value !== "string") invalid(path, "must be a string");
|
|
969
|
+
const text = value.trim();
|
|
970
|
+
if (text.length === 0) invalid(path, "must not be empty");
|
|
971
|
+
if (!singleLine(text)) invalid(path, "must be a single line");
|
|
972
|
+
if (text.length > max) invalid(path, `must be at most ${max} characters`);
|
|
973
|
+
if (ABSOLUTE_PATH_PATTERN.test(text)) invalid(path, "must not be an absolute path");
|
|
974
|
+
if (text.split("/").some((segment) => segment === "home" || segment === "Users")) {
|
|
975
|
+
invalid(path, "must not carry an absolute host path");
|
|
976
|
+
}
|
|
977
|
+
if (/[A-Za-z]:\\/.test(text)) invalid(path, "must not carry an absolute host path");
|
|
978
|
+
if (hasSecret(text)) invalid(path, "must not carry a secret value");
|
|
979
|
+
return text;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function evidenceList(value: unknown, path: string): string[] {
|
|
983
|
+
if (!Array.isArray(value)) invalid(path, "must be a list of strings");
|
|
984
|
+
const entries = new Set<string>();
|
|
985
|
+
for (const [index, entry] of value.entries()) {
|
|
986
|
+
const text = textValue(entry, `${path}[${index}]`);
|
|
987
|
+
if (text.includes("://")) invalid(`${path}[${index}]`, "must not be a URL");
|
|
988
|
+
if (text.split("/").includes("..")) invalid(`${path}[${index}]`, "must stay inside the host");
|
|
989
|
+
entries.add(text);
|
|
990
|
+
}
|
|
991
|
+
return [...entries].sort();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function oneSentence(value: unknown, path: string): string {
|
|
995
|
+
const text = textValue(value, path, DEFINITION_MAX);
|
|
996
|
+
const terminators = text.match(/[.!?](?=\s|$)/g) ?? [];
|
|
997
|
+
if (terminators.length !== 1 || !/[.!?]$/.test(text)) {
|
|
998
|
+
invalid(path, "must be exactly one sentence");
|
|
999
|
+
}
|
|
1000
|
+
return text;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
interface Leaf {
|
|
1004
|
+
value: unknown;
|
|
1005
|
+
evidence: string[];
|
|
1006
|
+
confidence: (typeof CONFIDENCES)[number];
|
|
1007
|
+
reason?: string;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function leafBase(node: unknown, path: string): { confidence: Leaf["confidence"]; reason?: string; evidence: string[] } {
|
|
1011
|
+
if (!isRecord(node)) invalid(path, "must be a leaf object");
|
|
1012
|
+
const declared = node.confidence;
|
|
1013
|
+
if (typeof declared !== "string" || !(CONFIDENCES as readonly string[]).includes(declared)) {
|
|
1014
|
+
invalid(`${path}.confidence`, "must be observed, declared, inferred, or unknown");
|
|
1015
|
+
}
|
|
1016
|
+
const confidence = declared as Leaf["confidence"];
|
|
1017
|
+
if (confidence === "unknown") {
|
|
1018
|
+
if (node.value !== null) invalid(`${path}.value`, "must be null when confidence is unknown");
|
|
1019
|
+
if (!Array.isArray(node.evidence) || node.evidence.length !== 0) {
|
|
1020
|
+
invalid(`${path}.evidence`, "must be empty when confidence is unknown");
|
|
1021
|
+
}
|
|
1022
|
+
const declaredReason = node.reason;
|
|
1023
|
+
if (typeof declaredReason !== "string") {
|
|
1024
|
+
invalid(`${path}.reason`, "must name why the value is unknown");
|
|
1025
|
+
}
|
|
1026
|
+
const reason = declaredReason.trim();
|
|
1027
|
+
if (reason.length === 0) invalid(`${path}.reason`, "must name why the value is unknown");
|
|
1028
|
+
if (!singleLine(reason) || reason.length > UNKNOWN_REASON_MAX) {
|
|
1029
|
+
invalid(`${path}.reason`, `must be one line of at most ${UNKNOWN_REASON_MAX} characters`);
|
|
1030
|
+
}
|
|
1031
|
+
if (hasSecret(reason)) invalid(`${path}.reason`, "must not carry a secret value");
|
|
1032
|
+
return { confidence: "unknown", reason, evidence: [] };
|
|
1033
|
+
}
|
|
1034
|
+
if (node.reason !== undefined) invalid(`${path}.reason`, "is only allowed when confidence is unknown");
|
|
1035
|
+
const evidence = evidenceList(node.evidence, `${path}.evidence`);
|
|
1036
|
+
if (evidence.length === 0) invalid(`${path}.evidence`, "must ground a non-unknown value");
|
|
1037
|
+
if (evidence.length > EVIDENCE_MAX) invalid(`${path}.evidence`, "has too many entries");
|
|
1038
|
+
return { confidence, evidence };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function leafText(node: unknown, path: string, options: { sentence?: boolean } = {}): Leaf {
|
|
1042
|
+
const base = leafBase(node, path);
|
|
1043
|
+
if (base.confidence === "unknown") return { value: null, ...base };
|
|
1044
|
+
const record = node as Record<string, unknown>;
|
|
1045
|
+
const value = options.sentence === true
|
|
1046
|
+
? oneSentence(record.value, `${path}.value`)
|
|
1047
|
+
: textValue(record.value, `${path}.value`);
|
|
1048
|
+
return { value, ...base };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function leafTexts(node: unknown, path: string): Leaf {
|
|
1052
|
+
const base = leafBase(node, path);
|
|
1053
|
+
if (base.confidence === "unknown") return { value: null, ...base };
|
|
1054
|
+
const record = node as Record<string, unknown>;
|
|
1055
|
+
if (!Array.isArray(record.value)) invalid(`${path}.value`, "must be a list of strings");
|
|
1056
|
+
if (record.value.length > LIST_MAX) invalid(`${path}.value`, "has too many entries");
|
|
1057
|
+
const values = [
|
|
1058
|
+
...new Set(record.value.map((entry, index) => textValue(entry, `${path}.value[${index}]`))),
|
|
1059
|
+
].sort();
|
|
1060
|
+
return { value: values, ...base };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Required CI checks, each with the line describing what it verifies.
|
|
1065
|
+
*
|
|
1066
|
+
* Sheets published before a gate carried its check stored gate names only;
|
|
1067
|
+
* accept that shape and read it as a gate with no recorded check so a host
|
|
1068
|
+
* keeps its Discover artifacts across the upgrade.
|
|
1069
|
+
*/
|
|
1070
|
+
function leafQualityGates(node: unknown, path: string): Leaf {
|
|
1071
|
+
const base = leafBase(node, path);
|
|
1072
|
+
if (base.confidence === "unknown") return { value: null, ...base };
|
|
1073
|
+
const record = node as Record<string, unknown>;
|
|
1074
|
+
if (!Array.isArray(record.value)) invalid(`${path}.value`, "must be a list");
|
|
1075
|
+
if (record.value.length > LIST_MAX) invalid(`${path}.value`, "has too many entries");
|
|
1076
|
+
const gates = new Map<string, { name: string; checks: string }>();
|
|
1077
|
+
for (const [index, entry] of record.value.entries()) {
|
|
1078
|
+
const entryPath = `${path}.value[${index}]`;
|
|
1079
|
+
const gate = typeof entry === "string"
|
|
1080
|
+
? { name: textValue(entry, entryPath), checks: "" }
|
|
1081
|
+
: recordFields(entry, entryPath, { name: textValue, checks: textValue }) as {
|
|
1082
|
+
name: string;
|
|
1083
|
+
checks: string;
|
|
1084
|
+
};
|
|
1085
|
+
if (gates.has(gate.name)) invalid(`${entryPath}.name`, "must be unique");
|
|
1086
|
+
gates.set(gate.name, gate);
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
value: [...gates.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
1090
|
+
...base,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function leafEnum<T extends string>(node: unknown, path: string, values: readonly T[]): Leaf {
|
|
1095
|
+
const base = leafBase(node, path);
|
|
1096
|
+
if (base.confidence === "unknown") return { value: null, ...base };
|
|
1097
|
+
const record = node as Record<string, unknown>;
|
|
1098
|
+
const value = record.value;
|
|
1099
|
+
if (typeof value !== "string" || !(values as readonly string[]).includes(value)) {
|
|
1100
|
+
invalid(`${path}.value`, `must be one of ${values.join(", ")}`);
|
|
1101
|
+
}
|
|
1102
|
+
return { value: value as T, ...base };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function leafRecords<T>(
|
|
1106
|
+
node: unknown,
|
|
1107
|
+
path: string,
|
|
1108
|
+
validate: (entry: unknown, entryPath: string) => T,
|
|
1109
|
+
identity: (entry: T) => string,
|
|
1110
|
+
): Leaf {
|
|
1111
|
+
const base = leafBase(node, path);
|
|
1112
|
+
if (base.confidence === "unknown") return { value: null, ...base };
|
|
1113
|
+
const record = node as Record<string, unknown>;
|
|
1114
|
+
if (!Array.isArray(record.value)) invalid(`${path}.value`, "must be a list");
|
|
1115
|
+
if (record.value.length > LIST_MAX) invalid(`${path}.value`, "has too many entries");
|
|
1116
|
+
const entries = record.value.map((entry, index) => validate(entry, `${path}.value[${index}]`));
|
|
1117
|
+
entries.sort((left, right) => identity(left).localeCompare(identity(right)));
|
|
1118
|
+
return { value: entries, ...base };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function recordFields(
|
|
1122
|
+
entry: unknown,
|
|
1123
|
+
path: string,
|
|
1124
|
+
required: Record<string, (value: unknown, fieldPath: string) => unknown>,
|
|
1125
|
+
optional: Record<string, (value: unknown, fieldPath: string) => unknown> = {},
|
|
1126
|
+
): Record<string, unknown> {
|
|
1127
|
+
if (!isRecord(entry)) invalid(path, "must be an object");
|
|
1128
|
+
const known = new Set([...Object.keys(required), ...Object.keys(optional)]);
|
|
1129
|
+
for (const key of Object.keys(entry)) {
|
|
1130
|
+
if (!known.has(key)) invalid(`${path}.${key}`, "is not a declared field");
|
|
1131
|
+
}
|
|
1132
|
+
const result: Record<string, unknown> = {};
|
|
1133
|
+
for (const [key, validate] of Object.entries(required)) {
|
|
1134
|
+
const value = entry[key];
|
|
1135
|
+
if (value === undefined) invalid(`${path}.${key}`, "is required");
|
|
1136
|
+
result[key] = validate(value, `${path}.${key}`);
|
|
1137
|
+
}
|
|
1138
|
+
for (const [key, validate] of Object.entries(optional)) {
|
|
1139
|
+
const value = entry[key];
|
|
1140
|
+
if (value === undefined) continue;
|
|
1141
|
+
result[key] = validate(value, `${path}.${key}`);
|
|
1142
|
+
}
|
|
1143
|
+
return result;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function recordTexts(value: unknown, path: string): string[] {
|
|
1147
|
+
if (!Array.isArray(value)) invalid(path, "must be a list of strings");
|
|
1148
|
+
if (value.length > LIST_MAX) invalid(path, "has too many entries");
|
|
1149
|
+
return [...new Set(value.map((entry, index) => textValue(entry, `${path}[${index}]`)))].sort();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ---------------------------------------------------------------- sheet
|
|
1153
|
+
|
|
1154
|
+
export interface Freshness {
|
|
1155
|
+
project: string;
|
|
1156
|
+
commit: string;
|
|
1157
|
+
dirty: boolean;
|
|
1158
|
+
discoveredAt: string;
|
|
1159
|
+
fingerprint: string;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
export interface Sheet {
|
|
1163
|
+
schemaVersion: number;
|
|
1164
|
+
freshness: Freshness;
|
|
1165
|
+
[section: string]: unknown;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function sheetSection(
|
|
1169
|
+
raw: Record<string, unknown>,
|
|
1170
|
+
key: string,
|
|
1171
|
+
path: string,
|
|
1172
|
+
): Record<string, unknown> {
|
|
1173
|
+
const value = raw[key];
|
|
1174
|
+
if (!isRecord(value)) invalid(path, "must be an object");
|
|
1175
|
+
return value;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function validateSectionRecord(
|
|
1179
|
+
raw: Record<string, unknown>,
|
|
1180
|
+
key: string,
|
|
1181
|
+
fields: Record<string, (node: unknown, path: string) => Leaf>,
|
|
1182
|
+
optionalFields: Record<string, (node: unknown, path: string) => Leaf> = {},
|
|
1183
|
+
): Record<string, Leaf> {
|
|
1184
|
+
const section = sheetSection(raw, key, key);
|
|
1185
|
+
const known = new Set([...Object.keys(fields), ...Object.keys(optionalFields)]);
|
|
1186
|
+
for (const field of Object.keys(section)) {
|
|
1187
|
+
if (!known.has(field)) invalid(`${key}.${field}`, "is not a declared field");
|
|
1188
|
+
}
|
|
1189
|
+
const result: Record<string, Leaf> = {};
|
|
1190
|
+
for (const [field, validate] of Object.entries(fields)) {
|
|
1191
|
+
if (section[field] === undefined) invalid(`${key}.${field}`, "is required");
|
|
1192
|
+
result[field] = validate(section[field], `${key}.${field}`);
|
|
1193
|
+
}
|
|
1194
|
+
for (const [field, validate] of Object.entries(optionalFields)) {
|
|
1195
|
+
if (section[field] === undefined) continue;
|
|
1196
|
+
result[field] = validate(section[field], `${key}.${field}`);
|
|
1197
|
+
}
|
|
1198
|
+
return result;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function commandLeaf(entry: unknown, path: string): Leaf {
|
|
1202
|
+
return leafText(entry, path);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Rejects an architecture dependency graph that points anywhere but at the
|
|
1207
|
+
* bounded contexts the same sheet records. Edges are directed: `from` depends
|
|
1208
|
+
* on `to`.
|
|
1209
|
+
*/
|
|
1210
|
+
function validateDependencyEdges(architecture: Record<string, Leaf>): void {
|
|
1211
|
+
const dependencies = architecture.dependencies;
|
|
1212
|
+
if (dependencies === undefined || dependencies.confidence === "unknown") return;
|
|
1213
|
+
const contextLeaf = architecture.contexts;
|
|
1214
|
+
if (contextLeaf === undefined) return;
|
|
1215
|
+
const contextRecords = Array.isArray(contextLeaf.value)
|
|
1216
|
+
? contextLeaf.value.filter(isRecord)
|
|
1217
|
+
: [];
|
|
1218
|
+
const names = new Set(contextRecords.map((context) => String(context.name)));
|
|
1219
|
+
const edgeRecords = Array.isArray(dependencies.value) ? dependencies.value : [];
|
|
1220
|
+
const seen = new Set<string>();
|
|
1221
|
+
for (const [index, record] of edgeRecords.entries()) {
|
|
1222
|
+
if (!isRecord(record) || typeof record.from !== "string" || typeof record.to !== "string") {
|
|
1223
|
+
invalid(`architecture.dependencies.value[${index}]`, "must carry a from and a to context");
|
|
1224
|
+
}
|
|
1225
|
+
const { from, to } = record;
|
|
1226
|
+
if (from === to) {
|
|
1227
|
+
invalid(`architecture.dependencies.value[${index}].to`, "must differ from the source context");
|
|
1228
|
+
}
|
|
1229
|
+
for (const [field, name] of [["from", from], ["to", to]] as const) {
|
|
1230
|
+
if (!names.has(name)) {
|
|
1231
|
+
invalid(
|
|
1232
|
+
`architecture.dependencies.value[${index}].${field}`,
|
|
1233
|
+
`must name a bounded context; "${name}" is not one`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const key = `${from}\u0000${to}`;
|
|
1238
|
+
if (seen.has(key)) {
|
|
1239
|
+
invalid(
|
|
1240
|
+
`architecture.dependencies.value[${index}]`,
|
|
1241
|
+
`repeats the dependency ${from} -> ${to}`,
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
seen.add(key);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const COMMAND_NAMES = [
|
|
1249
|
+
"install",
|
|
1250
|
+
"build",
|
|
1251
|
+
"test",
|
|
1252
|
+
"lint",
|
|
1253
|
+
"typecheck",
|
|
1254
|
+
"dev",
|
|
1255
|
+
"tryOut",
|
|
1256
|
+
"release",
|
|
1257
|
+
] as const;
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Canonicalizes the staged sheet into the published shape. Freshness is
|
|
1261
|
+
* injected by the server: the crawl cannot know the fingerprint it is
|
|
1262
|
+
* publishing against, and the published sheet is the only place it appears.
|
|
1263
|
+
*/
|
|
1264
|
+
export function normalizeSheet(raw: unknown, freshness: Freshness): Sheet {
|
|
1265
|
+
if (!isRecord(raw)) invalid("sheet", "must be an object");
|
|
1266
|
+
const known = new Set([
|
|
1267
|
+
"schemaVersion",
|
|
1268
|
+
...DISCOVER_SHEET_SECTIONS,
|
|
1269
|
+
]);
|
|
1270
|
+
for (const key of Object.keys(raw)) {
|
|
1271
|
+
if (!known.has(key)) invalid(key, "is not a declared field");
|
|
1272
|
+
}
|
|
1273
|
+
if (raw.schemaVersion !== DISCOVER_SCHEMA_VERSION) {
|
|
1274
|
+
invalid("schemaVersion", `must be ${DISCOVER_SCHEMA_VERSION}`);
|
|
1275
|
+
}
|
|
1276
|
+
for (const section of DISCOVER_SHEET_SECTIONS) {
|
|
1277
|
+
if (raw[section] === undefined) invalid(section, "is required");
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const architecture = validateSectionRecord(
|
|
1281
|
+
raw,
|
|
1282
|
+
"architecture",
|
|
1283
|
+
{
|
|
1284
|
+
contexts: (node, path) =>
|
|
1285
|
+
leafRecords(
|
|
1286
|
+
node,
|
|
1287
|
+
path,
|
|
1288
|
+
(entry, entryPath) =>
|
|
1289
|
+
recordFields(entry, entryPath, {
|
|
1290
|
+
name: textValue,
|
|
1291
|
+
path: textValue,
|
|
1292
|
+
responsibility: textValue,
|
|
1293
|
+
entryPoints: recordTexts,
|
|
1294
|
+
}),
|
|
1295
|
+
(entry) => String((entry as { name: string }).name),
|
|
1296
|
+
),
|
|
1297
|
+
dependencyDirection: leafText,
|
|
1298
|
+
externalBoundaries: leafTexts,
|
|
1299
|
+
sharedKernels: leafTexts,
|
|
1300
|
+
},
|
|
1301
|
+
{
|
|
1302
|
+
// Optional so a sheet published before the dependency graph existed
|
|
1303
|
+
// still loads; the current prompt records it for every crawl.
|
|
1304
|
+
dependencies: (node, path) =>
|
|
1305
|
+
leafRecords(
|
|
1306
|
+
node,
|
|
1307
|
+
path,
|
|
1308
|
+
(entry, entryPath) =>
|
|
1309
|
+
recordFields(entry, entryPath, {
|
|
1310
|
+
from: textValue,
|
|
1311
|
+
to: textValue,
|
|
1312
|
+
}),
|
|
1313
|
+
(entry) => `${String((entry as { from: string }).from)}\u0000${String((entry as { to: string }).to)}`,
|
|
1314
|
+
),
|
|
1315
|
+
},
|
|
1316
|
+
);
|
|
1317
|
+
validateDependencyEdges(architecture);
|
|
1318
|
+
|
|
1319
|
+
const sheet: Sheet = {
|
|
1320
|
+
schemaVersion: DISCOVER_SCHEMA_VERSION,
|
|
1321
|
+
freshness: {
|
|
1322
|
+
project: freshness.project,
|
|
1323
|
+
commit: freshness.commit,
|
|
1324
|
+
dirty: freshness.dirty,
|
|
1325
|
+
discoveredAt: freshness.discoveredAt,
|
|
1326
|
+
fingerprint: freshness.fingerprint,
|
|
1327
|
+
},
|
|
1328
|
+
definition: leafText(raw.definition, "definition", { sentence: true }),
|
|
1329
|
+
stack: leafTexts(raw.stack, "stack"),
|
|
1330
|
+
deployment: validateSectionRecord(raw, "deployment", {
|
|
1331
|
+
class: (node, path) => leafEnum(node, path, DEPLOYMENT_CLASSES),
|
|
1332
|
+
artifact: leafText,
|
|
1333
|
+
target: leafText,
|
|
1334
|
+
pipelineSources: leafTexts,
|
|
1335
|
+
}),
|
|
1336
|
+
environments: leafRecords(
|
|
1337
|
+
raw.environments,
|
|
1338
|
+
"environments",
|
|
1339
|
+
(entry, path) =>
|
|
1340
|
+
recordFields(entry, path, {
|
|
1341
|
+
name: textValue,
|
|
1342
|
+
purpose: textValue,
|
|
1343
|
+
}),
|
|
1344
|
+
(entry) => String((entry as { name: string }).name),
|
|
1345
|
+
),
|
|
1346
|
+
commands: validateSectionRecord(raw, "commands", Object.fromEntries(
|
|
1347
|
+
COMMAND_NAMES.map((name) => [name, commandLeaf] as const),
|
|
1348
|
+
)),
|
|
1349
|
+
branching: validateSectionRecord(raw, "branching", {
|
|
1350
|
+
defaultBranch: leafText,
|
|
1351
|
+
mergeMethod: leafText,
|
|
1352
|
+
releaseBranches: leafTexts,
|
|
1353
|
+
model: (node, path) => leafEnum(node, path, BRANCH_MODELS),
|
|
1354
|
+
}),
|
|
1355
|
+
testing: validateSectionRecord(raw, "testing", {
|
|
1356
|
+
levels: (node, path) =>
|
|
1357
|
+
leafRecords(
|
|
1358
|
+
node,
|
|
1359
|
+
path,
|
|
1360
|
+
(entry, entryPath) => {
|
|
1361
|
+
const record = recordFields(
|
|
1362
|
+
entry,
|
|
1363
|
+
entryPath,
|
|
1364
|
+
{
|
|
1365
|
+
level: (value, fieldPath) => {
|
|
1366
|
+
if (
|
|
1367
|
+
typeof value !== "string" ||
|
|
1368
|
+
!(TEST_LEVELS as readonly string[]).includes(value)
|
|
1369
|
+
) {
|
|
1370
|
+
invalid(fieldPath, `must be one of ${TEST_LEVELS.join(", ")}`);
|
|
1371
|
+
}
|
|
1372
|
+
return value;
|
|
1373
|
+
},
|
|
1374
|
+
tests: textValue,
|
|
1375
|
+
paths: recordTexts,
|
|
1376
|
+
command: textValue,
|
|
1377
|
+
fixtures: recordTexts,
|
|
1378
|
+
},
|
|
1379
|
+
// Optional so a sheet staged by an older prompt still validates;
|
|
1380
|
+
// the current prompt records the count for every level.
|
|
1381
|
+
{ happyPath: textValue },
|
|
1382
|
+
);
|
|
1383
|
+
const tests = Number.parseInt(String(record.tests), 10);
|
|
1384
|
+
const happy = Number.parseInt(String(record.happyPath ?? ""), 10);
|
|
1385
|
+
if (Number.isFinite(tests) && Number.isFinite(happy) && happy > tests) {
|
|
1386
|
+
invalid(`${entryPath}.happyPath`, "must not exceed the level's test count");
|
|
1387
|
+
}
|
|
1388
|
+
return record;
|
|
1389
|
+
},
|
|
1390
|
+
(entry) => String((entry as { level: string }).level),
|
|
1391
|
+
),
|
|
1392
|
+
pyramid: leafText,
|
|
1393
|
+
}),
|
|
1394
|
+
architecture,
|
|
1395
|
+
ci: validateSectionRecord(raw, "ci", {
|
|
1396
|
+
provider: leafText,
|
|
1397
|
+
triggers: leafTexts,
|
|
1398
|
+
jobs: leafTexts,
|
|
1399
|
+
qualityGates: leafQualityGates,
|
|
1400
|
+
artifacts: leafTexts,
|
|
1401
|
+
}),
|
|
1402
|
+
runtime: validateSectionRecord(raw, "runtime", {
|
|
1403
|
+
languages: leafTexts,
|
|
1404
|
+
runtimes: leafTexts,
|
|
1405
|
+
packageManagers: leafTexts,
|
|
1406
|
+
versions: leafTexts,
|
|
1407
|
+
manifests: leafTexts,
|
|
1408
|
+
}),
|
|
1409
|
+
dependencyMap: validateSectionRecord(raw, "dependencyMap", {
|
|
1410
|
+
binaries: leafTexts,
|
|
1411
|
+
packages: leafTexts,
|
|
1412
|
+
routes: leafTexts,
|
|
1413
|
+
jobs: leafTexts,
|
|
1414
|
+
externalServices: leafTexts,
|
|
1415
|
+
dataStores: leafTexts,
|
|
1416
|
+
keyDependencies: leafTexts,
|
|
1417
|
+
}),
|
|
1418
|
+
delivery: validateSectionRecord(raw, "delivery", {
|
|
1419
|
+
artifactTarget: leafText,
|
|
1420
|
+
releaseChannels: leafTexts,
|
|
1421
|
+
versionScheme: leafText,
|
|
1422
|
+
migrationPolicy: leafText,
|
|
1423
|
+
}),
|
|
1424
|
+
};
|
|
1425
|
+
return sheet;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* A recorded command paired with the installed invocation that runs it.
|
|
1430
|
+
*
|
|
1431
|
+
* The crawl reads programs, so it stages `node --test test/*.test.js` even when
|
|
1432
|
+
* the manifest declares `"test": "node --test test/*.test.js"`. The operator
|
|
1433
|
+
* reads the manifest's invocation. This derivation keeps the recorded evidence
|
|
1434
|
+
* untouched and maps each script body to the command that runs it: `npm test`
|
|
1435
|
+
* for the `test` script and `npm run <name>` for every other script.
|
|
1436
|
+
*/
|
|
1437
|
+
export function declaredCommandAliases(root: string): Array<{ command: string; alias: string }> {
|
|
1438
|
+
let manifest: unknown;
|
|
1439
|
+
try {
|
|
1440
|
+
manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
1441
|
+
} catch {
|
|
1442
|
+
return [];
|
|
1443
|
+
}
|
|
1444
|
+
if (!isRecord(manifest) || !isRecord(manifest.scripts)) return [];
|
|
1445
|
+
const scripts = manifest.scripts;
|
|
1446
|
+
const aliases = new Map<string, string>();
|
|
1447
|
+
const names = Object.keys(scripts).sort((left, right) => {
|
|
1448
|
+
if (left === "test") return -1;
|
|
1449
|
+
if (right === "test") return 1;
|
|
1450
|
+
return left.localeCompare(right);
|
|
1451
|
+
});
|
|
1452
|
+
for (const name of names) {
|
|
1453
|
+
const body = scripts[name];
|
|
1454
|
+
if (typeof body !== "string") continue;
|
|
1455
|
+
const command = body.replace(/\s+/g, " ").trim();
|
|
1456
|
+
if (command.length === 0 || aliases.has(command)) continue;
|
|
1457
|
+
aliases.set(command, name === "test" ? "npm test" : `npm run ${name}`);
|
|
1458
|
+
}
|
|
1459
|
+
return [...aliases]
|
|
1460
|
+
.map(([command, alias]) => ({ command, alias }))
|
|
1461
|
+
.sort((left, right) => left.command.localeCompare(right.command));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function canonicalLeaf(leaf: Leaf, prose: boolean): Record<string, unknown> {
|
|
1465
|
+
const result: Record<string, unknown> = {
|
|
1466
|
+
value: prose && leaf.value !== null ? "" : leaf.value,
|
|
1467
|
+
evidence: leaf.evidence,
|
|
1468
|
+
confidence: leaf.confidence,
|
|
1469
|
+
};
|
|
1470
|
+
if (leaf.confidence === "unknown") result.reason = "";
|
|
1471
|
+
return result;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function canonicalSectionValue(value: unknown, proseKeys: Set<string>, key: string): unknown {
|
|
1475
|
+
if (Array.isArray(value)) {
|
|
1476
|
+
return value.map((entry) =>
|
|
1477
|
+
isRecord(entry)
|
|
1478
|
+
? Object.fromEntries(
|
|
1479
|
+
Object.entries(entry).map(([field, fieldValue]) => [
|
|
1480
|
+
field,
|
|
1481
|
+
proseKeys.has(`${key}.${field}`) ? "" : fieldValue,
|
|
1482
|
+
]),
|
|
1483
|
+
)
|
|
1484
|
+
: entry,
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
return value;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** The sheet with volatile fields, bounded prose, and unknown reasons removed. */
|
|
1491
|
+
export function sheetStructuralSkeleton(sheet: Sheet): Record<string, unknown> {
|
|
1492
|
+
const proseKeys = new Set<string>();
|
|
1493
|
+
const skeleton: Record<string, unknown> = {
|
|
1494
|
+
schemaVersion: sheet.schemaVersion,
|
|
1495
|
+
freshness: {
|
|
1496
|
+
project: sheet.freshness.project,
|
|
1497
|
+
commit: sheet.freshness.commit,
|
|
1498
|
+
dirty: sheet.freshness.dirty,
|
|
1499
|
+
},
|
|
1500
|
+
};
|
|
1501
|
+
for (const section of DISCOVER_SHEET_SECTIONS) {
|
|
1502
|
+
const value = sheet[section] as unknown;
|
|
1503
|
+
const prose = section === "definition";
|
|
1504
|
+
if (isRecord(value) && "confidence" in value) {
|
|
1505
|
+
skeleton[section] = canonicalLeaf(value as unknown as Leaf, prose);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (!isRecord(value)) {
|
|
1509
|
+
skeleton[section] = value;
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
const sectionOut: Record<string, unknown> = {};
|
|
1513
|
+
for (const [key, field] of Object.entries(value)) {
|
|
1514
|
+
const fieldPath = `${section}.${key}`;
|
|
1515
|
+
if (isRecord(field) && "confidence" in field) {
|
|
1516
|
+
sectionOut[key] = canonicalLeaf(field as unknown as Leaf, false);
|
|
1517
|
+
} else {
|
|
1518
|
+
sectionOut[key] = canonicalSectionValue(field, proseKeys, fieldPath);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
skeleton[section] = sectionOut;
|
|
1522
|
+
}
|
|
1523
|
+
return skeleton;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
export function sheetDigest(sheet: Sheet): string {
|
|
1527
|
+
return createHash("sha256")
|
|
1528
|
+
.update(JSON.stringify(sheetStructuralSkeleton(sheet)))
|
|
1529
|
+
.digest("hex");
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// -------------------------------------------------------------- glossary
|
|
1533
|
+
|
|
1534
|
+
export interface Label {
|
|
1535
|
+
language: "en" | "de";
|
|
1536
|
+
text: string;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
export interface HiddenLabel extends Label {
|
|
1540
|
+
reason: string;
|
|
1541
|
+
rejectedOn: string;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
export type ConceptStatus = (typeof CONCEPT_STATUSES)[number];
|
|
1545
|
+
export type ConceptOrigin = (typeof CONCEPT_ORIGINS)[number];
|
|
1546
|
+
export type ConceptKind = (typeof CONCEPT_KINDS)[number];
|
|
1547
|
+
|
|
1548
|
+
export interface Concept {
|
|
1549
|
+
id: string;
|
|
1550
|
+
prefLabels: Label[];
|
|
1551
|
+
altLabels: Label[];
|
|
1552
|
+
hiddenLabels: HiddenLabel[];
|
|
1553
|
+
definitions: Label[];
|
|
1554
|
+
scopeNote: string | null;
|
|
1555
|
+
example: string | null;
|
|
1556
|
+
historyNote: string | null;
|
|
1557
|
+
status: ConceptStatus;
|
|
1558
|
+
origin: ConceptOrigin;
|
|
1559
|
+
standardRef: string | null;
|
|
1560
|
+
kind: ConceptKind;
|
|
1561
|
+
grounding: string[];
|
|
1562
|
+
broader: string | null;
|
|
1563
|
+
related: string[];
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
export interface Glossary {
|
|
1567
|
+
boundedContext: string;
|
|
1568
|
+
titles: Label[];
|
|
1569
|
+
concepts: Concept[];
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const CONCEPT_FIELDS = [
|
|
1573
|
+
"id",
|
|
1574
|
+
"prefLabels",
|
|
1575
|
+
"altLabels",
|
|
1576
|
+
"hiddenLabels",
|
|
1577
|
+
"definitions",
|
|
1578
|
+
"scopeNote",
|
|
1579
|
+
"example",
|
|
1580
|
+
"historyNote",
|
|
1581
|
+
"status",
|
|
1582
|
+
"origin",
|
|
1583
|
+
"standardRef",
|
|
1584
|
+
"kind",
|
|
1585
|
+
"grounding",
|
|
1586
|
+
"broader",
|
|
1587
|
+
"related",
|
|
1588
|
+
] as const;
|
|
1589
|
+
|
|
1590
|
+
const REQUIRED_CONCEPT_FIELDS: Record<string, true> = {
|
|
1591
|
+
id: true,
|
|
1592
|
+
prefLabels: true,
|
|
1593
|
+
altLabels: true,
|
|
1594
|
+
hiddenLabels: true,
|
|
1595
|
+
definitions: true,
|
|
1596
|
+
status: true,
|
|
1597
|
+
origin: true,
|
|
1598
|
+
kind: true,
|
|
1599
|
+
grounding: true,
|
|
1600
|
+
related: true,
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
function languageValue(value: unknown, path: string): "en" | "de" {
|
|
1604
|
+
if (typeof value !== "string" || !(LANGUAGES as readonly string[]).includes(value)) {
|
|
1605
|
+
invalid(path, "must be en or de");
|
|
1606
|
+
}
|
|
1607
|
+
return value as "en" | "de";
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function labelValue(value: unknown, path: string): Label {
|
|
1611
|
+
const label = recordFields(value, path, {
|
|
1612
|
+
language: languageValue,
|
|
1613
|
+
text: (entry, fieldPath) => textValue(entry, fieldPath),
|
|
1614
|
+
});
|
|
1615
|
+
return { language: label.language as "en" | "de", text: label.text as string };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function labelList(value: unknown, path: string): Label[] {
|
|
1619
|
+
if (!Array.isArray(value)) invalid(path, "must be a list of labels");
|
|
1620
|
+
const labels = value.map((entry, index) => labelValue(entry, `${path}[${index}]`));
|
|
1621
|
+
labels.sort((left, right) =>
|
|
1622
|
+
left.language === right.language
|
|
1623
|
+
? left.text.localeCompare(right.text)
|
|
1624
|
+
: left.language.localeCompare(right.language),
|
|
1625
|
+
);
|
|
1626
|
+
const seen = new Set<string>();
|
|
1627
|
+
for (const label of labels) {
|
|
1628
|
+
if (seen.has(label.language)) invalid(path, "carries two labels in one language");
|
|
1629
|
+
seen.add(label.language);
|
|
1630
|
+
}
|
|
1631
|
+
return labels;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function hiddenLabelList(value: unknown, path: string): HiddenLabel[] {
|
|
1635
|
+
if (!Array.isArray(value)) invalid(path, "must be a list of hidden labels");
|
|
1636
|
+
const labels = value.map((entry, index) => {
|
|
1637
|
+
const hidden = recordFields(entry, `${path}[${index}]`, {
|
|
1638
|
+
language: languageValue,
|
|
1639
|
+
text: (item, fieldPath) => textValue(item, fieldPath),
|
|
1640
|
+
reason: (item, fieldPath) => {
|
|
1641
|
+
const reason = textValue(item, fieldPath, 400);
|
|
1642
|
+
return reason;
|
|
1643
|
+
},
|
|
1644
|
+
rejectedOn: (item, fieldPath) => {
|
|
1645
|
+
if (typeof item !== "string" || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(item)) {
|
|
1646
|
+
invalid(fieldPath, "must be a YYYY-MM-DD date");
|
|
1647
|
+
}
|
|
1648
|
+
return item;
|
|
1649
|
+
},
|
|
1650
|
+
});
|
|
1651
|
+
return {
|
|
1652
|
+
language: hidden.language as "en" | "de",
|
|
1653
|
+
text: hidden.text as string,
|
|
1654
|
+
reason: hidden.reason as string,
|
|
1655
|
+
rejectedOn: hidden.rejectedOn as string,
|
|
1656
|
+
};
|
|
1657
|
+
});
|
|
1658
|
+
labels.sort((left, right) => `${left.language}:${left.text}`.localeCompare(`${right.language}:${right.text}`));
|
|
1659
|
+
return labels;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function groundingList(
|
|
1663
|
+
value: unknown,
|
|
1664
|
+
path: string,
|
|
1665
|
+
context: { repoDir: string; files: string[] },
|
|
1666
|
+
trustGrounding: boolean,
|
|
1667
|
+
): string[] {
|
|
1668
|
+
if (!Array.isArray(value)) invalid(path, "must be a list of grounding entries");
|
|
1669
|
+
const entries = new Set<string>();
|
|
1670
|
+
for (const [index, entry] of value.entries()) {
|
|
1671
|
+
const text = textValue(entry, `${path}[${index}]`, 300);
|
|
1672
|
+
if (text.startsWith("code:")) {
|
|
1673
|
+
const target = text.slice("code:".length);
|
|
1674
|
+
if (ABSOLUTE_PATH_PATTERN.test(target) || target.split("/").includes("..")) {
|
|
1675
|
+
invalid(`${path}[${index}]`, "must name a repository-relative path");
|
|
1676
|
+
}
|
|
1677
|
+
const absolute = join(context.repoDir, target);
|
|
1678
|
+
if (!trustGrounding && !existsSync(absolute)) {
|
|
1679
|
+
invalid(`${path}[${index}]`, `names ${target}, which the host does not carry`);
|
|
1680
|
+
}
|
|
1681
|
+
} else if (text.startsWith("model:")) {
|
|
1682
|
+
const name = text.slice("model:".length);
|
|
1683
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(name)) {
|
|
1684
|
+
invalid(`${path}[${index}]`, "must name a model element");
|
|
1685
|
+
}
|
|
1686
|
+
if (!trustGrounding && !modelNameExists(context, name)) {
|
|
1687
|
+
invalid(`${path}[${index}]`, `names ${name}, which the host does not carry`);
|
|
1688
|
+
}
|
|
1689
|
+
} else {
|
|
1690
|
+
invalid(`${path}[${index}]`, "must start with code: or model:");
|
|
1691
|
+
}
|
|
1692
|
+
entries.add(text);
|
|
1693
|
+
}
|
|
1694
|
+
return [...entries].sort();
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
const modelCache = new Map<string, Map<string, boolean>>();
|
|
1698
|
+
|
|
1699
|
+
/** True when the name appears as a whole word in one crawled host file. */
|
|
1700
|
+
function modelNameExists(context: { repoDir: string; files: string[] }, name: string): boolean {
|
|
1701
|
+
const key = `${context.repoDir}\u0000${context.files.length}`;
|
|
1702
|
+
let cache = modelCache.get(key);
|
|
1703
|
+
if (cache === undefined) {
|
|
1704
|
+
cache = new Map();
|
|
1705
|
+
modelCache.set(key, cache);
|
|
1706
|
+
}
|
|
1707
|
+
const cached = cache.get(name);
|
|
1708
|
+
if (cached !== undefined) return cached;
|
|
1709
|
+
const pattern = new RegExp(`(^|[^A-Za-z0-9_])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_]|$)`);
|
|
1710
|
+
let found = false;
|
|
1711
|
+
let budget = 4096;
|
|
1712
|
+
for (const path of context.files) {
|
|
1713
|
+
if (budget-- <= 0) break;
|
|
1714
|
+
let bytes: Buffer;
|
|
1715
|
+
try {
|
|
1716
|
+
bytes = readFileSync(join(context.repoDir, path));
|
|
1717
|
+
} catch {
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
if (bytes.length > 512 * 1024) continue;
|
|
1721
|
+
if (bytes.includes(0)) continue;
|
|
1722
|
+
if (pattern.test(bytes.toString("utf8"))) {
|
|
1723
|
+
found = true;
|
|
1724
|
+
break;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
cache.set(name, found);
|
|
1728
|
+
return found;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function conceptValue(
|
|
1732
|
+
raw: unknown,
|
|
1733
|
+
path: string,
|
|
1734
|
+
context: { repoDir: string; files: string[] },
|
|
1735
|
+
trustGrounding: boolean,
|
|
1736
|
+
): Concept {
|
|
1737
|
+
if (!isRecord(raw)) invalid(path, "must be an object");
|
|
1738
|
+
const known = new Set<string>(CONCEPT_FIELDS);
|
|
1739
|
+
for (const key of Object.keys(raw)) {
|
|
1740
|
+
if (!known.has(key)) invalid(`${path}.${key}`, "is not a UbiLang concept field");
|
|
1741
|
+
}
|
|
1742
|
+
for (const required of Object.keys(REQUIRED_CONCEPT_FIELDS)) {
|
|
1743
|
+
if (raw[required] === undefined) invalid(`${path}.${required}`, "is required");
|
|
1744
|
+
}
|
|
1745
|
+
const id = textValue(raw.id, `${path}.id`, 120);
|
|
1746
|
+
if (!CONCEPT_ID_PATTERN.test(id)) invalid(`${path}.id`, "must be kebab-case");
|
|
1747
|
+
const prefLabels = labelList(raw.prefLabels, `${path}.prefLabels`);
|
|
1748
|
+
if (prefLabels.length === 0) invalid(`${path}.prefLabels`, "must carry a preferred label");
|
|
1749
|
+
if (!prefLabels.some((label) => label.language === "en")) {
|
|
1750
|
+
invalid(`${path}.prefLabels`, "must carry an English preferred label");
|
|
1751
|
+
}
|
|
1752
|
+
const altLabels = labelList(raw.altLabels, `${path}.altLabels`);
|
|
1753
|
+
const hiddenLabels = hiddenLabelList(raw.hiddenLabels, `${path}.hiddenLabels`);
|
|
1754
|
+
const definitions = Array.isArray(raw.definitions)
|
|
1755
|
+
? raw.definitions.map((entry, index) => labelValue(entry, `${path}.definitions[${index}]`))
|
|
1756
|
+
: invalid(`${path}.definitions`, "must be a list of labels");
|
|
1757
|
+
if (definitions.length === 0) invalid(`${path}.definitions`, "must carry a definition");
|
|
1758
|
+
if (!definitions.some((label) => label.language === "en")) {
|
|
1759
|
+
invalid(`${path}.definitions`, "must carry an English definition");
|
|
1760
|
+
}
|
|
1761
|
+
definitions.sort((left, right) => left.language.localeCompare(right.language));
|
|
1762
|
+
|
|
1763
|
+
const optionalText = (key: string): string | null => {
|
|
1764
|
+
const value = raw[key];
|
|
1765
|
+
if (value === undefined || value === null) return null;
|
|
1766
|
+
return textValue(value, `${path}.${key}`, 500);
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
const status = raw.status;
|
|
1770
|
+
if (typeof status !== "string" || !(CONCEPT_STATUSES as readonly string[]).includes(status)) {
|
|
1771
|
+
invalid(`${path}.status`, `must be one of ${CONCEPT_STATUSES.join(", ")}`);
|
|
1772
|
+
}
|
|
1773
|
+
const origin = raw.origin;
|
|
1774
|
+
if (typeof origin !== "string" || !(CONCEPT_ORIGINS as readonly string[]).includes(origin)) {
|
|
1775
|
+
invalid(`${path}.origin`, `must be one of ${CONCEPT_ORIGINS.join(", ")}`);
|
|
1776
|
+
}
|
|
1777
|
+
const kind = raw.kind;
|
|
1778
|
+
if (typeof kind !== "string" || !(CONCEPT_KINDS as readonly string[]).includes(kind)) {
|
|
1779
|
+
invalid(`${path}.kind`, `must be one of ${CONCEPT_KINDS.join(", ")}`);
|
|
1780
|
+
}
|
|
1781
|
+
const standardRef = optionalText("standardRef");
|
|
1782
|
+
if (origin === "standard" && standardRef === null) {
|
|
1783
|
+
invalid(`${path}.standardRef`, "is required when origin is standard");
|
|
1784
|
+
}
|
|
1785
|
+
const grounding = groundingList(raw.grounding, `${path}.grounding`, context, trustGrounding);
|
|
1786
|
+
if (grounding.length === 0) invalid(`${path}.grounding`, "must ground the concept");
|
|
1787
|
+
|
|
1788
|
+
const broaderRaw = raw.broader;
|
|
1789
|
+
let broader: string | null = null;
|
|
1790
|
+
if (broaderRaw !== undefined && broaderRaw !== null) {
|
|
1791
|
+
broader = textValue(broaderRaw, `${path}.broader`, 120);
|
|
1792
|
+
}
|
|
1793
|
+
const related = Array.isArray(raw.related)
|
|
1794
|
+
? [...new Set(raw.related.map((entry, index) => textValue(entry, `${path}.related[${index}]`, 120)))].sort()
|
|
1795
|
+
: invalid(`${path}.related`, "must be a list of concept ids");
|
|
1796
|
+
|
|
1797
|
+
return {
|
|
1798
|
+
id,
|
|
1799
|
+
prefLabels,
|
|
1800
|
+
altLabels,
|
|
1801
|
+
hiddenLabels,
|
|
1802
|
+
definitions,
|
|
1803
|
+
scopeNote: optionalText("scopeNote"),
|
|
1804
|
+
example: optionalText("example"),
|
|
1805
|
+
historyNote: optionalText("historyNote"),
|
|
1806
|
+
status: status as ConceptStatus,
|
|
1807
|
+
origin: origin as ConceptOrigin,
|
|
1808
|
+
standardRef,
|
|
1809
|
+
kind: kind as ConceptKind,
|
|
1810
|
+
grounding,
|
|
1811
|
+
broader,
|
|
1812
|
+
related,
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
export function parseGlossary(
|
|
1817
|
+
text: string,
|
|
1818
|
+
source: string,
|
|
1819
|
+
context: { repoDir: string; files: string[] },
|
|
1820
|
+
options: { staged: boolean; trustGrounding?: boolean },
|
|
1821
|
+
): Glossary {
|
|
1822
|
+
let parsed: unknown;
|
|
1823
|
+
try {
|
|
1824
|
+
parsed = Bun.YAML.parse(text);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
invalid(source, `is not parseable YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
1827
|
+
}
|
|
1828
|
+
if (!isRecord(parsed)) invalid(source, "must be one YAML document object");
|
|
1829
|
+
const known = new Set(["boundedContext", "titles", "concepts"]);
|
|
1830
|
+
for (const key of Object.keys(parsed)) {
|
|
1831
|
+
if (!known.has(key)) invalid(`${source}:${key}`, "is not a UbiLang glossary field");
|
|
1832
|
+
}
|
|
1833
|
+
if (parsed.boundedContext === undefined) invalid(`${source}:boundedContext`, "is required");
|
|
1834
|
+
const boundedContext = textValue(parsed.boundedContext, `${source}:boundedContext`, 120);
|
|
1835
|
+
if (!CONTEXT_PATTERN.test(boundedContext)) {
|
|
1836
|
+
invalid(`${source}:boundedContext`, "must be kebab-case");
|
|
1837
|
+
}
|
|
1838
|
+
if (basename(source) !== `${boundedContext}.glossary.yaml`) {
|
|
1839
|
+
invalid(`${source}:boundedContext`, `must match the file name ${boundedContext}.glossary.yaml`);
|
|
1840
|
+
}
|
|
1841
|
+
const titles = Array.isArray(parsed.titles)
|
|
1842
|
+
? parsed.titles.map((entry, index) => labelValue(entry, `${source}:titles[${index}]`))
|
|
1843
|
+
: invalid(`${source}:titles`, "must be a list of labels");
|
|
1844
|
+
const rawConcepts = parsed.concepts;
|
|
1845
|
+
if (!Array.isArray(rawConcepts)) invalid(`${source}:concepts`, "must be a list");
|
|
1846
|
+
if (rawConcepts.length === 0) invalid(`${source}:concepts`, "must carry at least one concept");
|
|
1847
|
+
const concepts = rawConcepts.map((entry, index) =>
|
|
1848
|
+
conceptValue(entry, `${source}:concepts[${index}]`, context, options.trustGrounding === true),
|
|
1849
|
+
);
|
|
1850
|
+
const ids = new Set<string>();
|
|
1851
|
+
for (const concept of concepts) {
|
|
1852
|
+
if (ids.has(concept.id)) invalid(`${source}:concepts`, `carries duplicate id ${concept.id}`);
|
|
1853
|
+
ids.add(concept.id);
|
|
1854
|
+
if (options.staged) {
|
|
1855
|
+
if (concept.status === "confirmed" || concept.status === "deprecated" || concept.status === "invalidated") {
|
|
1856
|
+
invalid(`${source}:concepts`, `writes human status ${concept.status} for ${concept.id}`);
|
|
1857
|
+
}
|
|
1858
|
+
if (concept.status === "unconfirmed" && concept.origin === "llm") {
|
|
1859
|
+
invalid(`${source}:concepts`, `cannot mark the llm coinage ${concept.id} unconfirmed`);
|
|
1860
|
+
}
|
|
1861
|
+
if (concept.status === "proposed" && concept.origin !== "llm") {
|
|
1862
|
+
invalid(`${source}:concepts`, `cannot propose ${concept.id} with origin ${concept.origin}`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
for (const concept of concepts) {
|
|
1867
|
+
if (concept.broader !== null && !ids.has(concept.broader)) {
|
|
1868
|
+
invalid(`${source}:concepts.${concept.id}.broader`, `names unknown concept ${concept.broader}`);
|
|
1869
|
+
}
|
|
1870
|
+
for (const related of concept.related) {
|
|
1871
|
+
if (!ids.has(related)) {
|
|
1872
|
+
invalid(`${source}:concepts.${concept.id}.related`, `names unknown concept ${related}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
concepts.sort((left, right) => left.id.localeCompare(right.id));
|
|
1877
|
+
return { boundedContext, titles, concepts };
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** Deterministic YAML rendering of one canonical glossary document. */
|
|
1881
|
+
export function renderGlossary(glossary: Glossary): string {
|
|
1882
|
+
const scalar = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
1883
|
+
const lines: string[] = [];
|
|
1884
|
+
lines.push(`boundedContext: ${scalar(glossary.boundedContext)}`);
|
|
1885
|
+
lines.push("titles:");
|
|
1886
|
+
if (glossary.titles.length === 0) lines[lines.length - 1] = "titles: []";
|
|
1887
|
+
for (const title of glossary.titles) {
|
|
1888
|
+
lines.push(` - language: ${scalar(title.language)}`);
|
|
1889
|
+
lines.push(` text: ${scalar(title.text)}`);
|
|
1890
|
+
}
|
|
1891
|
+
lines.push("concepts:");
|
|
1892
|
+
for (const concept of glossary.concepts) {
|
|
1893
|
+
lines.push(` - id: ${scalar(concept.id)}`);
|
|
1894
|
+
const labels = (key: string, values: Label[]) => {
|
|
1895
|
+
if (values.length === 0) {
|
|
1896
|
+
lines.push(` ${key}: []`);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
lines.push(` ${key}:`);
|
|
1900
|
+
for (const label of values) {
|
|
1901
|
+
lines.push(` - language: ${scalar(label.language)}`);
|
|
1902
|
+
lines.push(` text: ${scalar(label.text)}`);
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
labels("prefLabels", concept.prefLabels);
|
|
1906
|
+
labels("altLabels", concept.altLabels);
|
|
1907
|
+
if (concept.hiddenLabels.length === 0) {
|
|
1908
|
+
lines.push(" hiddenLabels: []");
|
|
1909
|
+
} else {
|
|
1910
|
+
lines.push(" hiddenLabels:");
|
|
1911
|
+
for (const hidden of concept.hiddenLabels) {
|
|
1912
|
+
lines.push(` - language: ${scalar(hidden.language)}`);
|
|
1913
|
+
lines.push(` text: ${scalar(hidden.text)}`);
|
|
1914
|
+
lines.push(` reason: ${scalar(hidden.reason)}`);
|
|
1915
|
+
lines.push(` rejectedOn: ${scalar(hidden.rejectedOn)}`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
labels("definitions", concept.definitions);
|
|
1919
|
+
lines.push(` scopeNote: ${concept.scopeNote === null ? "null" : scalar(concept.scopeNote)}`);
|
|
1920
|
+
lines.push(` example: ${concept.example === null ? "null" : scalar(concept.example)}`);
|
|
1921
|
+
lines.push(` historyNote: ${concept.historyNote === null ? "null" : scalar(concept.historyNote)}`);
|
|
1922
|
+
lines.push(` status: ${scalar(concept.status)}`);
|
|
1923
|
+
lines.push(` origin: ${scalar(concept.origin)}`);
|
|
1924
|
+
lines.push(` standardRef: ${concept.standardRef === null ? "null" : scalar(concept.standardRef)}`);
|
|
1925
|
+
lines.push(` kind: ${scalar(concept.kind)}`);
|
|
1926
|
+
if (concept.grounding.length === 0) {
|
|
1927
|
+
lines.push(" grounding: []");
|
|
1928
|
+
} else {
|
|
1929
|
+
lines.push(" grounding:");
|
|
1930
|
+
for (const entry of concept.grounding) lines.push(` - ${scalar(entry)}`);
|
|
1931
|
+
}
|
|
1932
|
+
lines.push(` broader: ${concept.broader === null ? "null" : scalar(concept.broader)}`);
|
|
1933
|
+
if (concept.related.length === 0) {
|
|
1934
|
+
lines.push(" related: []");
|
|
1935
|
+
} else {
|
|
1936
|
+
lines.push(" related:");
|
|
1937
|
+
for (const entry of concept.related) lines.push(` - ${scalar(entry)}`);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return `${lines.join("\n")}\n`;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/** The glossary with bounded prose blanked, used for the structural digest. */
|
|
1944
|
+
function glossarySkeleton(glossary: Glossary): Record<string, unknown> {
|
|
1945
|
+
return {
|
|
1946
|
+
boundedContext: glossary.boundedContext,
|
|
1947
|
+
titles: glossary.titles.map((title) => ({ language: title.language, text: "" })),
|
|
1948
|
+
concepts: glossary.concepts.map((concept) => ({
|
|
1949
|
+
...concept,
|
|
1950
|
+
prefLabels: concept.prefLabels,
|
|
1951
|
+
altLabels: concept.altLabels,
|
|
1952
|
+
hiddenLabels: concept.hiddenLabels,
|
|
1953
|
+
definitions: concept.definitions.map((definition) => ({
|
|
1954
|
+
language: definition.language,
|
|
1955
|
+
text: "",
|
|
1956
|
+
})),
|
|
1957
|
+
scopeNote: null,
|
|
1958
|
+
historyNote: null,
|
|
1959
|
+
})),
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
export function glossaryDigest(glossary: Glossary): string {
|
|
1964
|
+
return createHash("sha256")
|
|
1965
|
+
.update(JSON.stringify(glossarySkeleton(glossary)))
|
|
1966
|
+
.digest("hex");
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// ----------------------------------------------------------- significance
|
|
1970
|
+
|
|
1971
|
+
/** One crawl-authored significance judgment for a single concept. */
|
|
1972
|
+
export interface ConceptSignificance {
|
|
1973
|
+
score: number;
|
|
1974
|
+
reason: string;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** Significance keyed by bounded context, then concept id. */
|
|
1978
|
+
export type GlossarySignificance = Record<string, Record<string, ConceptSignificance>>;
|
|
1979
|
+
|
|
1980
|
+
export function significancePath(repoDir: string): string {
|
|
1981
|
+
return join(discoverRoot(repoDir), "significance.json");
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Validates the staged significance sidecar against the staged glossaries.
|
|
1986
|
+
* Names must match exactly: a missing or extra context or concept fails, and
|
|
1987
|
+
* so does an entry that is not an integer score with one line of evidence.
|
|
1988
|
+
*/
|
|
1989
|
+
export function parseSignificance(
|
|
1990
|
+
raw: unknown,
|
|
1991
|
+
path: string,
|
|
1992
|
+
glossaries: Glossary[],
|
|
1993
|
+
): GlossarySignificance {
|
|
1994
|
+
if (!isRecord(raw)) invalid(path, "must be an object");
|
|
1995
|
+
for (const key of Object.keys(raw)) {
|
|
1996
|
+
if (!(SIGNIFICANCE_CONTEXT_KEYS as readonly string[]).includes(key)) {
|
|
1997
|
+
invalid(path, `carries an undeclared key ${key}`);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (raw.schemaVersion !== SIGNIFICANCE_SCHEMA_VERSION) {
|
|
2001
|
+
invalid(path, `schemaVersion must be ${SIGNIFICANCE_SCHEMA_VERSION}`);
|
|
2002
|
+
}
|
|
2003
|
+
const contexts = raw.contexts;
|
|
2004
|
+
if (!isRecord(contexts)) invalid(`${path}.contexts`, "must be an object");
|
|
2005
|
+
|
|
2006
|
+
const staged = new Map(glossaries.map((glossary) => [glossary.boundedContext, glossary]));
|
|
2007
|
+
for (const context of Object.keys(contexts)) {
|
|
2008
|
+
if (!staged.has(context)) invalid(`${path}.contexts`, `names the unknown context ${context}`);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
const parsed: GlossarySignificance = {};
|
|
2012
|
+
for (const glossary of glossaries) {
|
|
2013
|
+
const context = glossary.boundedContext;
|
|
2014
|
+
const entries = contexts[context];
|
|
2015
|
+
if (!isRecord(entries)) invalid(`${path}.contexts`, `has no entry for ${context}`);
|
|
2016
|
+
const ids = new Set(glossary.concepts.map((concept) => concept.id));
|
|
2017
|
+
for (const id of Object.keys(entries)) {
|
|
2018
|
+
if (!ids.has(id)) {
|
|
2019
|
+
invalid(`${path}.contexts.${context}`, `names the unknown concept ${id}`);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
const scored: Record<string, ConceptSignificance> = {};
|
|
2023
|
+
for (const id of [...ids].sort()) {
|
|
2024
|
+
const entry = entries[id];
|
|
2025
|
+
const trail = `${path}.contexts.${context}.${id}`;
|
|
2026
|
+
if (!isRecord(entry)) invalid(trail, "must be an object");
|
|
2027
|
+
for (const key of Object.keys(entry)) {
|
|
2028
|
+
if (!(SIGNIFICANCE_ENTRY_KEYS as readonly string[]).includes(key)) {
|
|
2029
|
+
invalid(trail, `carries an undeclared key ${key}`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
const score = entry.score;
|
|
2033
|
+
if (typeof score !== "number" || !Number.isInteger(score) || score < 0 || score > 100) {
|
|
2034
|
+
invalid(`${trail}.score`, "must be an integer from 0 to 100");
|
|
2035
|
+
}
|
|
2036
|
+
const reason = entry.reason;
|
|
2037
|
+
if (typeof reason !== "string") invalid(`${trail}.reason`, "must be a string");
|
|
2038
|
+
const text = reason.trim();
|
|
2039
|
+
if (text.length === 0) invalid(`${trail}.reason`, "must not be empty");
|
|
2040
|
+
if (!singleLine(text)) invalid(`${trail}.reason`, "must be one line");
|
|
2041
|
+
if (text.length > SIGNIFICANCE_REASON_MAX) {
|
|
2042
|
+
invalid(`${trail}.reason`, `must be at most ${SIGNIFICANCE_REASON_MAX} characters`);
|
|
2043
|
+
}
|
|
2044
|
+
if (ABSOLUTE_PATH_PATTERN.test(text)) {
|
|
2045
|
+
invalid(`${trail}.reason`, "must not be an absolute path");
|
|
2046
|
+
}
|
|
2047
|
+
if (hasSecret(text)) invalid(`${trail}.reason`, "must not carry a secret");
|
|
2048
|
+
scored[id] = { score, reason: text };
|
|
2049
|
+
}
|
|
2050
|
+
parsed[context] = scored;
|
|
2051
|
+
}
|
|
2052
|
+
return parsed;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/**
|
|
2056
|
+
* Reads a published sidecar, keeping only a score and reason that apply to a
|
|
2057
|
+
* published concept. A legacy manifest publishes no significance, and a
|
|
2058
|
+
* concept preserved from a human decision is published unscored.
|
|
2059
|
+
*/
|
|
2060
|
+
function readSignificance(
|
|
2061
|
+
repoDir: string,
|
|
2062
|
+
relativePath: string,
|
|
2063
|
+
glossaries: Glossary[],
|
|
2064
|
+
): GlossarySignificance {
|
|
2065
|
+
let raw: unknown;
|
|
2066
|
+
try {
|
|
2067
|
+
raw = JSON.parse(readFileSync(join(repoDir, relativePath), "utf8"));
|
|
2068
|
+
} catch {
|
|
2069
|
+
return {};
|
|
2070
|
+
}
|
|
2071
|
+
if (!isRecord(raw) || raw.schemaVersion !== SIGNIFICANCE_SCHEMA_VERSION) return {};
|
|
2072
|
+
const contexts = isRecord(raw.contexts) ? raw.contexts : {};
|
|
2073
|
+
const published: GlossarySignificance = {};
|
|
2074
|
+
for (const glossary of glossaries) {
|
|
2075
|
+
const entries = contexts[glossary.boundedContext];
|
|
2076
|
+
if (!isRecord(entries)) continue;
|
|
2077
|
+
const scored: Record<string, ConceptSignificance> = {};
|
|
2078
|
+
for (const concept of glossary.concepts) {
|
|
2079
|
+
const entry = entries[concept.id];
|
|
2080
|
+
if (!isRecord(entry)) continue;
|
|
2081
|
+
const score = entry.score;
|
|
2082
|
+
const reason = entry.reason;
|
|
2083
|
+
if (typeof score !== "number" || !Number.isInteger(score) || score < 0 || score > 100) {
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
if (typeof reason !== "string" || reason.trim().length === 0) continue;
|
|
2087
|
+
scored[concept.id] = { score, reason: reason.trim() };
|
|
2088
|
+
}
|
|
2089
|
+
if (Object.keys(scored).length > 0) published[glossary.boundedContext] = scored;
|
|
2090
|
+
}
|
|
2091
|
+
return published;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// ---------------------------------------------------------------- skills
|
|
2095
|
+
|
|
2096
|
+
interface SkillFrontmatter {
|
|
2097
|
+
fields: Map<string, string>;
|
|
2098
|
+
body: string;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function unquote(value: string): string {
|
|
2102
|
+
if (value.length >= 2) {
|
|
2103
|
+
const first = value[0];
|
|
2104
|
+
const last = value[value.length - 1];
|
|
2105
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
2106
|
+
return value.slice(1, -1);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
return value;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function parseSkill(source: string, path: string): SkillFrontmatter {
|
|
2113
|
+
if (!source.startsWith("---\n")) invalid(path, "must start with YAML frontmatter");
|
|
2114
|
+
const end = source.indexOf("\n---", 4);
|
|
2115
|
+
if (end < 0) invalid(path, "must close its frontmatter");
|
|
2116
|
+
const frontmatter = source.slice(4, end);
|
|
2117
|
+
const body = source.slice(end + 4).replace(/^\r?\n/, "");
|
|
2118
|
+
const fields = new Map<string, string>();
|
|
2119
|
+
let section = "";
|
|
2120
|
+
for (const rawLine of frontmatter.split("\n")) {
|
|
2121
|
+
if (rawLine.trim().length === 0 || rawLine.trimStart().startsWith("#")) continue;
|
|
2122
|
+
const indented = rawLine.startsWith(" ") || rawLine.startsWith("\t");
|
|
2123
|
+
const match = /^\s*([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rawLine);
|
|
2124
|
+
if (match === null) invalid(path, `cannot read frontmatter line ${rawLine.trim()}`);
|
|
2125
|
+
const key = match[1] as string;
|
|
2126
|
+
const value = (match[2] as string).trim();
|
|
2127
|
+
if (!indented && value.length === 0) {
|
|
2128
|
+
section = key;
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
const name = indented && section.length > 0 ? `${section}.${key}` : key;
|
|
2132
|
+
fields.set(name, unquote(value));
|
|
2133
|
+
}
|
|
2134
|
+
return { fields, body };
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function directiveText(line: string): string {
|
|
2138
|
+
return line.replace(/^\s*[-*+]\s+/, "").trim();
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
const DIRECTIVE_PATTERN =
|
|
2142
|
+
/^(always|never|must not|must|should not|should|do not|don't|avoid|prefer|use)\s+(.+)$/i;
|
|
2143
|
+
const NEGATIVE_DIRECTIVES: Record<string, true> = {
|
|
2144
|
+
never: true,
|
|
2145
|
+
"must not": true,
|
|
2146
|
+
"should not": true,
|
|
2147
|
+
"do not": true,
|
|
2148
|
+
"don't": true,
|
|
2149
|
+
avoid: true,
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
interface Directive {
|
|
2153
|
+
polarity: "positive" | "negative";
|
|
2154
|
+
subject: string;
|
|
2155
|
+
text: string;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function directivesOf(body: string): Directive[] {
|
|
2159
|
+
const directives: Directive[] = [];
|
|
2160
|
+
for (const line of body.split("\n")) {
|
|
2161
|
+
const match = DIRECTIVE_PATTERN.exec(directiveText(line));
|
|
2162
|
+
if (match === null) continue;
|
|
2163
|
+
const keyword = (match[1] as string).toLowerCase();
|
|
2164
|
+
const subject = (match[2] as string)
|
|
2165
|
+
.toLowerCase()
|
|
2166
|
+
.replace(/[`*_]/g, "")
|
|
2167
|
+
.replace(/[.:;]+$/, "")
|
|
2168
|
+
.replace(/\s+/g, " ")
|
|
2169
|
+
.trim();
|
|
2170
|
+
if (subject.length === 0) continue;
|
|
2171
|
+
directives.push({
|
|
2172
|
+
polarity: NEGATIVE_DIRECTIVES[keyword] === true ? "negative" : "positive",
|
|
2173
|
+
subject,
|
|
2174
|
+
text: directiveText(line),
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
return directives;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** Opposite policies about the same subject, across two skill bodies. */
|
|
2181
|
+
export function skillContradictions(
|
|
2182
|
+
skills: Array<{ name: string; body: string }>,
|
|
2183
|
+
): Array<{ skills: [string, string]; subject: string; directives: [string, string] }> {
|
|
2184
|
+
const contradictions: Array<{
|
|
2185
|
+
skills: [string, string];
|
|
2186
|
+
subject: string;
|
|
2187
|
+
directives: [string, string];
|
|
2188
|
+
}> = [];
|
|
2189
|
+
const seen = new Set<string>();
|
|
2190
|
+
for (let left = 0; left < skills.length; left += 1) {
|
|
2191
|
+
for (let right = left + 1; right < skills.length; right += 1) {
|
|
2192
|
+
const a = skills[left] as { name: string; body: string };
|
|
2193
|
+
const b = skills[right] as { name: string; body: string };
|
|
2194
|
+
for (const first of directivesOf(a.body)) {
|
|
2195
|
+
for (const second of directivesOf(b.body)) {
|
|
2196
|
+
if (first.polarity === second.polarity) continue;
|
|
2197
|
+
if (first.subject !== second.subject) continue;
|
|
2198
|
+
const key = `${a.name}\u0000${b.name}\u0000${first.subject}`;
|
|
2199
|
+
if (seen.has(key)) continue;
|
|
2200
|
+
seen.add(key);
|
|
2201
|
+
contradictions.push({
|
|
2202
|
+
skills: [a.name, b.name],
|
|
2203
|
+
subject: first.subject,
|
|
2204
|
+
directives: [first.text, second.text],
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
return contradictions;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
export interface StagedSkill {
|
|
2214
|
+
name: DiscoverSkillName;
|
|
2215
|
+
text: string;
|
|
2216
|
+
body: string;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function validateSkillText(name: string, text: string, path: string): StagedSkill {
|
|
2220
|
+
const { fields, body } = parseSkill(text, path);
|
|
2221
|
+
const declared = fields.get("name") ?? "";
|
|
2222
|
+
if (declared !== name) invalid(`${path}:name`, `must equal the directory name ${name}`);
|
|
2223
|
+
if (!SKILL_NAME_PATTERN.test(declared)) invalid(`${path}:name`, "must be kebab-case");
|
|
2224
|
+
if (declared.length > SKILL_NAME_MAX) invalid(`${path}:name`, `must be at most ${SKILL_NAME_MAX} characters`);
|
|
2225
|
+
const description = fields.get("description") ?? "";
|
|
2226
|
+
if (description.trim().length === 0) invalid(`${path}:description`, "must not be empty");
|
|
2227
|
+
if (description.length > SKILL_DESCRIPTION_MAX) {
|
|
2228
|
+
invalid(`${path}:description`, `must be at most ${SKILL_DESCRIPTION_MAX} characters`);
|
|
2229
|
+
}
|
|
2230
|
+
if (fields.get(`metadata.${MANAGED_MARKER}`) !== "true") {
|
|
2231
|
+
invalid(`${path}:metadata.${MANAGED_MARKER}`, 'must be "true"');
|
|
2232
|
+
}
|
|
2233
|
+
if (fields.get(`metadata.${FAMILY_MARKER}`) !== DISCOVER_FAMILY) {
|
|
2234
|
+
invalid(`${path}:metadata.${FAMILY_MARKER}`, 'must be "discover"');
|
|
2235
|
+
}
|
|
2236
|
+
const role = fields.get(`metadata.${SKILL_MARKER}`) ?? "";
|
|
2237
|
+
if (role.trim().length === 0) invalid(`${path}:metadata.${SKILL_MARKER}`, "must name the skill's role");
|
|
2238
|
+
if (hasSecret(description) || hasSecret(role) || hasSecret(body)) {
|
|
2239
|
+
invalid(path, "must not carry a secret value");
|
|
2240
|
+
}
|
|
2241
|
+
if (body.trim().length === 0) invalid(path, "must carry a body");
|
|
2242
|
+
const lines = body.split("\n");
|
|
2243
|
+
let bullets = 0;
|
|
2244
|
+
for (const line of lines) {
|
|
2245
|
+
const trimmed = line.trim();
|
|
2246
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
2247
|
+
if (!/^[-*+]\s+/.test(line.trimStart())) {
|
|
2248
|
+
invalid(path, "body prose must be imperative bullets, not paragraphs");
|
|
2249
|
+
}
|
|
2250
|
+
bullets += 1;
|
|
2251
|
+
}
|
|
2252
|
+
if (bullets === 0) invalid(path, "must carry at least one imperative bullet");
|
|
2253
|
+
return { name: name as DiscoverSkillName, text, body };
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// ------------------------------------------------------- staged validation
|
|
2257
|
+
|
|
2258
|
+
export interface StagedArtifacts {
|
|
2259
|
+
sheet: Sheet;
|
|
2260
|
+
glossaries: Glossary[];
|
|
2261
|
+
significance: GlossarySignificance;
|
|
2262
|
+
skills: StagedSkill[];
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function walkFiles(root: string, prefix = ""): string[] {
|
|
2266
|
+
const entries = readdirSync(join(root, prefix), { withFileTypes: true });
|
|
2267
|
+
const files: string[] = [];
|
|
2268
|
+
for (const entry of entries) {
|
|
2269
|
+
const next = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
|
|
2270
|
+
if (entry.isDirectory()) files.push(...walkFiles(root, next));
|
|
2271
|
+
else files.push(next);
|
|
2272
|
+
}
|
|
2273
|
+
return files.sort();
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
export function validateStaged(
|
|
2277
|
+
repoDir: string,
|
|
2278
|
+
stagingDir: string,
|
|
2279
|
+
freshness: Freshness,
|
|
2280
|
+
): StagedArtifacts {
|
|
2281
|
+
if (!existsSync(stagingDir)) {
|
|
2282
|
+
throw new DiscoverValidationError("the crawl left no staging directory");
|
|
2283
|
+
}
|
|
2284
|
+
const files = walkFiles(stagingDir);
|
|
2285
|
+
const skillDirs = new Set(
|
|
2286
|
+
files
|
|
2287
|
+
.filter((file) => file.startsWith(`${STAGED_SKILL_DIR}/`))
|
|
2288
|
+
.map((file) => file.split("/")[1] as string),
|
|
2289
|
+
);
|
|
2290
|
+
for (const file of files) {
|
|
2291
|
+
const parts = file.split("/");
|
|
2292
|
+
const allowed =
|
|
2293
|
+
file === STAGED_SHEET ||
|
|
2294
|
+
file === STAGED_SIGNIFICANCE ||
|
|
2295
|
+
(parts[0] === STAGED_GLOSSARY_DIR &&
|
|
2296
|
+
parts.length === 2 &&
|
|
2297
|
+
parts[1] !== undefined &&
|
|
2298
|
+
parts[1].endsWith(".glossary.yaml")) ||
|
|
2299
|
+
(parts[0] === STAGED_SKILL_DIR &&
|
|
2300
|
+
parts.length === 3 &&
|
|
2301
|
+
parts[2] === "SKILL.md" &&
|
|
2302
|
+
skillDirs.has(parts[1] as string));
|
|
2303
|
+
if (!allowed) {
|
|
2304
|
+
throw new DiscoverValidationError(
|
|
2305
|
+
`the crawl wrote ${file}, which is not part of the staged Discover set`,
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (!files.includes(STAGED_SHEET)) {
|
|
2310
|
+
throw new DiscoverValidationError("the crawl left no sheet.json");
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
let rawSheet: unknown;
|
|
2314
|
+
try {
|
|
2315
|
+
rawSheet = JSON.parse(readFileSync(join(stagingDir, STAGED_SHEET), "utf8"));
|
|
2316
|
+
} catch (error) {
|
|
2317
|
+
throw new DiscoverValidationError(
|
|
2318
|
+
`sheet.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
const sheet = normalizeSheet(rawSheet, freshness);
|
|
2322
|
+
|
|
2323
|
+
const context = { repoDir, files: hostFiles(repoDir) };
|
|
2324
|
+
const glossaryFiles = files.filter(
|
|
2325
|
+
(file) => file.startsWith(`${STAGED_GLOSSARY_DIR}/`) && file.endsWith(".glossary.yaml"),
|
|
2326
|
+
);
|
|
2327
|
+
if (glossaryFiles.length === 0) {
|
|
2328
|
+
throw new DiscoverValidationError("the crawl left no glossary document");
|
|
2329
|
+
}
|
|
2330
|
+
const glossaries: Glossary[] = [];
|
|
2331
|
+
const contexts = new Set<string>();
|
|
2332
|
+
for (const file of glossaryFiles) {
|
|
2333
|
+
let text: string;
|
|
2334
|
+
try {
|
|
2335
|
+
text = readFileSync(join(stagingDir, file), "utf8");
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
throw new DiscoverValidationError(`could not read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2338
|
+
}
|
|
2339
|
+
const glossary = parseGlossary(text, file, context, { staged: true });
|
|
2340
|
+
if (contexts.has(glossary.boundedContext)) {
|
|
2341
|
+
throw new DiscoverValidationError(`two glossaries name ${glossary.boundedContext}`);
|
|
2342
|
+
}
|
|
2343
|
+
contexts.add(glossary.boundedContext);
|
|
2344
|
+
glossaries.push(glossary);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
if (!files.includes(STAGED_SIGNIFICANCE)) {
|
|
2348
|
+
throw new DiscoverValidationError(`the crawl left no ${STAGED_SIGNIFICANCE}`);
|
|
2349
|
+
}
|
|
2350
|
+
let rawSignificance: unknown;
|
|
2351
|
+
try {
|
|
2352
|
+
rawSignificance = JSON.parse(readFileSync(join(stagingDir, STAGED_SIGNIFICANCE), "utf8"));
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
throw new DiscoverValidationError(
|
|
2355
|
+
`${STAGED_SIGNIFICANCE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
const significance = parseSignificance(rawSignificance, STAGED_SIGNIFICANCE, glossaries);
|
|
2359
|
+
|
|
2360
|
+
const skills: StagedSkill[] = [];
|
|
2361
|
+
for (const name of DISCOVER_SKILLS) {
|
|
2362
|
+
const file = `${STAGED_SKILL_DIR}/${name}/SKILL.md`;
|
|
2363
|
+
if (!files.includes(file)) {
|
|
2364
|
+
throw new DiscoverValidationError(`the crawl left no ${name} skill`);
|
|
2365
|
+
}
|
|
2366
|
+
skills.push(validateSkillText(name, readFileSync(join(stagingDir, file), "utf8"), file));
|
|
2367
|
+
}
|
|
2368
|
+
for (const directory of skillDirs) {
|
|
2369
|
+
if (!isDiscoverSkill(directory)) {
|
|
2370
|
+
throw new DiscoverValidationError(`the crawl wrote an undeclared skill ${directory}`);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
const contradictions = skillContradictions(
|
|
2375
|
+
skills.map((skill) => ({ name: skill.name, body: skill.body })),
|
|
2376
|
+
);
|
|
2377
|
+
if (contradictions.length > 0) {
|
|
2378
|
+
const [first] = contradictions;
|
|
2379
|
+
throw new DiscoverValidationError(
|
|
2380
|
+
`the generated skills contradict each other: ${first?.skills[0]} and ${first?.skills[1]} disagree about ${first?.subject}`,
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const glossarySkill = skills.find((skill) => skill.name === "discover-glossary");
|
|
2385
|
+
for (const glossary of glossaries) {
|
|
2386
|
+
const path = `.omp/discover/glossary/${glossary.boundedContext}.glossary.yaml`;
|
|
2387
|
+
if (glossarySkill === undefined || !glossarySkill.body.includes(path)) {
|
|
2388
|
+
throw new DiscoverValidationError(`discover-glossary does not name ${path}`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
return { sheet, glossaries, significance, skills };
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// ------------------------------------------------------------- decisions
|
|
2396
|
+
|
|
2397
|
+
export type DecisionAction = "confirm" | "reject";
|
|
2398
|
+
|
|
2399
|
+
export interface Decision {
|
|
2400
|
+
context: string;
|
|
2401
|
+
id: string;
|
|
2402
|
+
action: DecisionAction;
|
|
2403
|
+
reason: string | null;
|
|
2404
|
+
date: string;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function readDecisions(repoDir: string): Decision[] {
|
|
2408
|
+
const path = join(discoverRoot(repoDir), "decisions.json");
|
|
2409
|
+
if (!existsSync(path)) return [];
|
|
2410
|
+
let parsed: unknown;
|
|
2411
|
+
try {
|
|
2412
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2413
|
+
} catch (error) {
|
|
2414
|
+
throw new DiscoverValidationError(
|
|
2415
|
+
`decisions.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2418
|
+
if (!Array.isArray(parsed)) throw new DiscoverValidationError("decisions.json is not a list");
|
|
2419
|
+
return parsed.map((entry, index) => {
|
|
2420
|
+
if (!isRecord(entry)) invalid(`decisions[${index}]`, "must be an object");
|
|
2421
|
+
const action = entry.action;
|
|
2422
|
+
if (action !== "confirm" && action !== "reject") {
|
|
2423
|
+
invalid(`decisions[${index}].action`, "must be confirm or reject");
|
|
2424
|
+
}
|
|
2425
|
+
const reason = entry.reason;
|
|
2426
|
+
if (reason !== null && typeof reason !== "string") {
|
|
2427
|
+
invalid(`decisions[${index}].reason`, "must be a string or null");
|
|
2428
|
+
}
|
|
2429
|
+
const date = entry.date;
|
|
2430
|
+
if (typeof date !== "string" || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(date)) {
|
|
2431
|
+
invalid(`decisions[${index}].date`, "must be a YYYY-MM-DD date");
|
|
2432
|
+
}
|
|
2433
|
+
return {
|
|
2434
|
+
context: textValue(entry.context, `decisions[${index}].context`, 120),
|
|
2435
|
+
id: textValue(entry.id, `decisions[${index}].id`, 120),
|
|
2436
|
+
action,
|
|
2437
|
+
reason: reason as string | null,
|
|
2438
|
+
date,
|
|
2439
|
+
};
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function renderJson(value: unknown): string {
|
|
2444
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// ------------------------------------------------------------- manifest
|
|
2448
|
+
|
|
2449
|
+
export interface ManifestSkill {
|
|
2450
|
+
name: DiscoverSkillName;
|
|
2451
|
+
baselineHash: string;
|
|
2452
|
+
currentHash: string | null;
|
|
2453
|
+
state: "generated" | "edited" | "missing";
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
export interface Manifest {
|
|
2457
|
+
schemaVersion: number;
|
|
2458
|
+
fingerprint: string;
|
|
2459
|
+
publishedAt: string;
|
|
2460
|
+
runId: string;
|
|
2461
|
+
sheetDigest: string;
|
|
2462
|
+
glossaryDigests: Record<string, string>;
|
|
2463
|
+
skills: ManifestSkill[];
|
|
2464
|
+
paths: {
|
|
2465
|
+
manifest: string;
|
|
2466
|
+
sheet: string;
|
|
2467
|
+
decisions: string;
|
|
2468
|
+
glossaries: string[];
|
|
2469
|
+
baselines: Record<string, string>;
|
|
2470
|
+
live: Record<string, string>;
|
|
2471
|
+
/** Absent in a manifest published before the significance sidecar existed. */
|
|
2472
|
+
significance?: string;
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function isManifest(value: unknown): value is Manifest {
|
|
2477
|
+
return isRecord(value) && value.schemaVersion === DISCOVER_SCHEMA_VERSION;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function readManifest(repoDir: string): Manifest | undefined {
|
|
2481
|
+
const path = join(discoverRoot(repoDir), "manifest.json");
|
|
2482
|
+
if (!existsSync(path)) return undefined;
|
|
2483
|
+
let parsed: unknown;
|
|
2484
|
+
try {
|
|
2485
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
throw new DiscoverValidationError(
|
|
2488
|
+
`manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
if (!isManifest(parsed)) throw new DiscoverValidationError("manifest.json has an unsupported version");
|
|
2492
|
+
return parsed;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
function hashText(text: string): string {
|
|
2496
|
+
return createHash("sha256").update(text).digest("hex");
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// ------------------------------------------------------------ publishing
|
|
2500
|
+
|
|
2501
|
+
interface FileWrite {
|
|
2502
|
+
path: string;
|
|
2503
|
+
content: string;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
interface FileSnapshot {
|
|
2507
|
+
path: string;
|
|
2508
|
+
bytes: Buffer | undefined;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/** Writes every file or leaves all affected bytes exactly as they were. */
|
|
2512
|
+
function writeTransaction(writes: FileWrite[], deletions: string[]): void {
|
|
2513
|
+
const snapshots: FileSnapshot[] = [];
|
|
2514
|
+
const paths = [...new Set([...writes.map((write) => write.path), ...deletions])];
|
|
2515
|
+
for (const path of paths) {
|
|
2516
|
+
snapshots.push({ path, bytes: existsSync(path) ? readFileSync(path) : undefined });
|
|
2517
|
+
}
|
|
2518
|
+
try {
|
|
2519
|
+
for (const write of writes) {
|
|
2520
|
+
mkdirSync(dirname(write.path), { recursive: true });
|
|
2521
|
+
const temporary = `${write.path}.${process.pid}.tmp`;
|
|
2522
|
+
writeFileSync(temporary, write.content);
|
|
2523
|
+
renameSync(temporary, write.path);
|
|
2524
|
+
}
|
|
2525
|
+
for (const path of deletions) {
|
|
2526
|
+
try {
|
|
2527
|
+
unlinkSync(path);
|
|
2528
|
+
} catch {
|
|
2529
|
+
// Already gone.
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
for (const snapshot of snapshots) {
|
|
2534
|
+
try {
|
|
2535
|
+
if (snapshot.bytes === undefined) {
|
|
2536
|
+
if (existsSync(snapshot.path)) unlinkSync(snapshot.path);
|
|
2537
|
+
} else {
|
|
2538
|
+
writeFileSync(snapshot.path, snapshot.bytes);
|
|
2539
|
+
}
|
|
2540
|
+
} catch {
|
|
2541
|
+
// The original failure is the one worth reporting.
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
throw error;
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
interface PublishedRead {
|
|
2549
|
+
manifest: Manifest;
|
|
2550
|
+
sheet: Sheet;
|
|
2551
|
+
glossaries: Glossary[];
|
|
2552
|
+
significance: GlossarySignificance;
|
|
2553
|
+
decisions: Decision[];
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function readPublished(
|
|
2557
|
+
repoDir: string,
|
|
2558
|
+
trustGrounding = false,
|
|
2559
|
+
): PublishedRead | undefined {
|
|
2560
|
+
const manifest = readManifest(repoDir);
|
|
2561
|
+
if (manifest === undefined) return undefined;
|
|
2562
|
+
const context = { repoDir, files: hostFiles(repoDir) };
|
|
2563
|
+
const sheetPath = join(repoDir, manifest.paths.sheet);
|
|
2564
|
+
let rawSheet: unknown;
|
|
2565
|
+
try {
|
|
2566
|
+
rawSheet = JSON.parse(readFileSync(sheetPath, "utf8"));
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
throw new DiscoverValidationError(
|
|
2569
|
+
`sheet.json is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
if (!isRecord(rawSheet)) throw new DiscoverValidationError("sheet.json is not an object");
|
|
2573
|
+
const freshness = rawSheet.freshness;
|
|
2574
|
+
if (!isRecord(freshness)) throw new DiscoverValidationError("sheet.json has no freshness header");
|
|
2575
|
+
const sheet = normalizeSheet(
|
|
2576
|
+
Object.fromEntries(Object.entries(rawSheet).filter(([key]) => key !== "freshness")),
|
|
2577
|
+
{
|
|
2578
|
+
project: textValue(freshness.project, "freshness.project", 200),
|
|
2579
|
+
commit: textValue(freshness.commit, "freshness.commit", 64),
|
|
2580
|
+
dirty: freshness.dirty === true,
|
|
2581
|
+
discoveredAt: textValue(freshness.discoveredAt, "freshness.discoveredAt", 64),
|
|
2582
|
+
fingerprint: textValue(freshness.fingerprint, "freshness.fingerprint", 128),
|
|
2583
|
+
},
|
|
2584
|
+
);
|
|
2585
|
+
const glossaries: Glossary[] = [];
|
|
2586
|
+
for (const relativePath of manifest.paths.glossaries) {
|
|
2587
|
+
const file = join(repoDir, relativePath);
|
|
2588
|
+
let text: string;
|
|
2589
|
+
try {
|
|
2590
|
+
text = readFileSync(file, "utf8");
|
|
2591
|
+
} catch (error) {
|
|
2592
|
+
throw new DiscoverValidationError(
|
|
2593
|
+
`${relativePath} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
glossaries.push(parseGlossary(text, relativePath, context, { staged: false, trustGrounding }));
|
|
2597
|
+
}
|
|
2598
|
+
const significance =
|
|
2599
|
+
manifest.paths.significance === undefined
|
|
2600
|
+
? {}
|
|
2601
|
+
: readSignificance(repoDir, manifest.paths.significance, glossaries);
|
|
2602
|
+
const decisions = readDecisions(repoDir);
|
|
2603
|
+
return { manifest, sheet, glossaries, significance, decisions };
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
/** A live skill that is not this run's to replace. */
|
|
2607
|
+
function collisionAt(repoDir: string, name: string): string | undefined {
|
|
2608
|
+
const path = liveSkillPath(repoDir, name);
|
|
2609
|
+
if (!existsSync(path)) return undefined;
|
|
2610
|
+
let text: string;
|
|
2611
|
+
try {
|
|
2612
|
+
text = readFileSync(path, "utf8");
|
|
2613
|
+
} catch {
|
|
2614
|
+
return `${name} is unreadable, so Discover will not replace it`;
|
|
2615
|
+
}
|
|
2616
|
+
let fields: Map<string, string>;
|
|
2617
|
+
try {
|
|
2618
|
+
fields = parseSkill(text, name).fields;
|
|
2619
|
+
} catch {
|
|
2620
|
+
return `${name} is an unmanaged skill, so Discover will not replace it`;
|
|
2621
|
+
}
|
|
2622
|
+
if (fields.get(`metadata.${MANAGED_MARKER}`) !== "true") {
|
|
2623
|
+
return `${name} is an unmanaged skill, so Discover will not replace it`;
|
|
2624
|
+
}
|
|
2625
|
+
const family = fields.get(`metadata.${FAMILY_MARKER}`) ?? "prepare";
|
|
2626
|
+
if (family !== DISCOVER_FAMILY) {
|
|
2627
|
+
return `${name} belongs to the ${family} family, so Discover will not replace it`;
|
|
2628
|
+
}
|
|
2629
|
+
return undefined;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function mergeGlossary(existing: Glossary | undefined, staged: Glossary): Glossary {
|
|
2633
|
+
if (existing === undefined) return staged;
|
|
2634
|
+
const stagedById = new Map(staged.concepts.map((concept) => [concept.id, concept]));
|
|
2635
|
+
const preserved: Concept[] = staged.concepts.map((concept) => {
|
|
2636
|
+
const previous = existing.concepts.find((candidate) => candidate.id === concept.id);
|
|
2637
|
+
if (previous === undefined) return concept;
|
|
2638
|
+
if (previous.status !== "confirmed" && previous.status !== "invalidated") return concept;
|
|
2639
|
+
// A human decision is never overwritten; only grounding follows the code.
|
|
2640
|
+
return { ...previous, grounding: concept.grounding };
|
|
2641
|
+
});
|
|
2642
|
+
const seen = new Set(preserved.map((concept) => concept.id));
|
|
2643
|
+
for (const previous of existing.concepts) {
|
|
2644
|
+
if (previous.status !== "confirmed" && previous.status !== "invalidated") continue;
|
|
2645
|
+
if (seen.has(previous.id)) continue;
|
|
2646
|
+
preserved.push(previous);
|
|
2647
|
+
seen.add(previous.id);
|
|
2648
|
+
}
|
|
2649
|
+
preserved.sort((left, right) => left.id.localeCompare(right.id));
|
|
2650
|
+
return { boundedContext: staged.boundedContext, titles: staged.titles, concepts: preserved };
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
export interface PublishInput {
|
|
2654
|
+
repoDir: string;
|
|
2655
|
+
runId: string;
|
|
2656
|
+
facts: HostFacts;
|
|
2657
|
+
staged: StagedArtifacts;
|
|
2658
|
+
project: string;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
export interface PublishResult {
|
|
2662
|
+
manifest: Manifest;
|
|
2663
|
+
sheet: Sheet;
|
|
2664
|
+
glossaries: Glossary[];
|
|
2665
|
+
decisions: Decision[];
|
|
2666
|
+
warnings: string[];
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
export function publishStaged(input: PublishInput): PublishResult {
|
|
2670
|
+
const { repoDir, runId, facts, staged, project } = input;
|
|
2671
|
+
|
|
2672
|
+
for (const name of DISCOVER_SKILLS) {
|
|
2673
|
+
const collision = collisionAt(repoDir, name);
|
|
2674
|
+
if (collision !== undefined) throw new DiscoverValidationError(collision);
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
const previous = readPublished(repoDir);
|
|
2678
|
+
const previousByName = new Map(staged.glossaries.map((glossary) => [glossary.boundedContext, glossary]));
|
|
2679
|
+
const merged: Glossary[] = staged.glossaries.map((glossary) =>
|
|
2680
|
+
mergeGlossary(
|
|
2681
|
+
previous?.glossaries.find(
|
|
2682
|
+
(candidate) => candidate.boundedContext === glossary.boundedContext,
|
|
2683
|
+
),
|
|
2684
|
+
glossary,
|
|
2685
|
+
),
|
|
2686
|
+
);
|
|
2687
|
+
for (const previousGlossary of previous?.glossaries ?? []) {
|
|
2688
|
+
if (previousByName.has(previousGlossary.boundedContext)) continue;
|
|
2689
|
+
// A bounded context the crawl no longer sees keeps its human decisions.
|
|
2690
|
+
if (previousGlossary.concepts.some(
|
|
2691
|
+
(concept) => concept.status === "confirmed" || concept.status === "invalidated",
|
|
2692
|
+
)) {
|
|
2693
|
+
merged.push(previousGlossary);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
merged.sort((left, right) => left.boundedContext.localeCompare(right.boundedContext));
|
|
2697
|
+
|
|
2698
|
+
// Discovery does not ask the operator to confirm terms one by one: every
|
|
2699
|
+
// newly crawled term is published as confirmed. An invalidated term stays
|
|
2700
|
+
// invalidated because it records a human decision.
|
|
2701
|
+
for (const glossary of merged) {
|
|
2702
|
+
for (const concept of glossary.concepts) {
|
|
2703
|
+
if (concept.status === "proposed" || concept.status === "unconfirmed") {
|
|
2704
|
+
concept.status = "confirmed";
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
const freshness: Freshness = {
|
|
2710
|
+
project,
|
|
2711
|
+
commit: facts.commit,
|
|
2712
|
+
dirty: facts.dirty,
|
|
2713
|
+
discoveredAt: new Date().toISOString(),
|
|
2714
|
+
fingerprint: facts.fingerprint,
|
|
2715
|
+
};
|
|
2716
|
+
const sheet = normalizeSheet(
|
|
2717
|
+
Object.fromEntries(
|
|
2718
|
+
Object.entries(staged.sheet as unknown as Record<string, unknown>).filter(
|
|
2719
|
+
([key]) => key !== "freshness",
|
|
2720
|
+
),
|
|
2721
|
+
),
|
|
2722
|
+
freshness,
|
|
2723
|
+
);
|
|
2724
|
+
|
|
2725
|
+
const previousBaselines = new Map<string, string>(
|
|
2726
|
+
(previous?.manifest.skills ?? []).map((skill) => [skill.name, skill.baselineHash]),
|
|
2727
|
+
);
|
|
2728
|
+
const skillEntries: ManifestSkill[] = [];
|
|
2729
|
+
const skillWrites: FileWrite[] = [];
|
|
2730
|
+
const previousBaselineText = new Map<string, string>();
|
|
2731
|
+
for (const skill of DISCOVER_SKILLS) {
|
|
2732
|
+
const stagedSkill = staged.skills.find((entry) => entry.name === skill);
|
|
2733
|
+
if (stagedSkill === undefined) throw new DiscoverValidationError(`the crawl left no ${skill}`);
|
|
2734
|
+
const baseline = `${stagedSkill.text}`;
|
|
2735
|
+
const baselineHash = hashText(baseline);
|
|
2736
|
+
const livePath = liveSkillPath(repoDir, skill);
|
|
2737
|
+
const previousBaselineHash = previousBaselines.get(skill);
|
|
2738
|
+
const previousBaselinePath = baselinePath(repoDir, skill);
|
|
2739
|
+
if (existsSync(previousBaselinePath)) {
|
|
2740
|
+
previousBaselineText.set(skill, readFileSync(previousBaselinePath, "utf8"));
|
|
2741
|
+
}
|
|
2742
|
+
let state: ManifestSkill["state"];
|
|
2743
|
+
let currentHash: string | null;
|
|
2744
|
+
if (!existsSync(livePath)) {
|
|
2745
|
+
if (previous?.manifest === undefined) {
|
|
2746
|
+
// The first publication installs every live skill.
|
|
2747
|
+
state = "generated";
|
|
2748
|
+
currentHash = hashText(baseline);
|
|
2749
|
+
skillWrites.push({ path: livePath, content: baseline });
|
|
2750
|
+
} else {
|
|
2751
|
+
// A skill the developer deleted is not recreated; Restore copies the
|
|
2752
|
+
// current baseline.
|
|
2753
|
+
state = "missing";
|
|
2754
|
+
currentHash = null;
|
|
2755
|
+
}
|
|
2756
|
+
} else {
|
|
2757
|
+
const liveText = readFileSync(livePath, "utf8");
|
|
2758
|
+
currentHash = hashText(liveText);
|
|
2759
|
+
if (previousBaselineHash !== undefined && currentHash !== previousBaselineHash) {
|
|
2760
|
+
state = "edited";
|
|
2761
|
+
} else if (previousBaselineHash === undefined && currentHash !== baselineHash) {
|
|
2762
|
+
state = "edited";
|
|
2763
|
+
} else {
|
|
2764
|
+
state = "generated";
|
|
2765
|
+
currentHash = baselineHash;
|
|
2766
|
+
skillWrites.push({ path: livePath, content: baseline });
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
skillEntries.push({ name: skill, baselineHash, currentHash, state });
|
|
2770
|
+
skillWrites.push({ path: previousBaselinePath, content: baseline });
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
const warnings: string[] = [];
|
|
2774
|
+
const liveBodies = skillEntries
|
|
2775
|
+
.filter((entry) => entry.state === "edited")
|
|
2776
|
+
.map((entry) => ({
|
|
2777
|
+
name: entry.name,
|
|
2778
|
+
body: readFileSync(liveSkillPath(repoDir, entry.name), "utf8"),
|
|
2779
|
+
}));
|
|
2780
|
+
for (const contradiction of skillContradictions(liveBodies)) {
|
|
2781
|
+
warnings.push(
|
|
2782
|
+
`${contradiction.skills[0]} and ${contradiction.skills[1]} disagree about ${contradiction.subject}`,
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
const manifest: Manifest = {
|
|
2787
|
+
schemaVersion: DISCOVER_SCHEMA_VERSION,
|
|
2788
|
+
fingerprint: facts.fingerprint,
|
|
2789
|
+
publishedAt: new Date().toISOString(),
|
|
2790
|
+
runId,
|
|
2791
|
+
sheetDigest: sheetDigest(sheet),
|
|
2792
|
+
glossaryDigests: Object.fromEntries(
|
|
2793
|
+
merged.map((glossary) => [glossary.boundedContext, glossaryDigest(glossary)]),
|
|
2794
|
+
),
|
|
2795
|
+
skills: skillEntries,
|
|
2796
|
+
paths: {
|
|
2797
|
+
manifest: ".omp/discover/manifest.json",
|
|
2798
|
+
sheet: ".omp/discover/sheet.json",
|
|
2799
|
+
decisions: ".omp/discover/decisions.json",
|
|
2800
|
+
glossaries: merged.map(
|
|
2801
|
+
(glossary) => `.omp/discover/glossary/${glossary.boundedContext}.glossary.yaml`,
|
|
2802
|
+
),
|
|
2803
|
+
baselines: Object.fromEntries(
|
|
2804
|
+
DISCOVER_SKILLS.map((name) => [name, `.omp/discover/skills/${name}/SKILL.md`]),
|
|
2805
|
+
),
|
|
2806
|
+
live: Object.fromEntries(DISCOVER_SKILLS.map((name) => [name, `.omp/skills/${name}/SKILL.md`])),
|
|
2807
|
+
significance: ".omp/discover/significance.json",
|
|
2808
|
+
},
|
|
2809
|
+
};
|
|
2810
|
+
|
|
2811
|
+
const decisions = previous?.decisions ?? [];
|
|
2812
|
+
const writes: FileWrite[] = [
|
|
2813
|
+
{ path: join(repoDir, manifest.paths.sheet), content: renderJson(sheet) },
|
|
2814
|
+
{ path: join(repoDir, manifest.paths.manifest), content: renderJson(manifest) },
|
|
2815
|
+
{ path: join(repoDir, manifest.paths.decisions), content: renderJson(decisions) },
|
|
2816
|
+
{
|
|
2817
|
+
path: significancePath(repoDir),
|
|
2818
|
+
content: renderJson({
|
|
2819
|
+
schemaVersion: SIGNIFICANCE_SCHEMA_VERSION,
|
|
2820
|
+
contexts: staged.significance,
|
|
2821
|
+
}),
|
|
2822
|
+
},
|
|
2823
|
+
...merged.map((glossary) => ({
|
|
2824
|
+
path: glossaryPath(repoDir, glossary.boundedContext),
|
|
2825
|
+
content: renderGlossary(glossary),
|
|
2826
|
+
})),
|
|
2827
|
+
...skillWrites,
|
|
2828
|
+
];
|
|
2829
|
+
const desiredGlossaries = new Set(merged.map((glossary) => glossaryPath(repoDir, glossary.boundedContext)));
|
|
2830
|
+
const deletions: string[] = [];
|
|
2831
|
+
const glossaryRoot = join(discoverRoot(repoDir), "glossary");
|
|
2832
|
+
if (existsSync(glossaryRoot)) {
|
|
2833
|
+
for (const entry of readdirSync(glossaryRoot)) {
|
|
2834
|
+
const absolute = join(glossaryRoot, entry);
|
|
2835
|
+
if (!desiredGlossaries.has(absolute) && statSync(absolute).isFile()) deletions.push(absolute);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
writeTransaction(writes, deletions);
|
|
2839
|
+
|
|
2840
|
+
return { manifest, sheet, glossaries: merged, decisions, warnings };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// ---------------------------------------------------------- published view
|
|
2844
|
+
|
|
2845
|
+
export interface SkillView {
|
|
2846
|
+
name: DiscoverSkillName;
|
|
2847
|
+
description: string;
|
|
2848
|
+
state: "generated" | "edited" | "missing";
|
|
2849
|
+
baseline: string | null;
|
|
2850
|
+
live: string | null;
|
|
2851
|
+
baselineHash: string | null;
|
|
2852
|
+
currentHash: string | null;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
export interface DiscoverArtifacts {
|
|
2856
|
+
manifest: Manifest;
|
|
2857
|
+
sheet: Sheet;
|
|
2858
|
+
glossaries: Glossary[];
|
|
2859
|
+
usage: GlossaryUsage;
|
|
2860
|
+
significance: GlossarySignificance;
|
|
2861
|
+
decisions: Decision[];
|
|
2862
|
+
skills: SkillView[];
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
export interface ArtifactsRead {
|
|
2866
|
+
artifacts: DiscoverArtifacts | null;
|
|
2867
|
+
error: string | null;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
function readSkillView(repoDir: string, manifest: Manifest, name: DiscoverSkillName): SkillView {
|
|
2871
|
+
const entry = manifest.skills.find((skill) => skill.name === name);
|
|
2872
|
+
const baselineFile = baselinePath(repoDir, name);
|
|
2873
|
+
const liveFile = liveSkillPath(repoDir, name);
|
|
2874
|
+
const baseline = existsSync(baselineFile) ? readFileSync(baselineFile, "utf8") : null;
|
|
2875
|
+
const live = existsSync(liveFile) ? readFileSync(liveFile, "utf8") : null;
|
|
2876
|
+
const baselineHash = baseline === null ? null : hashText(baseline);
|
|
2877
|
+
const currentHash = live === null ? null : hashText(live);
|
|
2878
|
+
let description = "";
|
|
2879
|
+
try {
|
|
2880
|
+
description = parseSkill(live ?? baseline ?? "", name).fields.get("description") ?? "";
|
|
2881
|
+
} catch {
|
|
2882
|
+
description = "";
|
|
2883
|
+
}
|
|
2884
|
+
let state: SkillView["state"];
|
|
2885
|
+
if (live === null) state = "missing";
|
|
2886
|
+
else if (baselineHash !== null && currentHash === baselineHash) state = "generated";
|
|
2887
|
+
else if (entry !== undefined && entry.baselineHash === currentHash) state = "generated";
|
|
2888
|
+
else state = "edited";
|
|
2889
|
+
return { name, description, state, baseline, live, baselineHash, currentHash };
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
|
2893
|
+
|
|
2894
|
+
/**
|
|
2895
|
+
* The browsable repository an installed package declares in its manifest, or
|
|
2896
|
+
* null when the package is absent, its name is not a package name, or it names
|
|
2897
|
+
* no web repository. Installed manifests are the only source: a URL is never
|
|
2898
|
+
* guessed from a package name.
|
|
2899
|
+
*/
|
|
2900
|
+
export function packageRepository(repoDir: string, name: string): string | null {
|
|
2901
|
+
if (!PACKAGE_NAME.test(name) || name.includes("..")) return null;
|
|
2902
|
+
const manifestPath = join(repoDir, "node_modules", ...name.split("/"), "package.json");
|
|
2903
|
+
let manifest: unknown;
|
|
2904
|
+
try {
|
|
2905
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
2906
|
+
} catch {
|
|
2907
|
+
return null;
|
|
2908
|
+
}
|
|
2909
|
+
if (!isRecord(manifest)) return null;
|
|
2910
|
+
const repository = manifest.repository;
|
|
2911
|
+
let raw: string | undefined;
|
|
2912
|
+
let directory: string | undefined;
|
|
2913
|
+
if (typeof repository === "string") {
|
|
2914
|
+
raw = repository;
|
|
2915
|
+
} else if (isRecord(repository)) {
|
|
2916
|
+
if (typeof repository.url === "string") raw = repository.url;
|
|
2917
|
+
if (typeof repository.directory === "string") directory = repository.directory;
|
|
2918
|
+
}
|
|
2919
|
+
if (raw === undefined) return null;
|
|
2920
|
+
return normalizeRepositoryUrl(raw, directory);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
function dependencyCandidates(node: unknown, names: Set<string>): void {
|
|
2924
|
+
if (typeof node === "string") {
|
|
2925
|
+
const name = dependencyName(node);
|
|
2926
|
+
if (name.length > 0) names.add(name);
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
if (Array.isArray(node)) {
|
|
2930
|
+
for (const entry of node) dependencyCandidates(entry, names);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (!isRecord(node)) return;
|
|
2934
|
+
if ("confidence" in node && "evidence" in node) {
|
|
2935
|
+
dependencyCandidates(node.value, names);
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
for (const value of Object.values(node)) dependencyCandidates(value, names);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
/**
|
|
2942
|
+
* Repository links for every installed package the sheet's stack names, keyed
|
|
2943
|
+
* by the name each entry starts with. The stack is the one place the page
|
|
2944
|
+
* renders library chips; no other section is linked by name.
|
|
2945
|
+
*/
|
|
2946
|
+
export function dependencyRepositories(
|
|
2947
|
+
repoDir: string,
|
|
2948
|
+
sheet: Sheet | Record<string, unknown> | null,
|
|
2949
|
+
): Record<string, string> {
|
|
2950
|
+
if (sheet === null) return {};
|
|
2951
|
+
const names = new Set<string>();
|
|
2952
|
+
dependencyCandidates(sheet.stack, names);
|
|
2953
|
+
const links: Record<string, string> = {};
|
|
2954
|
+
for (const name of [...names].sort()) {
|
|
2955
|
+
const url =
|
|
2956
|
+
packageRepository(repoDir, name) ?? packageRepository(repoDir, name.toLowerCase());
|
|
2957
|
+
if (url !== null) links[name] = url;
|
|
2958
|
+
}
|
|
2959
|
+
return links;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
/**
|
|
2963
|
+
* Reads the published artifacts from disk; never writes or repairs them.
|
|
2964
|
+
* A retained remote snapshot carries the crawl's grounding evidence but not
|
|
2965
|
+
* the source tree, so its stored grounding is trusted rather than re-proved.
|
|
2966
|
+
*/
|
|
2967
|
+
export function readArtifacts(
|
|
2968
|
+
repoDir: string,
|
|
2969
|
+
options: { trustGrounding?: boolean } = {},
|
|
2970
|
+
): ArtifactsRead {
|
|
2971
|
+
try {
|
|
2972
|
+
const published = readPublished(repoDir, options.trustGrounding === true);
|
|
2973
|
+
if (published === undefined) return { artifacts: null, error: null };
|
|
2974
|
+
const skills = DISCOVER_SKILLS.map((name) =>
|
|
2975
|
+
readSkillView(repoDir, published.manifest, name),
|
|
2976
|
+
);
|
|
2977
|
+
return {
|
|
2978
|
+
artifacts: {
|
|
2979
|
+
manifest: published.manifest,
|
|
2980
|
+
sheet: published.sheet,
|
|
2981
|
+
glossaries: published.glossaries,
|
|
2982
|
+
usage: glossaryUsage(
|
|
2983
|
+
repoDir,
|
|
2984
|
+
published.glossaries,
|
|
2985
|
+
published.sheet.freshness.fingerprint,
|
|
2986
|
+
),
|
|
2987
|
+
significance: published.significance,
|
|
2988
|
+
decisions: published.decisions,
|
|
2989
|
+
skills,
|
|
2990
|
+
},
|
|
2991
|
+
error: null,
|
|
2992
|
+
};
|
|
2993
|
+
} catch (error) {
|
|
2994
|
+
return {
|
|
2995
|
+
artifacts: null,
|
|
2996
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/** Non-blocking contradictions among the live skills a developer edited. */
|
|
3002
|
+
export function liveWarnings(repoDir: string, artifacts: DiscoverArtifacts): string[] {
|
|
3003
|
+
const edited = artifacts.skills.filter((skill) => skill.state === "edited" && skill.live !== null);
|
|
3004
|
+
if (edited.length === 0) return [];
|
|
3005
|
+
return skillContradictions(
|
|
3006
|
+
edited.map((skill) => ({ name: skill.name, body: skill.live ?? "" })),
|
|
3007
|
+
).map(
|
|
3008
|
+
(contradiction) =>
|
|
3009
|
+
`${contradiction.skills[0]} and ${contradiction.skills[1]} disagree about ${contradiction.subject}`,
|
|
3010
|
+
);
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
export function artifactsStale(repoDir: string, artifacts: DiscoverArtifacts | null): boolean {
|
|
3014
|
+
if (artifacts === null) return false;
|
|
3015
|
+
try {
|
|
3016
|
+
return hostFacts(repoDir).fingerprint !== artifacts.manifest.fingerprint;
|
|
3017
|
+
} catch {
|
|
3018
|
+
return false;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// ------------------------------------------------------------ skill edits
|
|
3023
|
+
|
|
3024
|
+
export interface SkillMutation {
|
|
3025
|
+
artifacts: DiscoverArtifacts;
|
|
3026
|
+
error: string | null;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
export function saveLiveSkill(repoDir: string, name: string, text: string): string | null {
|
|
3030
|
+
if (!isDiscoverSkill(name)) {
|
|
3031
|
+
return `${name} is not one of the five Discover skills`;
|
|
3032
|
+
}
|
|
3033
|
+
const manifest = readManifest(repoDir);
|
|
3034
|
+
if (manifest === undefined) return "no Discover artifacts are published";
|
|
3035
|
+
const livePath = liveSkillPath(repoDir, name);
|
|
3036
|
+
if (!existsSync(livePath)) return `${name} is missing`;
|
|
3037
|
+
let staged: StagedSkill;
|
|
3038
|
+
try {
|
|
3039
|
+
staged = validateSkillText(name, text, `${name}/SKILL.md`);
|
|
3040
|
+
} catch (error) {
|
|
3041
|
+
return error instanceof Error ? error.message : String(error);
|
|
3042
|
+
}
|
|
3043
|
+
const currentHash = hashText(text);
|
|
3044
|
+
const baselineHash = manifest.skills.find((skill) => skill.name === name)?.baselineHash ?? currentHash;
|
|
3045
|
+
const state: ManifestSkill["state"] = currentHash === baselineHash ? "generated" : "edited";
|
|
3046
|
+
const next: Manifest = {
|
|
3047
|
+
...manifest,
|
|
3048
|
+
skills: manifest.skills.map((skill) =>
|
|
3049
|
+
skill.name === name ? { ...skill, currentHash, state } : skill,
|
|
3050
|
+
),
|
|
3051
|
+
};
|
|
3052
|
+
writeTransaction(
|
|
3053
|
+
[
|
|
3054
|
+
{ path: livePath, content: staged.text },
|
|
3055
|
+
{ path: join(repoDir, manifest.paths.manifest), content: renderJson(next) },
|
|
3056
|
+
],
|
|
3057
|
+
[],
|
|
3058
|
+
);
|
|
3059
|
+
return null;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
export function restoreLiveSkill(repoDir: string, name: string): string | null {
|
|
3063
|
+
if (!isDiscoverSkill(name)) {
|
|
3064
|
+
return `${name} is not one of the five Discover skills`;
|
|
3065
|
+
}
|
|
3066
|
+
const manifest = readManifest(repoDir);
|
|
3067
|
+
if (manifest === undefined) return "no Discover artifacts are published";
|
|
3068
|
+
const baselineFile = baselinePath(repoDir, name);
|
|
3069
|
+
if (!existsSync(baselineFile)) return `no generated baseline exists for ${name}`;
|
|
3070
|
+
const liveFile = liveSkillPath(repoDir, name);
|
|
3071
|
+
const baselineHash = hashText(readFileSync(baselineFile, "utf8"));
|
|
3072
|
+
const next: Manifest = {
|
|
3073
|
+
...manifest,
|
|
3074
|
+
skills: manifest.skills.map((skill) =>
|
|
3075
|
+
skill.name === name ? { ...skill, currentHash: baselineHash, state: "generated" } : skill,
|
|
3076
|
+
),
|
|
3077
|
+
};
|
|
3078
|
+
writeTransaction(
|
|
3079
|
+
[
|
|
3080
|
+
{ path: liveFile, content: readFileSync(baselineFile, "utf8") },
|
|
3081
|
+
{ path: join(repoDir, manifest.paths.manifest), content: renderJson(next) },
|
|
3082
|
+
],
|
|
3083
|
+
[],
|
|
3084
|
+
);
|
|
3085
|
+
return null;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// --------------------------------------------------------- glossary edits
|
|
3089
|
+
|
|
3090
|
+
function today(): string {
|
|
3091
|
+
return new Date().toISOString().slice(0, 10);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
function readGlossaryFile(
|
|
3095
|
+
repoDir: string,
|
|
3096
|
+
context: string,
|
|
3097
|
+
trustGrounding = false,
|
|
3098
|
+
): Glossary {
|
|
3099
|
+
if (!CONTEXT_PATTERN.test(context)) invalid("context", "must be a bounded context name");
|
|
3100
|
+
const file = glossaryPath(repoDir, context);
|
|
3101
|
+
if (!existsSync(file)) invalid("context", `has no glossary at ${context}.glossary.yaml`);
|
|
3102
|
+
const parsed = readFileSync(file, "utf8");
|
|
3103
|
+
return parseGlossary(parsed, `${context}.glossary.yaml`, {
|
|
3104
|
+
repoDir,
|
|
3105
|
+
files: hostFiles(repoDir),
|
|
3106
|
+
}, { staged: false, trustGrounding });
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function updateTerm(
|
|
3110
|
+
repoDir: string,
|
|
3111
|
+
context: string,
|
|
3112
|
+
id: string,
|
|
3113
|
+
update: (concept: Concept) => string | null,
|
|
3114
|
+
decision: Omit<Decision, "context" | "id" | "date">,
|
|
3115
|
+
trustGrounding = false,
|
|
3116
|
+
): string | null {
|
|
3117
|
+
const manifest = readManifest(repoDir);
|
|
3118
|
+
if (manifest === undefined) return "no Discover artifacts are published";
|
|
3119
|
+
let glossary: Glossary;
|
|
3120
|
+
try {
|
|
3121
|
+
glossary = readGlossaryFile(repoDir, context, trustGrounding);
|
|
3122
|
+
} catch (error) {
|
|
3123
|
+
return error instanceof Error ? error.message : String(error);
|
|
3124
|
+
}
|
|
3125
|
+
const concept = glossary.concepts.find((entry) => entry.id === id);
|
|
3126
|
+
if (concept === undefined) return `${id} is not a term of ${context}`;
|
|
3127
|
+
const refusal = update(concept);
|
|
3128
|
+
if (refusal !== null) return refusal;
|
|
3129
|
+
const decisions = readDecisions(repoDir);
|
|
3130
|
+
const nextDecision: Decision = { context, id, ...decision, date: today() };
|
|
3131
|
+
const nextManifest: Manifest = {
|
|
3132
|
+
...manifest,
|
|
3133
|
+
glossaryDigests: {
|
|
3134
|
+
...manifest.glossaryDigests,
|
|
3135
|
+
[context]: glossaryDigest(glossary),
|
|
3136
|
+
},
|
|
3137
|
+
};
|
|
3138
|
+
writeTransaction(
|
|
3139
|
+
[
|
|
3140
|
+
{ path: glossaryPath(repoDir, context), content: renderGlossary(glossary) },
|
|
3141
|
+
{ path: join(repoDir, manifest.paths.decisions), content: renderJson([...decisions, nextDecision]) },
|
|
3142
|
+
{ path: join(repoDir, manifest.paths.manifest), content: renderJson(nextManifest) },
|
|
3143
|
+
],
|
|
3144
|
+
[],
|
|
3145
|
+
);
|
|
3146
|
+
return null;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
export function confirmGlossaryTerm(
|
|
3150
|
+
repoDir: string,
|
|
3151
|
+
context: string,
|
|
3152
|
+
id: string,
|
|
3153
|
+
trustGrounding = false,
|
|
3154
|
+
): string | null {
|
|
3155
|
+
return updateTerm(
|
|
3156
|
+
repoDir,
|
|
3157
|
+
context,
|
|
3158
|
+
id,
|
|
3159
|
+
(concept) => {
|
|
3160
|
+
if (concept.status !== "proposed" && concept.status !== "unconfirmed") {
|
|
3161
|
+
return `${id} is ${concept.status}, not confirmable`;
|
|
3162
|
+
}
|
|
3163
|
+
concept.status = "confirmed";
|
|
3164
|
+
return null;
|
|
3165
|
+
},
|
|
3166
|
+
{ action: "confirm", reason: null },
|
|
3167
|
+
trustGrounding,
|
|
3168
|
+
);
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
export function rejectGlossaryTerm(
|
|
3172
|
+
repoDir: string,
|
|
3173
|
+
context: string,
|
|
3174
|
+
id: string,
|
|
3175
|
+
reason: string,
|
|
3176
|
+
trustGrounding = false,
|
|
3177
|
+
): string | null {
|
|
3178
|
+
const trimmed = typeof reason === "string" ? reason.trim() : "";
|
|
3179
|
+
if (trimmed.length === 0) return "a rejection requires a non-empty reason";
|
|
3180
|
+
return updateTerm(
|
|
3181
|
+
repoDir,
|
|
3182
|
+
context,
|
|
3183
|
+
id,
|
|
3184
|
+
(concept) => {
|
|
3185
|
+
if (
|
|
3186
|
+
concept.status !== "proposed" &&
|
|
3187
|
+
concept.status !== "unconfirmed" &&
|
|
3188
|
+
concept.status !== "confirmed"
|
|
3189
|
+
) {
|
|
3190
|
+
return `${id} is ${concept.status}, so it cannot be rejected`;
|
|
3191
|
+
}
|
|
3192
|
+
concept.status = "invalidated";
|
|
3193
|
+
return null;
|
|
3194
|
+
},
|
|
3195
|
+
{ action: "reject", reason: trimmed },
|
|
3196
|
+
trustGrounding,
|
|
3197
|
+
);
|
|
3198
|
+
}
|