mulmoterminal 6.8.0 → 7.0.0
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/README.md +12 -0
- package/blueprints/ask/checks/replies.mjs +15 -2
- package/blueprints/ask/skills/answer/SKILL.md +7 -3
- package/blueprints/ask/skills/keep/SKILL.md +2 -1
- package/blueprints/docs/checks/chaff.sh +1 -1
- package/blueprints/docs/checks/places.d.mts +7 -0
- package/blueprints/docs/checks/places.mjs +102 -0
- package/blueprints/docs/skills/workspace/SKILL.md +1 -1
- package/blueprints/firebase/checks/spec.sh +4 -0
- package/blueprints/from-collection/checks/actions.sh +48 -0
- package/blueprints/from-collection/checks/compare.mjs +42 -0
- package/blueprints/from-collection/checks/decisions.mjs +69 -0
- package/blueprints/from-collection/checks/firestore-verify.mjs +115 -0
- package/blueprints/from-collection/checks/import-firebase.sh +36 -0
- package/blueprints/from-collection/checks/import-verify.mjs +45 -0
- package/blueprints/from-collection/checks/import.sh +20 -0
- package/blueprints/from-collection/checks/spec.sh +18 -1
- package/blueprints/from-collection/checks/test-titles.mjs +101 -0
- package/blueprints/from-collection/hearing.json +9 -2
- package/blueprints/from-collection/manifest.json +2 -2
- package/blueprints/from-collection/skills/acceptance-firebase/SKILL.md +27 -0
- package/blueprints/from-collection/skills/actions/SKILL.md +47 -0
- package/blueprints/from-collection/skills/actions-firebase/SKILL.md +46 -0
- package/blueprints/from-collection/skills/features-firebase/SKILL.md +28 -0
- package/blueprints/from-collection/skills/import/SKILL.md +39 -0
- package/blueprints/from-collection/skills/import-firebase/SKILL.md +38 -0
- package/blueprints/from-collection/skills/import-production/SKILL.md +29 -0
- package/blueprints/from-collection/spec/conversion.md +52 -4
- package/blueprints/from-collection/spec/requirements.md +31 -2
- package/blueprints/from-collection/steps.json +66 -1
- package/blueprints/local/checks/security.sh +5 -0
- package/blueprints/local/skills/security/SKILL.md +1 -1
- package/blueprints/review/checks/findings.mjs +2 -1
- package/blueprints/review/checks/findingsView.d.mts +4 -1
- package/blueprints/review/checks/findingsView.mjs +7 -4
- package/blueprints/review/skills/report/SKILL.md +2 -1
- package/blueprints/style/skills/report/SKILL.md +1 -1
- package/blueprints/verify/checks/extract.mjs +2 -1
- package/blueprints/verify/checks/factsView.d.mts +1 -1
- package/blueprints/verify/checks/factsView.mjs +9 -6
- package/blueprints/verify/skills/report/SKILL.md +2 -1
- package/common/blueprint/collectionSource.ts +44 -5
- package/common/blueprint/hearing.ts +22 -5
- package/common/blueprint/refusal.ts +4 -0
- package/common/blueprint/sharedAppSource.ts +30 -0
- package/common/blueprint/state.ts +4 -0
- package/common/blueprint/stepPrompt.ts +69 -1
- package/common/fileGitStatus.ts +21 -0
- package/common/paletteConfig.ts +31 -0
- package/common/promptHistory.ts +9 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-Dy6jPGlc.js → abnfDiagram-O67JEVCF-D8GL0pEj.js} +1 -1
- package/dist/assets/architecture-7GRP2DOG-TdhGGkKM.js +1 -0
- package/dist/assets/{architectureDiagram-NJMV4G6O-Y1wqce2c.js → architectureDiagram-NJMV4G6O-kyzehsRR.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-B6haVvbR.js → blockDiagram-BEXU5L5S-9hqLban3.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-CbqMeQ5M.js → c4Diagram-YGBWAQC7-CUJxINHi.js} +1 -1
- package/dist/assets/channel-C2Xy1RUG.js +1 -0
- package/dist/assets/{chunk-3FUC2YCW-Dql-NfIM.js → chunk-3FUC2YCW-Dx91F6cc.js} +1 -1
- package/dist/assets/{chunk-5DYCD2WN-DPBLnLhJ.js → chunk-5DYCD2WN-e-5CFr2i.js} +1 -1
- package/dist/assets/{chunk-742MDFTN-Cfcav64I.js → chunk-742MDFTN-DLMZmirF.js} +1 -1
- package/dist/assets/{chunk-7INBJB4K-gd2sm2Qq.js → chunk-7INBJB4K-CwRDZro8.js} +1 -1
- package/dist/assets/{chunk-7M6MHVWA-Cga-bP1d.js → chunk-7M6MHVWA-Gm5DDkAY.js} +1 -1
- package/dist/assets/{chunk-7PRAP22T-B5CSlQEt.js → chunk-7PRAP22T-CoSNjn-E.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BzDxh0CI.js → chunk-GTNCS2PH-CLXhqupL.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-CTVycvRw.js → chunk-GWA4HPMP-DX5sIYil.js} +1 -1
- package/dist/assets/{chunk-MBY4JIJT-Dj2QOy3d.js → chunk-MBY4JIJT-Dzr4kplc.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-CVlwwnxD.js → chunk-NETBCI7D-DWhxcXYA.js} +1 -1
- package/dist/assets/{chunk-O7XYJQB3-CBuERAl7.js → chunk-O7XYJQB3-DaFEDFVm.js} +1 -1
- package/dist/assets/{chunk-UA2S7LBM-Dq3DhUK-.js → chunk-UA2S7LBM-bYQCj6FB.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-D8fWG-1Z.js → chunk-WEXAMYUT-DlYEsAVg.js} +1 -1
- package/dist/assets/{chunk-Z7XXMR3K-zrP0_WoK.js → chunk-Z7XXMR3K-Bn6kE_dK.js} +1 -1
- package/dist/assets/{chunk-ZIGJFQKS-Dzlt4QPL.js → chunk-ZIGJFQKS-CIZD8LBc.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-CjA-LmiN.js → classDiagram-v2-NBCMYWYE-CHcNkGSD.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CVRZEwmv.js → cynefin-OW5HDTMX-BgabrmIk.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-C9rD0DOQ.js → cynefinDiagram-VND7K2PF-DwdVdS9K.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BuE8Gjyb.js → dagre-6A5THRUB-DaqNJgIs.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Cw3M7VEV.js → diagram-22UHCM2B-BRDs0wCw.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-Pu3L65WF.js → diagram-3UASUU5V-BeXv9OSL.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-OI0bhF_A.js → diagram-ATOU4E4O-D2JkNL-b.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CH39M5AD.js → diagram-CDSNMT55-DeYKsB8E.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-B2nfDfEm.js → diagram-MLGK6HIB-BHFLtv-n.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CFiLA_-F.js → diagram-MPIPVDR6-Brtp-_IQ.js} +1 -1
- package/dist/assets/{dist-6UpZENeR.js → dist-B0sdAm9J.js} +1 -1
- package/dist/assets/dist-B2YXJLwR.js +11 -0
- package/dist/assets/{dist-unjTvGLV.js → dist-BSQTHmad.js} +1 -1
- package/dist/assets/dist-BTX-l2yD.js +1 -0
- package/dist/assets/dist-ByH4MDuI.js +9 -0
- package/dist/assets/dist-C1cqjsKD.js +1 -0
- package/dist/assets/{dist-BMBUd4uQ.js → dist-C1s7S4KF.js} +1 -1
- package/dist/assets/dist-CWSTMA5n.js +1 -0
- package/dist/assets/dist-CeWYOYWG.js +1 -0
- package/dist/assets/{dist-lS8XUoi1.js → dist-SAdtDOGy.js} +2 -2
- package/dist/assets/{ebnfDiagram-ZINNZB2B-ChqhmMzu.js → ebnfDiagram-ZINNZB2B-CE9roUQp.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-De2Iml3H.js → elk-276RUBZZ-DQHLfTBN.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-CUt6-AR7.js → erDiagram-OPXOYQCR-Be7__jhY.js} +1 -1
- package/dist/assets/eventmodeling-NTZA5JFV-BXnmc8kB.js +1 -0
- package/dist/assets/flowDiagram-KWPJA3E3-C2T1HY21.js +1 -0
- package/dist/assets/{ganttDiagram-FUAMR5RP-BETJ1ApO.js → ganttDiagram-FUAMR5RP-IKV0i-z0.js} +1 -1
- package/dist/assets/{gitGraph-4MIJSDKK-DGOqms6x.js → gitGraph-4MIJSDKK-EofSfZtF.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Btn58SWQ.js → gitGraphDiagram-X574FWY7-DaljLxKK.js} +1 -1
- package/dist/assets/{index-Dw_awLi8.js → index-0Wsb2_IF.js} +359 -350
- package/dist/assets/index-BZZNuDnO.css +1 -0
- package/dist/assets/{info-A6RAGUB7-BIn6JHsL.js → info-A6RAGUB7-B6ck_7jX.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-CLLPPwgt.js → infoDiagram-VRGFBTTK-C8jMc5nJ.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-C0dnEC2O.js → ishikawaDiagram-OU5B5YK6-BOM9dJ1W.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DiOFy0_3.js → journeyDiagram-ZHPQQLJL-DIYby1k-.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DL0cY3t2.js → kanban-definition-PNTS6WVX-CAmlLeaS.js} +1 -1
- package/dist/assets/{line-CInvdCWV.js → line-DLTV2ptB.js} +1 -1
- package/dist/assets/{mermaid-parser.core-BGDbqboQ.js → mermaid-parser.core-CW3k1W-u.js} +3 -3
- package/dist/assets/{mermaid.core-CZVPcJ9v.js → mermaid.core-B_zPtmsr.js} +4 -4
- package/dist/assets/{mindmap-definition-NLK3R4M7-U6yboX99.js → mindmap-definition-NLK3R4M7-Ji-mQMX0.js} +1 -1
- package/dist/assets/{packet-AYTQ26CC-DIDr2_-n.js → packet-AYTQ26CC-MR7J7hYw.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BbpYcKtu.js → pegDiagram-GJSIUBJH-D_wwEoYb.js} +1 -1
- package/dist/assets/{pie-WAS4IAKB-BsD46nmb.js → pie-WAS4IAKB-DNuGDXby.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-BgdP7pnf.js → pieDiagram-5QR66LMP-C1YCt7xK.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-mx6H7yGz.js → quadrantDiagram-O4NWA36T-DN-d9LoE.js} +1 -1
- package/dist/assets/{radar-RG4KPBEZ-BxzJE1SZ.js → radar-RG4KPBEZ-dW6gQx6l.js} +1 -1
- package/dist/assets/{railroad-74A4TZTK-d2ye0a_H.js → railroad-74A4TZTK-DnjwnHPx.js} +1 -1
- package/dist/assets/railroad-abnf-HS5TGJTU-COO_8DKu.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-H6w--gix.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-91dBFIdz.js +1 -0
- package/dist/assets/{railroadDiagram-XR7U4H2S-CdHoygiZ.js → railroadDiagram-XR7U4H2S-DdfYiy6T.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DgUPBmN3.js → requirementDiagram-PLB6GJNP-BzIEk2VN.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CCqhFBKm.js → sankeyDiagram-IPEJSGJF-D_zpgGm7.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-DsOw9dn3.js → sequenceDiagram-PO4LG4MO-WLxiEHkc.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-KgYznRou.js → stateDiagram-v2-GCMORJYK-DYPJzs6R.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-cJ978qAp.js → swimlanes-2SLR337P-C6VX1K4R.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CGKdav0R.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-DVrpY7-_.js → timeline-definition-EJHVYXUP-DqVxaMbg.js} +1 -1
- package/dist/assets/{treeView-Q6P3EWNA-C81INZMi.js → treeView-Q6P3EWNA-CswdkPra.js} +1 -1
- package/dist/assets/{treemap-WGGIJYW6-C6m5_3M_.js → treemap-WGGIJYW6-DlB2H8xd.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-MOU_fUIi.js → usecaseDiagram-POWQR4AR-BkdJels6.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-DQ03sLpX.js → vennDiagram-UO4OBE2U-Cj8A--rK.js} +1 -1
- package/dist/assets/{wardley-WFR3VGLG-D6oOZcfd.js → wardley-WFR3VGLG-B0WzVCNm.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DTu4wPK_.js → wardleyDiagram-VNRHLVJA-BtKepEjT.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-BnFJQnAh.js → xychartDiagram-PMCCYNJV-BIVIXtl9.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +5 -4
- package/server/blueprint/answersFile.ts +12 -0
- package/server/blueprint/collectionSnapshot.ts +151 -31
- package/server/blueprint/executor.ts +2 -1
- package/server/blueprint/routes.ts +49 -7
- package/server/blueprint/sharedAppsProvider.ts +53 -0
- package/server/blueprint/wiring.ts +11 -2
- package/server/blueprint/writeAllOrNone.ts +1 -1
- package/server/config/app-config.ts +12 -0
- package/server/config/config-body.ts +2 -1
- package/server/config/config-routes.ts +75 -32
- package/server/files/files-browse.ts +7 -0
- package/server/files/files-git-status.ts +65 -0
- package/server/git/statusEntries.ts +40 -0
- package/server/git/worktrees.ts +19 -3
- package/server/skills/mulmoterminal-keys/SKILL.md +41 -1
- package/dist/assets/architecture-7GRP2DOG-DyaLKyU5.js +0 -1
- package/dist/assets/channel-T986AWhs.js +0 -1
- package/dist/assets/dist-B2UfqsPh.js +0 -1
- package/dist/assets/dist-BSqJ_nUe.js +0 -9
- package/dist/assets/dist-CHPrzLIK.js +0 -1
- package/dist/assets/dist-D8e4q8MY.js +0 -1
- package/dist/assets/dist-Sj3hCOj7.js +0 -11
- package/dist/assets/dist-pmD9XVGa.js +0 -1
- package/dist/assets/eventmodeling-NTZA5JFV-DAxZbpPN.js +0 -1
- package/dist/assets/flowDiagram-KWPJA3E3-Bss7fLee.js +0 -1
- package/dist/assets/index-8B5Of03P.css +0 -1
- package/dist/assets/railroad-abnf-HS5TGJTU-BJ8b7QD7.js +0 -1
- package/dist/assets/railroad-ebnf-LZEXJU2U-YG_Upy_7.js +0 -1
- package/dist/assets/railroad-peg-WCYAUIDC-OgQyRk3s.js +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BOVOQpWl.js +0 -8
package/README.md
CHANGED
|
@@ -1296,6 +1296,18 @@ finder, the search or a clicked terminal path alike. Switching tabs saves the on
|
|
|
1296
1296
|
opening another file does, so only the front tab can hold unsaved edits; each tab keeps its own place
|
|
1297
1297
|
(Preview or editor, caret, scroll) and the whole strip is remembered with the rest of the pane. Close a
|
|
1298
1298
|
tab with its **×**, a middle click, or **Delete** while it has focus; ←/→, Home and End move between them.
|
|
1299
|
+
The tree follows the tab in front, as VS Code's explorer does: its folders open and its row comes into
|
|
1300
|
+
view (a reload still puts the tree back where you left it).
|
|
1301
|
+
|
|
1302
|
+
**The tree shows what git sees.** In a git repository a changed file is tinted and lettered as in VS
|
|
1303
|
+
Code's explorer — `M` modified, `A` added, `U` untracked, `R` renamed — and a folder holding changes
|
|
1304
|
+
carries a dot, so a collapsed tree still says where the agent has been. It is read when the tree
|
|
1305
|
+
loads or reloads, after a save or an outside change to the open file, and every thirty seconds.
|
|
1306
|
+
|
|
1307
|
+
**The editor marks what changed since the last commit.** A bar beside each line that is new (green)
|
|
1308
|
+
or changed (amber), and a notch where lines were removed, as VS Code's gutter shows them — kept up
|
|
1309
|
+
to date as you type. **Changes** in the header also shows the removed lines in place, as a unified
|
|
1310
|
+
diff. Nothing is marked for a file outside git or one not yet committed.
|
|
1299
1311
|
|
|
1300
1312
|
**HTML pages and images show in the pane too.** An `.html` file has a **Preview** like a Markdown
|
|
1301
1313
|
file: the page itself, sandboxed as presentHtml pages are — its scripts run on an opaque origin with
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Reads .blueprint/replies.json — an answer to each question the person asked — and answers one question
|
|
2
2
|
// per mode. Exit 0 is yes.
|
|
3
3
|
// answer every question has exactly one reply; a reply found in the documents quotes them (chaff cite)
|
|
4
|
-
// and names every
|
|
4
|
+
// and names every place it quotes (the address, or the heading of the section there); a reply not found says what was searched. Records the
|
|
5
5
|
// documents' and FAQ.md's fingerprints.
|
|
6
6
|
// keep the documents are unchanged; FAQ.md holds every question after what it held before when the
|
|
7
7
|
// person asked to keep the answers, and is untouched otherwise
|
|
@@ -9,6 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { fromBase } from "./base.mjs";
|
|
10
10
|
const { fail, quotationProblems, readJson } = await import(fromBase("chaff.mjs"));
|
|
11
11
|
const { digest, documentSource, documentsNamed, fingerprint } = await import(fromBase("documents.mjs"));
|
|
12
|
+
const { headingsOf, namesPlace } = await import(fromBase("places.mjs"));
|
|
12
13
|
|
|
13
14
|
const REPLIES = ".blueprint/replies.json";
|
|
14
15
|
const REPLIES_PAGE = ".blueprint/replies.md";
|
|
@@ -29,6 +30,15 @@ if (answers?.keep === KEEP && documents.includes(FAQ)) fail(`${FAQ} is one of th
|
|
|
29
30
|
const indented = (line) => " " + line;
|
|
30
31
|
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
31
32
|
|
|
33
|
+
// Read once per document, and only for one of the documents: a source that is not is refused by the quotation check.
|
|
34
|
+
const headingCache = new Map();
|
|
35
|
+
const headingAt = (source, address) => {
|
|
36
|
+
const resolved = documentSource(documents)(source);
|
|
37
|
+
if (!resolved.path) return undefined;
|
|
38
|
+
if (!headingCache.has(resolved.path)) headingCache.set(resolved.path, headingsOf(resolved.path));
|
|
39
|
+
return headingCache.get(resolved.path).get(address.trim());
|
|
40
|
+
};
|
|
41
|
+
|
|
32
42
|
const replyProblem = (reply) => {
|
|
33
43
|
if (typeof reply !== "object" || reply === null) return "a reply is not an object";
|
|
34
44
|
if (!nonEmpty(reply.question)) return "a reply names no question";
|
|
@@ -39,7 +49,10 @@ const replyProblem = (reply) => {
|
|
|
39
49
|
if (!Array.isArray(citations)) return `${which}: citations must be an array`;
|
|
40
50
|
if (reply.found && citations.length === 0) return `${which}: an answer found in the documents quotes them`;
|
|
41
51
|
if (!citations.every((citation) => nonEmpty(citation?.address))) return `${which}: every quotation needs an "address"`;
|
|
42
|
-
const unnamed = citations.filter(
|
|
52
|
+
const unnamed = citations.filter(
|
|
53
|
+
(citation) =>
|
|
54
|
+
!reply.answer.includes(citation.address.trim()) && !namesPlace(reply.answer, citation.address, headingAt(String(citation.source), citation.address)),
|
|
55
|
+
);
|
|
43
56
|
if (unnamed.length > 0) return `${which}: the answer does not name ${unnamed.map((citation) => citation.address).join(", ")}, which it quotes`;
|
|
44
57
|
if (!reply.found && (!Array.isArray(reply.searched) || !reply.searched.some(nonEmpty))) return `${which}: say what was searched ("searched")`;
|
|
45
58
|
return null;
|
|
@@ -48,11 +48,15 @@ the answers before anything is kept.
|
|
|
48
48
|
- `question`: the question exactly as it is in `questions`. One reply per question, none extra.
|
|
49
49
|
- `found`: `true` when the documents answer it. Then at least one citation.
|
|
50
50
|
- `citations`: `source` is the document's path as `documents` names it; `address` is where; `quote` is
|
|
51
|
-
copied character for character — the check runs `chaff cite` on every one. **
|
|
52
|
-
the `answer` text** so the person can open the document there
|
|
51
|
+
copied character for character — the check runs `chaff cite` on every one. **Name every cited place in
|
|
52
|
+
the `answer` text** so the person can open the document there: an article or item as the document numbers it
|
|
53
|
+
(第4条第2項, Section 3.1), and a section of a Markdown document by its heading, word for word, in 「」 (「作業用フォルダを信頼しておく」;
|
|
54
|
+
in an English answer, "Getting started") — the check looks for it in those marks.
|
|
55
|
+
chaff's index for a section (`h1.3`) goes in `address` only — it means nothing to the person.
|
|
53
56
|
- `searched`: when `found` is `false`, the words and places you looked for.
|
|
54
57
|
|
|
55
|
-
Also write `.blueprint/replies.md` for the person: each question as a heading, the answer, and the quotations
|
|
58
|
+
Also write `.blueprint/replies.md` for the person: each question as a heading, the answer, and the quotations,
|
|
59
|
+
each with its place named as in the answer (never `h1.3`).
|
|
56
60
|
|
|
57
61
|
## Done when
|
|
58
62
|
|
|
@@ -17,7 +17,8 @@ run the check and stop.
|
|
|
17
17
|
Otherwise add to `FAQ.md` in this folder (create it if there is none). **Add after what it already holds;
|
|
18
18
|
never edit or reorder what is there** — the check compares the beginning of the file with what it held
|
|
19
19
|
before. For each question: the question as a `##` heading, word for word; the answer; and where it is
|
|
20
|
-
written (document and
|
|
20
|
+
written (the document, and the place named as in the answer: 第4条第2項, or a section's heading in 「」 —
|
|
21
|
+
never chaff's `h1.3`). For a question the documents do not answer, say so — that is worth keeping
|
|
21
22
|
too.
|
|
22
23
|
|
|
23
24
|
## Done when
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Types for places.mjs, which the packs run with plain node and the specs import.
|
|
2
|
+
export function headingsIn(tree: unknown): Map<string, string>;
|
|
3
|
+
export function headingsOf(file: string): Map<string, string>;
|
|
4
|
+
export function namesPlace(text: string, address: string, heading: string | undefined): boolean;
|
|
5
|
+
export function placeNamesIn(tree: unknown): Map<string, string>;
|
|
6
|
+
export function placeNamesOf(file: string): Map<string, string>;
|
|
7
|
+
export function placeNamer(sourcePath: (source: string) => { path?: string; problem?: string }): (source: string, address: string) => string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Where a quotation sits, named for the person who reads it: a section of a Markdown document by its heading,
|
|
2
|
+
// since chaff's address for it (h1.3) tells nobody anything. Other addresses (第4条第2項, 3.1) already read as the
|
|
3
|
+
// document writes them.
|
|
4
|
+
import { runChaff } from "./chaff.mjs";
|
|
5
|
+
|
|
6
|
+
const sectionsIn = (node) => [
|
|
7
|
+
...(node?.kind === "section" && typeof node.address === "string" && typeof node.attrs?.heading === "string" ? [[node.address, node.attrs.heading]] : []),
|
|
8
|
+
...(Array.isArray(node?.children) ? node.children.flatMap(sectionsIn) : []),
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const PARTS = new Set(["section", "article"]);
|
|
12
|
+
|
|
13
|
+
const text = (value) => (typeof value === "string" && value !== "" ? value : null);
|
|
14
|
+
|
|
15
|
+
// A Japanese article (第4条, 第二十一条の二): what is under it is named as Japanese law cites it.
|
|
16
|
+
const JA_ARTICLE = /^第[^条\s]+条/u;
|
|
17
|
+
const halfWidth = (digits) => digits.replace(/[0-9]/gu, (digit) => String.fromCharCode(digit.charCodeAt(0) - 0xfee0));
|
|
18
|
+
|
|
19
|
+
// Under a Japanese article, one level down is a paragraph (第2項, numbered from the address when chaff gives it no
|
|
20
|
+
// label) and anything deeper an item (第三号, by its label). chaff calls both "item", so the depth tells them apart.
|
|
21
|
+
function japaneseName(label, own, above, article) {
|
|
22
|
+
const levels = own.slice(article.address.length + 1).split(".");
|
|
23
|
+
if (levels.length === 1) return `${above}第${halfWidth(label ?? levels[0])}項`;
|
|
24
|
+
return `${above}第${label ?? levels.at(-1)}号`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A part (an article, a section) is named by its label, or by its quoted heading when it has none (a Markdown
|
|
28
|
+
// section); anything below it (an item, a paragraph) adds its own label to the name of what holds it.
|
|
29
|
+
function nameOf(node, own, above, article) {
|
|
30
|
+
const label = text(node?.attrs?.label);
|
|
31
|
+
if (PARTS.has(node?.kind)) {
|
|
32
|
+
const heading = text(node?.attrs?.heading);
|
|
33
|
+
return label ?? (heading === null ? own : `「${heading}」`);
|
|
34
|
+
}
|
|
35
|
+
if (above === null) return label ?? own;
|
|
36
|
+
return article?.japanese && own.startsWith(`${article.address}.`) ? japaneseName(label, own, above, article) : `${above} ${label ?? own}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const partOf = (node, own, name, article) => (PARTS.has(node?.kind) && own !== null ? { address: own, japanese: JA_ARTICLE.test(name ?? "") } : article);
|
|
40
|
+
|
|
41
|
+
const namesIn = (node, above, article) => {
|
|
42
|
+
const own = text(node?.address);
|
|
43
|
+
const name = own === null ? above : nameOf(node, own, above, article);
|
|
44
|
+
const here = own === null ? [] : [[own, name]];
|
|
45
|
+
const within = partOf(node, own, name, article);
|
|
46
|
+
return [...here, ...(Array.isArray(node?.children) ? node.children.flatMap((child) => namesIn(child, name, within)) : [])];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Every address in a chaff tree, with the name a person reads for it: 第4条第2項, Section 3.2 (a) (i), 「用意するもの」. */
|
|
50
|
+
export const placeNamesIn = (tree) => new Map(namesIn(tree, null, null));
|
|
51
|
+
|
|
52
|
+
/** The place names of `file`, by address; none when chaff cannot read its tree. */
|
|
53
|
+
export const placeNamesOf = (file) => {
|
|
54
|
+
const run = runChaff(["tree", file, "--format", "json"]);
|
|
55
|
+
if (run.code !== 0) return new Map();
|
|
56
|
+
try {
|
|
57
|
+
return placeNamesIn(JSON.parse(run.stdout));
|
|
58
|
+
} catch {
|
|
59
|
+
return new Map();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a citation sits, for a view a person reads: its place name from the document's tree, read once per document
|
|
65
|
+
* and only for one of `documents` (through `sourcePath`, which resolves or refuses a source); the address itself
|
|
66
|
+
* when there is no name.
|
|
67
|
+
*/
|
|
68
|
+
export const placeNamer = (sourcePath) => {
|
|
69
|
+
const cache = new Map();
|
|
70
|
+
return (source, address) => {
|
|
71
|
+
const resolved = sourcePath(source);
|
|
72
|
+
if (!resolved.path) return address;
|
|
73
|
+
if (!cache.has(resolved.path)) cache.set(resolved.path, placeNamesOf(resolved.path));
|
|
74
|
+
return cache.get(resolved.path).get(String(address).trim()) ?? address;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Every section address in a chaff tree (`tree --format json`), with its heading. */
|
|
79
|
+
export const headingsIn = (tree) => new Map(sectionsIn(tree));
|
|
80
|
+
|
|
81
|
+
/** The section headings of `file`, by address; none when chaff cannot read its tree. */
|
|
82
|
+
export const headingsOf = (file) => {
|
|
83
|
+
const run = runChaff(["tree", file, "--format", "json"]);
|
|
84
|
+
if (run.code !== 0) return new Map();
|
|
85
|
+
try {
|
|
86
|
+
return headingsIn(JSON.parse(run.stdout));
|
|
87
|
+
} catch {
|
|
88
|
+
return new Map();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// A heading counts only in quotation marks: a short one (期限) is also an ordinary word, and bare it would be found by accident.
|
|
93
|
+
const QUOTES = [
|
|
94
|
+
["「", "」"],
|
|
95
|
+
['"', '"'],
|
|
96
|
+
["“", "”"],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Whether `text` names a place: by its address as written, or by the quoted heading of the section at that address. */
|
|
100
|
+
export const namesPlace = (text, address, heading) =>
|
|
101
|
+
text.includes(address.trim()) ||
|
|
102
|
+
(typeof heading === "string" && heading !== "" && QUOTES.some(([open, close]) => text.includes(`${open}${heading}${close}`)));
|
|
@@ -12,7 +12,7 @@ repository, `.git/info/exclude`) until the person has approved the models.
|
|
|
12
12
|
1. `.blueprint/` already exists (the build created it). Everything this build records goes there.
|
|
13
13
|
2. Run chaff once through the base pack's wrapper, so its first download happens now and not in the
|
|
14
14
|
middle of a later step: `sh <base pack>/checks/chaff.sh rules --json > /dev/null`. The wrapper runs the
|
|
15
|
-
pinned chaff (`npx -y chaffjs@0.
|
|
15
|
+
pinned chaff (`npx -y chaffjs@0.13`) unless `CHAFF_BIN` names another. If it fails, show the person the
|
|
16
16
|
error and stop — do not work around it.
|
|
17
17
|
3. If the folder is a git repository, add `.blueprint/` to `.git/info/exclude`. That keeps the build's
|
|
18
18
|
working files out of commits without touching `.gitignore`, which belongs to the person.
|
|
@@ -6,3 +6,7 @@ if grep -n '{{' .blueprint/spec.md >&2; then
|
|
|
6
6
|
echo "unfilled placeholders are left in .blueprint/spec.md" >&2
|
|
7
7
|
exit 1
|
|
8
8
|
fi
|
|
9
|
+
# A usecase may hold its spec to more than the template: when it ships its own spec check, that runs too.
|
|
10
|
+
if [ -n "${BLUEPRINT_USECASE:-}" ] && [ -f "$BLUEPRINT_USECASE/checks/spec.sh" ]; then
|
|
11
|
+
sh "$BLUEPRINT_USECASE/checks/spec.sh"
|
|
12
|
+
fi
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# What the spec decided for the source's actions and ingests is done: each one to be built (`feature`) has a test that
|
|
3
|
+
# names it and the tests pass; each one left to a person (`manual`) has its steps in the README. A source with no
|
|
4
|
+
# actions or ingests has nothing to do.
|
|
5
|
+
set -eu
|
|
6
|
+
base="$1"
|
|
7
|
+
file=.blueprint/actions.json
|
|
8
|
+
# The record itself first: a build that got here without the spec check is held to it all the same.
|
|
9
|
+
node --no-warnings "$(dirname "$0")/decisions.mjs"
|
|
10
|
+
[ -s "$file" ] || { echo "no $file: the source has no actions or ingests to handle"; exit 0; }
|
|
11
|
+
# A key for a model call lives in .env; the folder becomes a repository whenever its person adds git, and .env must not go with it.
|
|
12
|
+
if [ -f .env ] && ! grep -qxE '/?\.env(\*)?' .gitignore 2>/dev/null; then
|
|
13
|
+
echo ".env exists and .gitignore does not ignore it; add a line .env to .gitignore before anything secret goes in" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
case "$base" in
|
|
17
|
+
local) tests="test/actions.test.ts" ;;
|
|
18
|
+
firebase) tests="test/blueprint/actions.spec.ts" ;;
|
|
19
|
+
*) echo "usage: actions.sh local|firebase" >&2; exit 2 ;;
|
|
20
|
+
esac
|
|
21
|
+
# The titles of the tests the file really declares — parsed, so a name in a comment or a string is not a test. Only read
|
|
22
|
+
# when something is to be built.
|
|
23
|
+
wanted=$(node -e 'process.stdout.write(String((JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).actions ?? []).filter((entry) => entry.decision === "feature").length))' "$file")
|
|
24
|
+
titles="[]"
|
|
25
|
+
[ "$wanted" -eq 0 ] || titles=$(node --no-warnings "$(dirname "$0")/test-titles.mjs" "$tests")
|
|
26
|
+
features=$(node -e '
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const [file, tests, titlesJson] = process.argv.slice(1);
|
|
29
|
+
const entries = JSON.parse(fs.readFileSync(file, "utf8")).actions ?? [];
|
|
30
|
+
const titles = JSON.parse(titlesJson);
|
|
31
|
+
const readme = fs.existsSync("README.md") ? fs.readFileSync("README.md", "utf8") : "";
|
|
32
|
+
// A name counts only where it does its job: in the title of a test the file declares, or in a README heading.
|
|
33
|
+
const escaped = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
34
|
+
const inTestTitle = (name) => titles.some((title) => title.includes(name));
|
|
35
|
+
const inHeading = (name) => new RegExp(`^#{1,6}\\s.*${escaped(name)}`, "m").test(readme);
|
|
36
|
+
const problems = entries.flatMap(({ name, decision }) => {
|
|
37
|
+
if (decision === "feature" && !inTestTitle(name)) return [`${name} is to be built, and ${tests} has no test titled with it`];
|
|
38
|
+
if (decision === "manual" && !inHeading(name)) return [`${name} is left to a person, and README.md has no heading naming it`];
|
|
39
|
+
return [];
|
|
40
|
+
});
|
|
41
|
+
if (problems.length > 0) { console.error(problems.join("\n")); process.exit(1); }
|
|
42
|
+
process.stdout.write(String(entries.filter((entry) => entry.decision === "feature").length));
|
|
43
|
+
' "$file" "$tests" "$titles")
|
|
44
|
+
[ "$features" -gt 0 ] || exit 0
|
|
45
|
+
case "$base" in
|
|
46
|
+
local) sh "$BLUEPRINT_BASE/checks/tests-pass.sh" actions ;;
|
|
47
|
+
firebase) sh "$BLUEPRINT_BASE/checks/emulator-test.sh" actions ;;
|
|
48
|
+
esac
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// What the import checks share: the copied source, which fields are compared, and how a stored value is held against
|
|
2
|
+
// the source's. Each check reads its own store (SQLite, Firestore) and hands the values here.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
export const SOURCE = ".blueprint/source";
|
|
6
|
+
const MAX_REPORTED = 20;
|
|
7
|
+
// The field kinds whose value is kept as it is, under the field's key (spec/conversion.md).
|
|
8
|
+
const PLAIN = new Set(["string", "text", "email", "markdown", "number", "date", "datetime", "enum", "ref", "image", "file", "boolean"]);
|
|
9
|
+
|
|
10
|
+
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
11
|
+
|
|
12
|
+
export const sourceCollections = () => readJson(`${SOURCE}/source.json`).collections;
|
|
13
|
+
export const schemaOf = (slug) => readJson(`${SOURCE}/collections/${slug}/schema.json`);
|
|
14
|
+
export const recordsOf = (slug) =>
|
|
15
|
+
readFileSync(`${SOURCE}/collections/${slug}/records.jsonl`, "utf8")
|
|
16
|
+
.split("\n")
|
|
17
|
+
.filter((line) => line.trim() !== "")
|
|
18
|
+
.map((line) => JSON.parse(line));
|
|
19
|
+
|
|
20
|
+
export const plainFields = (schema) => Object.entries(schema.fields ?? {}).filter(([, spec]) => PLAIN.has(spec.type));
|
|
21
|
+
export const isFileField = (spec) => spec.type === "image" || spec.type === "file";
|
|
22
|
+
// A field the record left empty has nothing to carry over.
|
|
23
|
+
export const hasValue = (value) => value !== undefined && value !== null && value !== "";
|
|
24
|
+
export const labelOf = (schema, record) => `${schema.primaryKey}=${JSON.stringify(record[schema.primaryKey])}`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a stored value is the source's. `booleanAs` says how the store holds a boolean — 0/1 in SQLite, itself in
|
|
28
|
+
* Firestore; a number is compared as a number, anything else as its text.
|
|
29
|
+
*/
|
|
30
|
+
export function sameValue(type, sourceValue, stored, booleanAs) {
|
|
31
|
+
if (type === "number") return Number(sourceValue) === Number(stored);
|
|
32
|
+
const want = type === "boolean" ? booleanAs(sourceValue === true) : sourceValue;
|
|
33
|
+
return String(want) === String(stored);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Prints the problems, at most a screenful, and exits 1 when there are any. */
|
|
37
|
+
export function report(problems) {
|
|
38
|
+
if (problems.length === 0) return;
|
|
39
|
+
console.error(problems.slice(0, MAX_REPORTED).join("\n"));
|
|
40
|
+
if (problems.length > MAX_REPORTED) console.error(`… and ${problems.length - MAX_REPORTED} more`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What was decided for the source's actions and ingests, held against the source: every action (`actions`,
|
|
2
|
+
// `collectionActions`) and ingest has exactly one entry in .blueprint/actions.json, under its own name and kind, with a
|
|
3
|
+
// decision the actions step can act on — and a mutate is always built. A source with neither needs no file. Both the
|
|
4
|
+
// spec check and the actions check run this, so a build that reaches the actions step without a spec check behind it
|
|
5
|
+
// is held to the same record. Prints each problem, and exits 1 when there is any.
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const SOURCE = ".blueprint/source";
|
|
9
|
+
const FILE = ".blueprint/actions.json";
|
|
10
|
+
const DECISIONS = new Set(["feature", "manual", "drop"]);
|
|
11
|
+
|
|
12
|
+
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
13
|
+
|
|
14
|
+
// What the source has to be decided on: each action and ingest, by the name the spec uses, with the kind it was.
|
|
15
|
+
function wanted() {
|
|
16
|
+
return readJson(`${SOURCE}/source.json`).collections.flatMap((slug) => {
|
|
17
|
+
const schema = readJson(`${SOURCE}/collections/${slug}/schema.json`);
|
|
18
|
+
return [
|
|
19
|
+
...[...(schema.actions ?? []), ...(schema.collectionActions ?? [])].map((action) => ({ name: `${slug}.actions.${action.id}`, kind: action.kind })),
|
|
20
|
+
...(schema.ingest ? [{ name: `${slug}.ingest`, kind: schema.ingest.kind }] : []),
|
|
21
|
+
];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The recorded entries, or null when the file is not JSON with an "actions" list.
|
|
26
|
+
function recorded() {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = readJson(FILE);
|
|
29
|
+
return Array.isArray(parsed?.actions) ? parsed.actions : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function entryProblems({ name, kind }, found) {
|
|
36
|
+
if (found.length !== 1) return [`${FILE}: ${name} has ${found.length} entries, it needs one`];
|
|
37
|
+
const [entry] = found;
|
|
38
|
+
if (entry.kind !== kind) return [`${FILE}: ${name} is recorded as kind ${JSON.stringify(entry.kind)}; the source has it as ${JSON.stringify(kind)}`];
|
|
39
|
+
if (!DECISIONS.has(entry.decision)) return [`${FILE}: ${name} has decision ${JSON.stringify(entry.decision)}; it is feature, manual or drop`];
|
|
40
|
+
return kind === "mutate" && entry.decision !== "feature" ? [`${FILE}: ${name} is a mutate, which is always built (feature)`] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function problems() {
|
|
44
|
+
const items = wanted();
|
|
45
|
+
if (items.length === 0) return [];
|
|
46
|
+
// A copy is taken from a schema the collection engine accepted, which always names a kind; one without it is not a
|
|
47
|
+
// source this can judge — above all, not whether it is a mutate that has to be built.
|
|
48
|
+
const kindless = items.filter((item) => typeof item.kind !== "string" || item.kind === "");
|
|
49
|
+
if (kindless.length > 0) return kindless.map((item) => `${SOURCE}: ${item.name} has no kind, so what it was cannot be decided on`);
|
|
50
|
+
if (!existsSync(FILE)) return [`${FILE} is missing; it records how each of the ${items.length} actions and ingests is handled`];
|
|
51
|
+
const entries = recorded();
|
|
52
|
+
if (entries === null) return [`${FILE} is not JSON with an "actions" list`];
|
|
53
|
+
const known = new Set(items.map((item) => item.name));
|
|
54
|
+
return [
|
|
55
|
+
...items.flatMap((item) =>
|
|
56
|
+
entryProblems(
|
|
57
|
+
item,
|
|
58
|
+
entries.filter((entry) => entry?.name === item.name),
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
...entries.filter((entry) => !known.has(entry?.name)).map((entry) => `${FILE}: ${JSON.stringify(entry?.name)} is not an action or ingest of the source`),
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const found = problems();
|
|
66
|
+
if (found.length > 0) {
|
|
67
|
+
console.error(found.join("\n"));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Reads Firestore (and Cloud Storage) after `yarn import-source` and holds it against the copied source, over the REST
|
|
2
|
+
// APIs rather than the app's own code: every collection has as many documents as it had records, every stored field of
|
|
3
|
+
// every record reads back equal, and every file a record points at is in the bucket. Prints each mismatch, and exits 1
|
|
4
|
+
// when there is any.
|
|
5
|
+
//
|
|
6
|
+
// node firestore-verify.mjs emulator inside `firebase emulators:exec` (FIRESTORE_EMULATOR_HOST, GCLOUD_PROJECT)
|
|
7
|
+
// node firestore-verify.mjs prod with PROJECT_ID and ACCESS_TOKEN (gcloud auth print-access-token)
|
|
8
|
+
//
|
|
9
|
+
// The bucket the files went to is named in .blueprint/storage-bucket; in production it must be one of the project's.
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { SOURCE, hasValue, isFileField, labelOf, plainFields, recordsOf, report, sameValue, schemaOf, sourceCollections } from "./compare.mjs";
|
|
12
|
+
|
|
13
|
+
const PAGE_SIZE = 300;
|
|
14
|
+
const TIMEOUT_MS = 20000;
|
|
15
|
+
const target = process.argv[2];
|
|
16
|
+
|
|
17
|
+
function endpoints() {
|
|
18
|
+
if (target === "emulator") {
|
|
19
|
+
const project = process.env.GCLOUD_PROJECT || "demo-blueprint";
|
|
20
|
+
return {
|
|
21
|
+
documents: `http://${process.env.FIRESTORE_EMULATOR_HOST}/v1/projects/${project}/databases/(default)/documents`,
|
|
22
|
+
object: (bucket, file) => `http://${process.env.FIREBASE_STORAGE_EMULATOR_HOST}/v0/b/${bucket}/o/${encodeURIComponent(file)}`,
|
|
23
|
+
headers: { Authorization: "Bearer owner" },
|
|
24
|
+
bucketAllowed: () => true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const project = process.env.PROJECT_ID;
|
|
28
|
+
return {
|
|
29
|
+
documents: `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents`,
|
|
30
|
+
object: (bucket, file) => `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${encodeURIComponent(file)}`,
|
|
31
|
+
headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
|
|
32
|
+
bucketAllowed: (bucket) => bucket === `${project}.appspot.com` || bucket === `${project}.firebasestorage.app`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function get(url, headers) {
|
|
37
|
+
const response = await fetch(url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
38
|
+
return { status: response.status, body: response.ok ? await response.json() : null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A Firestore REST value as the plain value it holds.
|
|
42
|
+
function decode(value) {
|
|
43
|
+
if ("stringValue" in value) return value.stringValue;
|
|
44
|
+
if ("integerValue" in value) return Number(value.integerValue);
|
|
45
|
+
if ("doubleValue" in value) return value.doubleValue;
|
|
46
|
+
if ("booleanValue" in value) return value.booleanValue;
|
|
47
|
+
if ("timestampValue" in value) return value.timestampValue;
|
|
48
|
+
if ("referenceValue" in value) return value.referenceValue.split("/").at(-1);
|
|
49
|
+
if ("nullValue" in value) return null;
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function documentsOf(api, collection, pageToken = "") {
|
|
54
|
+
const query = new URLSearchParams({ pageSize: String(PAGE_SIZE), ...(pageToken ? { pageToken } : {}) });
|
|
55
|
+
const { status, body } = await get(`${api.documents}/${encodeURIComponent(collection)}?${query}`, api.headers);
|
|
56
|
+
if (status !== 200) throw new Error(`reading ${collection} from Firestore answered ${status}`);
|
|
57
|
+
const page = (body.documents ?? []).map((doc) => ({
|
|
58
|
+
id: doc.name.split("/").at(-1),
|
|
59
|
+
fields: Object.fromEntries(Object.entries(doc.fields ?? {}).map(([key, value]) => [key, decode(value)])),
|
|
60
|
+
}));
|
|
61
|
+
return body.nextPageToken ? [...page, ...(await documentsOf(api, collection, body.nextPageToken))] : page;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fieldProblems(slug, key, spec, record, doc, label) {
|
|
65
|
+
const value = record[key];
|
|
66
|
+
if (!hasValue(value)) return [];
|
|
67
|
+
if (!(key in doc.fields)) return [`${slug} ${label}: the document has no field ${key}`];
|
|
68
|
+
return sameValue(spec.type, value, doc.fields[key], (flag) => flag)
|
|
69
|
+
? []
|
|
70
|
+
: [`${slug} ${label}: ${key} is ${JSON.stringify(doc.fields[key])}, the source had ${JSON.stringify(value)}`];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function collectionProblems(api, slug) {
|
|
74
|
+
const schema = schemaOf(slug);
|
|
75
|
+
const records = recordsOf(slug);
|
|
76
|
+
const docs = new Map((await documentsOf(api, slug)).map((doc) => [doc.id, doc]));
|
|
77
|
+
const counted = docs.size === records.length ? [] : [`${slug}: Firestore has ${docs.size} documents, the source had ${records.length} records`];
|
|
78
|
+
// The primary key is the document id, matched here; the document need not repeat it as a field.
|
|
79
|
+
const fields = plainFields(schema).filter(([key]) => key !== schema.primaryKey);
|
|
80
|
+
const perRecord = records.flatMap((record) => {
|
|
81
|
+
const doc = docs.get(String(record[schema.primaryKey]));
|
|
82
|
+
if (!doc) return [`${slug}: no document ${JSON.stringify(record[schema.primaryKey])}`];
|
|
83
|
+
return fields.flatMap(([key, spec]) => fieldProblems(slug, key, spec, record, doc, labelOf(schema, record)));
|
|
84
|
+
});
|
|
85
|
+
return [...counted, ...perRecord];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Every file the records point at and the source holds, once.
|
|
89
|
+
const pointedAt = () => [
|
|
90
|
+
...new Set(
|
|
91
|
+
sourceCollections().flatMap((slug) => {
|
|
92
|
+
const fileKeys = plainFields(schemaOf(slug))
|
|
93
|
+
.filter(([, spec]) => isFileField(spec))
|
|
94
|
+
.map(([key]) => key);
|
|
95
|
+
return recordsOf(slug).flatMap((record) =>
|
|
96
|
+
fileKeys.map((key) => record[key]).filter((value) => hasValue(value) && existsSync(`${SOURCE}/files/${value}`)),
|
|
97
|
+
);
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
async function fileProblems(api) {
|
|
103
|
+
const files = pointedAt();
|
|
104
|
+
if (files.length === 0) return [];
|
|
105
|
+
const bucketFile = ".blueprint/storage-bucket";
|
|
106
|
+
if (!existsSync(bucketFile)) return [`the records point at ${files.length} files, and ${bucketFile} does not name the bucket they went to`];
|
|
107
|
+
const bucket = readFileSync(bucketFile, "utf8").trim();
|
|
108
|
+
if (!api.bucketAllowed(bucket)) return [`${bucketFile} names ${bucket}, which is not this project's bucket`];
|
|
109
|
+
const found = await Promise.all(files.map(async (file) => (await get(api.object(bucket, file), api.headers)).status === 200));
|
|
110
|
+
return files.filter((_file, index) => !found[index]).map((file) => `${file} is not in the bucket ${bucket}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const api = endpoints();
|
|
114
|
+
const problems = [...(await Promise.all(sourceCollections().map((slug) => collectionProblems(api, slug)))).flat(), ...(await fileProblems(api))];
|
|
115
|
+
report(problems);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The records the build copied are in Firestore, and the files they point at in Cloud Storage — read back over the REST
|
|
3
|
+
# APIs, not through the app. `emulator`: `yarn import-source --target emulator` runs twice inside the emulators, then the
|
|
4
|
+
# emulators are read. `prod`: production is read with the person's gcloud token (the step itself did the import, after
|
|
5
|
+
# its approval). A build that copied only the shape has nothing to move.
|
|
6
|
+
set -eu
|
|
7
|
+
target="$1"
|
|
8
|
+
here="$(dirname "$0")"
|
|
9
|
+
source=.blueprint/source/source.json
|
|
10
|
+
[ -s "$source" ] || { echo "missing $source; the build was started without a collection" >&2; exit 1; }
|
|
11
|
+
if [ "$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).records === true))' "$source")" != true ]; then
|
|
12
|
+
echo "only the shape was copied; there are no records to move"
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
grep -q '"import-source"' package.json || { echo "package.json has no import-source script" >&2; exit 1; }
|
|
16
|
+
verify="node --no-warnings $here/firestore-verify.mjs"
|
|
17
|
+
case "$target" in
|
|
18
|
+
emulator)
|
|
19
|
+
. "$BLUEPRINT_BASE/checks/java21.sh"
|
|
20
|
+
only="firestore"
|
|
21
|
+
[ ! -d .blueprint/source/files ] || only="firestore,storage"
|
|
22
|
+
# Twice: moving the records again must leave the same documents, not a second copy of each.
|
|
23
|
+
firebase emulators:exec --project demo-blueprint --only "$only" \
|
|
24
|
+
"yarn -s import-source --target emulator && yarn -s import-source --target emulator && $verify emulator"
|
|
25
|
+
;;
|
|
26
|
+
prod)
|
|
27
|
+
# Taken one at a time: a failure inside an assignment that prefixes a command would not stop `set -e`.
|
|
28
|
+
prod=$(sh "$BLUEPRINT_BASE/checks/project-id.sh" prod)
|
|
29
|
+
token=$(gcloud auth print-access-token)
|
|
30
|
+
PROJECT_ID="$prod" ACCESS_TOKEN="$token" $verify prod
|
|
31
|
+
;;
|
|
32
|
+
*)
|
|
33
|
+
echo "usage: import-firebase.sh emulator|prod" >&2
|
|
34
|
+
exit 2
|
|
35
|
+
;;
|
|
36
|
+
esac
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Reads a database `yarn import-source` filled and holds it against the copied source: every collection's table has as
|
|
2
|
+
// many rows as it had records, every stored field of every record reads back equal, and every file a record points at
|
|
3
|
+
// is in data/files/. Prints each mismatch, and exits 1 when there is any.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { SOURCE, hasValue, isFileField, labelOf, plainFields, recordsOf, report, sameValue, schemaOf, sourceCollections } from "./compare.mjs";
|
|
7
|
+
|
|
8
|
+
const tableOf = (slug) => slug.replaceAll("-", "_");
|
|
9
|
+
const quoted = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
10
|
+
const asColumn = (flag) => (flag ? 1 : 0);
|
|
11
|
+
|
|
12
|
+
function fieldProblems(slug, key, spec, record, row, label) {
|
|
13
|
+
const value = record[key];
|
|
14
|
+
if (!hasValue(value)) return [];
|
|
15
|
+
if (!(key in row)) return [`${slug}: table ${tableOf(slug)} has no column ${key}`];
|
|
16
|
+
const problems = sameValue(spec.type, value, row[key], asColumn)
|
|
17
|
+
? []
|
|
18
|
+
: [`${slug} ${label}: ${key} is ${JSON.stringify(row[key])}, the source had ${JSON.stringify(value)}`];
|
|
19
|
+
const missingFile = isFileField(spec) && existsSync(`${SOURCE}/files/${value}`) && !existsSync(`data/files/${value}`);
|
|
20
|
+
return missingFile ? [...problems, `${slug} ${label}: ${key} points at ${value}, which is not in data/files/`] : problems;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function collectionProblems(db, slug) {
|
|
24
|
+
const schema = schemaOf(slug);
|
|
25
|
+
const records = recordsOf(slug);
|
|
26
|
+
const table = tableOf(slug);
|
|
27
|
+
const exists = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
28
|
+
if (!exists) return [`${slug}: there is no table ${table}`];
|
|
29
|
+
const count = db.prepare(`SELECT COUNT(*) AS n FROM ${quoted(table)}`).get().n;
|
|
30
|
+
const counted = count === records.length ? [] : [`${slug}: table ${table} has ${count} rows, the source had ${records.length} records`];
|
|
31
|
+
const primary = schema.primaryKey;
|
|
32
|
+
const fields = plainFields(schema);
|
|
33
|
+
const find = db.prepare(`SELECT * FROM ${quoted(table)} WHERE ${quoted(primary)} = ?`);
|
|
34
|
+
const perRecord = records.flatMap((record) => {
|
|
35
|
+
const row = find.get(record[primary]);
|
|
36
|
+
if (!row) return [`${slug}: no row for ${primary} = ${JSON.stringify(record[primary])}`];
|
|
37
|
+
return fields.flatMap(([key, spec]) => fieldProblems(slug, key, spec, record, row, labelOf(schema, record)));
|
|
38
|
+
});
|
|
39
|
+
return [...counted, ...perRecord];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const db = new DatabaseSync(process.argv[2], { readOnly: true });
|
|
43
|
+
const problems = sourceCollections().flatMap((slug) => collectionProblems(db, slug));
|
|
44
|
+
db.close();
|
|
45
|
+
report(problems);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The records the build copied are in the app: `yarn import-source` is run twice into a fresh database, and the
|
|
3
|
+
# database is then held against the source by reading it directly — the import's own word is not taken. A build that
|
|
4
|
+
# copied only the shape has nothing to move.
|
|
5
|
+
set -eu
|
|
6
|
+
source=.blueprint/source/source.json
|
|
7
|
+
[ -s "$source" ] || { echo "missing $source; the build was started without a collection" >&2; exit 1; }
|
|
8
|
+
if [ "$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).records === true))' "$source")" != true ]; then
|
|
9
|
+
echo "only the shape was copied; there are no records to move"
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
grep -q '"import-source"' package.json || { echo "package.json has no import-source script" >&2; exit 1; }
|
|
13
|
+
work=$(mktemp -d)
|
|
14
|
+
trap 'rm -rf "$work"' EXIT INT TERM
|
|
15
|
+
db="$work/app.db"
|
|
16
|
+
yarn -s import-source "$db" >/dev/null || { echo "yarn import-source failed" >&2; yarn -s import-source "$db" >&2 || true; exit 1; }
|
|
17
|
+
# Twice: moving the records again must leave the same rows, not a second copy of each.
|
|
18
|
+
yarn -s import-source "$db" >/dev/null || { echo "yarn import-source failed the second time over the same database" >&2; exit 1; }
|
|
19
|
+
node --no-warnings "$(dirname "$0")/import-verify.mjs" "$db"
|
|
20
|
+
yarn test >/dev/null || { echo "yarn test fails" >&2; yarn test >&2 || true; exit 1; }
|