From fa204e5d6b9b1f53ae514dfd8e7c49ab3c04b183 Mon Sep 17 00:00:00 2001 From: "songtianyi.theo" Date: Thu, 9 Jul 2026 13:08:07 +0800 Subject: [PATCH] fix: require svglide bundle receipts before publish --- .../slides/slides_create_svglide_manifest.go | 46 ++++++++++++ .../slides/slides_create_svglide_test.go | 70 ++++++++++++++++--- .../lark-slides/scripts/svg_slides_bundle.mjs | 10 ++- .../scripts/svg_slides_bundle_test.mjs | 8 +++ .../slides_create_svglide_helpers_test.go | 14 +++- 5 files changed, 136 insertions(+), 12 deletions(-) diff --git a/shortcuts/slides/slides_create_svglide_manifest.go b/shortcuts/slides/slides_create_svglide_manifest.go index 871704022..4484964e6 100644 --- a/shortcuts/slides/slides_create_svglide_manifest.go +++ b/shortcuts/slides/slides_create_svglide_manifest.go @@ -43,6 +43,11 @@ type svglideManifestSize struct { type svglideManifestReceipts struct { ValidateSVGDeck string `json:"validate_svg_deck"` + PromptReadTrace string `json:"prompt_read_trace"` + AssetReadiness string `json:"asset_readiness"` + TypographyPlan string `json:"typography_plan"` + FrameDiscipline string `json:"frame_discipline"` + ParserContract string `json:"parser_contract"` } type svglideManifestPage struct { @@ -160,6 +165,11 @@ type svglideValidateSVGDeckReceipt struct { TotalErrors *int `json:"totalErrors"` } +type svglideZeroErrorReceipt struct { + TotalErrors *int `json:"totalErrors"` + OK *bool `json:"ok"` +} + func validateSVGlideBundleReceipt(runtime *common.RuntimeContext, baseDir string, receipts svglideManifestReceipts) error { receiptPath, err := svglideManifestChildPath("receipts.validate_svg_deck", baseDir, receipts.ValidateSVGDeck) if err != nil { @@ -176,6 +186,42 @@ func validateSVGlideBundleReceipt(runtime *common.RuntimeContext, baseDir string if receipt.TotalErrors == nil || *receipt.TotalErrors != 0 { return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest validate_svg_deck receipt must have totalErrors=0").WithParam("--manifest") } + required := []struct { + label string + rel string + }{ + {"receipts.prompt_read_trace", receipts.PromptReadTrace}, + {"receipts.asset_readiness", receipts.AssetReadiness}, + {"receipts.typography_plan", receipts.TypographyPlan}, + {"receipts.frame_discipline", receipts.FrameDiscipline}, + {"receipts.parser_contract", receipts.ParserContract}, + } + for _, item := range required { + label, rel := item.label, item.rel + if strings.TrimSpace(rel) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest") + } + joined, err := svglideManifestChildPath(label, baseDir, rel) + if err != nil { + return err + } + raw, err := readSVGlideManifestInput(runtime, joined, "--manifest %s receipt not found: %s", label, rel) + if err != nil { + return err + } + if label == "receipts.frame_discipline" || label == "receipts.parser_contract" { + var zero svglideZeroErrorReceipt + if err := json.Unmarshal(raw, &zero); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s receipt invalid JSON: %v", label, err).WithParam("--manifest").WithCause(err) + } + if zero.TotalErrors != nil && *zero.TotalErrors != 0 { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have totalErrors=0", label).WithParam("--manifest") + } + if zero.OK != nil && !*zero.OK { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have ok=true", label).WithParam("--manifest") + } + } + } return nil } diff --git a/shortcuts/slides/slides_create_svglide_test.go b/shortcuts/slides/slides_create_svglide_test.go index 66a5b13fe..ecb6c7992 100644 --- a/shortcuts/slides/slides_create_svglide_test.go +++ b/shortcuts/slides/slides_create_svglide_test.go @@ -22,13 +22,19 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +const ( + svglideValidateOnlyReceiptsJSON = `"validate_svg_deck":"receipts/validate_svg_deck.json"` + svglideRequiredReceiptsJSON = svglideValidateOnlyReceiptsJSON + `,"prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json"` +) + func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) { dir := t.TempDir() withSlidesTestWorkingDir(t, dir) page := `` mustWriteSVGlideTestFile(t, "pages/page-001.svg", page) mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`) - mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{ + mustWriteSVGlideRequiredNonValidateReceipts(t) + mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{ "version": "svglide.manifest.v1", "protocol": "svg-slides.v1", "title": "Manifest Title", @@ -37,7 +43,7 @@ func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) { "published": false, "pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}], "receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"} - }`, sha256Hex([]byte(page)))) + }`, sha256Hex([]byte(page))))) runtime, _ := newSVGlideTestRuntime(t) spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "") @@ -64,7 +70,8 @@ func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) { page := `` mustWriteSVGlideTestFile(t, "page.svg", page) mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`) - mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{ + mustWriteSVGlideRequiredNonValidateReceipts(t) + mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{ "version": "svglide.manifest.v1", "protocol": "svg-slides.v1", "title": "Manifest Title", @@ -73,7 +80,7 @@ func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) { "published": false, "pages": [{"id": "p1", "index": 1, "file": "page.svg", "sha256": %q}], "receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"} - }`, sha256Hex([]byte(page)))) + }`, sha256Hex([]byte(page))))) runtime, _ := newSVGlideTestRuntime(t) spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "CLI Title") @@ -87,10 +94,11 @@ func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) { func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) { tests := []struct { - name string - manifest string - pageFiles map[string]string - wantMessage string + name string + manifest string + pageFiles map[string]string + skipDefaultReceipts bool + wantMessage string }{ { name: "wrong version", @@ -140,6 +148,20 @@ func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) { pageFiles: map[string]string{"page.svg": ``, "receipts/validate_svg_deck.json": `{"totalErrors":1}`}, wantMessage: "--manifest validate_svg_deck receipt must have totalErrors=0", }, + { + name: "missing parser contract receipt", + manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","frame_discipline":"receipts/frame_discipline.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":""}]}`, + pageFiles: map[string]string{ + "page.svg": ``, + "receipts/validate_svg_deck.json": `{"totalErrors":0}`, + "receipts/prompt_read_trace.json": `{"ok":true}`, + "receipts/asset_readiness.json": `{"ok":true}`, + "receipts/typography_plan.json": `{"ok":true}`, + "receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`, + }, + skipDefaultReceipts: true, + wantMessage: "--manifest receipts.parser_contract is required", + }, { name: "sha mismatch", manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"bad"}]}`, @@ -161,6 +183,10 @@ func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) { for path, body := range tt.pageFiles { mustWriteSVGlideTestFile(t, path, body) } + if !tt.skipDefaultReceipts { + mustWriteSVGlideRequiredNonValidateReceipts(t) + tt.manifest = withSVGlideRequiredReceipts(tt.manifest) + } if body, ok := tt.pageFiles["page.svg"]; ok { tt.manifest = strings.ReplaceAll(tt.manifest, "", sha256Hex([]byte(body))) } @@ -194,6 +220,29 @@ func mustWriteSVGlideTestFile(t *testing.T, path string, body string) { } } +func withSVGlideRequiredReceipts(manifest string) string { + manifest = strings.ReplaceAll(manifest, `"receipts":{`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`) + manifest = strings.ReplaceAll(manifest, `"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`) + manifest = strings.ReplaceAll(manifest, `"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`) + return strings.ReplaceAll(manifest, `"receipts": {`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`) +} + +func mustWriteSVGlideRequiredNonValidateReceipts(t *testing.T) { + t.Helper() + for path, body := range map[string]string{ + "receipts/prompt_read_trace.json": `{"ok":true}`, + "receipts/asset_readiness.json": `{"ok":true}`, + "receipts/typography_plan.json": `{"ok":true}`, + "receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`, + "receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`, + } { + if _, err := os.Stat(path); err == nil { + continue + } + mustWriteSVGlideTestFile(t, path, body) + } +} + func TestPlanSVGlideImageRewritesResolvesAndDedupes(t *testing.T) { dir := t.TempDir() withSlidesTestWorkingDir(t, dir) @@ -415,7 +464,8 @@ func mustWriteSVGlideBundle(t *testing.T, title string, page string) { t.Helper() mustWriteSVGlideTestFile(t, "pages/page-001.svg", page) mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`) - mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{ + mustWriteSVGlideRequiredNonValidateReceipts(t) + mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{ "version": "svglide.manifest.v1", "protocol": "svg-slides.v1", "title": %q, @@ -424,7 +474,7 @@ func mustWriteSVGlideBundle(t *testing.T, title string, page string) { "published": false, "pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}], "receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"} - }`, title, sha256Hex([]byte(page)))) + }`, title, sha256Hex([]byte(page))))) } func newSVGlideTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { diff --git a/skills/lark-slides/scripts/svg_slides_bundle.mjs b/skills/lark-slides/scripts/svg_slides_bundle.mjs index 705d822ca..5f9a5d190 100755 --- a/skills/lark-slides/scripts/svg_slides_bundle.mjs +++ b/skills/lark-slides/scripts/svg_slides_bundle.mjs @@ -60,6 +60,13 @@ if (!validate.stdout.trim()) { const receipt = JSON.parse(validate.stdout); fs.mkdirSync(path.join(root, "receipts"), { recursive: true }); fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`); +const parserContractReceipt = { + version: "svg-slides.parser-contract.v1", + ok: receipt.totalErrors === 0, + source: "validate_svg_deck.mjs", + totalErrors: receipt.totalErrors, +}; +fs.writeFileSync(path.join(root, "receipts", "parser_contract.json"), `${JSON.stringify(parserContractReceipt, null, 2)}\n`); if (receipt.totalErrors !== 0) { fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1); } @@ -141,7 +148,8 @@ const manifest = { prompt_read_trace: "receipts/prompt_read_trace.json", asset_readiness: "receipts/asset_readiness.json", typography_plan: "receipts/typography_plan.json", - frame_discipline: "receipts/frame_discipline.json" + frame_discipline: "receipts/frame_discipline.json", + parser_contract: "receipts/parser_contract.json" } }; diff --git a/skills/lark-slides/scripts/svg_slides_bundle_test.mjs b/skills/lark-slides/scripts/svg_slides_bundle_test.mjs index f8b530899..b18d0b7a5 100644 --- a/skills/lark-slides/scripts/svg_slides_bundle_test.mjs +++ b/skills/lark-slides/scripts/svg_slides_bundle_test.mjs @@ -172,11 +172,15 @@ test("bundle builder writes manifest and validation receipt", () => { assert.equal(manifest.receipts.asset_readiness, "receipts/asset_readiness.json"); assert.equal(manifest.receipts.typography_plan, "receipts/typography_plan.json"); assert.equal(manifest.receipts.frame_discipline, "receipts/frame_discipline.json"); + assert.equal(manifest.receipts.parser_contract, "receipts/parser_contract.json"); assert.equal(manifest.pages.length, 1); assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/); const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8")); assert.equal(receipt.totalErrors, 0); assert.equal(fs.existsSync(path.join(root, "receipts", "frame_discipline.json")), true); + const parserContract = JSON.parse(fs.readFileSync(path.join(root, "receipts", "parser_contract.json"), "utf8")); + assert.equal(parserContract.totalErrors, 0); + assert.equal(parserContract.ok, true); }); test("bundle builder rejects missing asset readiness receipt", () => { @@ -327,4 +331,8 @@ test("bundle builder accepts chart deck with deck and chart prompt reads", () => assert.equal(manifest.receipts.asset_readiness, "receipts/asset_readiness.json"); assert.equal(manifest.receipts.typography_plan, "receipts/typography_plan.json"); assert.equal(manifest.receipts.frame_discipline, "receipts/frame_discipline.json"); + assert.equal(manifest.receipts.parser_contract, "receipts/parser_contract.json"); + const parserContract = JSON.parse(fs.readFileSync(path.join(root, "receipts", "parser_contract.json"), "utf8")); + assert.equal(parserContract.totalErrors, 0); + assert.equal(parserContract.ok, true); }); diff --git a/tests/cli_e2e/slides/slides_create_svglide_helpers_test.go b/tests/cli_e2e/slides/slides_create_svglide_helpers_test.go index 152bafee0..4d84e9943 100644 --- a/tests/cli_e2e/slides/slides_create_svglide_helpers_test.go +++ b/tests/cli_e2e/slides/slides_create_svglide_helpers_test.go @@ -43,6 +43,11 @@ func prepareSVGlideImageSmokeBundle(t *testing.T, title string) string { validateOut := runSVGlideNodeTool(t, root, "skills/lark-slides/scripts/validate_svg_deck.mjs", dir, "--json") require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "validate_svg_deck.json"), validateOut, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "prompt_read_trace.json"), []byte(`{"ok":true}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "asset_readiness.json"), []byte(`{"ok":true}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "typography_plan.json"), []byte(`{"ok":true}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "frame_discipline.json"), []byte(`{"ok":true,"totalErrors":0}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "parser_contract.json"), []byte(`{"ok":true,"totalErrors":0}`), 0o644)) manifest := map[string]any{ "version": "svglide.manifest.v1", @@ -57,7 +62,14 @@ func prepareSVGlideImageSmokeBundle(t *testing.T, title string) string { "file": "slides/page-001.svg", "sha256": sha256HexForE2E([]byte(page)), }}, - "receipts": map[string]any{"validate_svg_deck": "receipts/validate_svg_deck.json"}, + "receipts": map[string]any{ + "validate_svg_deck": "receipts/validate_svg_deck.json", + "prompt_read_trace": "receipts/prompt_read_trace.json", + "asset_readiness": "receipts/asset_readiness.json", + "typography_plan": "receipts/typography_plan.json", + "frame_discipline": "receipts/frame_discipline.json", + "parser_contract": "receipts/parser_contract.json", + }, } raw, err := json.MarshalIndent(manifest, "", " ") require.NoError(t, err)