Compare commits

..

7 Commits

Author SHA1 Message Date
zhanghuanxu
d7be4205d0 fix(slides): lint unsupported slide font families 2026-06-30 21:28:56 +08:00
zhanghuanxu
9b05a71de3 feat(slides):template rewrite 2026-06-30 14:45:20 +08:00
zhanghuanxu
c906fcac7e feat: support PDF imports as slides 2026-06-26 15:42:09 +08:00
zhanghuanxu
6ddbbafc4f feat: expose slides presentation url 2026-06-26 14:46:47 +08:00
zhanghuanxu
bf9264c901 fix: stop advertising slides screenshot scope 2026-06-26 14:46:46 +08:00
zhanghuanxu
e9f8d1d94b feat: add slides xml get shortcut 2026-06-26 14:46:46 +08:00
zhanghuanxu
a520b7ca93 feat: add slides replace-pages shortcut 2026-06-26 14:46:46 +08:00
661 changed files with 78099 additions and 88054 deletions

View File

@@ -2,103 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.62] - 2026-07-01
### Features
- **vc**: Add meeting message send shortcut (#1643)
- **doc**: Add document word statistics helper (#1697)
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
### Bug Fixes
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
- **base**: Support JSON array input for field create (#1661)
- **task**: Expose completion state in `my tasks` output (#1641)
- **cli**: Reduce public content credential false positives (#1700)
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
@@ -1333,11 +1236,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55

View File

@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -1,49 +0,0 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.

View File

@@ -1,19 +0,0 @@
# contact
> skill: lark-contact
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

View File

@@ -67,21 +67,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]

View File

@@ -22,11 +22,6 @@ import (
// NewCmdAuth creates the auth command with subcommands.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
return NewCmdAuthWithContext(context.Background(), f)
}
// NewCmdAuthWithContext creates the auth command with subcommands.
func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
@@ -43,7 +38,7 @@ func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Comma
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdAuthLoginWithContext(ctx, f, nil))
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))

View File

@@ -42,11 +42,6 @@ var pollDeviceToken = larkauth.PollDeviceToken
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
return NewCmdAuthLoginWithContext(context.Background(), f, runF)
}
// NewCmdAuthLoginWithContext creates the auth login subcommand.
func NewCmdAuthLoginWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
cmd := &cobra.Command{
@@ -78,7 +73,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand core.LarkBrand
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}

View File

@@ -19,7 +19,6 @@ import (
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
@@ -90,9 +89,8 @@ func WithoutPlugins() BuildOption {
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning or credential-backed bootstrap
// probes. It is intended for offline inspection tools and pure local commands
// that must not require account configuration.
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
@@ -147,9 +145,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
o(cfg)
}
}
if cfg.skipStrictMode {
ctx = cmdutil.ContextWithCredentialBootstrapDisabled(ctx)
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
@@ -175,10 +170,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
@@ -196,10 +187,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuthWithContext(ctx, f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoamiWithContext(ctx, f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
@@ -215,17 +205,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if !cfg.skipStrictMode {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {

View File

@@ -129,10 +129,7 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,19 +4,14 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -145,84 +140,14 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
return check
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

View File

@@ -10,22 +10,10 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -39,8 +27,6 @@ func TestRunList_TextOutput(t *testing.T) {
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -71,15 +57,9 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if key, ok := row["key"].(string); ok {
gotKeys[key] = row
}
}
var foundTask bool
for key, row := range gotKeys {
if key == "task.task.update_user_access_v2" {
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
@@ -89,12 +69,4 @@ func TestRunList_JSONOutput(t *testing.T) {
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := gotKeys[want]; !ok {
t.Errorf("JSON list output missing %q", want)
}
}
}

View File

@@ -124,45 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["key"] != key {
t.Errorf("key = %v, want %s", payload["key"], key)
}
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
}
properties, ok := resolved["properties"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
}
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
if _, ok := properties[field]; !ok {
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
}
}
if _, ok := properties["end_time"]; ok {
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
}
})
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -11,11 +11,9 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
@@ -30,60 +28,43 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Search users
lark-cli contact +search-user --query "John"
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Install all skills:
npx skills add larksuite/cli -g -y
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Learn more: https://github.com/larksuite/cli#agent-skills
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -103,16 +84,10 @@ func Execute() int {
configureFlagCompletions(os.Args)
ctx := context.Background()
buildOpts := []BuildOption{
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
}
if isLocalSVGlideInvocation(rawInvocationArgs) {
buildOpts = append(buildOpts, WithoutStrictMode())
}
f, rootCmd, reg := buildInternal(
ctx, inv,
buildOpts...,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
)
// --- Notices (non-blocking) ---
@@ -136,30 +111,6 @@ func Execute() int {
return 0
}
func isLocalSVGlideInvocation(args []string) bool {
positionals := make([]string, 0, 2)
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--profile":
if i+1 < len(args) {
i++
}
continue
case strings.HasPrefix(arg, "--profile="):
continue
case strings.HasPrefix(arg, "-"):
continue
default:
positionals = append(positionals, arg)
if len(positionals) == 2 {
return positionals[0] == "slides" && positionals[1] == "+create-svglide"
}
}
}
return false
}
// setupNotices wires both the binary update notice and the skills
// staleness notice into output.PendingNotice as a composed function.
// Each provider populates an independent key under _notice; either
@@ -578,49 +529,6 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
return available, deprecated
}
// Root command help groups, so an agent sees content domains, agent tooling, and
// CLI management as distinct blocks instead of one flat alphabetical dump.
const (
groupDomains = "lark-domains"
groupTooling = "agent-tooling"
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
if c.GroupID != "" {
continue
}
switch {
case tooling[c.Name()]:
c.GroupID = groupTooling
case management[c.Name()]:
c.GroupID = groupManagement
case isLarkDomain(c):
c.GroupID = groupDomains
}
}
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
return true
}
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
@@ -702,17 +610,6 @@ func installTipsHelpFunc(root *cobra.Command) {
defer func() { f.Hidden = true }()
}
}
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {

View File

@@ -5,12 +5,9 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
@@ -29,27 +26,6 @@ import (
"github.com/larksuite/cli/internal/registry"
)
type countingKeychain struct {
gets int
sets int
removes int
}
func (k *countingKeychain) Get(service, account string) (string, error) {
k.gets++
return "", fmt.Errorf("unexpected keychain Get for %s/%s", service, account)
}
func (k *countingKeychain) Set(service, account, value string) error {
k.sets++
return fmt.Errorf("unexpected keychain Set for %s/%s", service, account)
}
func (k *countingKeychain) Remove(service, account string) error {
k.removes++
return fmt.Errorf("unexpected keychain Remove for %s/%s", service, account)
}
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
// auth, config, and schema commands have auth check disabled,
// while api does not.
@@ -99,71 +75,12 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
}
func TestIsLocalSVGlideInvocation(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{name: "local svglide", args: []string{"slides", "+create-svglide", "--action", "init"}, want: true},
{name: "with profile", args: []string{"--profile", "demo", "slides", "+create-svglide"}, want: true},
{name: "with profile equals", args: []string{"--profile=demo", "slides", "+create-svglide"}, want: true},
{name: "other slides shortcut", args: []string{"slides", "+create"}, want: false},
{name: "root help", args: []string{"--help"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isLocalSVGlideInvocation(tt.args); got != tt.want {
t.Fatalf("isLocalSVGlideInvocation(%v) = %v, want %v", tt.args, got, tt.want)
}
})
}
}
func TestLocalSVGlideRootCommandDoesNotTouchKeychain(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
t.Fatal(err)
}
var in, out, errOut bytes.Buffer
kc := &countingKeychain{}
_, rootCmd, _ := buildInternal(
context.Background(),
cmdutil.InvocationContext{},
WithIO(&in, &out, &errOut),
WithKeychain(kc),
WithoutStrictMode(),
WithoutPlugins(),
)
rootCmd.SetArgs([]string{
"slides",
"+create-svglide",
"--action", "init",
"--title", "Demo",
"--input", "source.md",
"--out", "run-demo",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstdout=%s\nstderr=%s", err, out.String(), errOut.String())
}
if kc.gets != 0 || kc.sets != 0 || kc.removes != 0 {
t.Fatalf("keychain touched: gets=%d sets=%d removes=%d", kc.gets, kc.sets, kc.removes)
}
if _, err := os.Stat(filepath.Join("run-demo", "run.json")); err != nil {
t.Fatalf("missing run.json: %v", err)
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
// The human skills-install guidance now lives in the root usage-template
// footer (below the command list), not in the agent-facing Long.
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}

View File

@@ -1,90 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
// (install-method detection, output, error handling). Package-level var so
// tests can stub it and avoid real network / self-update.
var runRootUpgrade = func(cmd *cobra.Command) {
for _, c := range cmd.Root().Commands() {
if c.Name() == "update" && c.RunE != nil {
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
return
}
}
}
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
// AND no flag tokens in the raw invocation.
func isBareRootInvocation(args []string) bool {
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
}
// readYes reads one line and reports whether it is an affirmative y/yes.
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
func readYes(r io.Reader) bool {
line, _ := bufio.NewReader(r).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}
runRootUpgrade(cmd)
}
// installRootUpgradePrompt wraps the root command's RunE (set to
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}
}

View File

@@ -1,191 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadYes(t *testing.T) {
cases := map[string]bool{
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
}
for in, want := range cases {
if got := readYes(strings.NewReader(in)); got != want {
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsBareRootInvocation(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
if !isBareRootInvocation([]string{}) {
t.Error("empty args + no raw flag tokens should be bare")
}
rawInvocationArgs = []string{"--profile", "x"}
if isBareRootInvocation([]string{}) {
t.Error("flag token present → not bare")
}
rawInvocationArgs = nil
if isBareRootInvocation([]string{"im"}) {
t.Error("positional arg → not bare")
}
}
func TestOfferRootUpgrade(t *testing.T) {
origV := build.Version
build.Version = "1.0.0" // release version so shouldSkip()==false
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
// This test builds a Factory literal (no NewDefault), so it never runs
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
cases := []struct {
name string
in, out, err bool
input string
latest string // "" → no state file (CheckCached nil)
optOut bool
wantPrompt, wantRun bool
}{
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Clear env that update.shouldSkip treats as "suppress" so the
// test is deterministic regardless of host (GitHub Actions sets
// CI=true, which would otherwise suppress the prompt).
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(tc.input),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: tc.in,
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
})
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
innerCalls := 0
root := &cobra.Command{Use: "lark-cli"}
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
}
if err := root.RunE(root, []string{"im"}); err != nil {
t.Fatalf("non-bare RunE err = %v", err)
}
if innerCalls != 2 {
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
}
}
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
// path (not the stub used elsewhere): from any command it must locate the
// registered "update" subcommand via cmd.Root() and invoke its RunE.
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
ran := 0
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
child := &cobra.Command{Use: "im"}
root.AddCommand(child)
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
if ran != 1 {
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
}
}
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
// when root has no RunE, installRootUpgradePrompt must not wrap it.
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}
}

View File

@@ -4,211 +4,41 @@
package service
import (
"encoding/json"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
// skill pointer) to a top-level Lark domain's description, returning false for
// anything that is not such a domain. Built lazily at help time because
// shortcuts attach after service registration. skillFS (nil-safe) gates the
// skill pointer.
//
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
// consume <EventKey>'…"); service domains carry only a Short at this point, so
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
// Direct child of root only — so Domain() reads this command's own tag, and
// nested resource groups are excluded.
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
return false
}
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
return false
}
if !cmd.HasAvailableSubCommands() {
return false
}
hasShortcuts, hasResources := false, false
for _, c := range cmd.Commands() {
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
continue
}
if strings.HasPrefix(c.Name(), "+") {
hasShortcuts = true
} else {
hasResources = true
}
}
var b strings.Builder
b.WriteString(domainHelpBase(cmd))
if hasShortcuts && hasResources { // routing only matters when both styles exist
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
cmd.Long = b.String()
return true
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
if base == "" {
base = cmd.Short
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
return base
}
// methodLong is the build-time Long (description + schema pointer +
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
// so command construction never parses the overlay.
func methodLong(description, schemaPath, paramsOnly string) string {
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
}
}
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
}
schemaPath, ok := ann[schemaPathAnnotation]
if !ok {
return false
}
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
skills = a.Skills
}
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
// it carries none.
func RenderAffordanceForCmd(cmd *cobra.Command) string {
raw, ok := affordanceRaw(cmd)
if !ok {
return ""
}
return renderAffordance(meta.Method{Affordance: raw})
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
}
// renderAffordance renders a method's affordance as a help block, or "" when it
// has none. Sections are joined with blank lines so they scan as distinct groups.
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var sections []string
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
@@ -219,18 +49,15 @@ func renderAffordance(m meta.Method) string {
if len(nonEmpty) == 0 {
return
}
var s strings.Builder
fmt.Fprintf(&s, "%s:\n", title)
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&s, " • %s\n", it)
fmt.Fprintf(&b, " • %s\n", it)
}
sections = append(sections, strings.TrimRight(s.String(), "\n"))
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.AvoidWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
bullets("Tips", a.Tips)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
@@ -244,13 +71,10 @@ func renderAffordance(m meta.Method) string {
}
}
if len(lines) > 0 {
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
for _, ext := range a.Extensions {
bullets(ext.Label, ext.Items)
}
bullets("Related", a.Related)
return strings.Join(sections, "\n\n")
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -8,18 +8,15 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"avoid_when": ["群已解散"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"tips": ["富文本用 msg_type=post"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
@@ -32,7 +29,6 @@ func TestRenderAffordance(t *testing.T) {
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Tips:", "富文本用 msg_type=post",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
@@ -52,12 +48,9 @@ func TestRenderAffordance(t *testing.T) {
}
}
// Affordance is rendered lazily (at --help time) rather than baked into the
// command's Long, so building a command never carries the affordance block —
// even for a method whose metadata happens to declare one.
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
@@ -66,120 +59,14 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if strings.Contains(cmd.Long, "Examples:") {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
// lookup and renders it; commands without a ref render nothing.
func TestRenderAffordanceForCmd(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service != "im" || methodID != "messages.create" {
return nil, false
}
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
block := RenderAffordanceForCmd(cmd)
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
if !strings.Contains(block, want) {
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
}
}
// No overlay for this method id -> empty block.
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
if got := RenderAffordanceForCmd(cmd2); got != "" {
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
}
}
// PrepareMethodHelp composes the guidance into Long at the top: description,
// then the affordance block, then the full-schema pointer — so an agent reads
// when-to-use/examples before the flag list.
func TestPrepareMethodHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
// Description leads; affordance block sits above the schema pointer.
descAt := strings.Index(long, "发送消息")
useAt := strings.Index(long, "When to use:")
exAt := strings.Index(long, "Examples:")
schemaAt := strings.Index(long, "Full parameter schema:")
if descAt != 0 {
t.Errorf("description should lead Long, got:\n%s", long)
}
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "event", Short: short, Long: long}
cmdmeta.SetDomain(dom, "event")
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
return dom
}
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
dom := domainCmd("Consume and manage real-time events", long)
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, long) {
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
}
if !strings.Contains(dom.Long, "Risk levels") {
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
}
// Re-rendering must not append the guidance a second time.
PrepareDomainHelp(dom, nil)
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
}
}
// A service domain carries only a Short at help time; it seeds the base.
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
}
}

View File

@@ -60,11 +60,8 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
// The redundant "<name>, required|optional." prefix is gone: required-ness is
// carried by the Required:/Optional: subheadings, and the snake-case --params
// key by the schema envelope — so it isn't echoed on every flag line.
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)

View File

@@ -30,11 +30,6 @@ func fieldFacts(f meta.Field) []string {
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if f.CanonicalType() == "boolean" {
// cobra shows no type word for bools and swallows a separate value as a
// positional, so spell out the presence-only contract.
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
@@ -47,15 +42,20 @@ func fieldFacts(f meta.Field) []string {
return facts
}
// paramFlagUsage renders the typed param flag's help line: the field's facts
// joined inline. Required/optional is not repeated here — the grouped help's
// Required:/Optional: subheadings already partition the flags — and the
// snake-case --params key is carried by the schema envelope (each param's
// property + "flag") and the params-only addendum, so it isn't echoed on every
// line either. Returns "" when the field has no facts (cobra then shows the bare
// flag with its type).
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
func paramFlagUsage(f meta.Field) string {
return strings.Join(fieldFacts(f), ". ")
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
@@ -103,23 +103,8 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
// cross-reference is dropped first (see cutDocRef).
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";\n\r", 60) }
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
// On the compact flag line the markdown link's URL is stripped, so the
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
// so a subject that runs straight into the phrase isn't orphaned.
var docRefRe = regexp.MustCompile(`[。;;,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
// cutDocRef truncates s at the first doc-reference breadcrumb.
func cutDocRef(s string) string {
if loc := docRefRe.FindStringIndex(s); loc != nil {
return s[:loc[0]]
}
return s
}
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -65,38 +64,15 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
refs := apicatalog.ServiceMethods(svc, nil)
// Collect each resource's verbs up front so resourceShort can summarize a
// resource as its verb list from the first ensureChildCommand call.
verbs := map[string][]string{}
for _, ref := range refs {
key := strings.Join(ref.ResourcePath, ".")
verbs[key] = append(verbs[key], ref.Method.Name)
}
for _, ref := range refs {
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// resourceShort summarizes a resource as its sorted verb list, or the
// "<name> operations" placeholder for an intermediate group with no methods.
func resourceShort(seg string, verbs []string) string {
if len(verbs) == 0 {
return seg + " operations"
}
sorted := append([]string(nil), verbs...)
sort.Strings(sorted)
return strings.Join(sorted, ", ")
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
@@ -201,19 +177,7 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
paginates bool // method accepts a page_token param (so --page-all is meaningful)
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
}
// methodPaginates reports whether a method takes a page_token param, the signal
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
func methodPaginates(m meta.Method) bool {
for _, f := range m.Params() {
if f.Name == "page_token" {
return true
}
}
return false
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -222,7 +186,6 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
serviceName: ref.Service.Name,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
@@ -230,7 +193,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
paginates: methodPaginates(m),
affordance: renderAffordance(m),
}
}
@@ -291,14 +254,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
// Keep the pagination flags registered (a harmless no-op if passed) but hide
// them from help on non-paginating commands, so help doesn't imply a
// get/write can paginate.
if !spec.paginates {
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
@@ -316,11 +271,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
// (setMethodHelpData records the coordinates it needs).
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
@@ -338,11 +292,13 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// Keep the precedence rule on the flag's own one line (not a multi-line
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
// meaningful when typed flags exist to override.
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
if len(spec.params) > 0 {
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {

View File

@@ -1,169 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"context"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
// whoamiResult is the structured output of `lark-cli whoami`.
//
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
// the app acting as itself; a user identity is the app acting *on behalf of* a
// person (calls are attributed to that user, who is not necessarily present).
// onBehalfOf only *names* that person and so appears only once a user is
// resolved — a user identity that is not signed in still has identity "user"
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
DefaultAs string `json:"defaultAs"`
Identity string `json:"identity"`
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
}
// delegatedUser is the user a user-identity acts on behalf of.
type delegatedUser struct {
UserName string `json:"userName,omitempty"`
OpenID string `json:"openId,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. Output is always
// JSON — whoami is consumed by agents. With the built-in credential path it is
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
return NewCmdWhoamiWithContext(context.Background(), f)
}
// NewCmdWhoamiWithContext creates the whoami command using the build context
// for registration-time strict-mode presentation.
func NewCmdWhoamiWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &opts.As)
// Output is always JSON. Accept (and ignore) --json so existing
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
// mode exists.
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
_ = cmd.Flags().MarkHidden("json")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
return err
}
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
if err := f.CheckStrictMode(ctx, as); err != nil {
return err
}
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
source := resolveSource(
cmd.Flags().Changed("as"),
flagAs,
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
// resolveSource derives how the effective identity became effective.
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto_detect"
}
if strictForced != "" {
return "strict_mode"
}
return "default_as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = diag.Bot.Hint
}
default: // user
res.Available = diag.User.Available
// Use Status (not the raw TokenStatus) so the vocab matches the bot
// branch: "ready" means usable for both. available stays the canonical
// usable signal; tokenStatus is the readable state behind it.
res.TokenStatus = diag.User.Status
// Set onBehalfOf only when a user is actually resolved; an unresolved
// user identity (not signed in) has no one to act on behalf of yet.
if diag.User.UserName != "" || diag.User.OpenID != "" {
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
}
if !diag.User.Available {
res.Hint = diag.User.Hint
}
}
return res
}

View File

@@ -1,320 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestResolveSource(t *testing.T) {
tests := []struct {
name string
changedAs bool
flagAs core.Identity
autoDetected bool
strictForced core.Identity
want string
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
{"auto detected", false, "", true, "", "auto_detect"},
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
{"default_as", false, "", false, "", "default_as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
if got != tt.want {
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
t.Fatalf("app context = %#v", r)
}
}
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
// whoami renders the diagnosed hint verbatim (single source of truth) so it
// stays correct for the external-provider path without whoami knowing about it.
if r.Hint != diag.User.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
}
}
func TestBuildResult_BotReady(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OnBehalfOf != nil {
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
}
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint != diag.Bot.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
}
}
func TestWhoami_BotJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if !got.Available || got.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
}
if got.Profile != "test-profile" {
t.Fatalf("profile = %q, want test-profile", got.Profile)
}
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OnBehalfOf != nil {
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
}
}
func TestWhoami_RejectsInvalidAs(t *testing.T) {
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
t.Run("as="+bad, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", bad})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
}
// Lock in the typed validation contract: an unsupported identity must
// surface as a *errs.ValidationError on --as, not just any error.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--as" {
t.Errorf("Param = %q, want %q", ve.Param, "--as")
}
})
}
}
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
wantErr := fmt.Errorf("boom")
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want propagated config error")
}
// The f.Config() failure must propagate unchanged, not be masked by a later
// command-execution error.
if !errors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// Regression for the external-provider blind spot: with credentials managed by
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
t.Fatalf("got %#v, want user/available/ready", got)
}
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
}
if got.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.Hint)
}
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || got.Available {
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
}
if strings.Contains(got.Hint, "auth login") {
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
}
if !strings.Contains(got.Hint, "external") {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list. The embed must live
// in this root package because go:embed cannot reach up out of a package's dir.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
// init wires the embedded content into the CLI. It compiles into `go build .` but
// not the single-file preview build (`go build ./main.go`), so that build stays
// self-contained (shipping no embedded content). Assembly failures warn on stderr
// rather than panicking — embedded content is nice-to-have, not load-bearing.
func init() {
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
affordance.SetSource(sub)
}
}

View File

@@ -1,259 +0,0 @@
# `slides +create-svglide` Codex Runtime Design
Date: 2026-07-02
Branch: `feat-svglide-07`
Scope: first local-only version of `lark-cli slides +create-svglide`
## Result
Build `slides +create-svglide` as a staged local runtime for AnyGen SVG Slides. The command creates and manages a run directory that Codex can fill with generated content, assets, and SVG slides. The CLI owns state, prompts, schemas, validation, preview, receipts, and recovery. Codex owns LLM reasoning, web research, image/search execution, chart design, and SVG authoring.
The first version does not publish to Feishu Slides. It must produce a local, inspectable SVG deck workbench.
## Context
`feat-svglide-07` currently starts from the latest `origin/main` and has only the existing Slides XML shortcut surface. There is no current `+create-svglide` implementation on this branch.
The AnyGen SVG Slides prompt should be reused as contracts and workflow rules, not pasted as one large prompt. Its value is split across request interpretation, research, design brief, outline, `slide_content.md`, asset planning, SVG authoring, protocol validation, preview, and repair.
## Goals
- Add a staged `slides +create-svglide` command group.
- Create a local run directory under a user-specified `--out` path, usually `.lark-slides/svglide-runs/<run-id>`.
- Generate prompt task files that tell Codex exactly what to produce for each stage.
- Generate JSON schemas for stage outputs.
- Track stage state in `run.json`.
- Validate JSON outputs, SVG protocol basics, asset href existence, slide count, placeholder slides, and preview generation.
- Generate `preview.html` for local inspection.
- Write receipts and `repair_queue.md` so failed runs can resume from the current stage.
## Non-Goals
- No online Feishu Slides creation.
- No `slide_engine` or `slide` server changes.
- No SVG-to-SXSD conversion.
- No built-in model API provider.
- No built-in web search, image generation, or image search client.
- No complete 12-agent process runner.
- No PPTX import/edit workflow.
## Command Surface
```bash
lark-cli slides +create-svglide init --title "Demo" --input ./source.md --audience "..." --delivery-mode self_read --pages 8 --out ./.lark-slides/svglide-runs/demo
lark-cli slides +create-svglide next <run-dir>
lark-cli slides +create-svglide status <run-dir>
lark-cli slides +create-svglide validate <run-dir>
lark-cli slides +create-svglide preview <run-dir>
```
`init` creates the run directory, writes the initial request files, schemas, stage prompts, and `run.json`.
`next` reads `run.json`, finds the next stage, verifies required inputs, renders or refreshes that stage's Codex task prompt, and reports the exact files Codex must create. It must not pretend LLM work is complete.
`status` checks declared outputs and receipts for each stage, then prints the current stage, missing files, and next useful command.
`validate` runs deterministic checks and writes validation receipts.
`preview` writes `preview.html` from `outline/deck.json` and `slides/*.svg`.
## Run Directory Contract
```text
<run-dir>/
run.json
README.md
request/request.json
request/source_manifest.json
research/research_notes.md
research/sources.json
brief/design_brief.json
brief/visual_system.json
outline/deck.json
content/slide_content.md
content/slide_content.json
assets/assets_plan.json
assets/images/
assets/charts/
slides/*.svg
prompts/*.task.md
schemas/*.schema.json
receipts/*.json
receipts/generation_summary.md
repair_queue.md
preview.html
```
The run directory is local agent state. It should not be committed by default.
## State Model
`run.json` stores:
- version
- runtime, always `codex` in v1
- command name
- title
- created and updated timestamps
- current stage
- stage list with status, inputs, outputs, and receipt path
- important artifact paths
- policy flags: `publish_enabled=false`, `network_by_codex=true`, `image_generation_by_codex=true`, `overwrite=false`
Stage statuses:
```text
pending
ready
in_progress
done
failed
blocked
needs_repair
```
## Stage Design
### 1. request
Role: Request Interpreter
Input: CLI flags and local source path.
Output: `request/request.json`, `request/source_manifest.json`.
Validation: title, audience, delivery mode, page count, and source references must be explicit or marked missing.
### 2. research
Role: Researcher
Input: request files and source files.
Output: `research/research_notes.md`, `research/sources.json`.
Validation: key facts need source references. Codex may perform web research, but the CLI only validates resulting files.
### 3. design_brief
Role: Design Brief Resolver and Visual System Planner
Input: request and research outputs.
Output: `brief/design_brief.json`, `brief/visual_system.json`.
Validation: narrative spine, depth, tone, and visual system dimensions must be present.
### 4. outline
Role: Outline Planner
Input: design brief.
Output: `outline/deck.json`.
Validation: page count matches request; each slide has id, title, summary, role, and key message.
### 5. slide_content
Role: Content Builder
Input: deck outline and research notes.
Output: `content/slide_content.md`, `content/slide_content.json`.
Validation: every slide has key material, content blocks, and source notes. This is content planning, not final layout.
### 6. assets
Role: Asset Planner and Chart Generator
Input: slide content and visual system.
Output: `assets/assets_plan.json`, optional `assets/images/*`, optional `assets/charts/*.svg`.
Validation: every planned asset has purpose plus either a local path or a fallback. Chart takeaway must be written before chart type.
### 7. svg_author
Role: SVG Author
Input: deck, slide content, visual system, and assets.
Output: `slides/*.svg`.
Validation: each slide must contain more than a background. Each slide needs a background, title, visible content or visual element, semantic id, and valid SVG root.
### 8. validate_preview_repair
Role: Protocol Validator, Preview Agent, and Repair Agent
Input: generated slides.
Output: `receipts/lint.json`, `receipts/preview.json`, `repair_queue.md`, `preview.html`.
Validation: SVG protocol lint, local href checks, slide count match, preview write success, and unresolved issues recorded in the repair queue.
## Code Layout
```text
shortcuts/slides/
slides_create_svglide.go
slides_create_svglide_test.go
internal/svglide/
run.go
init.go
stage.go
prompt.go
schema.go
validate.go
preview.go
receipt.go
```
The shortcut package should stay thin. State, prompt rendering, validation, and preview logic belong in `internal/svglide` so they can be tested without a Cobra/runtime-heavy command harness.
## Skill Documentation
Update `skills/lark-slides/SKILL.md` and add a focused reference file for the local SVG runtime. The skill should explain that `+create-svglide` is local-only in v1, requires Codex to fill stage outputs, and must not be described as an online publish path.
## Error Handling
- Missing required inputs block the stage and write a receipt.
- Invalid JSON or schema mismatch marks the stage failed.
- Invalid SVG marks `needs_repair` and writes `repair_queue.md`.
- Existing output paths are not overwritten unless an explicit overwrite policy is enabled.
- Partially completed stages remain inspectable; reruns resume from the current stage.
## Tests
Unit tests:
- `init` creates the expected directory tree and `run.json`.
- `init` refuses to overwrite an existing run directory by default.
- `status` identifies missing outputs.
- `next` renders the correct stage prompt and does not mark Codex-only stages done.
- `validate` catches invalid SVG, missing hrefs, placeholder slides, and slide count mismatch.
- `preview` writes HTML that references generated SVG files.
Fixtures:
- `testdata/svglide_run_valid/`
- `testdata/svglide_run_invalid/`
No live end-to-end test is required for v1 because this version does not call Feishu APIs.
## Acceptance Criteria
- A user can initialize a run directory from local input.
- Codex can follow generated task prompts stage by stage.
- The CLI can report status and missing artifacts.
- The CLI can validate a completed local SVG deck.
- The CLI can generate local preview HTML.
- Failed validation produces actionable repair output.
- No online presentation is created.
## Further Judgment
This design deliberately optimizes for artifact contracts rather than agent-count symmetry. Once the local runtime is stable, individual stages can be split into fuller agents without changing the run directory contract.

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
{
"doc_url": "https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd",
"local_full_snapshot": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.md",
"local_handoff": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md",
"fetched_by": "local export",
"fetched_for": "slides +create-svglide AnyGen SVG prompt runtime experiment",
"experiment_mode": "experiment_unrestricted_assets"
}

View File

@@ -1,20 +0,0 @@
# AnyGen SVG Slides Local Outline
Source full snapshot: `docs/vendor/anygen-svg/source.full.md`
Source handoff: `/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md`
Remote doc: `https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd`
Required sections to split:
- System prompt编排 / mode_system_prompt_svg
- SVG reference协议 schema + 设计规范 / svg_reference
- resolve_design_brief
- slide_outline
- activate_slides_edit
- slides_edit
- finish_slides_edit
- slide_organize
- compute_custom_shape_bbox
- generate_svg_chart
- slides_convert
- slides_parse_template

View File

@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
WithMissingScopes("mail:user_mailbox.message:send").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
WithHint("run lark-cli auth login --scope calendar:event:create").
WithMissingScopes("calendar:event:create").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
buf, err := json.Marshal(e)
if err != nil {
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"identity": "user",
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
"missing_scopes": []any{"calendar:event:create"},
}
for k, want := range wantFields {

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
type VCParticipantMeetingJoinedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingJoinedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -1,281 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
schemaType reflect.Type
}{
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if def.Schema.Custom.Type != tc.schemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{
name: "started",
eventType: eventTypeMeetingStarted,
process: processVCParticipantMeetingStarted,
},
{
name: "joined",
eventType: eventTypeMeetingJoined,
process: processVCParticipantMeetingJoined,
},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_001",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
if out["type"] != tc.eventType {
t.Errorf("type = %q", out["type"])
}
if out["event_id"] != "ev_vc_lifecycle_001" {
t.Errorf("event_id = %q", out["event_id"])
}
if out["timestamp"] != "1608725989000" {
t.Errorf("timestamp = %q", out["timestamp"])
}
if out["meeting_id"] != "6911188411934433028" {
t.Errorf("meeting_id = %q", out["meeting_id"])
}
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
}
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
t.Errorf("start_time = %q, want %q", out["start_time"], want)
}
if _, hasEndTime := out["end_time"]; hasEndTime {
t.Error("end_time should not be present in started/joined output")
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_002",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
switch tc.eventType {
case eventTypeMeetingStarted:
var started VCParticipantMeetingStartedOutput
if err := json.Unmarshal(out, &started); err != nil {
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
}
if started.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", started.StartTime)
}
case eventTypeMeetingJoined:
var joined VCParticipantMeetingJoinedOutput
if err := json.Unmarshal(out, &joined); err != nil {
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
}
if joined.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
}
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventType)
})
}
}
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
t.Helper()
got := runMeetingLifecycleRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out map[string]string
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
}
return out
}
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
type VCParticipantMeetingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingStartedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -11,8 +11,6 @@ import (
)
const (
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
@@ -32,38 +30,6 @@ const (
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingStarted,
DisplayName: "Participant meeting started",
Description: "Triggered when a meeting the current user participates in has started",
EventType: eventTypeMeetingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
},
Process: processVCParticipantMeetingStarted,
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
},
{
Key: eventTypeMeetingJoined,
DisplayName: "Participant meeting joined",
Description: "Triggered when the current user joins a meeting",
EventType: eventTypeMeetingJoined,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
},
Process: processVCParticipantMeetingJoined,
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
},
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance is the lazily-loaded store of usage guidance for
// service-API methods. The source of truth is one markdown file per service in
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
// domain owners maintain it next to skills/ and shortcuts/. A service is read
// and parsed at most once, on first access, so normal command execution never
// touches it.
package affordance
import (
"encoding/json"
"io/fs"
"strings"
"sync"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
var (
mu sync.Mutex
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
// For returns the raw affordance overlay for one method, loading the owning
// service on first access. ok is false when there is no entry (absent source,
// parse failure, or unknown method all collapse to "no guidance").
func For(service, methodID string) (json.RawMessage, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return nil
}
m := map[string]json.RawMessage{}
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list") via the registry's
// authoritative resource↔id table. Resource names are irregularly pluralised
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"encoding/json"
"testing"
"testing/fstest"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
// paragraph (use_when) and a fenced example.
const fixtureMD = "# approval\n" +
"> skill: lark-approval\n\n" +
"## instances cc\n" +
"把一个审批实例抄送给指定用户。\n\n" +
"### Examples\n\n" +
"**抄送给用户**\n" +
"```bash\n" +
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
"```\n\n" +
"## instances get\n" +
"查询某审批实例详情。\n\n" +
"### Examples\n\n" +
"**按 code 查询**\n" +
"```bash\n" +
"lark-cli approval instances get --instance-code \"x\"\n" +
"```\n"
func TestFor(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
// A seeded method in a seeded service resolves to its overlay.
raw, ok := For("approval", "instances.cc")
if !ok {
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
}
var a struct {
UseWhen []string `json:"use_when"`
Examples []struct {
Command string `json:"command"`
} `json:"examples"`
}
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("overlay is not valid affordance JSON: %v", err)
}
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
t.Errorf("overlay missing use_when/examples: %s", raw)
}
// Misses: unknown method in a known service, and an unknown service, both
// resolve to ok=false (no panic, no error) so callers treat them as "no
// guidance".
if _, ok := For("approval", "instances.no_such_method"); ok {
t.Error("unknown method should be ok=false")
}
if _, ok := For("no_such_service", "x.y"); ok {
t.Error("unknown service should be ok=false")
}
// A second lookup of the same service is served from cache (parsed at most
// once) and stays consistent.
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
// dropped (regression: they previously only updated pending, lost without a fence).
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
t.Errorf("Tips paragraph dropped: %v", a.Tips)
}
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}

View File

@@ -1,180 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
//
// # domain optional `> skill: <name>` applied to every method
// ## command e.g. `instances get`
// <lead paragraph> -> use_when (when this command is right)
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
// Parsing is lazy and cached (see For), so the constrained grammar is read at
// most once per domain.
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
// standardSection maps a section heading to its typed Affordance field; any
// other heading becomes an extension.
var standardSection = map[string]string{
"Avoid when": "avoid_when",
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
label string
items []string
cases []meta.AffordanceCase
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
out := map[string]meta.Affordance{}
var skill, curKey string
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
var secs []*mdSection
var sec *mdSection
var pending string
var fence []string
inFence := false
assemble := func() {
if curKey == "" {
return
}
if len(para) > 0 {
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
para = nil
}
var a meta.Affordance
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
a.AvoidWhen = s.items
case "prerequisites":
a.Prerequisites = s.items
case "tips":
a.Tips = s.items
case "examples":
a.Examples = s.cases
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
// flushPending appends a non-bullet paragraph line that was not consumed as
// an example description (i.e. no fence followed) to the current section's
// items, so prose under any section is preserved rather than dropped.
flushPending := func() {
if sec != nil && pending != "" {
sec.items = append(sec.items, linkToBacktick(pending))
pending = ""
}
}
for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
flushPending()
assemble()
curKey = resolve(line[3:])
reset()
continue
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
sec = &mdSection{label: strings.TrimSpace(line[4:])}
secs = append(secs, sec)
pending, fence, inFence = "", nil, false
continue
}
if curKey == "" {
continue
}
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
if t == "" {
if len(para) > 0 {
useWhen = append(useWhen, strings.Join(para, " "))
para = nil
}
} else {
para = append(para, t)
}
continue
}
// inside a section: a fenced block is an example command; otherwise the
// shape follows the writing (bullet item vs **description** before a fence).
if strings.HasPrefix(t, "```") {
if !inFence {
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue
}
if inFence {
fence = append(fence, line)
continue
}
if strings.HasPrefix(t, "-") {
flushPending()
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
} else if t != "" {
flushPending()
pending = strings.Trim(t, "* ")
}
}
flushPending()
assemble()
return out
}

View File

@@ -48,22 +48,6 @@ type Factory struct {
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
type skipCredentialBootstrapKey struct{}
// ContextWithCredentialBootstrapDisabled marks a command-tree build as
// credential-free. Use it only for purely local command surfaces that must be
// constructed without probing strict-mode, profile, or keychain state.
func ContextWithCredentialBootstrapDisabled(ctx context.Context) context.Context {
return context.WithValue(ctx, skipCredentialBootstrapKey{}, true)
}
// IsCredentialBootstrapDisabled reports whether credential-backed bootstrap
// probes must be skipped for this context.
func IsCredentialBootstrapDisabled(ctx context.Context) bool {
v, _ := ctx.Value(skipCredentialBootstrapKey{}).(bool)
return v
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
@@ -125,9 +109,6 @@ func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
}
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
if IsCredentialBootstrapDisabled(ctx) {
return nil
}
if f.Credential == nil {
return nil
}
@@ -167,9 +148,6 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
// ResolveStrictMode returns the effective strict mode by reading
// Account.SupportedIdentities from the credential provider chain.
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if IsCredentialBootstrapDisabled(ctx) {
return core.StrictModeOff
}
if f.Credential == nil {
return core.StrictModeOff
}

View File

@@ -18,9 +18,6 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
@@ -30,24 +27,19 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}

View File

@@ -10,14 +10,12 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
@@ -446,27 +444,28 @@ func extractMissingScopes(resp map[string]any) []string {
return out
}
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` query parameter so the console can pre-select them.
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
// QueryEscape both values — clientID and scopes both sit in the query
// string, and untrusted content must not be able to inject extra query
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
// open-platform base URL stays a single source of truth.
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
if len(scopes) == 0 {
return base
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
}
func intFromAny(v any) int {

View File

@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
}
}
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
}
}
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"scopes=scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"scopes=scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"clientID=good?q=injected"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"clientID=good#fragment"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
},
{
name: "slash in appID does not open a new path segment",
name: "slash in appID escapes path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
},
}
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
}
}
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") {

View File

@@ -10,20 +10,8 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -27,13 +27,6 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
// Secure label endpoint codes observed from drive +secure-label-update
// failure telemetry.
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -102,35 +102,6 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
}
}
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_Unknown(t *testing.T) {
_, ok := LookupCodeMeta(999999)
if ok {

View File

@@ -13,7 +13,6 @@ import (
"strings"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -62,131 +61,12 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
if ctx == nil {
ctx = context.Background()
}
// An external provider mints tokens on demand and blocks interactive auth,
// so the built-in keychain heuristics and "auth login" hints don't apply.
if provider := activeExternalProvider(ctx, f); provider != "" {
return diagnoseExternal(ctx, f, cfg, provider, verify)
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
// activeExternalProvider returns the active extension provider name, or "".
// An error degrades to the built-in path: an unreachable provider would already
// have failed the f.Config() that produced cfg.
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
if f == nil || f.Credential == nil {
return ""
}
name, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return ""
}
return name
}
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
if cfg == nil || cfg.AppID == "" {
notConfigured := Identity{
Status: StatusNotConfigured,
Message: "not configured (missing app config)",
Hint: externalCredentialHint(provider),
}
return Result{Bot: notConfigured, User: notConfigured}
}
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
return Result{
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
}
}
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("Bot", provider)
}
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("User", provider)
}
// enrichUserInfo populates UserOpenId only after the provider returns and
// verifies a UAT (and clears it on failure), so a resolved open id is the
// external analogue of a keychain token being present.
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: not signed in via credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
id := Identity{
Status: StatusReady,
Available: true,
TokenStatus: StatusReady,
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
Message: "User identity: ready (provided by " + provider + ")",
}
if !verify {
return id
}
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
return externalVerifyFailed(id, "User", provider, err)
}
id.Verified = boolPtr(true)
return id
}
func notProvidedExternally(label, provider string) Identity {
return Identity{
Status: StatusNotConfigured,
Message: label + " identity: not provided by credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
// (open id, user name) already resolved before the probe.
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
id.Available = false
id.Verified = boolPtr(false)
id.Status = StatusVerifyFailed
id.TokenStatus = ""
id.Message = label + " identity: verify failed: " + err.Error()
id.Hint = externalCredentialHint(provider)
return id
}
// externalCredentialHint reports the constraint, not a remediation: the
// identity is the provider's to manage, not lark-cli's to fix. What to do about
// it is the caller's call — there may be no user to ask.
func externalCredentialHint(provider string) string {
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{

View File

@@ -10,11 +10,9 @@ import (
"testing"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
@@ -350,136 +348,3 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
// external-credential diagnosis path. account makes the provider "active";
// token (when set) satisfies ResolveToken during verify.
type fakeExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return p.token, nil
}
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
cred := credential.NewCredentialProvider(
[]extcred.Provider{prov}, nil, nil,
func() (*http.Client, error) { return nil, nil },
)
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{},
}
}
// assertExternalHint locks the contract that an external-provider hint never
// points at interactive commands blocked under an external provider.
func assertExternalHint(t *testing.T, hint string) {
t.Helper()
if hint == "" {
t.Fatalf("hint empty, want external guidance")
}
for _, blocked := range []string{"auth login", "config --help"} {
if strings.Contains(hint, blocked) {
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
}
}
if !strings.Contains(hint, "external") {
t.Fatalf("hint %q should explain credentials are external", hint)
}
}
func TestDiagnose_External_UserReady(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
// The bug this guards: the built-in path read the keychain (empty under an
// external provider) and reported the user as missing. Now availability
// follows the resolved account, so a signed-in user reads as ready.
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
t.Fatalf("user identity = %#v", got.User)
}
if got.User.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
}
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
}
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Available || got.User.Status != StatusMissing {
t.Fatalf("user = %#v, want missing/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_BotOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
// Provider declares bot-only: user is unavailable even though an open id is
// present, and the hint is external (not "auth login").
if got.User.Available || got.User.Status != StatusNotConfigured {
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_UserOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.User.Available || got.User.Status != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
}
assertExternalHint(t, got.Bot.Hint)
}
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user = %#v, want available and verified", got.User)
}
}
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Available || got.User.Status != StatusVerifyFailed {
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("verified = %v, want false", got.User.Verified)
}
assertExternalHint(t, got.User.Hint)
}

View File

@@ -5,39 +5,30 @@ package meta
import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
// Affordance is the hand-authored usage guidance overlaid on a method: when to
// use it, when not to, prerequisites, few-shot examples, and related methods.
// It is the single typed model of the affordance shape; the envelope renderer
// and the command help both parse through ParsedAffordance so the vocabulary
// is defined once. The JSON tags double as the envelope's wire shape.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Tips []string `json:"tips,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Extensions []AffordanceSection `json:"extensions,omitempty"`
Related []string `json:"related,omitempty"`
Skills []string `json:"skills,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
type AffordanceCase struct {
Description string `json:"description,omitempty"`
Description string `json:"description"`
Command string `json:"command"`
}
// AffordanceSection is a custom guidance section: any heading beyond the
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
// here with its label preserved, so authors can add sections without code
// changes.
type AffordanceSection struct {
Label string `json:"label"`
Items []string `json:"items,omitempty"`
}
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
// malformed, or wholly empty — callers treat all three as "no guidance".
// ParsedAffordance decodes the method's raw affordance overlay into the typed
// Affordance. ok is false when the method carries no affordance, the JSON is
// malformed, or every section is empty — so callers can treat "no guidance"
// uniformly.
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
@@ -46,7 +37,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
return Affordance{}, false
}
return a, true

View File

@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
notOK := map[string]string{
"empty payload": ``,
"empty object": `{}`,
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
@@ -35,9 +35,8 @@ func TestMethod_ParsedAffordance(t *testing.T) {
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"avoid_when": ["已知具体 calendar_id"],
"do_not_use_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
@@ -48,22 +47,10 @@ func TestMethod_ParsedAffordance(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
t.Errorf("Tips = %v", a.Tips)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
// A method whose only guidance is Tips still parses as populated.
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
if !ok {
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
}
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
t.Errorf("Tips = %v", tipsOnly.Tips)
}
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"sync"
"time"
)
// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}
done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()
go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished
out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}

View File

@@ -52,9 +52,6 @@ func isPlaceholderValue(value string) bool {
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
printfPlaceholderValue(normalized) ||
htmlEntityAnglePlaceholder(normalized) ||
starMaskedPlaceholder(normalized) ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
@@ -64,42 +61,12 @@ func isPlaceholderValue(value string) bool {
return namedPlaceholderValue(normalized)
}
func htmlEntityAnglePlaceholder(value string) bool {
if !strings.HasPrefix(value, "&lt;") || !strings.HasSuffix(value, "&gt;") {
return false
}
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "&lt;"), "&gt;"))
}
func starMaskedPlaceholder(value string) bool {
var stars int
for _, r := range value {
if r == '*' {
stars++
continue
}
return false
}
return stars >= 3
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") ||
allXPlaceholder(value) ||
conventionalNamedPlaceholderValue(value)
}
func printfPlaceholderValue(value string) bool {
switch value {
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
return true
default:
return false
}
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
}
func allXPlaceholder(value string) bool {
@@ -114,41 +81,6 @@ func allXPlaceholder(value string) bool {
return true
}
func conventionalNamedPlaceholderValue(value string) bool {
if !delimitedPlaceholderIdentifier(value) {
return false
}
normalized := strings.ReplaceAll(value, "-", "_")
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
return conventionalCredentialPlaceholderName(rest)
}
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
return conventionalCredentialPlaceholderName(rest)
}
return false
}
func conventionalCredentialPlaceholderName(value string) bool {
switch value {
case "api_key",
"access_key",
"private_key",
"secret",
"password",
"passwd",
"token",
"webhook",
"access_token",
"refresh_token",
"bearer_token",
"session_token",
"client_secret":
return true
default:
return false
}
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||

View File

@@ -4,10 +4,7 @@
package publiccontent
import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -55,9 +52,8 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isBenignCodeCredentialExpression(file, value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
@@ -67,27 +63,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
if isSchemaDottedIdentifier(line, match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
if isPlaceholderBearerHeader(match) {
continue
}
for range bearerHeaderRE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(file, match) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
@@ -134,9 +124,6 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -274,7 +261,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(key, value)
return tokenLikePlaceholderValue(value)
default:
return false
}
@@ -286,16 +273,12 @@ func tokenLikePlaceholderKey(key string) bool {
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(key, value string) bool {
func tokenLikePlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
@@ -305,149 +288,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
strings.HasPrefix(normalized, ".")
}
func maskedTokenFixturePlaceholderValue(key, value string) bool {
if authCredentialTokenKey(key) {
return false
}
var stars, alnum int
for _, r := range value {
switch {
case r == '*':
stars++
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
alnum++
default:
return false
}
}
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"api_token",
"bot_token",
"refresh_token",
"secret_token",
"session_token",
"service_token",
"bearer_token",
"auth_token",
"authorization_token",
"id_token":
return true
default:
return false
}
}
func isPermissionScopeIdentifierAssignment(key, value string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
switch strings.ToLower(strings.Trim(value, `"',;`)) {
case "read", "write", "modify", "readonly", "get_as_user":
return true
default:
return false
}
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
@@ -488,87 +328,20 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
func isBenignCodeCredentialExpression(file, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || credentialShapedValue(value) {
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
}
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
return true
}
if strings.Contains(match, "+") {
return true
}
if rawValueQuoted {
return false
}
if quotedLiteral(value) {
return sourceCodeLiteralLooksNonSecret(value, false)
}
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
assignmentIdx := strings.Index(typeAndRHS, "=")
if assignmentIdx < 0 {
return "", false
}
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ",;")
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
return true
}
if credentialShapedValue(value) {
return false
}
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
return true
}
if quotedLiteral(value) {
return false
}
return codeReferenceExpression(value)
}
func credentialAssignmentRawValueQuoted(match string) bool {
key, ok := credentialAssignmentKey(match)
if !ok {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
case ".go", ".py":
return true
default:
return false
@@ -582,147 +355,7 @@ func quotedLiteral(value string) bool {
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
literal := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(literal, "/") {
return true
}
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
sourceCodeEnvVarNameLiteral(literal) ||
sourceCodeAttributeNameLiteral(literal) ||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
}
func sourceCodeFormatArgumentContext(line, match string) bool {
idx := strings.Index(line, match)
if idx < 0 {
return false
}
prefix := line[:idx]
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
prefix = prefix[semicolon+1:]
}
return strings.Contains(prefix, "fmt.") ||
strings.Contains(prefix, "log.") ||
strings.Contains(prefix, "printf(") ||
strings.Contains(prefix, "Printf(") ||
strings.Contains(prefix, "Errorf(") ||
strings.Contains(prefix, "Fprintf(")
}
func sourceCodeFormatStringLiteral(value string) bool {
for i := 0; i < len(value)-1; i++ {
if value[i] != '%' {
continue
}
if value[i+1] == '%' {
i++
continue
}
j := i + 1
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
j++
}
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
return true
}
}
return false
}
func sourceCodeEnvVarNameLiteral(value string) bool {
if value == "" || !strings.Contains(value, "_") {
return false
}
var hasCredentialMarker bool
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_':
default:
return false
}
}
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
if strings.Contains(value, marker) {
hasCredentialMarker = true
break
}
}
return hasCredentialMarker
}
func sourceCodeAttributeNameLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
}
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "fake_") ||
strings.HasPrefix(normalized, "fake-") ||
strings.Contains(normalized, "placeholder") ||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
}
func sourceCodeCredentialTermLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
return conventionalCredentialPlaceholderName(normalized)
}
func sourceCodeCredentialPrefixLiteral(value string) bool {
switch strings.ToLower(value) {
case "appsecret:":
return true
default:
return false
}
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
return true
default:
return false
}
}
func sourceCodeSchemaTypeLiteral(value string) bool {
normalized := strings.ToLower(value)
return normalized == "string" || strings.HasPrefix(normalized, "string(")
}
func benignCredentialStatusLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
if !delimitedPlaceholderIdentifier(normalized) {
return false
}
for _, marker := range []string{
"bad_fmt",
"expired",
"format",
"invalid",
"missing",
"permission",
"status",
"type",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func codeReferenceExpression(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ";")
if value == "" {
return false
}
@@ -731,10 +364,7 @@ func codeReferenceExpression(value string) bool {
return true
}
}
if !codeIdentifier(value) {
return false
}
return codeIdentifier(value)
return codeIdentifier(value) && !credentialNameFragment(value)
}
func codeIdentifier(value string) bool {
@@ -751,6 +381,20 @@ func codeIdentifier(value string) bool {
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isSchemaDottedIdentifier(line, match string) bool {
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
@@ -760,40 +404,6 @@ func isNonSecretLiteralValue(value string) bool {
}
}
func isJWTToken(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 3 {
return false
}
header, err := decodeBase64URLSegment(parts[0])
if err != nil || !json.Valid(header) {
return false
}
var fields map[string]interface{}
if err := json.Unmarshal(header, &fields); err != nil {
return false
}
alg, ok := fields["alg"].(string)
return ok && alg != ""
}
func decodeBase64URLSegment(value string) ([]byte, error) {
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.URLEncoding.DecodeString(value)
}
func isPlaceholderBearerHeader(match string) bool {
normalized := strings.ToLower(match)
idx := strings.LastIndex(normalized, "bearer ")
if idx < 0 {
return false
}
value := strings.TrimSpace(match[idx+len("bearer "):])
return isPlaceholderValue(value)
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
@@ -952,7 +562,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(file, raw string) bool {
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -961,8 +571,7 @@ func isPlaceholderCredentialURL(file, raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -976,46 +585,6 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {
@@ -1172,12 +741,7 @@ func sanitizeSemanticExcerpt(text string) string {
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
if isJWTToken(match) {
return "<jwt-like-token>"
}
return match
})
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}

View File

@@ -61,19 +61,6 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
}
}
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
if len(benign) != 0 {
@@ -224,7 +211,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
}
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
if !findingRules(got)["public_content_detector_fingerprint"] {
t.Fatalf("expected detector fingerprint finding, got %#v", got)
}
@@ -562,7 +549,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
}
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
"inPrivateKey = true",
"inPrivateKey = false",
"hasPrivateKey: false",
@@ -645,45 +632,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
for _, item := range got {
@@ -700,7 +648,6 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -714,8 +661,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
}
}
@@ -777,70 +724,8 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
}
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
@@ -849,7 +734,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -858,7 +743,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
}
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
"AppSecret: stored,",
"AccessToken: result.Token.AccessToken,",
`token := runtime.Str("token")`,
@@ -871,7 +756,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
}
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
"def normalize_token(value: str) -> str:",
" token = rest[index]",
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
@@ -885,174 +770,8 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
}
}
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
"class Counter:",
" def __init__(self) -> None:",
" self._token_kind: TokenKind | None = None",
" self.access_token: AccessToken | None = None",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
`return fmt.Errorf("failed to remove token: %v", err)`,
`const LarkErrTokenMissing = "token_missing"`,
`const LarkErrTokenExpired = 99991677`,
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
`const LargeAttachmentTokenAttr = "data-mail-token"`,
`const fakeOfficeTokenPrefix = "fake_office_"`,
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
`tokenTypeHint := "access_token"`,
`const TokenTenant Token = "tenant"`,
`const secretKeyPrefix = "appsecret:"`,
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
`return &credential.TokenResult{Token: "test-token"}, nil`,
`fmt.Fprintf(w, "password=%s\n", pat)`,
`text += "(img_token:" + imgToken + ")"`,
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
`this.token = token;`,
`// AppSecret: "appsecret:<appId>"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
`{"token":"&lt;wiki_token&gt;"}`,
`{"token":"Pgrrwvr***********UnRb"}`,
`"scope_name": "auth:user_access_token:read"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
"LARKSUITE_CLI_APP_SECRET=dry-run",
"client_secret: dry_run",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
cases := []struct {
name string
file string
text string
}{
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ScanFile(tc.file, []byte(tc.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("typed credential assignment should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
"client_secret=%s",
"access_token=%v",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
`<img token="..." url="https://..." width="..." height="..."/>`,
`<sheet token="..." sheet-id="...">`,
}, "\n")+"\n"))
@@ -1064,7 +783,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
}
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
@@ -1072,38 +791,8 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
}
}
func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) {
got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{
"| Method | Permission |",
"| --- | --- |",
"| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |",
"| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got)
}
}
}
func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) {
got := ScanFile("docs/api.md", []byte(strings.Join([]string{
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"corehr:employment.international_assignment.custom_field.apaas_id__c:read",
"user_mailbox.sent_messages.get_recall_detail queries recall detail.",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got)
}
}
}
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"1704067200"}`,
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
}, "\n")+"\n"))
@@ -1116,7 +805,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
@@ -1132,7 +821,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
}
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
`{ "block_token": "boardXXXX" }`,
`{ "resource_token": "doc_token_or_url" }`,
`{ "token": "canonical_token" }`,
@@ -1152,7 +841,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
@@ -1167,12 +856,10 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
}
}
@@ -1271,19 +958,6 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
}
}
func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) {
got := ScanFile("docs/auth.md", []byte(strings.Join([]string{
"Authorization: Bearer YOUR_ACCESS_TOKEN",
`{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`,
"Authorization: Bearer <access-token>",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
t.Fatalf("bearer placeholder should not be bearer finding: %#v", got)
}
}
}
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
token := "abcdefghijklmnopqrstuvwxyz"
text := "private launch plan for internal rollout on Friday\n" +
@@ -1301,22 +975,6 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
}
}
func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) {
text := "private launch plan for internal rollout on Friday\n" +
"Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n"
got := semanticCandidate("docs/public.md", "file", text, 1)
if len(got) != 1 {
t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
}
if strings.Contains(got[0].Excerpt, "<jwt-like-token>") {
t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0])
}
if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") {
t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0])
}
}
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
text := strings.Join([]string{
"Generated with automated code assistant",
@@ -1354,37 +1012,6 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
}
}
func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: YOUR_CLIENT_SECRET",
"api_key: YOUR_API_KEY",
"password: YOUR_PASSWORD",
"access_token: ACCESS_TOKEN_HERE",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"

View File

@@ -59,9 +59,13 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"%s/page/scope-apply?clientID=%s&scopes=%s",
core.ResolveOpenBaseURL(brand),
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)

View File

@@ -4,11 +4,8 @@
package schema
import (
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
@@ -25,7 +22,7 @@ func Convert(f meta.Field) Property {
if f.Type == "file" {
p.Format = "binary"
}
p.Description = normalizeDesc(f.Description)
p.Description = f.Description
p.Default = f.CoercedDefault()
p.Example = f.CoercedExample()
p.Minimum = f.MinBound()
@@ -55,24 +52,6 @@ func Convert(f meta.Field) Property {
return p
}
var (
sepRunRe = regexp.MustCompile(`[;]{2,}`)
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
)
// normalizeDesc de-crufts a meta_data description for the envelope — strips
// markdown emphasis and collapses doubled separators/spaces — but keeps content
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
func normalizeDesc(s string) string {
if s == "" {
return ""
}
s = strings.ReplaceAll(s, "**", "")
s = sepRunRe.ReplaceAllString(s, "; ")
s = spaceRunRe.ReplaceAllString(s, " ")
return strings.TrimRight(s, " ;;。.,、\n")
}
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
// arrays for the envelope. enumDescriptions is nil unless at least one value
// carries a description (so the bare-enum form stays values-only), keeping the
@@ -107,18 +86,6 @@ func propsOf(fields []meta.Field) *OrderedProps {
return op
}
// paramPropsOf is propsOf for the params section: each property also carries
// its CLI flag (--kebab-name).
func paramPropsOf(fields []meta.Field) *OrderedProps {
op := &OrderedProps{}
for _, f := range fields {
p := Convert(f)
p.Flag = "--" + f.FlagName()
op.Set(f.Name, p)
}
return op
}
// requiredOf returns the alphabetized names of the required fields.
func requiredOf(fields []meta.Field) []string {
var required []string
@@ -141,17 +108,16 @@ func buildInputSchema(m meta.Method) *InputSchema {
Properties: &OrderedProps{},
}
addInputObject(is, "params", "", m.Params(), true, "")
addInputObject(is, "data", "", m.Data(), false, "--data")
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
addInputObject(is, "params", "", m.Params())
addInputObject(is, "data", "", m.Data())
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
if m.Risk == core.RiskHighRiskWrite {
falseVal := false
is.Properties.Set("yes", Property{
Type: "boolean",
Flag: "--yes",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
})
}
@@ -159,24 +125,20 @@ func buildInputSchema(m meta.Method) *InputSchema {
return is
}
// addInputObject adds one section (params/data/file) when it has fields, marking
// the section required at top level when any field is. asFlags tags each property
// with its --flag (params only); carrier names the section's flag (--data/--file).
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
// addInputObject adds one named sub-object section (params/data/file) to the
// input schema when it has fields: its Properties come from the fields, its
// Required lists the mandatory keys, and the section itself is required at top
// level when any field is required. Empty sections are skipped.
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
if len(fields) == 0 {
return
}
props := propsOf(fields)
if asFlags {
props = paramPropsOf(fields)
}
req := requiredOf(fields)
is.Properties.Set(name, Property{
Type: "object",
Description: description,
Carrier: carrier,
Required: req,
Properties: props,
Properties: propsOf(fields),
})
if len(req) > 0 {
is.Required = append(is.Required, name)
@@ -217,13 +179,7 @@ func buildMeta(m meta.Method) *Meta {
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
m := ref.Method
// The affordance overlay lives in the CLI, not the metadata; look it up
// lazily here (it takes precedence over any affordance the metadata carries).
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
m.Affordance = raw
}
return assemble(ref.Service.Name, ref.ResourcePath, m)
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
}
// Envelopes renders the given method refs into envelopes, sorted by name. The
@@ -249,7 +205,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
return Envelope{
Name: name,
Description: normalizeDesc(m.Description),
Description: m.Description,
InputSchema: buildInputSchema(m),
OutputSchema: buildOutputSchema(m),
Meta: buildMeta(m),

View File

@@ -9,9 +9,7 @@ import (
"reflect"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
@@ -506,31 +504,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
// service + method id), so a method whose metadata carries none still gets
// guidance in its envelope when an overlay entry exists.
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
// The overlay source is the top-level affordance/ tree, injected at startup;
// inject a fixture so this unit test does not depend on the shipped content.
// Reset afterwards (this binary installs no source by default) for isolation.
t.Cleanup(func() { affordance.SetSource(nil) })
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
if env.Meta == nil || env.Meta.Affordance == nil {
t.Fatal("expected affordance from the approval overlay, got none")
}
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
}
// A method id with no overlay entry carries no affordance.
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
if bare.Meta != nil && bare.Meta.Affordance != nil {
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},

View File

@@ -13,10 +13,6 @@ import (
)
// Envelope is the MCP Tool spec contract for a single API method command.
//
// The REST route (httpMethod/path) is deliberately NOT exposed: every
// schema-resolvable method already has a typed command, so the raw path would
// only tempt an agent toward the `api` escape hatch.
type Envelope struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -48,15 +44,9 @@ type OutputSchema struct {
// "params" / "data" sub-objects inside inputSchema): it lists which keys
// inside that object's Properties are mandatory. Leaf fields ignore it.
type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
// absent on body/file fields, which travel via the section's Carrier.
Flag string `json:"flag,omitempty"`
// Carrier names the flag a whole inputSchema section travels on ("--data" /
// "--file"); empty on the params section, whose properties carry their Flag.
Carrier string `json:"carrier,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
// each allowed value, in the same order. Omitted when no value carries a
// description. This is the widely-recognized JSON-Schema extension (VS Code,

View File

@@ -1,411 +0,0 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestFakeAgentHappyPathProducesSVGDeck(t *testing.T) {
t.Chdir(t.TempDir())
writeDefaultSemanticContractForTest(t)
opts := InitOptions{Title: "电影介绍", Pages: 1}
setStringInitOptionField(t, &opts, "Topic", "介绍一部电影")
setStringInitOptionField(t, &opts, "Language", "zh")
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
setStringInitOptionField(t, &opts, "AgentID", "fake-agent-e2e")
if err := InitRun("demo", opts); err != nil {
t.Fatalf("topic-only fake-agent init should succeed: %v", err)
}
if err := os.MkdirAll(filepath.Join("demo", "receipts", "prompt_context"), 0o755); err != nil {
t.Fatal(err)
}
for _, stage := range []string{
StageRequest,
StageRequestResolution,
StageResearch,
StageDesignBrief,
StageOutline,
StageSlideContent,
StageAssets,
StageSVGAuthor,
} {
run := readStatusTestRunFile(t)
if run.CurrentStage != stage {
t.Fatalf("current stage = %q, want %q", run.CurrentStage, stage)
}
next, err := NextTask("demo")
if err != nil {
t.Fatalf("NextTask(%s): %v", stage, err)
}
assertNextTaskHasRuntimeProtocolFields(t, next, stage)
writeFakeAgentReceiptsFromNext(t, next)
writeFakeAgentStageArtifacts(t, stage)
if _, err := CompleteCurrentStage("demo"); err != nil {
t.Fatalf("complete %s: %v", stage, err)
}
}
next, err := NextTask("demo")
if err != nil {
t.Fatalf("NextTask(%s): %v", StageValidatePreviewRepair, err)
}
assertNextTaskHasRuntimeProtocolFields(t, next, StageValidatePreviewRepair)
writeFakeAgentReceiptsFromNext(t, next)
repair, err := RepairRun("demo")
if err != nil {
t.Fatalf("repair: %v", err)
}
if repair.Status != "passed" {
t.Fatalf("repair status = %q, want passed: %+v", repair.Status, repair)
}
mustWritePassedScreenshotEvidenceForTest(t)
if _, err := CompleteCurrentStage("demo"); err != nil {
t.Fatalf("complete %s: %v", StageValidatePreviewRepair, err)
}
for _, rel := range []string{
"slides/01.svg",
"preview.html",
"receipts/image_usage.json",
"receipts/media_pressure.json",
"receipts/content_payload.json",
"receipts/editorial_quality.json",
"receipts/screenshot_evidence.json",
"receipts/chart_quality.json",
"quality_report.json",
"anygen_semantic_report.json",
"receipts/delivery.json",
} {
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
t.Fatalf("missing final artifact %s: %v", rel, err)
}
}
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
if err != nil {
t.Fatal(err)
}
var delivery DeliveryReceipt
if err := json.Unmarshal(raw, &delivery); err != nil {
t.Fatal(err)
}
if delivery.SemanticMetrics.VisibleLeakCount != 0 || delivery.SemanticMetrics.MissingFontTokenCount != 0 {
t.Fatalf("delivery semantic metrics = %+v, want no visible leaks and all font tokens", delivery.SemanticMetrics)
}
if delivery.Status != StatusReady {
t.Fatalf("delivery status = %q, want ready with full-chain screenshot evidence: %+v", delivery.Status, delivery.FullChainEvidence)
}
if len(delivery.FullChainEvidence.ScreenshotEvidence) == 0 {
t.Fatalf("delivery screenshot evidence is empty: %+v", delivery.FullChainEvidence)
}
}
func TestFakeAgentChartChainRendersAndValidatesVegaLite(t *testing.T) {
t.Chdir(t.TempDir())
writeDefaultSemanticContractForTest(t)
opts := InitOptions{Title: "Chart Deck", Pages: 1}
setStringInitOptionField(t, &opts, "Topic", "chart-only revenue comparison")
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
setStringInitOptionField(t, &opts, "AgentID", "fake-agent-chart-e2e")
if err := InitRun("demo", opts); err != nil {
t.Fatalf("chart fake-agent init should succeed: %v", err)
}
if err := os.MkdirAll(filepath.Join("demo", "receipts", "prompt_context"), 0o755); err != nil {
t.Fatal(err)
}
setCurrentStageForStatusTest(t, StageAssets)
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"input_text":"chart-only revenue comparison","resolved_entity":{"name":"chart-only revenue comparison","type":"topic","confidence_bp":9000,"confidence_band":"high","reason":"chart-only E2E fixture"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"visual_quality_contract":{"profile":"data_report","requires_real_images":false,"required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1,"reason":"chart-only E2E"},"clarification_question":""}`)
mustWriteTestFile(t, "demo/request/theme_contract.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"theme_contract":{"content_type":{"primary":"data_report","secondary":["chart_only"]},"subject_type":{"primary":"topic","named_entity":false,"entity_name":"chart-only revenue comparison"},"delivery_format":{"primary":"self_read","density":"medium"},"evidence_type":{"primary":"quantitative_comparison","requires_sources":true},"asset_needs":{"requires_real_images":false,"required_roles":[],"min_real_image_pages":0,"min_dominant_real_image_pages":0,"min_unique_real_images":0,"cover_requires_dominant_real_image":false},"layout_rhythm":{"min_slide_count":1,"min_distinct_layout_archetypes":1,"max_adjacent_same_archetype":0,"required_page_roles":["cover","chart","closing"]},"typography_identity":{"profile":"data_report","display_category":"sans","body_category":"sans","number_category":"mono"},"quality_floor":{"profile":"chart_only","reason":"chart-only E2E fixture; no raster images required."},"rationale":"This fixture verifies Vega-Lite chart rendering without real image requirements."}}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"web1","path":"https://example.com/filing","title":"Company filing","excerpt":"Segment revenue data","usage":"chart data","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#76B900"},"typography":{"title":32,"body":16},"layout_language":"financial chart page"}`)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","requires_real_images":false,"required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1,"topic_archetype":"","media_pressure":{},"editorial_quality_target":{},"reason":"chart-only E2E fixture"}}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"prompt_contract":`+promptContractJSON(StageOutline)+`,"title":"Chart Deck","slides":[{"id":"s1","title":"Data center leads","summary":"Revenue mix comparison","role":"content","key_message":"Data center revenue leads the mix","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Data center leads","body":"Data center revenue leads the mix","labels":["Revenue $B","Source: web1"]},"production_instruction":{"layout":"Embed the rendered chart asset with rect role","asset_ids":["revenue_mix"]}}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Data center revenue leads the mix","central_claim":"Data center revenue is the dominant segment in this comparison.","audience_takeaway":"The audience should read the chart as a segment mix proof point, not decoration.","supporting_points":[{"text":"The chart compares reported segment revenue values from the filing source.","source_refs":["web1"]},{"text":"The data center bar is intentionally the visual anchor because it leads the mix.","source_refs":["web1"]}],"source_bound_facts":[{"fact":"Segment revenue data comes from the company filing source.","source_ref":"web1","usage":"visual_data"}],"visual_data_items":[{"label":"Data Center","value":"22.1","role":"metric","explanation":"Largest segment in the comparison.","source_ref":"web1"},{"label":"Gaming","value":"2.9","role":"metric","explanation":"Secondary segment for contrast.","source_ref":"web1"},{"label":"Professional Visualization","value":"0.5","role":"metric","explanation":"Smaller segment for scale context.","source_ref":"web1"}],"source_refs":["web1"],"visuals":[{"id":"revenue_mix","type":"chart","instruction":"Compare segment revenue"}],"so_what":"This proves the slide's message with a real quantitative relationship."}]}`)
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"chart-only E2E fixture; no raster image required.","candidates":[]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[],"no_image_reason":"chart-only E2E fixture; no raster image required."}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[],"no_image_reason":"chart-only E2E fixture; no raster image required."}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[{"id":"revenue_mix","slide_id":"s1","purpose":"comparison","takeaway":"Data center revenue leads the mix","renderer":"vega-lite","data_source_ids":["web1"],"unit":"$B","min_width":600,"min_height":320}]}`)
mustWriteTestFile(t, "demo/assets/charts/specs/revenue_mix.vl.json", `{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","width":640,"height":360,"title":{"text":"Segment revenue comparison ($B)","subtitle":"Source: web1 company filing"},"data":{"values":[{"segment":"Data Center","revenue":22.1},{"segment":"Gaming","revenue":2.9},{"segment":"Professional Visualization","revenue":0.5}]},"mark":{"type":"bar","tooltip":true},"encoding":{"x":{"field":"segment","type":"nominal","sort":"-y","axis":{"title":"Segment"}},"y":{"field":"revenue","type":"quantitative","axis":{"title":"Revenue $B"}},"color":{"field":"segment","type":"nominal","legend":null}}}`)
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"revenue_mix","slide_id":"s1","renderer":"vega-lite","brief_id":"revenue_mix","spec_path":"assets/charts/specs/revenue_mix.vl.json","svg_path":"assets/charts/revenue_mix.svg","source_id":"web1","unit":"$B","takeaway":"Data center revenue leads the mix","render_receipt":"receipts/chart_render.json"}]}`)
next, err := NextTask("demo")
if err != nil {
t.Fatalf("NextTask(%s): %v", StageAssets, err)
}
assertNextTaskHasRuntimeProtocolFields(t, next, StageAssets)
writeFakeAgentReceiptsFromNext(t, next)
status, err := CompleteCurrentStage("demo")
if err != nil {
t.Fatalf("complete %s: %v", StageAssets, err)
}
if status.CurrentStage != StageSVGAuthor {
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageSVGAuthor)
}
if _, err := os.Stat(filepath.Join("demo", "assets", "charts", "revenue_mix.svg")); err != nil {
t.Fatalf("missing rendered chart SVG: %v", err)
}
var renderReport ChartRenderReport
raw, err := readRunRegularArtifact("demo", chartRenderReceiptPath)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &renderReport); err != nil {
t.Fatal(err)
}
if renderReport.Status != "passed" || len(renderReport.Charts) != 1 {
t.Fatalf("chart render report = %+v, want one passed chart", renderReport)
}
next, err = NextTask("demo")
if err != nil {
t.Fatalf("NextTask(%s): %v", StageSVGAuthor, err)
}
assertNextTaskHasRuntimeProtocolFields(t, next, StageSVGAuthor)
writeFakeAgentReceiptsFromNext(t, next)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" width="960" height="540" slide:role="slide" viewBox="0 0 960 540"><slide:note>Source: web1</slide:note><rect width="960" height="540" fill="#fff"/>`+parserSafeTextBody()+`<rect slide:role="chart" href="../assets/charts/revenue_mix.svg" x="80" y="120" width="720" height="360"/></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"evidence","layout_family":"data_report","layout_archetype":"chart_forward","layout_signature":"hero_chart_with_title","thumbnail_job":"chart","visual_center":"Vega-Lite rendered revenue chart","topic_fit_claim":"uses chart evidence for revenue comparison","information_density_plan":"one chart plus one title","page_difference_from_previous":"single-slide chart fixture","primary_asset":"assets/charts/revenue_mix.svg","asset_role":"chart evidence","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"chart-forward financial evidence","data_visual_rationale":"Revenue comparison needs a standard chart","source_evidence":["web1 supports revenue data"],"container_fit_plan":"chart has open canvas and title outside chart bounds","container_decision":"no text card needed","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"chart_forward","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"revenue_mix","renderer":"vega-lite","unit":"$B","source":"web1","why_chart_is_needed":"compare segment revenue"},"fusion_spec":{"enabled":false},"qa_expectations":["chart is rendered asset, not hand drawn"]}]}`)
status, err = CompleteCurrentStage("demo")
if err != nil {
t.Fatalf("complete %s: %v", StageSVGAuthor, err)
}
if status.CurrentStage != StageValidatePreviewRepair {
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageValidatePreviewRepair)
}
quality, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if quality.Status != "passed" {
t.Fatalf("quality = %+v, want passed", quality)
}
var usage ChartUsageReport
raw, err = readRunRegularArtifact("demo", chartUsageReceiptPath)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &usage); err != nil {
t.Fatal(err)
}
if usage.Status != "passed" || len(usage.Charts) != 1 {
t.Fatalf("chart usage = %+v, want one passed chart", usage)
}
}
func TestE2EChineseTeaThemeContractQualityGate(t *testing.T) {
root := copySVGlideE2EFixtureToTempRun(t, "testdata/svglide/e2e/chinese_tea_minimal")
if err := ValidateStageOutputs(root); err != nil {
t.Fatalf("ValidateStageOutputs returned %v", err)
}
report, err := CheckQuality(root)
if err != nil {
t.Fatalf("CheckQuality returned %v", err)
}
if report.Status != "passed" {
t.Fatalf("quality status = %q, issues = %#v", report.Status, report.Issues)
}
if !report.Metrics.ThemeContractPresent || !report.Metrics.ThemeAssetNeedsApplied || report.Metrics.DominantRealImagePages < 3 {
t.Fatalf("metrics = %+v, want theme contract applied with at least 3 dominant real-image pages", report.Metrics)
}
creative, err := CheckCreativeQuality(root)
if err != nil {
t.Fatalf("CheckCreativeQuality returned %v", err)
}
if creative.Status != "passed" {
t.Fatalf("creative status = %q, issues = %#v", creative.Status, creative.Issues)
}
}
func TestE2EChineseTeaRejectsGenericVectorOnlyDeck(t *testing.T) {
root := copySVGlideE2EFixtureToTempRun(t, "testdata/svglide/e2e/chinese_tea_minimal")
mustWriteTestFile(t, filepath.Join(root, "assets", "assets_manifest.json"), `{"assets":[]}`)
mustWriteTestFile(t, filepath.Join(root, "assets", "asset_inventory.json"), `{"items":[]}`)
mustWriteTestFile(t, filepath.Join(root, "assets", "image_candidates.json"), `{"requires_real_images":true,"candidates":[]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatalf("CheckQuality returned %v", err)
}
if report.Status != "failed" || !qualityIssueCodesContain(report.Issues, "svglide.media_pressure.real_image_pages") {
t.Fatalf("report = %#v, want real-image media pressure failure", report)
}
}
func copySVGlideE2EFixtureToTempRun(t *testing.T, fixtureRel string) string {
t.Helper()
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
cwd := t.TempDir()
t.Chdir(cwd)
writeDefaultSemanticContractForTest(t)
runRoot := filepath.Join(cwd, "fixture")
copyTestDir(t, filepath.Join(repoRoot, fixtureRel), runRoot)
for name, schema := range DefaultSchemas() {
mustWriteTestFile(t, filepath.Join("fixture", "schemas", name), schema)
}
return "fixture"
}
func assertNextTaskHasRuntimeProtocolFields(t *testing.T, next NextTaskReport, stage string) {
t.Helper()
raw, err := json.Marshal(next)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload["protocol"] != "anygen-svg-slides" {
t.Fatalf("%s next.protocol = %v, want anygen-svg-slides", stage, payload["protocol"])
}
agentTask, ok := payload["agent_task"].(map[string]any)
if !ok {
t.Fatalf("%s next.agent_task missing: %+v", stage, payload)
}
if agentTask["stage"] != stage {
t.Fatalf("%s agent_task.stage = %v, want %s", stage, agentTask["stage"], stage)
}
if payload["prompt_context"] == nil || payload["tool_invocation_contract"] == nil {
t.Fatalf("%s next missing prompt_context/tool_invocation_contract: %+v", stage, payload)
}
}
func writeFakeAgentReceiptsFromNext(t *testing.T, next NextTaskReport) {
t.Helper()
raw, err := json.Marshal(next)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
toolContract, _ := payload["tool_invocation_contract"].(map[string]any)
for _, call := range callsFromContract(toolContract, "required_calls", "conditional_calls") {
id, _ := call["id"].(string)
if id == "" {
continue
}
writeToolCallReceiptFromContractForE2E(t, next, call)
}
}
func callsFromContract(contract map[string]any, keys ...string) []map[string]any {
out := []map[string]any{}
for _, key := range keys {
values, _ := contract[key].([]any)
for _, value := range values {
if object, ok := value.(map[string]any); ok {
out = append(out, object)
}
}
}
return out
}
func writeToolCallReceiptFromContractForE2E(t *testing.T, next NextTaskReport, call map[string]any) {
t.Helper()
id, _ := call["id"].(string)
promptID, _ := call["prompt_id"].(string)
if promptID == "" {
promptID = id
}
consumed := stringsFromJSONValue(call["consumes"])
if len(consumed) == 0 {
consumed = next.Inputs
}
produced := stringsFromJSONValue(call["produces"])
if len(produced) == 0 {
produced = next.Outputs
}
raw, err := json.MarshalIndent(map[string]any{
"protocol": "anygen-svg-slides",
"stage": next.Stage,
"call_id": id,
"prompt_id": promptID,
"invocation": stringFromJSONValue(call["invocation"], "required"),
"condition": stringFromJSONValue(call["condition"], "always"),
"condition_matched": true,
"order": intFromJSONValue(call["order"]),
"cardinality": stringFromJSONValue(call["cardinality"], "once"),
"consumed": consumed,
"produced": produced,
"status": "done",
}, "", " ")
if err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", next.Stage, id+".json"), string(append(raw, '\n')))
}
func stringFromJSONValue(value any, fallback string) string {
if text, ok := value.(string); ok && text != "" {
return text
}
return fallback
}
func intFromJSONValue(value any) int {
switch typed := value.(type) {
case float64:
return int(typed)
case int:
return typed
default:
return 0
}
}
func stringsFromJSONValue(value any) []string {
values, _ := value.([]any)
out := make([]string, 0, len(values))
for _, item := range values {
if text, ok := item.(string); ok {
out = append(out, text)
}
}
return out
}
func writeFakeAgentStageArtifacts(t *testing.T, stage string) {
t.Helper()
switch stage {
case StageRequest:
return
case StageRequestResolution:
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"input_text":"介绍一部电影","resolved_entity":{"name":"介绍一部电影","type":"topic","confidence_bp":5000,"confidence_band":"medium","reason":"用户请求是开放主题,需要先研究确定内容方向"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"clarification_question":""}`)
mustWriteTestFile(t, "demo/request/theme_contract.json", validThemeContractJSON())
case StageResearch:
mustWriteTestFile(t, "demo/research/research_notes.md", "# 电影资料\n\n用户提供主题。")
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"user1","path":"topic://介绍一部电影","title":"用户主题","excerpt":"介绍一部电影","usage":"primary brief","retrieval":"user_provided"}]}`)
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"介绍一部电影","type":"topic"},"queries":[{"query":"介绍一部电影","purpose":"context"}],"sources":[{"id":"user1","title":"用户主题","url":"topic://介绍一部电影","retrieved_at":"2026-07-04T00:00:00Z","usage":"context","status":"retrieved"}],"coverage":{"identity_confirmed":false,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":"开放主题需要用研究材料确定内容边界。"}}`)
case StageDesignBrief:
writeValidDesignBriefOutputs(t)
case StageOutline:
mustWriteTestFile(t, "demo/outline/deck.json", `{"prompt_contract":`+promptContractJSON(StageOutline)+`,"main_title":"电影介绍","style_instruction":{"aesthetic_direction":"Editorial cinematic deck","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"一部电影","summary":"用一个清晰观点介绍电影","role":"cover","key_message":"电影的核心吸引力","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"image_claim","story_function":"hook","primary_asset_role":"cinematic topic anchor","fusion_candidate":false,"path":"slides/01.svg"}]}`)
case StageSlideContent:
mustWriteTestFile(t, "demo/content/slide_content.md", "# 一部电影\n\n电影的核心吸引力。")
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"一部电影","body":"电影的核心吸引力","labels":["电影"]},"production_instruction":{"layout":"Use local hero image, no visible source note","asset_ids":["hero"]}}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"电影的核心吸引力","central_claim":"这页用一个清晰观点说明电影为什么值得被介绍。","audience_takeaway":"观众应先获得电影主题的核心吸引力,再进入后续分析。","supporting_points":[{"text":"用户请求是介绍一部电影,因此封面需要先建立主题识别。","source_refs":["user1"]},{"text":"视觉和标题共同承担开场钩子,而不是只放一个片名。","source_refs":["user1"]}],"source_bound_facts":[{"fact":"用户主题要求生成电影介绍。","source_ref":"user1","usage":"context"}],"source_refs":["user1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a cinematic hero image"}],"so_what":"这页应作为有观点的开场,而不是空泛标题页。"}]}`)
case StageAssets:
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"cand-hero","query":"movie hero image","source_url":"https://example.com/movie-hero.png","source_class":"user_provided","format":"png","width":1200,"height":800,"has_alpha":false,"asset_role":"hero_photo","fit_role":"split_panel","local_path":"assets/images/movie-hero.png","score_bp":9000,"selected":true,"selection_reason":"user-provided cinematic hero image","format_exception_reason":"","rejection_reason":""}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/movie-hero.png","usage":"Cinematic hero image","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","kind":"image","source_url":"https://example.com/movie-hero.png","local_path":"assets/images/movie-hero.png","usage":"Cinematic hero image","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/movie-hero.png","source_url":"https://example.com/movie-hero.png","width":1200,"height":800,"semantic_type":"hero","large_ok":true,"full_bleed_ok":false,"recommended_use":"cover split image","avoid_reason":"","format":"png","has_alpha":false,"asset_role":"hero_photo","fit_role":"split_panel","candidate_id":"cand-hero","selection_reason":"user-provided cinematic hero image","format_exception_reason":""}]}`)
mustWriteTestPNGFile(t, "demo/assets/images/movie-hero.png")
case StageSVGAuthor:
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" width="960" height="540" slide:role="slide" viewBox="0 0 960 540"><slide:note>Source: user1</slide:note><rect width="960" height="540" fill="#fff"/><image slide:role="image" href="../assets/images/movie-hero.png" x="520" y="80" width="320" height="240"/><foreignObject x="48" y="72" width="360" height="120" slide:role="shape" slide:shape-type="text"><p xmlns="http://www.w3.org/1999/xhtml" style="margin:0;font-family:Inter,Arial,sans-serif;font-size:28px;line-height:1.25;color:#111;">电影介绍</p><p xmlns="http://www.w3.org/1999/xhtml" style="margin:12px 0 0 0;font-family:Inter,Arial,sans-serif;font-size:20px;line-height:1.35;color:#333;">电影的核心吸引力</p></foreignObject></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"image_claim","thumbnail_job":"电影介绍","visual_center":"movie hero image and title","topic_fit_claim":"introduces the requested movie topic","information_density_plan":"one claim plus one visual anchor","page_difference_from_previous":"opening page","primary_asset":"assets/images/movie-hero.png","asset_role":"cinematic topic anchor","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"image-led cinematic introduction","data_visual_rationale":"","source_evidence":["user1 supports the topic"],"container_fit_plan":"text sits in image-safe open area with no default card","container_decision":"image-led open composition","text_carrier":"image_dark_zone","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"image_annotation","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["no visible process text"]}]}`)
default:
t.Fatalf("unexpected fake-agent stage %q", stage)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
package svglide
import (
"fmt"
"strings"
)
func validatePreparedImageAssetPath(raw string) (string, error) {
path := strings.TrimSpace(raw)
if path == "" {
return "", fmt.Errorf("image asset path must not be empty")
}
if strings.Contains(path, `\`) {
return "", fmt.Errorf("image asset path %q must use forward slashes", raw)
}
if strings.Contains(path, "%") {
return "", fmt.Errorf("image asset path %q must not contain percent encoding", raw)
}
if strings.Contains(path, ":") || strings.Contains(path, "//") || isAbsoluteRunPath(path) {
return "", fmt.Errorf("image asset path %q must be a local assets/images/<file> path", raw)
}
parts := strings.Split(path, "/")
if len(parts) != 3 || parts[0] != "assets" || parts[1] != "images" {
return "", fmt.Errorf("image asset path %q must match assets/images/<file>", raw)
}
fileName := parts[2]
if fileName == "" || fileName == "." || fileName == ".." {
return "", fmt.Errorf("image asset path %q must include a file name", raw)
}
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
return "", fmt.Errorf("image asset file name %q must not contain dot segments", fileName)
}
return path, nil
}

View File

@@ -1,46 +0,0 @@
package svglide
import "testing"
func TestValidatePreparedImageAssetPath(t *testing.T) {
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "valid", path: "assets/images/hero.png", want: "assets/images/hero.png"},
{name: "trim", path: " assets/images/hero.png ", want: "assets/images/hero.png"},
{name: "empty", path: "", wantErr: true},
{name: "remote", path: "https://example.com/hero.png", wantErr: true},
{name: "parent directory", path: "../hero.png", wantErr: true},
{name: "absolute", path: "/Users/example/hero.png", wantErr: true},
{name: "file url", path: "file:///tmp/hero.png", wantErr: true},
{name: "protocol relative", path: "//example.com/hero.png", wantErr: true},
{name: "data url", path: "data:image/png;base64,AAAA", wantErr: true},
{name: "percent", path: "assets/images/hero%2epng", wantErr: true},
{name: "nested", path: "assets/images/nested/hero.png", wantErr: true},
{name: "wrong directory", path: "assets/other/hero.png", wantErr: true},
{name: "leading dot", path: "assets/images/.hero.png", wantErr: true},
{name: "dot dot filename", path: "assets/images/hero..png", wantErr: true},
{name: "backslash", path: `assets\images\hero.png`, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := validatePreparedImageAssetPath(tt.path)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got path %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("path = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -1,236 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
)
const (
assetsPlanPath = "assets/assets_plan.json"
assetsManifestPath = "assets/assets_manifest.json"
assetInventoryPath = "assets/asset_inventory.json"
)
type deckAssetsFile struct {
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
Assets []deckAsset `json:"assets"`
NoImageReason string `json:"no_image_reason"`
Mode string `json:"mode"`
}
type deckAsset struct {
ID string `json:"id"`
SlideID string `json:"slide_id"`
VisualID string `json:"visual_id"`
Type string `json:"type"`
Kind string `json:"kind"`
Path string `json:"path"`
LocalPath string `json:"local_path"`
SourceURL string `json:"source_url"`
Status string `json:"status"`
Usage string `json:"usage"`
MissingReason string `json:"missing_reason"`
}
type assetInventoryFile struct {
Items []assetInventoryItem `json:"items"`
}
type assetInventoryItem struct {
ID string `json:"id"`
Path string `json:"path"`
SourceURL string `json:"source_url"`
Width int `json:"width"`
Height int `json:"height"`
SemanticType string `json:"semantic_type"`
LargeOK bool `json:"large_ok"`
FullBleedOK bool `json:"full_bleed_ok"`
RecommendedUse string `json:"recommended_use"`
AvoidReason string `json:"avoid_reason"`
Format string `json:"format"`
HasAlpha bool `json:"has_alpha"`
AssetRole string `json:"asset_role"`
FitRole string `json:"fit_role"`
CandidateID string `json:"candidate_id"`
SelectionReason string `json:"selection_reason"`
FormatExceptionReason string `json:"format_exception_reason"`
}
func readDeckAssetsArtifact(safeRoot string, path string) (deckAssetsFile, error) {
raw, err := readRunRegularArtifact(safeRoot, path)
if err != nil {
return deckAssetsFile{}, err
}
var file deckAssetsFile
if err := json.Unmarshal(raw, &file); err != nil {
return deckAssetsFile{}, fmt.Errorf("read assets artifact %q: %w", path, err)
}
return file, nil
}
func readAssetInventory(safeRoot string) (assetInventoryFile, error) {
raw, err := readRunRegularArtifact(safeRoot, assetInventoryPath)
if err != nil {
return assetInventoryFile{}, fmt.Errorf("read asset inventory %q: %w", assetInventoryPath, err)
}
var inventory assetInventoryFile
if err := json.Unmarshal(raw, &inventory); err != nil {
return assetInventoryFile{}, fmt.Errorf("%s: invalid JSON: %w", assetInventoryPath, err)
}
return inventory, nil
}
func readAssetsManifest(safeRoot string) (deckAssetsFile, error) {
file, err := readDeckAssetsArtifact(safeRoot, assetsManifestPath)
if err != nil {
return deckAssetsFile{}, fmt.Errorf("read assets manifest %q: %w", assetsManifestPath, err)
}
file, changed, err := normalizeAssetsManifestFromPlan(safeRoot, file)
if err != nil {
return deckAssetsFile{}, err
}
if changed {
target, err := ensureRunFileTargetForWrite(safeRoot, assetsManifestPath)
if err != nil {
return deckAssetsFile{}, err
}
if err := writeJSON(target, file); err != nil {
return deckAssetsFile{}, err
}
}
return file, nil
}
func normalizeAssetsManifestFromPlan(safeRoot string, manifest deckAssetsFile) (deckAssetsFile, bool, error) {
plan, err := readDeckAssetsArtifact(safeRoot, assetsPlanPath)
if err != nil {
return manifest, false, nil
}
changed := false
if len(manifest.PromptContract) == 0 && len(plan.PromptContract) > 0 {
manifest.PromptContract = plan.PromptContract
changed = true
}
if strings.TrimSpace(manifest.Mode) == "" && strings.TrimSpace(plan.Mode) != "" {
manifest.Mode = plan.Mode
changed = true
}
if strings.TrimSpace(manifest.NoImageReason) == "" && strings.TrimSpace(plan.NoImageReason) != "" {
manifest.NoImageReason = plan.NoImageReason
changed = true
}
existing := make(map[string]bool, len(manifest.Assets))
for _, asset := range manifest.Assets {
key := assetManifestConsistencyKey(asset)
if key != "" {
existing[key] = true
}
}
for _, asset := range plan.Assets {
key := assetManifestConsistencyKey(asset)
if key == "" || existing[key] {
continue
}
status := assetStatus(asset)
if status != "ready" && status != "deferred" && status != "needs_generation" {
continue
}
manifest.Assets = append(manifest.Assets, asset)
existing[key] = true
changed = true
}
return manifest, changed, nil
}
func assetManifestConsistencyKey(asset deckAsset) string {
slideID := assetSlideID(asset)
id := assetID(asset)
if id == "" {
id = strings.TrimSpace(asset.VisualID)
}
if slideID == "" || id == "" {
return ""
}
return slideID + "/" + id
}
func assetType(asset deckAsset) string {
if value := strings.TrimSpace(asset.Kind); value != "" {
return value
}
return strings.TrimSpace(asset.Type)
}
func assetPath(asset deckAsset) string {
if value := strings.TrimSpace(asset.LocalPath); value != "" {
return value
}
return strings.TrimSpace(asset.Path)
}
func assetStatus(asset deckAsset) string {
return strings.TrimSpace(asset.Status)
}
func assetSlideID(asset deckAsset) string {
return strings.TrimSpace(asset.SlideID)
}
func assetID(asset deckAsset) string {
return strings.TrimSpace(asset.ID)
}
func assetExt(asset deckAsset) string {
raw := assetPath(asset)
if raw == "" {
raw = strings.TrimSpace(asset.SourceURL)
}
raw = strings.TrimSpace(raw)
if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
raw = parsed.Path
}
if i := strings.IndexAny(raw, "?#"); i >= 0 {
raw = raw[:i]
}
return strings.ToLower(filepath.Ext(raw))
}
func isRasterImageAsset(asset deckAsset) bool {
if assetStatus(asset) != "ready" || assetType(asset) != "image" {
return false
}
switch assetExt(asset) {
case ".png", ".jpg", ".jpeg", ".webp", ".avif":
return true
default:
return false
}
}
func isGeneratedSVGAsset(asset deckAsset) bool {
if assetStatus(asset) != "ready" {
return false
}
if assetType(asset) == "generated_svg" {
return true
}
return assetExt(asset) == ".svg" && assetType(asset) != "chart"
}
func isChartSVGAsset(asset deckAsset) bool {
if assetStatus(asset) != "ready" {
return false
}
return assetType(asset) == "chart" && assetExt(asset) == ".svg"
}
func isPreviewWrapperImageAsset(asset deckAsset) bool {
if assetStatus(asset) != "ready" || assetType(asset) != "image" {
return false
}
normalized := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(assetPath(asset))), "./")
return strings.HasPrefix(normalized, "slides/") && assetExt(asset) == ".svg"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,324 +0,0 @@
package svglide
import (
"fmt"
"strings"
)
const (
authorVisualFormGeneric = "generic"
authorVisualFormFourQuadrant = "four_quadrant"
authorVisualFormSpectrum = "spectrum"
authorVisualFormMapRoute = "map_route"
authorVisualFormProcessFlow = "process_flow"
authorVisualFormParameterMatrix = "parameter_matrix"
authorVisualFormSensoryWheel = "sensory_wheel"
authorVisualFormObjectCallout = "object_callout"
)
func renderAuthorInlineDiagram(b *strings.Builder, visual authorSlideVisual, content authorSlideContent, x, y, width, height int, theme authorTheme) {
form := authorVisualForm(visual, content)
labels := authorDiagramLabels(content, visual, 8)
if len(labels) == 0 {
labels = []string{firstNonEmpty(visual.Instruction, visual.ID, "visual")}
}
switch form {
case authorVisualFormFourQuadrant:
renderAuthorFourQuadrantDiagram(b, labels, x, y, width, height, theme)
case authorVisualFormSpectrum:
renderAuthorSpectrumDiagram(b, labels, x, y, width, height, theme)
case authorVisualFormMapRoute:
renderAuthorMapRouteDiagram(b, labels, x, y, width, height, theme)
case authorVisualFormProcessFlow:
renderAuthorProcessFlowDiagram(b, labels, x, y, width, height, theme)
case authorVisualFormParameterMatrix:
renderAuthorParameterMatrixDiagram(b, labels, x, y, width, height, theme)
case authorVisualFormSensoryWheel:
renderAuthorSensoryWheelDiagram(b, labels, x, y, width, height, theme)
case authorVisualFormObjectCallout:
renderAuthorObjectCalloutDiagram(b, labels, x, y, width, height, theme)
default:
renderAuthorGenericDiagram(b, labels, x, y, width, height, theme)
}
}
func authorVisualForm(visual authorSlideVisual, content authorSlideContent) string {
if form := normalizeAuthorVisualForm(visual.VisualForm); form != "" {
return form
}
haystack := strings.ToLower(strings.Join([]string{
visual.Type,
visual.ID,
visual.Instruction,
content.Content,
content.Notes,
}, " "))
switch {
case strings.TrimSpace(visual.Type) == "map":
return authorVisualFormMapRoute
case containsAny(haystack, []string{"four_quadrant", "four quadrant", "quadrant", "2x2", "四象限", "象限"}):
return authorVisualFormFourQuadrant
case containsAny(haystack, []string{"spectrum", "gradient", "liquor color", "color spectrum", "six tea", "六大茶类", "光谱", "茶汤", "色谱"}):
return authorVisualFormSpectrum
case containsAny(haystack, []string{"map", "route", "region", "origin", "geography", "province", "产区", "地图", "地域", "路线"}):
return authorVisualFormMapRoute
case containsAny(haystack, []string{"process", "flow", "timeline", "craft", "fermentation", "firing", "工艺", "流程", "制作", "发酵", "杀青"}):
return authorVisualFormProcessFlow
case containsAny(haystack, []string{"parameter", "matrix", "temperature", "steep", "water", "ratio", "参数", "矩阵", "水温", "冲泡", "投茶"}):
return authorVisualFormParameterMatrix
case containsAny(haystack, []string{"sensory", "wheel", "taste", "flavor", "aroma", "五感", "风味", "香气", "口感", "品鉴"}):
return authorVisualFormSensoryWheel
case containsAny(haystack, []string{"callout", "object", "teaware", "vessel", "utensil", "器物", "茶具", "盖碗", "紫砂", "标注"}):
return authorVisualFormObjectCallout
default:
return authorVisualFormGeneric
}
}
func normalizeAuthorVisualForm(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "-", "_")
value = strings.ReplaceAll(value, " ", "_")
switch value {
case authorVisualFormFourQuadrant, "quadrant", "two_by_two", "2x2":
return authorVisualFormFourQuadrant
case authorVisualFormSpectrum, "color_spectrum", "liquor_spectrum":
return authorVisualFormSpectrum
case authorVisualFormMapRoute, "region_map", "map", "route_map":
return authorVisualFormMapRoute
case authorVisualFormProcessFlow, "flow", "timeline", "craft_process":
return authorVisualFormProcessFlow
case authorVisualFormParameterMatrix, "matrix", "parameter_bridge", "parameters":
return authorVisualFormParameterMatrix
case authorVisualFormSensoryWheel, "wheel", "flavor_wheel", "taste_wheel":
return authorVisualFormSensoryWheel
case authorVisualFormObjectCallout, "callout", "object_annotation", "teaware_callout":
return authorVisualFormObjectCallout
case authorVisualFormGeneric:
return authorVisualFormGeneric
default:
return ""
}
}
func renderAuthorFourQuadrantDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormFourQuadrant)
cx, cy := x+width/2, y+height/2
fmt.Fprintf(b, ` <rect x="%d" y="%d" width="%d" height="%d" fill="none" stroke="%s" stroke-width="1.2" opacity="0.56"/>`+"\n", x, y, width, height, escapeAttr(theme.Muted))
fmt.Fprintf(b, ` <path d="M%d %d V%d M%d %d H%d" stroke="%s" stroke-width="1.1" opacity="0.50"/>`+"\n", cx, y, y+height, x, cy, x+width, escapeAttr(theme.Muted))
points := [][2]int{{x + width/4, y + height/3}, {x + width*3/4, y + height/3}, {x + width/4, y + height*3/4}, {x + width*3/4, y + height*3/4}}
for i, pt := range points {
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.88"/>`+"\n", pt[0], pt[1]-9, compactDiagramRadius(height, 8), escapeAttr(authorDiagramColor(theme, i)))
writeAuthorDiagramLabel(b, pt[0], pt[1]+18, 120, compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
}
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorSpectrumDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormSpectrum)
count := clampInt(len(labels), 4, 6)
gap := 6
barW := (width - gap*(count-1)) / count
if barW < 18 {
barW = 18
}
barY := y + height/2 - compactDiagramRadius(height, 11)
for i := 0; i < count; i++ {
bh := compactDiagramRadius(height, 20) + i%3*8
xx := x + i*(barW+gap)
yy := barY - bh/2 + (i%2)*8
fmt.Fprintf(b, ` <rect x="%d" y="%d" width="%d" height="%d" fill="%s" opacity="0.86"/>`+"\n", xx, yy, barW, bh, escapeAttr(authorSpectrumColor(theme, i)))
writeAuthorDiagramLabel(b, xx+barW/2, y+height-8, maxInt(72, barW+20), compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
}
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="1" opacity="0.42"/>`+"\n", x, y+height/2, x+width, escapeAttr(theme.Muted))
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorMapRouteDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormMapRoute)
fmt.Fprintf(b, ` <path d="M%d %d C%d %d %d %d %d %d C%d %d %d %d %d %d" fill="none" stroke="%s" stroke-width="1.2" opacity="0.38"/>`+"\n",
x+width/5, y+height/6, x+width/2, y+4, x+width*4/5, y+height/5, x+width*3/4, y+height/2, x+width*2/3, y+height*4/5, x+width/3, y+height-4, x+width/5, y+height*2/3, escapeAttr(theme.Muted))
fmt.Fprintf(b, ` <path d="M%d %d C%d %d %d %d %d %d" fill="none" stroke="%s" stroke-width="2.6" opacity="0.78"/>`+"\n",
x+20, y+height-26, x+width/3, y+height/2, x+width*2/3, y+height/3, x+width-22, y+24, escapeAttr(theme.Accent))
points := [][2]int{{x + 24, y + height - 28}, {x + width/2, y + height/2 - 3}, {x + width - 22, y + 24}}
for i, pt := range points {
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" stroke="%s" stroke-width="2"/>`+"\n", pt[0], pt[1], compactDiagramRadius(height, 8), escapeAttr(authorDiagramColor(theme, i)), escapeAttr(theme.Background))
writeAuthorDiagramLabel(b, pt[0], pt[1]+24, 120, compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
}
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorProcessFlowDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormProcessFlow)
count := clampInt(len(labels), 3, 5)
step := width / count
midY := y + height/2
for i := 0; i < count; i++ {
xx := x + i*step
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="2" opacity="0.55"/>`+"\n", xx+compactDiagramRadius(height, 9), midY, xx+step-compactDiagramRadius(height, 9), escapeAttr(theme.Muted))
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.90"/>`+"\n", xx+step/2, midY, compactDiagramRadius(height, 12), escapeAttr(authorDiagramColor(theme, i)))
writeAuthorDiagramNumber(b, xx+step/2, midY+4, 38, compactDiagramFontSize(height), theme.Background, i+1)
writeAuthorDiagramLabel(b, xx+step/2, midY+30, maxInt(72, step-10), compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
}
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorParameterMatrixDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormParameterMatrix)
rows, cols := 3, 3
cellW, cellH := width/cols, height/rows
for r := 0; r <= rows; r++ {
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="1" opacity="0.45"/>`+"\n", x, y+r*cellH, x+width, escapeAttr(theme.Muted))
}
for c := 0; c <= cols; c++ {
fmt.Fprintf(b, ` <path d="M%d %d V%d" stroke="%s" stroke-width="1" opacity="0.45"/>`+"\n", x+c*cellW, y, y+height, escapeAttr(theme.Muted))
}
for i := 0; i < rows*cols && i < len(labels); i++ {
c, r := i%cols, i/cols
fmt.Fprintf(b, ` <rect x="%d" y="%d" width="%d" height="%d" fill="%s" opacity="0.14"/>`+"\n", x+c*cellW+4, y+r*cellH+4, cellW-8, cellH-8, escapeAttr(authorDiagramColor(theme, i)))
writeAuthorDiagramLabel(b, x+c*cellW+cellW/2, y+r*cellH+cellH/2+4, maxInt(72, cellW-14), compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
}
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorSensoryWheelDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormSensoryWheel)
cx, cy := x+width/2, y+height/2
radius := minInt(width, height) / 3
if radius < 28 {
radius = minInt(width, height) / 2
}
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="none" stroke="%s" stroke-width="1.2" opacity="0.54"/>`+"\n", cx, cy, radius, escapeAttr(theme.Muted))
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.15"/>`+"\n", cx, cy, radius/2, escapeAttr(theme.Accent))
points := [][2]int{{cx, cy - radius}, {cx + radius, cy}, {cx, cy + radius}, {cx - radius, cy}, {cx + radius*7/10, cy - radius*7/10}, {cx - radius*7/10, cy + radius*7/10}}
for i, pt := range points {
fmt.Fprintf(b, ` <path d="M%d %d L%d %d" stroke="%s" stroke-width="1" opacity="0.45"/>`+"\n", cx, cy, pt[0], pt[1], escapeAttr(theme.Muted))
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.88"/>`+"\n", pt[0], pt[1], compactDiagramRadius(height, 7), escapeAttr(authorDiagramColor(theme, i)))
writeAuthorDiagramLabel(b, pt[0], pt[1]+18, 112, compactDiagramFontSize(height), theme.Ink, "middle", authorLabelAt(labels, i))
}
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorObjectCalloutDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormObjectCallout)
cx, cy := x+width/2, y+height/2
fmt.Fprintf(b, ` <ellipse cx="%d" cy="%d" rx="%d" ry="%d" fill="%s" opacity="0.10" stroke="%s" stroke-width="1.2"/>`+"\n", cx, cy, width/5, height/4, escapeAttr(theme.Accent), escapeAttr(theme.Muted))
fmt.Fprintf(b, ` <path d="M%d %d C%d %d %d %d %d %d" fill="none" stroke="%s" stroke-width="2.4" opacity="0.72"/>`+"\n", cx-width/4, cy, cx-width/8, cy-height/4, cx+width/8, cy-height/4, cx+width/4, cy, escapeAttr(theme.Accent))
callouts := [][4]int{
{cx - width/5, cy - height/7, x + 8, y + 16},
{cx + width/5, cy, x + width - 8, y + height/2},
{cx, cy + height/4, x + width/2, y + height - 10},
}
for i, c := range callouts {
fmt.Fprintf(b, ` <path d="M%d %d L%d %d" stroke="%s" stroke-width="1" opacity="0.58"/>`+"\n", c[0], c[1], c[2], c[3], escapeAttr(theme.Muted))
anchor := "middle"
if i == 0 {
anchor = "start"
} else if i == 1 {
anchor = "end"
}
writeAuthorDiagramLabel(b, c[2], c[3], 118, compactDiagramFontSize(height), theme.Ink, anchor, authorLabelAt(labels, i))
}
fmt.Fprintf(b, " </g>\n")
}
func renderAuthorGenericDiagram(b *strings.Builder, labels []string, x, y, width, height int, theme authorTheme) {
fmt.Fprintf(b, ` <g slide:role="shape" slide:shape-type="freeform" data-svglide-visual-form="%s">`+"\n", authorVisualFormGeneric)
fmt.Fprintf(b, ` <path d="M%d %d H%d" stroke="%s" stroke-width="1.2" opacity="0.62"/>`+"\n", x, y+height/2, x+width, escapeAttr(theme.Muted))
step := width
if len(labels) > 1 {
step = width / (len(labels) - 1)
}
for i, label := range labels {
cx := x
if len(labels) > 1 {
cx = x + i*step
}
cy := y + height/2
if i%2 == 1 {
cy -= compactDiagramRadius(height, 22)
} else {
cy += compactDiagramRadius(height, 22)
}
r := compactDiagramRadius(height, 14+(i%3)*3)
fmt.Fprintf(b, ` <circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.88"/>`+"\n", cx, cy, r, escapeAttr(authorDiagramColor(theme, i)))
fmt.Fprintf(b, ` <path d="M%d %d L%d %d" stroke="%s" stroke-width="1" opacity="0.55"/>`+"\n", cx, cy, cx, y+height/2, escapeAttr(theme.Muted))
writeAuthorDiagramLabel(b, cx, cy+compactDiagramRadius(height, 24), 128, compactDiagramFontSize(height), theme.Ink, "middle", label)
}
fmt.Fprintf(b, " </g>\n")
}
func writeAuthorDiagramNumber(b *strings.Builder, x, baselineY, maxWidth int, fontSize int, color string, value int) {
writeAuthorDiagramLabelWithFamily(b, x, baselineY, maxWidth, fontSize, color, "middle", fmt.Sprintf("%02d", value), authorFontNumber, 700)
}
func writeAuthorDiagramLabel(b *strings.Builder, x, baselineY, maxWidth int, fontSize int, color string, anchor string, text string) {
writeAuthorDiagramLabelWithFamily(b, x, baselineY, maxWidth, fontSize, color, anchor, text, authorFontLabel, 600)
}
func writeAuthorDiagramLabelWithFamily(b *strings.Builder, x, baselineY, maxWidth int, fontSize int, color string, anchor string, text string, family string, weight int) {
text = strings.TrimSpace(text)
if text == "" {
return
}
width := clampInt(len([]rune(text))*fontSize/2+24, 44, maxInt(44, maxWidth))
height := maxInt(fontSize*2, 24)
left := x - width/2
textAlign := "center"
switch anchor {
case "start":
left = x
textAlign = "left"
case "end":
left = x - width
textAlign = "right"
}
top := baselineY - fontSize
fmt.Fprintf(b, ` <foreignObject x="%d" y="%d" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", left, top, width, height)
fmt.Fprintf(b, ` <p xmlns="http://www.w3.org/1999/xhtml" style="margin:0;font-family:%s;color:%s;font-size:%dpx;line-height:1.12;font-weight:%d;text-align:%s;">%s</p>`+"\n", family, escapeAttr(color), fontSize, weight, textAlign, escapeText(text))
fmt.Fprintf(b, " </foreignObject>\n")
}
func authorSpectrumColor(theme authorTheme, index int) string {
switch index % 6 {
case 0:
return "#D9D7A3"
case 1:
return "#A7B56A"
case 2:
return "#C89A4B"
case 3:
return theme.Accent
case 4:
return "#A63E2B"
default:
return "#6C4A2E"
}
}
func authorLabelAt(labels []string, index int) string {
if len(labels) == 0 {
return "item"
}
if index >= 0 && index < len(labels) {
return labels[index]
}
return labels[index%len(labels)]
}
func compactDiagramFontSize(height int) int {
if height < 120 {
return 10
}
if height < 180 {
return 11
}
return 13
}
func compactDiagramRadius(height int, value int) int {
if height < 120 {
return maxInt(5, value*2/3)
}
return value
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
)
const chartBriefsPath = "assets/charts/chart_briefs.json"
type chartBriefFile struct {
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
Charts []chartBriefEntry `json:"charts"`
}
type chartBriefEntry struct {
ID string `json:"id"`
SlideID string `json:"slide_id"`
Purpose string `json:"purpose"`
Takeaway string `json:"takeaway"`
Renderer string `json:"renderer"`
SourceIDs []string `json:"data_source_ids"`
Unit string `json:"unit"`
MinWidth int `json:"min_width,omitempty"`
MinHeight int `json:"min_height,omitempty"`
FallbackPolicy string `json:"fallback_policy,omitempty"`
}
func readChartBriefs(safeRoot string) (chartBriefFile, bool, error) {
exists, err := runRegularFileExists(safeRoot, chartBriefsPath)
if err != nil {
return chartBriefFile{}, false, err
}
if !exists {
return chartBriefFile{}, false, nil
}
raw, err := readRunRegularArtifact(safeRoot, chartBriefsPath)
if err != nil {
return chartBriefFile{}, true, err
}
var file chartBriefFile
if err := json.Unmarshal(raw, &file); err != nil {
return chartBriefFile{}, true, fmt.Errorf("%s: invalid JSON: %w", chartBriefsPath, err)
}
return file, true, nil
}
func ensureEmptyChartBriefsForNoChartDeck(safeRoot string) error {
if exists, err := runRegularFileExists(safeRoot, chartBriefsPath); err != nil {
return err
} else if exists {
return nil
}
content, err := readQualityContent(safeRoot)
if err != nil {
return err
}
if slideContentHasChartVisual(content) {
return nil
}
run, err := readRunFile(safeRoot)
if err != nil {
return err
}
contract, err := RequiredPromptContractForStage(StageAssets, run)
if err != nil {
return err
}
rawContract, err := json.Marshal(contract)
if err != nil {
return err
}
target, err := ensureRunFileTargetForWrite(safeRoot, chartBriefsPath)
if err != nil {
return err
}
return writeJSON(target, chartBriefFile{
PromptContract: rawContract,
Charts: []chartBriefEntry{},
})
}
func ValidateChartBriefsGate(safeRoot string) error {
content, err := readQualityContent(safeRoot)
if err != nil {
return err
}
briefs, present, err := readChartBriefs(safeRoot)
if err != nil {
return err
}
hasChartVisual := slideContentHasChartVisual(content)
if !present {
if hasChartVisual {
return fmt.Errorf("chart_briefs_gate: chart visual exists but %s is missing", chartBriefsPath)
}
return nil
}
if hasChartVisual && len(briefs.Charts) == 0 {
return fmt.Errorf("chart_briefs_gate: chart visual exists but %s has no chart briefs", chartBriefsPath)
}
for _, entry := range briefs.Charts {
id := strings.TrimSpace(entry.ID)
if id == "" {
return fmt.Errorf("chart_briefs_gate: chart brief id must not be empty")
}
if renderer := strings.TrimSpace(entry.Renderer); renderer != requiredChartRendererVegaLite {
return fmt.Errorf("chart_briefs_gate: chart brief %q renderer = %q, want %q", id, renderer, requiredChartRendererVegaLite)
}
if strings.TrimSpace(entry.SlideID) == "" {
return fmt.Errorf("chart_briefs_gate: chart brief %q slide_id must not be empty", id)
}
if strings.TrimSpace(entry.Takeaway) == "" {
return fmt.Errorf("chart_briefs_gate: chart brief %q takeaway must not be empty", id)
}
if len(entry.SourceIDs) == 0 {
return fmt.Errorf("chart_briefs_gate: chart brief %q data_source_ids must not be empty", id)
}
}
return nil
}
func slideContentHasChartVisual(content qualityContentFile) bool {
for _, slide := range content.Slides {
for _, visual := range slide.Visuals {
if strings.TrimSpace(visual.Type) == "chart" {
return true
}
}
}
return false
}
func chartSpecPathForBrief(id string) string {
return filepath.ToSlash(filepath.Join("assets", "charts", "specs", strings.TrimSpace(id)+".vl.json"))
}
func chartSVGPathForBrief(id string) string {
return filepath.ToSlash(filepath.Join("assets", "charts", strings.TrimSpace(id)+".svg"))
}

View File

@@ -1,50 +0,0 @@
package svglide
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureEmptyChartBriefsForNoChartDeck(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
if err := ensureEmptyChartBriefsForNoChartDeck("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", chartBriefsPath))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"charts": []`) {
t.Fatalf("chart_briefs = %s, want empty charts array", raw)
}
}
func TestEnsureEmptyChartBriefsDoesNotHideMissingChartBriefForChartDeck(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Revenue","source_refs":["web1"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
if err := ensureEmptyChartBriefsForNoChartDeck("demo"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join("demo", chartBriefsPath)); !os.IsNotExist(err) {
t.Fatalf("chart_briefs should not be auto-created for chart deck, stat err = %v", err)
}
}
func TestChartBriefRejectsNativeSVGRenderer(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Revenue","source_refs":["web1"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[{"id":"revenue","slide_id":"s1","purpose":"comparison","takeaway":"Revenue increased","renderer":"native-svg","data_source_ids":["web1"],"unit":"$"}]}`)
err := ValidateChartBriefsGate("demo")
if err == nil {
t.Fatal("expected native-svg chart brief renderer to be rejected")
}
if !strings.Contains(err.Error(), "renderer") || !strings.Contains(err.Error(), "vega-lite") {
t.Fatalf("error = %v, want renderer vega-lite rejection", err)
}
}

View File

@@ -1,74 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"strings"
)
const chartManifestPath = "assets/charts/chart_manifest.json"
type chartManifestFile struct {
Renderer string `json:"renderer"`
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
Charts []chartManifestEntry `json:"charts"`
}
type chartManifestEntry struct {
ID string `json:"id"`
SlideID string `json:"slide_id"`
Renderer string `json:"renderer"`
BriefID string `json:"brief_id,omitempty"`
SpecPath string `json:"spec_path"`
SVGPath string `json:"svg_path"`
SourceID string `json:"source_id"`
Unit string `json:"unit,omitempty"`
Takeaway string `json:"takeaway,omitempty"`
RenderReceipt string `json:"render_receipt,omitempty"`
}
func readChartManifest(safeRoot string) (chartManifestFile, bool, error) {
exists, err := runRegularFileExists(safeRoot, chartManifestPath)
if err != nil {
return chartManifestFile{}, false, err
}
if !exists {
return chartManifestFile{}, false, nil
}
raw, err := readRunRegularArtifact(safeRoot, chartManifestPath)
if err != nil {
return chartManifestFile{}, false, err
}
var file chartManifestFile
if err := json.Unmarshal(raw, &file); err != nil {
return chartManifestFile{}, true, fmt.Errorf("%s: invalid JSON: %w", chartManifestPath, err)
}
return file, true, nil
}
func chartEntryRenderer(file chartManifestFile, entry chartManifestEntry) string {
if value := strings.TrimSpace(entry.Renderer); value != "" {
return value
}
return strings.TrimSpace(file.Renderer)
}
func countVegaLiteSpecEntries(file chartManifestFile) int {
count := 0
for _, entry := range file.Charts {
if chartEntryRenderer(file, entry) == requiredChartRendererVegaLite && strings.TrimSpace(entry.SpecPath) != "" {
count++
}
}
return count
}
func countChartSVGEntries(file chartManifestFile) int {
count := 0
for _, entry := range file.Charts {
if strings.TrimSpace(entry.SVGPath) != "" {
count++
}
}
return count
}

View File

@@ -1,343 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
)
const chartQualityReportPath = "receipts/chart_quality.json"
type ChartQualityReport struct {
Status string `json:"status"`
Metrics ChartQualityMetrics `json:"metrics"`
Issues []ChartQualityIssue `json:"issues"`
Charts []ChartQualityChart `json:"charts"`
}
type ChartQualityMetrics struct {
Charts int `json:"charts"`
VegaLiteCharts int `json:"vega_lite_charts"`
MissingAxisCount int `json:"missing_axis_count"`
MissingUnitCount int `json:"missing_unit_count"`
MissingSourceCount int `json:"missing_source_count"`
MissingDirectLabelCount int `json:"missing_direct_label_count"`
DecorativeChartCount int `json:"decorative_chart_count"`
}
type ChartQualityIssue struct {
Path string `json:"path"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
}
type ChartQualityChart struct {
ID string `json:"id"`
SlideID string `json:"slide_id"`
Renderer string `json:"renderer"`
SVGPath string `json:"svg_path"`
SpecPath string `json:"spec_path,omitempty"`
}
func CheckChartQuality(root string) (ChartQualityReport, error) {
safeRoot, _, err := readRun(root)
if err != nil {
return ChartQualityReport{}, err
}
manifest, present, err := readChartManifest(safeRoot)
if err != nil {
return ChartQualityReport{}, err
}
report := ChartQualityReport{
Status: "passed",
Issues: []ChartQualityIssue{},
Charts: []ChartQualityChart{},
}
if !present {
if err := writeChartQualityReport(safeRoot, report); err != nil {
return report, err
}
return report, nil
}
sourceIDs, sourceErr := readKnownSourceIDs(safeRoot)
if sourceErr != nil {
addChartQualityIssue(&report, "research/sources.json", "svglide.chart_quality.sources_unreadable", sourceErr.Error())
sourceIDs = map[string]bool{}
}
renderReport, renderErr := readChartRenderReport(safeRoot)
if renderErr != nil {
addChartQualityIssue(&report, chartRenderReceiptPath, "svglide.chart_quality.missing_render_receipt", renderErr.Error())
}
renderByID := chartRenderEntriesByID(renderReport)
for _, chart := range manifest.Charts {
renderer := normalizedRequiredChartRenderer(chartEntryRenderer(manifest, chart))
svgPath := strings.TrimSpace(chart.SVGPath)
item := ChartQualityChart{
ID: strings.TrimSpace(chart.ID),
SlideID: strings.TrimSpace(chart.SlideID),
Renderer: renderer,
SVGPath: svgPath,
SpecPath: strings.TrimSpace(chart.SpecPath),
}
report.Charts = append(report.Charts, item)
report.Metrics.Charts++
if renderer == requiredChartRendererVegaLite {
report.Metrics.VegaLiteCharts++
validateVegaLiteChartQuality(&report, safeRoot, chart, sourceIDs, renderByID, renderErr == nil)
}
if svgPath == "" {
addChartQualityIssue(&report, chartManifestPath, "svglide.chart_quality.missing_svg", fmt.Sprintf("chart %q has no svg_path", chart.ID))
continue
}
raw, err := readRunRegularArtifact(safeRoot, svgPath)
if err != nil {
addChartQualityIssue(&report, svgPath, "svglide.chart_quality.missing_svg", fmt.Sprintf("chart %q SVG cannot be read: %v", chart.ID, err))
continue
}
checkChartSVGQuality(&report, svgPath, string(raw))
}
if len(report.Issues) > 0 {
report.Status = "failed"
}
if err := writeChartQualityReport(safeRoot, report); err != nil {
return report, err
}
return report, nil
}
func validateVegaLiteChartQuality(report *ChartQualityReport, safeRoot string, chart chartManifestEntry, sourceIDs map[string]bool, renderByID map[string]ChartRenderEntry, hasRenderReceipt bool) {
id := strings.TrimSpace(chart.ID)
if id == "" {
id = strings.TrimSpace(chart.SVGPath)
}
for _, required := range []struct {
value string
code string
name string
}{
{strings.TrimSpace(chart.BriefID), "svglide.chart_quality.missing_brief_id", "brief_id"},
{strings.TrimSpace(chart.SpecPath), "svglide.chart_quality.missing_spec_path", "spec_path"},
{strings.TrimSpace(chart.SVGPath), "svglide.chart_quality.missing_svg", "svg_path"},
{strings.TrimSpace(chart.SourceID), "svglide.chart_quality.missing_source", "source_id"},
{strings.TrimSpace(chart.Unit), "svglide.chart_quality.missing_unit", "unit"},
{strings.TrimSpace(chart.Takeaway), "svglide.chart_quality.missing_takeaway", "takeaway"},
{strings.TrimSpace(chart.RenderReceipt), "svglide.chart_quality.missing_render_receipt", "render_receipt"},
} {
if required.value == "" {
addChartQualityIssue(report, chartManifestPath, required.code, fmt.Sprintf("chart %q is missing %s", id, required.name))
}
}
if chart.RenderReceipt != "" && chart.RenderReceipt != chartRenderReceiptPath {
addChartQualityIssue(report, chartManifestPath, "svglide.chart_quality.missing_render_receipt", fmt.Sprintf("chart %q render_receipt = %q, want %q", id, chart.RenderReceipt, chartRenderReceiptPath))
}
if sourceID := strings.TrimSpace(chart.SourceID); sourceID != "" && !sourceIDs[sourceID] {
addChartQualityIssue(report, chartManifestPath, "svglide.chart_quality.unknown_source_id", fmt.Sprintf("chart %q references unknown source_id %q", id, sourceID))
}
validateVegaLiteSpec(report, safeRoot, chart)
if !hasRenderReceipt {
return
}
renderEntry, ok := renderByID[id]
if !ok {
addChartQualityIssue(report, chartRenderReceiptPath, "svglide.chart_quality.render_receipt_missing_chart", fmt.Sprintf("render receipt has no entry for chart %q", id))
return
}
if strings.TrimSpace(chart.SpecPath) != "" {
raw, err := readRunRegularArtifact(safeRoot, chart.SpecPath)
if err != nil {
addChartQualityIssue(report, chart.SpecPath, "svglide.chart_quality.missing_spec_path", err.Error())
} else if got := sha256Hex(raw); got != renderEntry.SpecSHA256 {
addChartQualityIssue(report, chart.SpecPath, "svglide.chart_quality.spec_hash_mismatch", fmt.Sprintf("chart %q spec hash %s, want %s", id, got, renderEntry.SpecSHA256))
}
}
if strings.TrimSpace(chart.SVGPath) != "" {
raw, err := readRunRegularArtifact(safeRoot, chart.SVGPath)
if err != nil {
addChartQualityIssue(report, chart.SVGPath, "svglide.chart_quality.missing_svg", err.Error())
} else if got := sha256Hex(raw); got != renderEntry.SVGSHA256 {
addChartQualityIssue(report, chart.SVGPath, "svglide.chart_quality.svg_hash_mismatch", fmt.Sprintf("chart %q SVG hash %s, want %s", id, got, renderEntry.SVGSHA256))
}
}
}
func validateVegaLiteSpec(report *ChartQualityReport, safeRoot string, chart chartManifestEntry) {
specPath := strings.TrimSpace(chart.SpecPath)
if specPath == "" {
return
}
raw, err := readRunRegularArtifact(safeRoot, specPath)
if err != nil {
addChartQualityIssue(report, specPath, "svglide.chart_quality.missing_spec_path", err.Error())
return
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw, &spec); err != nil {
addChartQualityIssue(report, specPath, "svglide.chart_quality.invalid_spec_json", err.Error())
return
}
if len(spec["$schema"]) == 0 {
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_schema", "Vega-Lite spec must include $schema")
}
if len(spec["mark"]) == 0 {
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_mark", "Vega-Lite spec must include mark")
}
if len(spec["encoding"]) == 0 {
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_encoding", "Vega-Lite spec must include encoding")
}
if !vegaLiteSpecHasData(spec) {
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_data", "Vega-Lite spec must include data.values or a local data reference")
}
}
func vegaLiteSpecHasData(spec map[string]json.RawMessage) bool {
raw := spec["data"]
if len(raw) == 0 {
return false
}
var data map[string]json.RawMessage
if err := json.Unmarshal(raw, &data); err != nil {
return false
}
if len(data["values"]) > 0 {
return true
}
var urlValue string
if err := json.Unmarshal(data["url"], &urlValue); err == nil && strings.HasPrefix(urlValue, "assets/charts/data/") {
return true
}
return false
}
func readChartRenderReport(safeRoot string) (ChartRenderReport, error) {
raw, err := readRunRegularArtifact(safeRoot, chartRenderReceiptPath)
if err != nil {
return ChartRenderReport{}, err
}
var report ChartRenderReport
if err := json.Unmarshal(raw, &report); err != nil {
return ChartRenderReport{}, fmt.Errorf("%s: invalid JSON: %w", chartRenderReceiptPath, err)
}
return report, nil
}
func chartRenderEntriesByID(report ChartRenderReport) map[string]ChartRenderEntry {
out := map[string]ChartRenderEntry{}
for _, entry := range report.Charts {
if id := strings.TrimSpace(entry.ID); id != "" {
out[id] = entry
}
}
return out
}
func checkChartSVGQuality(report *ChartQualityReport, path, svg string) {
visible := strings.ToLower(visibleSemanticText(svg))
raw := strings.ToLower(svg)
if !chartHasUnit(visible) {
report.Metrics.MissingUnitCount++
addChartQualityIssue(report, path, "svglide.chart_quality.missing_unit", "chart must include a visible unit such as $, %, bps, billion, million, points, goals, or score")
}
if !chartHasSource(visible) {
report.Metrics.MissingSourceCount++
addChartQualityIssue(report, path, "svglide.chart_quality.missing_source", "chart must include a visible source note or source label")
}
hasAxis := chartHasAxis(raw, visible)
hasDirectLabel := chartHasDirectLabel(raw, visible)
if !hasAxis {
report.Metrics.MissingAxisCount++
}
if !hasDirectLabel {
report.Metrics.MissingDirectLabelCount++
}
if !hasAxis || !hasDirectLabel {
addChartQualityIssue(report, path, "svglide.chart_quality.missing_labeling", "chart must include readable axes or direct labels")
}
if chartLooksDecorative(raw, visible) {
report.Metrics.DecorativeChartCount++
addChartQualityIssue(report, path, "svglide.chart_quality.decorative_chart", "chart looks decorative: it lacks enough labels, axes, units, or source context")
}
}
func writeChartQualityReport(safeRoot string, report ChartQualityReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, chartQualityReportPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func addChartQualityIssue(report *ChartQualityReport, path, code, message string) {
report.Issues = append(report.Issues, ChartQualityIssue{
Path: filepath.ToSlash(path),
Code: code,
Message: message,
Severity: "error",
})
}
func chartHasUnit(visible string) bool {
for _, token := range []string{"$", "%", "bps", "bp", "points", "point", "score", "goals", "goal", "usd", "rmb", "billion", "million", "bn", "分", "美元", "亿元", "亿", "倍"} {
if strings.Contains(visible, token) {
return true
}
}
return false
}
func chartHasSource(visible string) bool {
normalized := strings.NewReplacer("", ":", "﹕", ":", "", ":", "\n", " ").Replace(visible)
for _, token := range []string{
"source:",
"sources:",
"data source:",
"source note:",
"来源:",
"数据源:",
"资料来源:",
"数据来源:",
"sec 10-k",
"sec 10-q",
"company filings",
"company filing",
"annual report",
"quarterly report",
"official statistics",
"official data",
"fifa official",
"olympics official",
"年报",
"财报",
} {
if strings.Contains(normalized, token) {
return true
}
}
return false
}
func chartHasAxis(raw, visible string) bool {
if strings.Contains(raw, "role=\"axis\"") || strings.Contains(raw, "aria-label=\"axis") || strings.Contains(raw, "class=\"axis") {
return true
}
for _, token := range []string{"x-axis", "y-axis", "axis", "year", "quarter", "fy", "q1", "q2", "q3", "q4", "年度", "季度"} {
if strings.Contains(raw, token) || strings.Contains(visible, token) {
return true
}
}
return false
}
func chartHasDirectLabel(raw, visible string) bool {
if strings.Contains(raw, "direct-label") || strings.Contains(raw, "data-label") || strings.Contains(raw, "mark-text") {
return true
}
return strings.Count(raw, "<text") >= 2 && containsDigit(visible)
}
func chartLooksDecorative(raw, visible string) bool {
barCount := strings.Count(raw, "<rect") + strings.Count(raw, "<path")
textCount := strings.Count(raw, "<text")
return barCount >= 2 && (textCount == 0 || !containsDigit(visible) || !chartHasUnit(visible) || !chartHasSource(visible))
}

View File

@@ -1,113 +0,0 @@
package svglide
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestChartQualityRequiresUnitsSourcesAndLabels(t *testing.T) {
initStatusTestRun(t)
copyChartQualityTestData(t, "weak_financial_chart", "demo")
report, err := CheckChartQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if report.Metrics.Charts != 1 || report.Metrics.VegaLiteCharts != 1 {
t.Fatalf("metrics = %+v, want one Vega-Lite chart", report.Metrics)
}
if report.Metrics.MissingUnitCount != 1 {
t.Fatalf("missing unit count = %d, want 1", report.Metrics.MissingUnitCount)
}
if report.Metrics.MissingSourceCount != 1 {
t.Fatalf("missing source count = %d, want 1", report.Metrics.MissingSourceCount)
}
if report.Metrics.MissingAxisCount != 1 || report.Metrics.MissingDirectLabelCount != 1 {
t.Fatalf("label metrics = %+v, want missing axis and direct label", report.Metrics)
}
if report.Metrics.DecorativeChartCount != 1 {
t.Fatalf("decorative chart count = %d, want 1", report.Metrics.DecorativeChartCount)
}
for _, code := range []string{
"svglide.chart_quality.missing_unit",
"svglide.chart_quality.missing_source",
"svglide.chart_quality.missing_labeling",
"svglide.chart_quality.decorative_chart",
} {
if !chartQualityIssueCodesContain(report.Issues, code) {
t.Fatalf("issues = %+v, want %s", report.Issues, code)
}
}
}
func TestChartQualityDoesNotTreatCompanyComparisonAsSource(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"peer","slide_id":"s1","renderer":"vega-lite","spec_path":"assets/charts/specs/peer.vl.json","svg_path":"assets/charts/peer.svg"}]}`)
mustWriteTestFile(t, "demo/assets/charts/peer.svg", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 240"><g role="axis"><text>FY2024</text></g><text>Company comparison</text><text>$22.1B</text><rect width="120" height="160"/></svg>`)
report, err := CheckChartQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for missing source", report.Status)
}
if report.Metrics.MissingSourceCount != 1 {
t.Fatalf("missing source count = %d, want 1", report.Metrics.MissingSourceCount)
}
if !chartQualityIssueCodesContain(report.Issues, "svglide.chart_quality.missing_source") {
t.Fatalf("issues = %+v, want missing source", report.Issues)
}
}
func copyChartQualityTestData(t *testing.T, name string, root string) {
t.Helper()
srcRoot := chartQualityTestDataRoot(t, name)
err := filepath.WalkDir(srcRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(srcRoot, path)
if err != nil {
return err
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
target := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
return os.WriteFile(target, raw, 0o644)
})
if err != nil {
t.Fatal(err)
}
}
func chartQualityTestDataRoot(t *testing.T, name string) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Join(filepath.Dir(file), "testdata", "chart_quality", name)
}
func chartQualityIssueCodesContain(issues []ChartQualityIssue, code string) bool {
for _, issue := range issues {
if issue.Code == code {
return true
}
}
return false
}

View File

@@ -1,211 +0,0 @@
package svglide
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
const chartRenderReceiptPath = "receipts/chart_render.json"
type ChartRenderReport struct {
Status string `json:"status"`
Renderer string `json:"renderer"`
Charts []ChartRenderEntry `json:"charts"`
Issues []ChartRenderIssue `json:"issues"`
}
type ChartRenderEntry struct {
ID string `json:"id"`
SlideID string `json:"slide_id"`
SpecPath string `json:"spec_path"`
SVGPath string `json:"svg_path"`
SpecSHA256 string `json:"spec_sha256"`
SVGSHA256 string `json:"svg_sha256"`
Command string `json:"command"`
}
type ChartRenderIssue struct {
Code string `json:"code"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
}
func RenderVegaLiteCharts(root string) (ChartRenderReport, error) {
safeRoot, _, err := readRun(root)
if err != nil {
return ChartRenderReport{}, err
}
report := ChartRenderReport{
Status: "passed",
Renderer: "node-vega-lite",
Charts: []ChartRenderEntry{},
Issues: []ChartRenderIssue{},
}
manifest, present, err := readChartManifest(safeRoot)
if err != nil {
return ChartRenderReport{}, err
}
if !present || len(manifest.Charts) == 0 {
return report, writeChartRenderReport(safeRoot, report)
}
nodePath, err := exec.LookPath("node")
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{
Code: "svglide.chart_render.missing_node",
Message: "node executable is not available in PATH",
})
return report, writeChartRenderReport(safeRoot, report)
}
rendererScript, err := findNodeChartRendererScript()
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{
Code: "svglide.chart_render.missing_node_dependencies",
Path: "internal/svglide/chart_renderer",
Message: err.Error(),
})
return report, writeChartRenderReport(safeRoot, report)
}
if err := validateNodeChartRendererDependencies(rendererScript); err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{
Code: "svglide.chart_render.missing_node_dependencies",
Path: "internal/svglide/chart_renderer",
Message: err.Error(),
})
return report, writeChartRenderReport(safeRoot, report)
}
for _, chart := range manifest.Charts {
if chartEntryRenderer(manifest, chart) != requiredChartRendererVegaLite {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{
Code: "svglide.chart_render.unsupported_renderer",
Path: chartManifestPath,
Message: fmt.Sprintf("chart %q renderer must be vega-lite for local SVG deck", chart.ID),
})
continue
}
specPath := strings.TrimSpace(chart.SpecPath)
svgPath := strings.TrimSpace(chart.SVGPath)
if specPath == "" || svgPath == "" {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{
Code: "svglide.chart_render.missing_path",
Path: chartManifestPath,
Message: fmt.Sprintf("chart %q must include spec_path and svg_path", chart.ID),
})
continue
}
specAbs, err := safeRunPath(safeRoot, specPath)
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.invalid_spec_path", Path: specPath, Message: err.Error()})
continue
}
svgAbs, err := safeRunPath(safeRoot, svgPath)
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.invalid_svg_path", Path: svgPath, Message: err.Error()})
continue
}
if err := os.MkdirAll(filepath.Dir(svgAbs), 0o755); err != nil {
return report, err
}
cmd := exec.Command(nodePath, rendererScript, "--input", specAbs, "--output", svgAbs)
output, err := cmd.CombinedOutput()
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{
Code: "svglide.chart_render.node_renderer_failed",
Path: specPath,
Message: strings.TrimSpace(string(output)),
})
continue
}
specRaw, err := readRunRegularArtifact(safeRoot, specPath)
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.read_spec", Path: specPath, Message: err.Error()})
continue
}
svgRaw, err := readRunRegularArtifact(safeRoot, svgPath)
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.read_svg", Path: svgPath, Message: err.Error()})
continue
}
report.Charts = append(report.Charts, ChartRenderEntry{
ID: strings.TrimSpace(chart.ID),
SlideID: strings.TrimSpace(chart.SlideID),
SpecPath: specPath,
SVGPath: svgPath,
SpecSHA256: sha256Hex(specRaw),
SVGSHA256: sha256Hex(svgRaw),
Command: "node internal/svglide/chart_renderer/render-vegalite.mjs --input " + specPath + " --output " + svgPath,
})
}
return report, writeChartRenderReport(safeRoot, report)
}
func writeChartRenderReport(safeRoot string, report ChartRenderReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, chartRenderReceiptPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func sha256Hex(raw []byte) string {
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
func findNodeChartRendererScript() (string, error) {
if _, file, _, ok := runtime.Caller(0); ok {
candidate := filepath.Join(filepath.Dir(file), "chart_renderer", "render-vegalite.mjs")
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
return candidate, nil
}
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
for dir := cwd; ; dir = filepath.Dir(dir) {
for _, rel := range []string{
filepath.Join("internal", "svglide", "chart_renderer", "render-vegalite.mjs"),
filepath.Join("chart_renderer", "render-vegalite.mjs"),
} {
candidate := filepath.Join(dir, rel)
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
return candidate, nil
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
return "", fmt.Errorf("cannot locate internal/svglide/chart_renderer/render-vegalite.mjs from %s", cwd)
}
func validateNodeChartRendererDependencies(scriptPath string) error {
root := filepath.Dir(scriptPath)
for _, rel := range []string{
filepath.Join("node_modules", "vega", "package.json"),
filepath.Join("node_modules", "vega-lite", "package.json"),
} {
path := filepath.Join(root, rel)
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
return fmt.Errorf("missing %s; run npm --prefix internal/svglide/chart_renderer install", filepath.ToSlash(rel))
}
}
return nil
}

View File

@@ -1,66 +0,0 @@
package svglide
import (
"os"
"path/filepath"
"testing"
)
func TestChartRenderRendersVegaLiteSpecWithNodeRenderer(t *testing.T) {
if script, err := findNodeChartRendererScript(); err != nil {
t.Skip(err)
} else if err := validateNodeChartRendererDependencies(script); err != nil {
t.Skip(err)
}
initStatusTestRun(t)
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"revenue","slide_id":"s1","renderer":"vega-lite","brief_id":"revenue","spec_path":"assets/charts/specs/revenue.vl.json","svg_path":"assets/charts/revenue.svg","source_id":"web1","unit":"$","takeaway":"Revenue increased","render_receipt":"receipts/chart_render.json"}]}`)
mustWriteTestFile(t, "demo/assets/charts/specs/revenue.vl.json", minimalVegaLiteSpecForTest())
report, err := RenderVegaLiteCharts("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, issues = %+v", report.Status, report.Issues)
}
if report.Renderer != "node-vega-lite" {
t.Fatalf("renderer = %q, want node-vega-lite", report.Renderer)
}
if len(report.Charts) != 1 || report.Charts[0].SpecSHA256 == "" || report.Charts[0].SVGSHA256 == "" {
t.Fatalf("render report = %+v, want one hashed chart", report)
}
if info, err := os.Stat(filepath.Join("demo", "assets", "charts", "revenue.svg")); err != nil || info.Size() == 0 {
t.Fatalf("rendered SVG missing or empty, info=%+v err=%v", info, err)
}
}
func TestChartRenderWritesEmptyReceiptForNoChartManifest(t *testing.T) {
initStatusTestRun(t)
report, err := RenderVegaLiteCharts("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" || len(report.Charts) != 0 {
t.Fatalf("report = %+v, want passed empty report", report)
}
}
func minimalVegaLiteSpecForTest() string {
return `{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"width": 640,
"height": 320,
"data": {
"values": [
{"quarter": "Q1", "revenue": 2},
{"quarter": "Q2", "revenue": 5}
]
},
"mark": "bar",
"encoding": {
"x": {"field": "quarter", "type": "nominal", "title": "Quarter"},
"y": {"field": "revenue", "type": "quantitative", "title": "Revenue ($B)"}
}
}`
}

View File

@@ -1 +0,0 @@
node_modules/

View File

@@ -1,917 +0,0 @@
{
"name": "@svglide/chart-renderer",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@svglide/chart-renderer",
"dependencies": {
"vega": "^6.2.0",
"vega-lite": "^6.4.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://bnpm.byted.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://bnpm.byted.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://bnpm.byted.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://bnpm.byted.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://bnpm.byted.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://bnpm.byted.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://bnpm.byted.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://bnpm.byted.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://bnpm.byted.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://bnpm.byted.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://bnpm.byted.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://bnpm.byted.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://bnpm.byted.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://bnpm.byted.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo-projection": {
"version": "4.0.0",
"resolved": "https://bnpm.byted.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
"integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
"license": "ISC",
"dependencies": {
"commander": "7",
"d3-array": "1 - 3",
"d3-geo": "1.12.0 - 3"
},
"bin": {
"geo2svg": "bin/geo2svg.js",
"geograticule": "bin/geograticule.js",
"geoproject": "bin/geoproject.js",
"geoquantize": "bin/geoquantize.js",
"geostitch": "bin/geostitch.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://bnpm.byted.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://bnpm.byted.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://bnpm.byted.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://bnpm.byted.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://bnpm.byted.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://bnpm.byted.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://bnpm.byted.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://bnpm.byted.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://bnpm.byted.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://bnpm.byted.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://bnpm.byted.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://bnpm.byted.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://bnpm.byted.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://bnpm.byted.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://bnpm.byted.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://bnpm.byted.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://bnpm.byted.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://bnpm.byted.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://bnpm.byted.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://bnpm.byted.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://bnpm.byted.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://bnpm.byted.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://bnpm.byted.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/topojson-client/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://bnpm.byted.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://bnpm.byted.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/vega": {
"version": "6.2.0",
"resolved": "https://bnpm.byted.org/vega/-/vega-6.2.0.tgz",
"integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-crossfilter": "~5.1.0",
"vega-dataflow": "~6.1.0",
"vega-encode": "~5.1.0",
"vega-event-selector": "~4.0.0",
"vega-expression": "~6.1.0",
"vega-force": "~5.1.0",
"vega-format": "~2.1.0",
"vega-functions": "~6.1.0",
"vega-geo": "~5.1.0",
"vega-hierarchy": "~5.1.0",
"vega-label": "~2.1.0",
"vega-loader": "~5.1.0",
"vega-parser": "~7.1.0",
"vega-projection": "~2.1.0",
"vega-regression": "~2.1.0",
"vega-runtime": "~7.1.0",
"vega-scale": "~8.1.0",
"vega-scenegraph": "~5.1.0",
"vega-statistics": "~2.0.0",
"vega-time": "~3.1.0",
"vega-transforms": "~5.1.0",
"vega-typings": "~2.1.0",
"vega-util": "~2.1.0",
"vega-view": "~6.1.0",
"vega-view-transforms": "~5.1.0",
"vega-voronoi": "~5.1.0",
"vega-wordcloud": "~5.1.0"
},
"funding": {
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
}
},
"node_modules/vega-canvas": {
"version": "2.0.0",
"resolved": "https://bnpm.byted.org/vega-canvas/-/vega-canvas-2.0.0.tgz",
"integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==",
"license": "BSD-3-Clause"
},
"node_modules/vega-crossfilter": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz",
"integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-dataflow": {
"version": "6.1.0",
"resolved": "https://bnpm.byted.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz",
"integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-format": "^2.1.0",
"vega-loader": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-encode": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-encode/-/vega-encode-5.1.0.tgz",
"integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-interpolate": "^3.0.1",
"vega-dataflow": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-event-selector": {
"version": "4.0.0",
"resolved": "https://bnpm.byted.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz",
"integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==",
"license": "BSD-3-Clause"
},
"node_modules/vega-expression": {
"version": "6.1.0",
"resolved": "https://bnpm.byted.org/vega-expression/-/vega-expression-6.1.0.tgz",
"integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/estree": "^1.0.8",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-force": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-force/-/vega-force-5.1.0.tgz",
"integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-force": "^3.0.0",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-format": {
"version": "2.1.0",
"resolved": "https://bnpm.byted.org/vega-format/-/vega-format-2.1.0.tgz",
"integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-format": "^3.1.0",
"d3-time-format": "^4.1.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-functions": {
"version": "6.1.1",
"resolved": "https://bnpm.byted.org/vega-functions/-/vega-functions-6.1.1.tgz",
"integrity": "sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-geo": "^3.1.1",
"vega-dataflow": "^6.1.0",
"vega-expression": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-scenegraph": "^5.1.0",
"vega-selections": "^6.1.0",
"vega-statistics": "^2.0.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-geo": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-geo/-/vega-geo-5.1.0.tgz",
"integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-geo": "^3.1.1",
"vega-canvas": "^2.0.0",
"vega-dataflow": "^6.1.0",
"vega-projection": "^2.1.0",
"vega-statistics": "^2.0.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-hierarchy": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz",
"integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-hierarchy": "^3.1.2",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-label": {
"version": "2.1.0",
"resolved": "https://bnpm.byted.org/vega-label/-/vega-label-2.1.0.tgz",
"integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-canvas": "^2.0.0",
"vega-dataflow": "^6.1.0",
"vega-scenegraph": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-lite": {
"version": "6.4.3",
"resolved": "https://bnpm.byted.org/vega-lite/-/vega-lite-6.4.3.tgz",
"integrity": "sha512-d/7hPjfz560UERaQuTmGgIVfXAe3g2hJWeC+igDeaGohUdEoNrHLXgR/yTOBT8vV/lIuuKnw+0/xWWblkDwkMQ==",
"license": "BSD-3-Clause",
"dependencies": {
"json-stringify-pretty-compact": "~4.0.0",
"tslib": "~2.8.1",
"vega-event-selector": "~4.0.0",
"vega-expression": "~6.1.0",
"vega-util": "~2.1.0",
"yargs": "~18.0.0"
},
"bin": {
"vl2pdf": "bin/vl2pdf",
"vl2png": "bin/vl2png",
"vl2svg": "bin/vl2svg",
"vl2vg": "bin/vl2vg"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
},
"peerDependencies": {
"vega": "^6.0.0"
}
},
"node_modules/vega-loader": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-loader/-/vega-loader-5.1.0.tgz",
"integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dsv": "^3.0.1",
"topojson-client": "^3.1.0",
"vega-format": "^2.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-parser": {
"version": "7.1.0",
"resolved": "https://bnpm.byted.org/vega-parser/-/vega-parser-7.1.0.tgz",
"integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-dataflow": "^6.1.0",
"vega-event-selector": "^4.0.0",
"vega-functions": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-projection": {
"version": "2.1.0",
"resolved": "https://bnpm.byted.org/vega-projection/-/vega-projection-2.1.0.tgz",
"integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-geo": "^3.1.1",
"d3-geo-projection": "^4.0.0",
"vega-scale": "^8.1.0"
}
},
"node_modules/vega-regression": {
"version": "2.1.0",
"resolved": "https://bnpm.byted.org/vega-regression/-/vega-regression-2.1.0.tgz",
"integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"vega-dataflow": "^6.1.0",
"vega-statistics": "^2.0.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-runtime": {
"version": "7.1.0",
"resolved": "https://bnpm.byted.org/vega-runtime/-/vega-runtime-7.1.0.tgz",
"integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-scale": {
"version": "8.1.0",
"resolved": "https://bnpm.byted.org/vega-scale/-/vega-scale-8.1.0.tgz",
"integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-scenegraph": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz",
"integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "^3.1.0",
"d3-shape": "^3.2.0",
"vega-canvas": "^2.0.0",
"vega-loader": "^5.1.0",
"vega-scale": "^8.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-selections": {
"version": "6.1.2",
"resolved": "https://bnpm.byted.org/vega-selections/-/vega-selections-6.1.2.tgz",
"integrity": "sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "3.2.4",
"vega-expression": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-statistics": {
"version": "2.0.0",
"resolved": "https://bnpm.byted.org/vega-statistics/-/vega-statistics-2.0.0.tgz",
"integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4"
}
},
"node_modules/vega-time": {
"version": "3.1.0",
"resolved": "https://bnpm.byted.org/vega-time/-/vega-time-3.1.0.tgz",
"integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-transforms": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-transforms/-/vega-transforms-5.1.0.tgz",
"integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"vega-dataflow": "^6.1.0",
"vega-statistics": "^2.0.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-typings": {
"version": "2.1.0",
"resolved": "https://bnpm.byted.org/vega-typings/-/vega-typings-2.1.0.tgz",
"integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/geojson": "7946.0.16",
"vega-event-selector": "^4.0.0",
"vega-expression": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-util": {
"version": "2.1.1",
"resolved": "https://bnpm.byted.org/vega-util/-/vega-util-2.1.1.tgz",
"integrity": "sha512-tpNmm8bGtUa8gKfFDSjXPffxqSyPr91vaWIEBnJS/rijhoLZMwM+mgYQG6XfwdcBSN1+jkZ57P0sYSEW/jophw==",
"license": "BSD-3-Clause"
},
"node_modules/vega-view": {
"version": "6.1.0",
"resolved": "https://bnpm.byted.org/vega-view/-/vega-view-6.1.0.tgz",
"integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-timer": "^3.0.1",
"vega-dataflow": "^6.1.0",
"vega-format": "^2.1.0",
"vega-functions": "^6.1.0",
"vega-runtime": "^7.1.0",
"vega-scenegraph": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-view-transforms": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz",
"integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-dataflow": "^6.1.0",
"vega-scenegraph": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-voronoi": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz",
"integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-delaunay": "^6.0.4",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-wordcloud": {
"version": "5.1.0",
"resolved": "https://bnpm.byted.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz",
"integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-canvas": "^2.0.0",
"vega-dataflow": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-statistics": "^2.0.0",
"vega-util": "^2.1.0"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://bnpm.byted.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://bnpm.byted.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://bnpm.byted.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://bnpm.byted.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
}
}
}

View File

@@ -1,15 +0,0 @@
{
"name": "@svglide/chart-renderer",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"render": "node ./render-vegalite.mjs"
},
"dependencies": {
"vega": "^6.2.0",
"vega-lite": "^6.4.3"
}
}

View File

@@ -1,28 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import * as vega from "vega";
import * as vegaLite from "vega-lite";
function readArg(name) {
const index = process.argv.indexOf(name);
if (index < 0 || index + 1 >= process.argv.length) {
throw new Error(`missing required argument ${name}`);
}
return process.argv[index + 1];
}
const input = readArg("--input");
const output = readArg("--output");
const raw = fs.readFileSync(input, "utf8");
const vlSpec = JSON.parse(raw);
const vgSpec = vegaLite.compile(vlSpec).spec;
const view = new vega.View(vega.parse(vgSpec), {
renderer: "svg",
logLevel: vega.Warn
});
await view.runAsync();
const svg = await view.toSVG();
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, svg);
view.finalize();

View File

@@ -1,195 +0,0 @@
package svglide
import (
"encoding/xml"
"fmt"
"strconv"
"strings"
)
const chartUsageReceiptPath = "receipts/chart_usage.json"
type ChartUsageReport struct {
Status string `json:"status"`
Charts []ChartUsageChart `json:"charts"`
Issues []ChartUsageIssue `json:"issues"`
}
type ChartUsageChart struct {
ID string `json:"id"`
SlideID string `json:"slide_id"`
SVGPath string `json:"svg_path"`
ReferenceCount int `json:"reference_count"`
}
type ChartUsageIssue struct {
Code string `json:"code"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
}
type chartUsageReference struct {
SlideID string
Path string
Href string
Width float64
Height float64
}
func EvaluateChartUsageRun(safeRoot string, deck authorDeck, manifest chartManifestFile, briefs chartBriefFile) ChartUsageReport {
report := ChartUsageReport{Status: "passed", Charts: []ChartUsageChart{}, Issues: []ChartUsageIssue{}}
refsByPath := map[string][]chartUsageReference{}
rawBySlide := map[string]string{}
for _, slide := range deck.Slides {
slidePath := strings.TrimSpace(slide.Path)
raw, err := readRunRegularArtifact(safeRoot, slidePath)
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.read_slide", Path: slidePath, Message: err.Error()})
continue
}
rawText := string(raw)
rawBySlide[strings.TrimSpace(slide.ID)] = rawText
refs, issues := extractChartUsageReferences(strings.TrimSpace(slide.ID), slidePath, rawText)
if len(issues) > 0 {
report.Status = "failed"
report.Issues = append(report.Issues, issues...)
}
for _, ref := range refs {
refsByPath[ref.Path] = append(refsByPath[ref.Path], ref)
}
}
briefByID := chartBriefByID(briefs)
expectedSlideIDs := map[string]bool{}
for _, chart := range manifest.Charts {
id := strings.TrimSpace(chart.ID)
slideID := strings.TrimSpace(chart.SlideID)
svgPath := strings.TrimSpace(chart.SVGPath)
expectedSlideIDs[slideID] = true
refs := refsByPath[svgPath]
report.Charts = append(report.Charts, ChartUsageChart{ID: id, SlideID: slideID, SVGPath: svgPath, ReferenceCount: len(refs)})
if len(refs) == 0 {
report.Status = "failed"
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.not_referenced", Path: svgPath, Message: fmt.Sprintf("chart %q is not referenced by a <rect slide:role=\"chart\">", id)})
continue
}
if len(refs) > 1 {
report.Status = "failed"
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.duplicate_reference", Path: svgPath, Message: fmt.Sprintf("chart %q has %d references; expected exactly one", id, len(refs))})
}
minWidth, minHeight := chartUsageMinSize(briefByID[strings.TrimSpace(chart.BriefID)])
for _, ref := range refs {
if ref.SlideID != slideID {
report.Status = "failed"
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.wrong_slide", Path: svgPath, Message: fmt.Sprintf("chart %q referenced on slide %q, want %q", id, ref.SlideID, slideID)})
}
if ref.Width < float64(minWidth) || ref.Height < float64(minHeight) {
report.Status = "failed"
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.too_small", Path: svgPath, Message: fmt.Sprintf("chart %q rendered at %.0fx%.0f, minimum is %dx%d", id, ref.Width, ref.Height, minWidth, minHeight)})
}
}
}
for slideID := range expectedSlideIDs {
hasValidRef := false
for _, refs := range refsByPath {
for _, ref := range refs {
if ref.SlideID == slideID {
hasValidRef = true
break
}
}
}
if !hasValidRef && chartSlideLooksHandDrawn(rawBySlide[slideID]) {
report.Status = "failed"
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.hand_drawn_chart", Path: slideID, Message: "slide appears to hand-draw chart primitives instead of embedding a rendered chart asset"})
}
}
return report
}
func writeChartUsageReport(safeRoot string, report ChartUsageReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, chartUsageReceiptPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func extractChartUsageReferences(slideID, slidePath, svg string) ([]chartUsageReference, []ChartUsageIssue) {
refs := []chartUsageReference{}
issues := []ChartUsageIssue{}
decoder := xml.NewDecoder(strings.NewReader(svg))
for {
token, err := decoder.Token()
if err != nil {
break
}
start, ok := token.(xml.StartElement)
if !ok {
continue
}
attrs := parseSVGAttrs(start.Attr)
if strings.TrimSpace(attrs["role"]) != "chart" {
continue
}
if start.Name.Local != "rect" {
issues = append(issues, ChartUsageIssue{Code: "svglide.chart_usage.invalid_chart_element", Path: slidePath, Message: fmt.Sprintf("<%s slide:role=\"chart\"> is invalid; use <rect slide:role=\"chart\">", start.Name.Local)})
continue
}
href := strings.TrimSpace(attrs["href"])
refs = append(refs, chartUsageReference{
SlideID: slideID,
Path: normalizeChartHref(slidePath, href),
Href: href,
Width: parseChartUsageFloatAttr(attrs["width"]),
Height: parseChartUsageFloatAttr(attrs["height"]),
})
}
return refs, issues
}
func normalizeChartHref(slidePath, href string) string {
href = strings.TrimSpace(href)
if strings.HasPrefix(href, "assets/charts/") {
return href
}
return normalizeSlideAssetHref(slidePath, href)
}
func parseChartUsageFloatAttr(raw string) float64 {
raw = strings.TrimSpace(strings.TrimSuffix(raw, "px"))
value, _ := strconv.ParseFloat(raw, 64)
return value
}
func chartBriefByID(briefs chartBriefFile) map[string]chartBriefEntry {
out := map[string]chartBriefEntry{}
for _, brief := range briefs.Charts {
if id := strings.TrimSpace(brief.ID); id != "" {
out[id] = brief
}
}
return out
}
func chartUsageMinSize(brief chartBriefEntry) (int, int) {
minWidth := 480
minHeight := 260
if brief.MinWidth > minWidth {
minWidth = brief.MinWidth
}
if brief.MinHeight > minHeight {
minHeight = brief.MinHeight
}
return minWidth, minHeight
}
func chartSlideLooksHandDrawn(svg string) bool {
raw := strings.ToLower(svg)
rects := strings.Count(raw, "<rect") - strings.Count(raw, `slide:role="chart"`)
circles := strings.Count(raw, "<circle")
lines := strings.Count(raw, "<line")
paths := strings.Count(raw, "<path")
texts := strings.Count(raw, "<text")
return rects >= 4 || circles >= 6 || (lines >= 2 && (rects+paths+circles) >= 4) || (texts >= 5 && (rects+paths+circles+lines) >= 4)
}

View File

@@ -1,121 +0,0 @@
package svglide
import "testing"
func TestChartUsageAcceptsRectChartReference(t *testing.T) {
initStatusTestRun(t)
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
if report.Status != "passed" {
t.Fatalf("report = %+v, want passed", report)
}
}
func TestChartUsageRejectsImageRoleChart(t *testing.T) {
initStatusTestRun(t)
deck := writeChartUsageDeckForTest(t, `<image slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.invalid_chart_element") {
t.Fatalf("issues = %+v, want invalid_chart_element", report.Issues)
}
}
func TestChartUsageRejectsGroupRoleChart(t *testing.T) {
initStatusTestRun(t)
deck := writeChartUsageDeckForTest(t, `<g slide:role="chart" href="../assets/charts/revenue.svg"></g>`)
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.invalid_chart_element") {
t.Fatalf("issues = %+v, want invalid_chart_element", report.Issues)
}
}
func TestChartUsageRejectsWrongSlide(t *testing.T) {
initStatusTestRun(t)
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("other-slide"), chartUsageBriefsForTest())
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.wrong_slide") {
t.Fatalf("issues = %+v, want wrong_slide", report.Issues)
}
}
func TestChartUsageRejectsTinyChart(t *testing.T) {
initStatusTestRun(t)
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="240" height="120"/>`)
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.too_small") {
t.Fatalf("issues = %+v, want too_small", report.Issues)
}
}
func TestChartUsageRejectsHandDrawnChart(t *testing.T) {
initStatusTestRun(t)
deck := writeChartUsageDeckForTest(t, `<line x1="100" y1="420" x2="700" y2="420"/><line x1="100" y1="100" x2="100" y2="420"/><rect x="150" y="320" width="60" height="100"/><rect x="250" y="260" width="60" height="160"/><rect x="350" y="210" width="60" height="210"/><rect x="450" y="180" width="60" height="240"/><text x="150" y="450">$2B</text><text x="250" y="450">$5B</text><text x="350" y="450">$8B</text><text x="450" y="450">$9B</text>`)
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.hand_drawn_chart") {
t.Fatalf("issues = %+v, want hand_drawn_chart", report.Issues)
}
}
func TestQualityWritesChartUsageReceipt(t *testing.T) {
initStatusTestRun(t)
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[],"no_image_reason":"Chart usage receipt smoke does not exercise raster image selection."}`)
if _, err := CheckQuality("demo"); err != nil {
t.Fatal(err)
}
if _, err := readRunRegularArtifact("demo", chartUsageReceiptPath); err != nil {
t.Fatalf("missing chart usage receipt: %v", err)
}
}
func writeChartUsageDeckForTest(t *testing.T, body string) authorDeck {
t.Helper()
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">`+body+`</svg>`)
return authorDeck{Slides: []authorDeckSlide{{ID: "s1", Path: "slides/01.svg"}}}
}
func chartUsageManifestForTest(slideID string) chartManifestFile {
return chartManifestFile{
Renderer: "vega-lite",
Charts: []chartManifestEntry{{
ID: "revenue",
SlideID: slideID,
Renderer: "vega-lite",
BriefID: "revenue",
SpecPath: "assets/charts/specs/revenue.vl.json",
SVGPath: "assets/charts/revenue.svg",
SourceID: "web1",
Unit: "$",
Takeaway: "Revenue increased",
RenderReceipt: chartRenderReceiptPath,
}},
}
}
func chartUsageBriefsForTest() chartBriefFile {
return chartBriefFile{Charts: []chartBriefEntry{{
ID: "revenue",
SlideID: "s1",
Purpose: "trend",
Takeaway: "Revenue increased",
Renderer: "vega-lite",
SourceIDs: []string{"web1"},
Unit: "$",
}}}
}
func chartUsageIssuesContain(issues []ChartUsageIssue, code string) bool {
for _, issue := range issues {
if issue.Code == code {
return true
}
}
return false
}

View File

@@ -1,445 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode"
)
const contentPayloadReportPath = "receipts/content_payload.json"
type ContentPayloadReport struct {
Status string `json:"status"`
Metrics ContentPayloadMetrics `json:"metrics"`
Issues []ContentPayloadIssue `json:"issues,omitempty"`
}
type ContentPayloadMetrics struct {
Slides int `json:"slides"`
SubstantiveSlides int `json:"substantive_slides"`
SparseLabelListCount int `json:"sparse_label_list_count"`
MissingCentralClaimCount int `json:"missing_central_claim_count"`
MissingSupportingPointsCount int `json:"missing_supporting_points_count"`
MissingSourceBoundFactCount int `json:"missing_source_bound_fact_count"`
MissingVisualDataItemsCount int `json:"missing_visual_data_items_count"`
SourceBindingIssueCount int `json:"source_binding_issue_count"`
IssueCount int `json:"issue_count"`
}
type ContentPayloadIssue struct {
Code string `json:"code"`
SlideID string `json:"slide_id,omitempty"`
Message string `json:"message"`
}
type contentPayloadFile struct {
PromptContract json.RawMessage `json:"prompt_contract"`
Slides []contentPayloadSlide `json:"slides"`
}
type contentPayloadSlide struct {
ID string `json:"id"`
Role string `json:"role"`
Title string `json:"title"`
Content string `json:"content"`
CentralClaim string `json:"central_claim"`
AudienceTakeaway string `json:"audience_takeaway"`
SupportingPoints []contentPayloadSupportingPoint `json:"supporting_points"`
SourceBoundFacts []contentPayloadSourceBoundFact `json:"source_bound_facts"`
ExamplesOrParameters []contentPayloadExampleOrParameter `json:"examples_or_parameters"`
VisualDataItems []contentPayloadVisualDataItem `json:"visual_data_items"`
SoWhat string `json:"so_what"`
SourceRefs []string `json:"source_refs"`
Visuals []contentPayloadVisual `json:"visuals"`
}
type contentPayloadSupportingPoint struct {
Text string `json:"text"`
SourceRefs []string `json:"source_refs"`
}
type contentPayloadSourceBoundFact struct {
Fact string `json:"fact"`
SourceRef string `json:"source_ref"`
Usage string `json:"usage"`
}
type contentPayloadExampleOrParameter struct {
Label string `json:"label"`
Value string `json:"value"`
Explanation string `json:"explanation"`
SourceRef string `json:"source_ref"`
}
type contentPayloadVisualDataItem struct {
Label string `json:"label"`
Value string `json:"value"`
Role string `json:"role"`
Explanation string `json:"explanation"`
SourceRef string `json:"source_ref"`
}
type contentPayloadVisual struct {
ID string `json:"id"`
Type string `json:"type"`
Instruction string `json:"instruction"`
VisualForm string `json:"visual_form"`
}
type contentPayloadDeckSlideMeta struct {
ID string
Title string
Role string
}
func EvaluateContentPayloadRun(root string) (ContentPayloadReport, error) {
safeRoot, _, err := readRun(root)
if err != nil {
return ContentPayloadReport{}, err
}
return evaluateContentPayloadAtRoot(safeRoot)
}
func evaluateContentPayloadAtRoot(safeRoot string) (ContentPayloadReport, error) {
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
if err != nil {
return ContentPayloadReport{}, fmt.Errorf("content/slide_content.json: read artifact: %w", err)
}
var file contentPayloadFile
if err := json.Unmarshal(raw, &file); err != nil {
return ContentPayloadReport{}, fmt.Errorf("content/slide_content.json: invalid JSON: %w", err)
}
report := ContentPayloadReport{
Status: "passed",
Metrics: ContentPayloadMetrics{Slides: len(file.Slides)},
Issues: []ContentPayloadIssue{},
}
if !contentPayloadStrict(file) {
return report, nil
}
sourceIDs, err := readKnownSourceIDs(safeRoot)
if err != nil {
return ContentPayloadReport{}, err
}
metaByID := readContentPayloadDeckMeta(safeRoot)
for i, slide := range file.Slides {
if meta, ok := metaByID[strings.TrimSpace(slide.ID)]; ok {
if strings.TrimSpace(slide.Role) == "" {
slide.Role = meta.Role
}
if strings.TrimSpace(slide.Title) == "" {
slide.Title = meta.Title
}
}
evaluateContentPayloadSlide(&report, slide, sourceIDs, i, len(file.Slides))
}
report.Metrics.IssueCount = len(report.Issues)
if report.Metrics.IssueCount > 0 {
report.Status = "failed"
}
return report, nil
}
func writeContentPayloadReport(safeRoot string, report ContentPayloadReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, contentPayloadReportPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func contentPayloadStrict(file contentPayloadFile) bool {
if raw := strings.TrimSpace(string(file.PromptContract)); raw != "" && raw != "null" && raw != "{}" {
return true
}
for _, slide := range file.Slides {
if strings.TrimSpace(slide.CentralClaim) != "" ||
strings.TrimSpace(slide.AudienceTakeaway) != "" ||
len(slide.SupportingPoints) > 0 ||
len(slide.SourceBoundFacts) > 0 ||
len(slide.VisualDataItems) > 0 ||
strings.TrimSpace(slide.SoWhat) != "" {
return true
}
}
return false
}
func readContentPayloadDeckMeta(safeRoot string) map[string]contentPayloadDeckSlideMeta {
raw, err := readRunRegularArtifact(safeRoot, "outline/deck.json")
if err != nil {
return map[string]contentPayloadDeckSlideMeta{}
}
var deck struct {
Slides []struct {
ID string `json:"id"`
Title string `json:"title"`
Role string `json:"role"`
} `json:"slides"`
}
if err := json.Unmarshal(raw, &deck); err != nil {
return map[string]contentPayloadDeckSlideMeta{}
}
byID := make(map[string]contentPayloadDeckSlideMeta, len(deck.Slides))
for _, slide := range deck.Slides {
id := strings.TrimSpace(slide.ID)
if id == "" {
continue
}
byID[id] = contentPayloadDeckSlideMeta{ID: id, Title: strings.TrimSpace(slide.Title), Role: strings.TrimSpace(slide.Role)}
}
return byID
}
func evaluateContentPayloadSlide(report *ContentPayloadReport, slide contentPayloadSlide, sourceIDs map[string]bool, index int, total int) {
substantive := isSubstantiveContentPayloadSlide(slide, index, total)
if substantive {
report.Metrics.SubstantiveSlides++
}
id := strings.TrimSpace(slide.ID)
hasFloor := contentPayloadHasFloor(slide)
if substantive && isSparseLabelList(slide.Content) && !hasFloor {
addContentPayloadIssue(report, "svglide.content_payload.sparse_label_list", id, "slide content is a label list without enough structured audience payload")
}
if substantive && len([]rune(strings.TrimSpace(slide.CentralClaim))) < 12 {
addContentPayloadIssue(report, "svglide.content_payload.missing_central_claim", id, "substantive slide needs a central_claim of at least 12 characters")
}
if substantive && validSupportingPointCount(slide.SupportingPoints) < 2 {
addContentPayloadIssue(report, "svglide.content_payload.missing_supporting_points", id, "substantive slide needs at least two source-backed supporting_points")
}
if substantive && validSourceBoundFactCount(slide.SourceBoundFacts) < 1 {
addContentPayloadIssue(report, "svglide.content_payload.missing_source_bound_fact", id, "substantive slide needs at least one source_bound_fact")
}
checkContentPayloadSourceBindings(report, slide, sourceIDs)
checkContentPayloadVisualData(report, slide)
}
func contentPayloadHasFloor(slide contentPayloadSlide) bool {
return len([]rune(strings.TrimSpace(slide.CentralClaim))) >= 12 &&
validSupportingPointCount(slide.SupportingPoints) >= 2 &&
validSourceBoundFactCount(slide.SourceBoundFacts) >= 1 &&
contentPayloadVisualDataRequirementMet(slide)
}
func validSupportingPointCount(points []contentPayloadSupportingPoint) int {
count := 0
for _, point := range points {
if len([]rune(strings.TrimSpace(point.Text))) < 12 || len(point.SourceRefs) == 0 {
continue
}
count++
}
return count
}
func validSourceBoundFactCount(facts []contentPayloadSourceBoundFact) int {
count := 0
for _, fact := range facts {
if len([]rune(strings.TrimSpace(fact.Fact))) < 8 || strings.TrimSpace(fact.SourceRef) == "" {
continue
}
count++
}
return count
}
func isSubstantiveContentPayloadSlide(slide contentPayloadSlide, index int, total int) bool {
role := strings.ToLower(strings.TrimSpace(slide.Role))
switch role {
case "cover", "opening", "agenda", "section", "section_divider", "divider":
return false
case "closing", "end", "appendix":
return false
}
idTitle := strings.ToLower(strings.Join([]string{slide.ID, slide.Title, slide.Content}, " "))
if index == 0 && (strings.Contains(idTitle, "cover") || strings.Contains(idTitle, "opening") || strings.Contains(idTitle, "封面") || strings.Contains(idTitle, "开场")) {
return false
}
if total > 1 && index == total-1 && (strings.Contains(idTitle, "closing") || strings.Contains(idTitle, "结语") || strings.Contains(idTitle, "总结") || strings.Contains(idTitle, "end")) {
return false
}
return true
}
func checkContentPayloadSourceBindings(report *ContentPayloadReport, slide contentPayloadSlide, sourceIDs map[string]bool) {
id := strings.TrimSpace(slide.ID)
for _, point := range slide.SupportingPoints {
for _, ref := range point.SourceRefs {
checkContentPayloadSourceRef(report, id, ref, sourceIDs)
}
}
for _, fact := range slide.SourceBoundFacts {
checkContentPayloadSourceRef(report, id, fact.SourceRef, sourceIDs)
}
for _, item := range slide.ExamplesOrParameters {
if strings.TrimSpace(item.SourceRef) != "" {
checkContentPayloadSourceRef(report, id, item.SourceRef, sourceIDs)
}
}
for _, item := range slide.VisualDataItems {
if strings.TrimSpace(item.SourceRef) != "" {
checkContentPayloadSourceRef(report, id, item.SourceRef, sourceIDs)
}
if strings.TrimSpace(item.Explanation) == "" {
addContentPayloadIssue(report, "svglide.content_payload.visual_data_without_explanation", id, fmt.Sprintf("visual_data_item %q needs an explanation", strings.TrimSpace(item.Label)))
}
}
}
func checkContentPayloadSourceRef(report *ContentPayloadReport, slideID string, ref string, sourceIDs map[string]bool) {
ref = strings.TrimSpace(ref)
if ref == "" || sourceIDs[ref] {
return
}
addContentPayloadIssue(report, "svglide.content_payload.unknown_source_ref", slideID, fmt.Sprintf("structured payload references unknown source id %q", ref))
}
func checkContentPayloadVisualData(report *ContentPayloadReport, slide contentPayloadSlide) {
if contentPayloadVisualDataRequirementMet(slide) {
return
}
addContentPayloadIssue(report, "svglide.content_payload.visual_form_missing_data", strings.TrimSpace(slide.ID), "declared visual_form needs matching visual_data_items")
}
func contentPayloadVisualDataRequirementMet(slide contentPayloadSlide) bool {
requiredRole, minItems := contentPayloadVisualDataRequirement(slide.Visuals)
if minItems == 0 {
return true
}
if requiredRole == "" {
return len(slide.VisualDataItems) >= minItems
}
count := 0
for _, item := range slide.VisualDataItems {
if strings.TrimSpace(item.Explanation) == "" {
continue
}
if strings.TrimSpace(item.Role) == requiredRole {
count++
}
}
return count >= minItems
}
func contentPayloadVisualDataRequirement(visuals []contentPayloadVisual) (string, int) {
requiredRole := ""
minItems := 0
for _, visual := range visuals {
typ := strings.TrimSpace(visual.Type)
form := normalizeAuthorVisualForm(visual.VisualForm)
if typ == "chart" {
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "metric", 2)
continue
}
if typ == "table" {
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "", 3)
continue
}
switch form {
case authorVisualFormProcessFlow:
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "step", 3)
case authorVisualFormMapRoute:
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "map_anchor", 3)
case authorVisualFormParameterMatrix, authorVisualFormFourQuadrant, authorVisualFormSpectrum, authorVisualFormSensoryWheel:
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "", 3)
case authorVisualFormObjectCallout:
requiredRole, minItems = strongerVisualDataRequirement(requiredRole, minItems, "callout", 3)
}
}
return requiredRole, minItems
}
func strongerVisualDataRequirement(currentRole string, currentMin int, nextRole string, nextMin int) (string, int) {
if nextMin > currentMin {
return nextRole, nextMin
}
return currentRole, currentMin
}
func addContentPayloadIssue(report *ContentPayloadReport, code string, slideID string, message string) {
report.Issues = append(report.Issues, ContentPayloadIssue{Code: code, SlideID: slideID, Message: message})
switch code {
case "svglide.content_payload.sparse_label_list":
report.Metrics.SparseLabelListCount++
case "svglide.content_payload.missing_central_claim":
report.Metrics.MissingCentralClaimCount++
case "svglide.content_payload.missing_supporting_points":
report.Metrics.MissingSupportingPointsCount++
case "svglide.content_payload.missing_source_bound_fact":
report.Metrics.MissingSourceBoundFactCount++
case "svglide.content_payload.visual_form_missing_data", "svglide.content_payload.visual_data_without_explanation":
report.Metrics.MissingVisualDataItemsCount++
case "svglide.content_payload.unknown_source_ref":
report.Metrics.SourceBindingIssueCount++
}
}
func summarizeContentPayloadIssues(issues []ContentPayloadIssue) string {
if len(issues) == 0 {
return ""
}
parts := make([]string, 0, minPositive(len(issues), 3))
for i, issue := range issues {
if i >= 3 {
break
}
if strings.TrimSpace(issue.SlideID) != "" {
parts = append(parts, fmt.Sprintf("%s:%s", issue.SlideID, issue.Code))
continue
}
parts = append(parts, issue.Code)
}
if len(issues) > len(parts) {
parts = append(parts, fmt.Sprintf("+%d more", len(issues)-len(parts)))
}
return strings.Join(parts, ", ")
}
var sparseLabelSplitRE = regexp.MustCompile(`[,\n\r;/|,、;::]+`)
func isSparseLabelList(content string) bool {
content = strings.TrimSpace(content)
if content == "" {
return false
}
rawTokens := sparseLabelSplitRE.Split(content, -1)
tokens := make([]string, 0, len(rawTokens))
totalRunes := 0
richTokens := 0
for _, raw := range rawTokens {
token := strings.TrimSpace(strings.Trim(raw, "-•· \t"))
if token == "" {
continue
}
tokens = append(tokens, token)
totalRunes += len([]rune(token))
if contentPayloadTokenHasExplanation(token) {
richTokens++
}
}
if len(tokens) < 3 {
return false
}
avg := totalRunes / len(tokens)
return avg <= 8 && richTokens < 2
}
func contentPayloadTokenHasExplanation(token string) bool {
for _, r := range token {
if unicode.IsDigit(r) {
return true
}
}
for _, marker := range []string{
"是", "为", "有", "由", "因", "使", "让", "会", "能", "把", "从", "到",
"决定", "来自", "意味着", "用于", "形成", "影响", "体现", "because", "drives", "means",
"%", "℃", "°", "ml", "g", "kg", "年", "月", "倍", "x",
} {
if strings.Contains(strings.ToLower(token), strings.ToLower(marker)) {
return true
}
}
return false
}

View File

@@ -1,175 +0,0 @@
package svglide
import "testing"
func TestContentPayloadRejectsSparseLabelList(t *testing.T) {
initContentPayloadTestRun(t, `{
"prompt_contract": `+promptContractJSON(StageSlideContent)+`,
"slides": [{
"id": "02-tea-types",
"content": "白茶\n绿茶\n黄茶\n乌龙\n红茶\n黑茶",
"central_claim": "",
"audience_takeaway": "",
"supporting_points": [],
"source_bound_facts": [],
"source_refs": ["tea-source"],
"visuals": [{"id": "tea_taxonomy", "type": "diagram", "instruction": "Six tea classes", "visual_form": "parameter_matrix"}],
"so_what": ""
}]
}`)
report, err := EvaluateContentPayloadRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
for _, want := range []string{
"svglide.content_payload.sparse_label_list",
"svglide.content_payload.missing_supporting_points",
"svglide.content_payload.missing_source_bound_fact",
"svglide.content_payload.visual_form_missing_data",
} {
if !contentPayloadIssueCodesContain(report.Issues, want) {
t.Fatalf("issues = %+v, want %s", report.Issues, want)
}
}
}
func TestContentPayloadPassesStructuredCultureSlide(t *testing.T) {
initContentPayloadTestRun(t, `{
"prompt_contract": `+promptContractJSON(StageSlideContent)+`,
"slides": [{
"id": "02-tea-types",
"content": "白茶\n绿茶\n黄茶\n乌龙\n红茶\n黑茶",
"central_claim": "六大茶类的核心差异来自氧化程度和工艺路径。",
"audience_takeaway": "理解分类逻辑后,观众能把茶名、工艺和风味联系起来。",
"supporting_points": [
{"text": "绿茶通过杀青固定鲜爽风味,因此呈现低氧化特征。", "source_refs": ["tea-source"]},
{"text": "乌龙茶处在半氧化区间,香气和焙火层次更复杂。", "source_refs": ["tea-source"]}
],
"source_bound_facts": [
{"fact": "茶类划分和加工方式直接相关。", "source_ref": "tea-source", "usage": "evidence"}
],
"examples_or_parameters": [
{"label": "氧化程度", "value": "低到高", "explanation": "用同一条尺度解释绿茶、乌龙、红茶的差异。", "source_ref": "tea-source"}
],
"visual_data_items": [
{"label": "白茶", "role": "comparison", "explanation": "轻加工,适合放在低干预端。", "source_ref": "tea-source"},
{"label": "绿茶", "role": "comparison", "explanation": "杀青保持鲜爽,氧化程度低。", "source_ref": "tea-source"},
{"label": "黄茶", "role": "comparison", "explanation": "闷黄形成不同汤色和口感。", "source_ref": "tea-source"},
{"label": "乌龙", "role": "comparison", "explanation": "半氧化形成花果香和焙火感。", "source_ref": "tea-source"},
{"label": "红茶", "role": "comparison", "explanation": "较高氧化带来甜香和红汤。", "source_ref": "tea-source"},
{"label": "黑茶", "role": "comparison", "explanation": "后发酵带来陈化和醇厚感。", "source_ref": "tea-source"}
],
"source_refs": ["tea-source"],
"visuals": [{"id": "tea_taxonomy", "type": "diagram", "instruction": "Six tea classes", "visual_form": "parameter_matrix"}],
"so_what": "这页应把分类从名词列表变成可理解的风味地图。"
}]
}`)
report, err := EvaluateContentPayloadRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed: %+v", report.Status, report.Issues)
}
if report.Metrics.SparseLabelListCount != 0 {
t.Fatalf("sparse count = %d, want 0", report.Metrics.SparseLabelListCount)
}
}
func TestContentPayloadRejectsUnknownSourceRefs(t *testing.T) {
initContentPayloadTestRun(t, structuredContentPayloadSlideJSON("missing-source"))
report, err := EvaluateContentPayloadRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !contentPayloadIssueCodesContain(report.Issues, "svglide.content_payload.unknown_source_ref") {
t.Fatalf("issues = %+v, want unknown_source_ref", report.Issues)
}
}
func TestContentPayloadRequiresVisualDataForDiagram(t *testing.T) {
initContentPayloadTestRun(t, `{
"prompt_contract": `+promptContractJSON(StageSlideContent)+`,
"slides": [{
"id": "03-process",
"content": "采摘\n萎凋\n揉捻\n氧化\n干燥",
"central_claim": "茶的风味是在连续加工步骤里逐渐形成的。",
"audience_takeaway": "观众需要看到每一步如何改变茶叶状态,而不是只看到步骤名称。",
"supporting_points": [
{"text": "萎凋会改变叶片含水状态,为后续揉捻做准备。", "source_refs": ["tea-source"]},
{"text": "氧化程度会影响汤色、香气和滋味表达。", "source_refs": ["tea-source"]}
],
"source_bound_facts": [
{"fact": "加工步骤会影响茶叶最终品质。", "source_ref": "tea-source", "usage": "evidence"}
],
"visual_data_items": [],
"source_refs": ["tea-source"],
"visuals": [{"id": "craft_flow", "type": "diagram", "instruction": "Tea craft process", "visual_form": "process_flow"}],
"so_what": "这页应解释工艺如何塑造风味,而不是画空流程线。"
}]
}`)
report, err := EvaluateContentPayloadRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !contentPayloadIssueCodesContain(report.Issues, "svglide.content_payload.visual_form_missing_data") {
t.Fatalf("issues = %+v, want visual_form_missing_data", report.Issues)
}
}
func initContentPayloadTestRun(t *testing.T, slideContent string) {
t.Helper()
initStatusTestRun(t)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"tea-source","path":"https://example.com/tea","title":"Tea source","excerpt":"Tea classification and process facts","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"品中国茶","slides":[{"id":"02-tea-types","title":"六大茶类","role":"content","summary":"分类","key_message":"分类来自工艺","path":"slides/02.svg"},{"id":"03-process","title":"工艺路径","role":"content","summary":"工艺","key_message":"工艺塑造风味","path":"slides/03.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", slideContent)
}
func structuredContentPayloadSlideJSON(sourceRef string) string {
return `{
"prompt_contract": ` + promptContractJSON(StageSlideContent) + `,
"slides": [{
"id": "02-tea-types",
"content": "白茶\n绿茶\n黄茶",
"central_claim": "六大茶类的核心差异来自氧化程度和工艺路径。",
"audience_takeaway": "理解分类逻辑后,观众能把茶名、工艺和风味联系起来。",
"supporting_points": [
{"text": "绿茶通过杀青固定鲜爽风味,因此呈现低氧化特征。", "source_refs": ["` + sourceRef + `"]},
{"text": "乌龙茶处在半氧化区间,香气和焙火层次更复杂。", "source_refs": ["tea-source"]}
],
"source_bound_facts": [
{"fact": "茶类划分和加工方式直接相关。", "source_ref": "tea-source", "usage": "evidence"}
],
"visual_data_items": [
{"label": "白茶", "role": "comparison", "explanation": "轻加工,适合放在低干预端。", "source_ref": "tea-source"},
{"label": "绿茶", "role": "comparison", "explanation": "杀青保持鲜爽,氧化程度低。", "source_ref": "tea-source"},
{"label": "黄茶", "role": "comparison", "explanation": "闷黄形成不同汤色和口感。", "source_ref": "tea-source"}
],
"source_refs": ["tea-source"],
"visuals": [{"id": "tea_taxonomy", "type": "diagram", "instruction": "Six tea classes", "visual_form": "parameter_matrix"}],
"so_what": "这页应把分类从名词列表变成可理解的风味地图。"
}]
}`
}
func contentPayloadIssueCodesContain(issues []ContentPayloadIssue, want string) bool {
for _, issue := range issues {
if issue.Code == want {
return true
}
}
return false
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,393 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckCreativeQualityRejectsMissingVisualReceipts(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
}
if !creativeIssueCodesContain(report.Issues, "svglide.creative.missing_visual_receipts") {
t.Fatalf("Issues = %+v, want missing_visual_receipts", report.Issues)
}
}
func TestCheckCreativeQualityWarnModeDowngradesHardFailures(t *testing.T) {
initStatusTestRun(t)
setRunVisualQualityModeForTest(t, VisualQualityModeWarn)
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("Status = %q, want passed in warn mode; report=%+v", report.Status, report)
}
if len(report.Issues) == 0 || report.Issues[0].Severity != "warning" {
t.Fatalf("Issues = %+v, want warning issues", report.Issues)
}
}
func TestCheckCreativeQualityRejectsProcessLeakAndWeakTextBoxStack(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "card_stack", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540"/><rect rx="12" x="40" y="40" width="220" height="100"/><rect rx="12" x="300" y="40" width="220" height="100"/><rect rx="12" x="560" y="40" width="220" height="100"/><text x="48" y="90">接缝取色说明</text></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"card_stack","thumbnail_job":"cards","visual_center":"","topic_fit_claim":"","information_density_plan":"same","page_difference_from_previous":"same","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"stacked cards","data_visual_rationale":"","source_evidence":["web1"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
}
for _, code := range []string{"svglide.creative.process_leak", "svglide.creative.weak_slide"} {
if !creativeIssueCodesContain(report.Issues, code) {
t.Fatalf("Issues = %+v, want %s", report.Issues, code)
}
}
}
func TestCheckCreativeQualityRejectsDataVisualWithoutNumericEvidence(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "data_scoreboard", "scoreboard", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540"/><g class="chart"><rect x="48" y="200" width="100" height="200"/></g><text x="48" y="80">Scoreboard</text></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"proof","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"scoreboard","thumbnail_job":"score","visual_center":"score panel","topic_fit_claim":"shows data claim","information_density_plan":"one metric and one explanation","page_difference_from_previous":"first data page","primary_asset":"","asset_role":"data proof","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"data scoreboard","data_visual_rationale":"compare result shape","source_evidence":["match report"],"fusion_spec":{"enabled":false},"qa_expectations":["numeric evidence required"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" || !creativeIssueCodesContain(report.Issues, "svglide.creative.chart_without_evidence") {
t.Fatalf("report = %+v, want chart_without_evidence failure", report)
}
}
func TestCheckCreativeQualityRejectsPseudoTacticalDiagram(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "evidence_board", "pitch_lane_diagram", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f6f8fa"/><rect x="90" y="160" width="560" height="300" fill="#0B3D2E"/><path d="M120 230 C260 180,420 280,600 220" fill="none" stroke="#E8C15A" stroke-width="5"/><path d="M120 320 C260 270,420 370,600 310" fill="none" stroke="#E8C15A" stroke-width="5"/><text x="690" y="230">边路推进</text><text x="690" y="320">禁区终点</text></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"tactical_explanation","layout_family":"evidence_board","layout_archetype":"annotated_image","layout_signature":"pitch_diagram_with_forward_lanes","thumbnail_job":"terminal chain","visual_center":"pitch diagram showing lanes ending at the striker zone","topic_fit_claim":"explains a tactical system","information_density_plan":"one tactical map and lane labels","page_difference_from_previous":"different abstract tactical page","primary_asset":"tactical pitch diagram","asset_role":"system explanation graphic","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"pitch-map annotation using lines and coordinates","data_visual_rationale":"uses scoring facts to justify the diagram","source_evidence":["src_group_i includes match sequence","src_bio gives 62 goals in 54 appearances"],"container_fit_plan":"labels outside lanes","container_decision":"no cards","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"pitch_lane_diagram","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["tactical claim must remain tied to source-backed scoring evidence"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" || report.Metrics.PseudoAnalysisDiagramCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.pseudo_analysis_diagram") {
t.Fatalf("report = %+v, want pseudo_analysis_diagram failure", report)
}
}
func TestCheckCreativeQualityAllowsSourceBoundTacticalDiagram(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "evidence_board", "pitch_lane_diagram", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f6f8fa"/><rect x="90" y="160" width="560" height="300" fill="#0B3D2E"/><path d="M120 230 C260 180,420 280,600 220" fill="none" stroke="#E8C15A" stroke-width="5"/><text x="690" y="230">79' right-channel pass</text></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"tactical_explanation","layout_family":"evidence_board","layout_archetype":"annotated_image","layout_signature":"pitch_diagram_with_forward_lanes","thumbnail_job":"terminal chain","visual_center":"pitch diagram showing source-bound right-channel sequence","topic_fit_claim":"explains a tactical system","information_density_plan":"one tactical map and lane labels","page_difference_from_previous":"different abstract tactical page","primary_asset":"tactical pitch diagram","asset_role":"system explanation graphic","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"pitch-map annotation using lines and coordinates","data_visual_rationale":"79' sequence explains the right-channel lane","source_evidence":["src_match: 79 minute right channel pass sequence into the penalty area"],"container_fit_plan":"labels outside lanes","container_decision":"no cards","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"pitch_lane_diagram","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["tactical geometry must bind to match source"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if creativeIssueCodesContain(report.Issues, "svglide.creative.pseudo_analysis_diagram") {
t.Fatalf("report = %+v, did not want pseudo_analysis_diagram failure", report)
}
}
func TestCheckCreativeQualityIgnoresNegativeDiagramGuardrail(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "open_warning_ledger_no_diagram", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#07111F"/><text x="72" y="120">Risk statement</text><text x="72" y="180">No fake tactical diagram is used here.</text></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"risk","layout_family":"quiet_synthesis","layout_archetype":"statement_ledger","layout_signature":"open_warning_ledger_no_diagram","thumbnail_job":"risk statement","visual_center":"open editorial statement without diagram","topic_fit_claim":"does not draw fake tactical geometry","information_density_plan":"one sourced risk claim and no fake map","page_difference_from_previous":"different open statement page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"not a diagram; text-led synthesis","data_visual_rationale":"","source_evidence":["src_group_i: France 4-1 Norway scoreline"],"container_fit_plan":"open text","container_decision":"no cards","text_carrier":"open_grid","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"open_warning_ledger_no_diagram","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["no fake tactical diagram"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if creativeIssueCodesContain(report.Issues, "svglide.creative.pseudo_analysis_diagram") {
t.Fatalf("report = %+v, did not want pseudo_analysis_diagram failure", report)
}
}
func TestCreativeQualityDetectsDefaultCardTextContainer(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "editorial_text", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f7f6f1"/><rect x="64" y="70" width="360" height="320" rx="24" fill="#101319"/><text x="96" y="140" fill="#fff">Athlete story</text><text x="96" y="202" fill="#fff">Every major claim is simply placed inside a rounded card.</text></svg>`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"single_claim_poster","layout_signature":"editorial_text","thumbnail_job":"text card","visual_center":"main text block","topic_fit_claim":"introduces the sports topic","information_density_plan":"one main claim and supporting explanation","page_difference_from_previous":"opening page with a text-led composition","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"plain text card for a simple claim","data_visual_rationale":"","source_evidence":["official athlete bio"],"fusion_spec":{"enabled":false},"qa_expectations":["use open editorial text when no panel is needed"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" || report.Metrics.DefaultCardTextContainerCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.default_card_text_container") {
t.Fatalf("report = %+v, want default_card_text_container failure", report)
}
}
func TestCreativeQualityDetectsTopicTypographyMismatch(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "character_product_focus", "sports_profile", creativeQualityGoodSVG())
if err := os.WriteFile(filepath.Join("demo", "brief", "typography_contract.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"profile":"sports_editorial","font_source":"slide_font_theme_presets","selected_moods":["culture_heritage"],"roles":{"display":{"family":"ChillJinshuSongMedium","weight":"700","size":"42","usage":"cover title"},"body":{"family":"Noto Serif SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Noto Sans SC","weight":"700","size":"34","usage":"scores"},"label":{"family":"ChillDuanHeiSong_CompactRegular","weight":"600","size":"13","usage":"labels"}},"rules":["sports deck typography should carry athletic score identity"]}`), 0o644); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"sports_profile","thumbnail_job":"sports profile","visual_center":"athlete profile and opening claim","topic_fit_claim":"matches the sports profile topic","information_density_plan":"one claim plus athlete context","page_difference_from_previous":"opening page","primary_asset":"assets/images/athlete.png","asset_role":"sports topic anchor","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"sports editorial profile","data_visual_rationale":"","source_evidence":["league profile"],"fusion_spec":{"enabled":false},"qa_expectations":["typography carries sports identity"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" || report.Metrics.TopicTypographyMismatchCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.typography.identity.profile_mismatch") {
t.Fatalf("report = %+v, want typography profile mismatch failure", report)
}
}
func TestCheckCreativeQualityAppliesThemeLayoutRhythm(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/request/theme_contract.json", chineseTeaThemeContractJSONForTest())
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"single_claim_poster","layout_signature":"single_claim_poster","thumbnail_job":"opening","visual_center":"opening claim","topic_fit_claim":"introduces the tea topic","information_density_plan":"one opening claim","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"opening editorial poster","data_visual_rationale":"","source_evidence":["source"],"text_carrier":"open_grid","shape_language":"open editorial text","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"renderer":"none"},"fusion_spec":{"enabled":false},"qa_expectations":["theme rhythm applies"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for theme rhythm", report.Status)
}
for _, want := range []string{"svglide.creative.theme_min_slide_count", "svglide.creative.theme_required_page_role"} {
if !creativeIssueCodesContain(report.Issues, want) {
t.Fatalf("issues = %+v, want %s", report.Issues, want)
}
}
}
func TestCheckCreativeQualityRejectsRepeatedLayoutArchetype(t *testing.T) {
initStatusTestRun(t)
mustWriteRepeatedArchetypeDeck(t)
mustWriteRepeatedArchetypeReceipts(t)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
}
for _, code := range []string{
"svglide.creative.layout_archetype_overuse",
"svglide.creative.adjacent_layout_archetype",
"svglide.creative.left_right_chart_overuse",
} {
if !creativeIssueCodesContain(report.Issues, code) {
t.Fatalf("Issues = %+v, want %s", report.Issues, code)
}
}
}
func TestCheckCreativeQualityRejectsRepeatedRenderedVisualSkeleton(t *testing.T) {
initStatusTestRun(t)
deck := authorDeck{Title: "Repeated Skeleton Deck"}
receipts := visualReceiptsFile{}
for i := 1; i <= 6; i++ {
id := fmt.Sprintf("s%d", i)
path := fmt.Sprintf("slides/%02d.svg", i)
deck.Slides = append(deck.Slides, authorDeckSlide{
ID: id,
Title: fmt.Sprintf("Page %d", i),
Summary: "Summary",
Role: "content",
KeyMessage: "Key message",
LayoutFamily: "quiet_synthesis",
LayoutArchetype: "poster_stat_lockup",
LayoutSignature: fmt.Sprintf("unique_layout_%02d", i),
StoryFunction: "proof",
Path: path,
})
mustWriteTestFile(t, filepath.Join("demo", path), repeatedGenericNodeLineSVGForTest())
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt(id, "quiet_synthesis", "poster_stat_lockup", fmt.Sprintf("unique_layout_%02d", i), "unique metadata but same rendered skeleton", "source evidence 2026"))
}
rawDeck, err := json.Marshal(deck)
if err != nil {
t.Fatal(err)
}
rawReceipts, err := json.Marshal(receipts)
if err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/outline/deck.json", string(rawDeck))
mustWriteTestFile(t, "demo/visual_receipts.json", string(rawReceipts))
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" || report.Metrics.VisualSkeletonMaxRatioBP != 10000 || !creativeIssueCodesContain(report.Issues, "svglide.creative.visual_skeleton_repetition") {
t.Fatalf("report = %+v, want visual_skeleton_repetition failure", report)
}
}
func TestCheckCreativeQualityRejectsVisualIntentMismatch(t *testing.T) {
initStatusTestRun(t)
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "sensory_wheel_page", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f6f8fa"/><g data-svglide-visual-form="spectrum"><rect x="80" y="220" width="80" height="80"/><rect x="180" y="220" width="80" height="80"/><rect x="280" y="220" width="80" height="80"/><rect x="380" y="220" width="80" height="80"/></g><text x="80" y="120">Taste</text></svg>`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Taste wheel","source_refs":["source"],"visuals":[{"id":"taste","type":"diagram","visual_form":"sensory_wheel","instruction":"Render a sensory wheel"}]}]}`)
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"proof","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"sensory_wheel_page","thumbnail_job":"taste wheel","visual_center":"taste wheel","topic_fit_claim":"explains sensory dimensions","information_density_plan":"one sensory diagram","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"composition_intent":"sensory wheel diagram","data_visual_rationale":"","source_evidence":["source evidence 2026"],"container_fit_plan":"open diagram labels","container_decision":"no cards","text_carrier":"line_annotation","typography_role_usage":{"display":"Noto Serif SC","body":"Noto Sans SC","number":"Roboto Mono","label":"Noto Sans SC"},"shape_language":"diagram_sensory_wheel","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"renderer":"none"},"fusion_spec":{"enabled":false},"qa_expectations":["visual form must match"]}]}`)
report, err := CheckCreativeQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" || report.Metrics.VisualIntentMismatchCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.visual_intent_mismatch") {
t.Fatalf("report = %+v, want visual_intent_mismatch failure", report)
}
}
func TestCreativeQualityVisualFixtures(t *testing.T) {
t.Chdir(filepath.Join("..", ".."))
base := filepath.Join("testdata", "svglide", "visual_quality")
weak, err := CheckCreativeQuality(filepath.Join(base, "germany_2026_weak_visual_run"))
if err != nil {
t.Fatal(err)
}
if weak.Status != "failed" || !creativeIssueCodesContain(weak.Issues, "svglide.creative.weak_slide") || !creativeIssueCodesContain(weak.Issues, "svglide.creative.process_leak") {
t.Fatalf("weak fixture report = %+v, want weak/process failure", weak)
}
good, err := CheckCreativeQuality(filepath.Join(base, "fusion_split_good_run"))
if err != nil {
t.Fatal(err)
}
if good.Status != "passed" {
t.Fatalf("fusion fixture report = %+v, want passed", good)
}
}
func mustWriteCreativeQualityBaseDeck(t *testing.T, family, signature, svg string) {
t.Helper()
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Creative Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Opening key","layout_family":"`+family+`","layout_archetype":"`+inferAuthorLayoutArchetype(family, signature)+`","layout_signature":"`+signature+`","story_function":"hook","primary_asset_role":"topic anchor","fusion_candidate":false,"path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/slides/01.svg", svg)
}
func mustWriteRepeatedArchetypeDeck(t *testing.T) {
t.Helper()
deck := authorDeck{Title: "Financial Deck"}
deck.Slides = append(deck.Slides,
authorDeckSlide{ID: "s1", Title: "Cover", Summary: "Cover", Role: "cover", KeyMessage: "Cover", LayoutFamily: "full_bleed_hero", LayoutArchetype: "full_bleed_photo_title", LayoutSignature: "chip_cover", StoryFunction: "hook", PrimaryAssetRole: "hero image", Path: "slides/01.svg"},
)
for i, title := range []string{"Executive summary", "Income", "Segment", "Margin", "Cash flow"} {
page := i + 2
deck.Slides = append(deck.Slides, authorDeckSlide{
ID: fmt.Sprintf("s%d", page),
Title: title,
Summary: title,
Role: "content",
KeyMessage: title,
LayoutFamily: "data_scoreboard",
LayoutArchetype: "image_argument_split",
LayoutSignature: fmt.Sprintf("left_text_right_chart_%d", page),
StoryFunction: "proof",
PrimaryAssetRole: "chart",
Path: fmt.Sprintf("slides/%02d.svg", page),
})
}
deck.Slides = append(deck.Slides,
authorDeckSlide{ID: "s7", Title: "Close", Summary: "Close", Role: "close", KeyMessage: "Close", LayoutFamily: "quiet_synthesis", LayoutArchetype: "closing_poster", LayoutSignature: "closing_poster", StoryFunction: "synthesis", PrimaryAssetRole: "closing", Path: "slides/07.svg"},
)
raw, err := json.Marshal(deck)
if err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/outline/deck.json", string(raw))
for i := 1; i <= 7; i++ {
body := `<rect width="960" height="540"/><text x="48" y="80">NVIDIA financial report</text>`
if i >= 2 && i <= 6 {
body += `<g class="chart"><rect x="600" y="160" width="220" height="160"/></g>`
}
mustWriteTestFile(t, fmt.Sprintf("demo/slides/%02d.svg", i), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+body+`</svg>`)
}
}
func mustWriteRepeatedArchetypeReceipts(t *testing.T) {
t.Helper()
receipts := visualReceiptsFile{}
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt("s1", "full_bleed_hero", "full_bleed_photo_title", "chip_cover", "cover", "NVIDIA image"))
for i, label := range []string{"revenue $22.1B", "net income $12.3B", "data center $18.4B", "gross margin 76.0%", "free cash flow $11.2B"} {
page := i + 2
receipt := repeatedArchetypeReceipt(
fmt.Sprintf("s%d", page),
"data_scoreboard",
"image_argument_split",
fmt.Sprintf("left_text_right_chart_%d", page),
"left text right chart",
label,
)
receipt.DataVisualRationale = label
receipts.Slides = append(receipts.Slides, receipt)
}
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt("s7", "quiet_synthesis", "closing_poster", "closing_poster", "closing", "NVIDIA report"))
raw, err := json.Marshal(receipts)
if err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/visual_receipts.json", string(raw))
}
func repeatedArchetypeReceipt(slideID string, family string, archetype string, signature string, intent string, evidence string) visualReceipt {
return visualReceipt{
SlideID: slideID,
StoryJob: "proof",
LayoutFamily: family,
LayoutArchetype: archetype,
LayoutSignature: signature,
ThumbnailJob: "thumbnail",
VisualCenter: "visual center",
TopicFitClaim: "topic fit",
InformationDensityPlan: "one claim plus supporting visual",
PageDifferenceFromPrevious: "different named page in sequence",
PrimaryAsset: "chart.svg",
AssetRole: "chart",
FontRoleUsage: map[string]string{"display": "Inter", "body": "Aptos", "number": "Roboto Mono", "label": "Inter"},
CompositionIntent: intent,
SourceEvidence: []string{evidence},
FusionSpec: visualFusionReceipt{Enabled: false},
QAExpectations: []string{"vary layout"},
}
}
func creativeQualityGoodSVG() string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540"/><text x="48" y="80">Opening</text><text x="48" y="132">A focused claim</text></svg>`
}
func repeatedGenericNodeLineSVGForTest() string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540" fill="#f6f8fa"/><g data-svglide-visual-form="generic"><path d="M120 270 H840" stroke="#999"/><circle cx="180" cy="230" r="20"/><circle cx="340" cy="310" r="20"/><circle cx="500" cy="230" r="20"/><circle cx="660" cy="310" r="20"/><text x="80" y="120">Repeated generic diagram</text></g></svg>`
}
func setRunVisualQualityModeForTest(t *testing.T, mode string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
if err != nil {
t.Fatal(err)
}
var run Run
if err := json.Unmarshal(raw, &run); err != nil {
t.Fatal(err)
}
run.VisualQualityMode = mode
if err := writeJSON(filepath.Join("demo", "run.json"), run); err != nil {
t.Fatal(err)
}
}
func creativeIssueCodesContain(issues []CreativeQualityIssue, want string) bool {
for _, issue := range issues {
if issue.Code == want || strings.Contains(issue.Code, want) {
return true
}
}
return false
}

View File

@@ -1,153 +0,0 @@
package svglide
import (
"fmt"
"regexp"
"strings"
)
var (
svgVisualFormAttrPattern = regexp.MustCompile(`(?i)\bdata-svglide-visual-form\s*=\s*"([^"]+)"`)
svgPathTagPattern = regexp.MustCompile(`(?is)<path\b`)
svgLineTagPattern = regexp.MustCompile(`(?is)<line\b`)
svgCircleTagPattern = regexp.MustCompile(`(?is)<circle\b`)
svgEllipseTagPattern = regexp.MustCompile(`(?is)<ellipse\b`)
)
type visualSkeletonSummary struct {
FormHint string
RectCount int
PathCount int
LineCount int
CircleCount int
EllipseCount int
ImageCount int
TextCount int
}
func analyzeVisualSkeleton(svg string) visualSkeletonSummary {
summary := visualSkeletonSummary{
RectCount: len(svgRectTagForCreativePattern.FindAllStringIndex(svg, -1)),
PathCount: len(svgPathTagPattern.FindAllStringIndex(svg, -1)),
LineCount: len(svgLineTagPattern.FindAllStringIndex(svg, -1)),
CircleCount: len(svgCircleTagPattern.FindAllStringIndex(svg, -1)),
EllipseCount: len(svgEllipseTagPattern.FindAllStringIndex(svg, -1)),
ImageCount: countSVGImageElements(svg),
TextCount: len(svgTextBlockForCreativePattern.FindAllStringIndex(svg, -1)),
}
if match := svgVisualFormAttrPattern.FindStringSubmatch(svg); len(match) == 2 {
summary.FormHint = normalizeAuthorVisualForm(match[1])
}
return summary
}
func visualSkeletonSignature(summary visualSkeletonSummary) string {
if summary.FormHint != "" {
return "diagram:" + summary.FormHint + "|" + visualSkeletonGeometryToken(summary)
}
switch {
case summary.ImageCount > 0 && summary.PathCount+summary.LineCount+summary.CircleCount+summary.EllipseCount >= 5:
return "image_annotation|" + visualSkeletonGeometryToken(summary)
case summary.ImageCount > 0:
return "image_forward|" + visualSkeletonGeometryToken(summary)
case summary.CircleCount >= 4 && summary.PathCount+summary.LineCount >= 4:
return "node_line|" + visualSkeletonGeometryToken(summary)
case summary.RectCount >= 8 && summary.PathCount+summary.LineCount >= 4:
return "matrix_grid|" + visualSkeletonGeometryToken(summary)
case summary.RectCount >= 4:
return "rect_series|" + visualSkeletonGeometryToken(summary)
case summary.PathCount+summary.LineCount >= 3:
return "rule_path|" + visualSkeletonGeometryToken(summary)
case summary.TextCount > 0:
return "open_text|" + visualSkeletonGeometryToken(summary)
default:
return "minimal|" + visualSkeletonGeometryToken(summary)
}
}
func visualSkeletonGeometryToken(summary visualSkeletonSummary) string {
return fmt.Sprintf("r%d-p%d-l%d-c%d-e%d-i%d-t%d",
bucketVisualSkeletonCount(summary.RectCount),
bucketVisualSkeletonCount(summary.PathCount),
bucketVisualSkeletonCount(summary.LineCount),
bucketVisualSkeletonCount(summary.CircleCount),
bucketVisualSkeletonCount(summary.EllipseCount),
bucketVisualSkeletonCount(summary.ImageCount),
bucketVisualSkeletonCount(summary.TextCount),
)
}
func bucketVisualSkeletonCount(value int) int {
switch {
case value <= 0:
return 0
case value == 1:
return 1
case value <= 3:
return 3
case value <= 6:
return 6
default:
return 9
}
}
func expectedSlideVisualForm(content authorSlideContent) string {
for _, visual := range content.Visuals {
if !requiresConcreteVisualForm(visual.Type) {
continue
}
if form := normalizeAuthorVisualForm(visual.VisualForm); form != "" {
return form
}
return authorVisualForm(visual, content)
}
return ""
}
func visualSkeletonMatchesForm(summary visualSkeletonSummary, expected string) bool {
expected = normalizeAuthorVisualForm(expected)
if expected == "" {
return true
}
if summary.FormHint == expected {
return true
}
switch expected {
case authorVisualFormFourQuadrant:
return summary.RectCount >= 1 && summary.PathCount+summary.LineCount >= 2 && summary.CircleCount >= 4
case authorVisualFormSpectrum:
return summary.RectCount >= 4 && summary.CircleCount <= 2
case authorVisualFormMapRoute:
return summary.PathCount >= 2 && summary.CircleCount >= 2
case authorVisualFormProcessFlow:
return summary.CircleCount >= 3 && summary.PathCount+summary.LineCount >= 3
case authorVisualFormParameterMatrix:
return summary.PathCount+summary.LineCount >= 4 && summary.RectCount >= 1
case authorVisualFormSensoryWheel:
return summary.CircleCount >= 4 && summary.PathCount+summary.LineCount >= 4
case authorVisualFormObjectCallout:
return summary.EllipseCount >= 1 && summary.PathCount+summary.LineCount >= 3
case authorVisualFormGeneric:
return true
default:
return false
}
}
func isRepeatedVisualSkeletonRisk(signature string) bool {
return !containsAny(strings.ToLower(signature), []string{
"open_text|",
"minimal|",
"image_forward|",
})
}
func repeatedVisualSkeletonRisk(counts map[string]int) bool {
for signature, count := range counts {
if count >= 3 && isRepeatedVisualSkeletonRisk(signature) {
return true
}
}
return false
}

View File

@@ -1,184 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
)
const (
DeliveryTargetLocalPreview = "local_preview"
DeliveryTargetOnlineSlide = "online_slide"
DeliveryTargetBoth = "both"
deliveryContractPath = "request/delivery_contract.json"
)
type DeliveryContractFile struct {
PromptContract StagePromptContract `json:"prompt_contract,omitempty"`
DeliveryContract DeliveryContract `json:"delivery_contract"`
}
type DeliveryContract struct {
DeliveryTarget string `json:"delivery_target"`
RequiresOnlineSlide bool `json:"requires_online_slide"`
RequiresLocalPreview bool `json:"requires_local_preview"`
RequiresRealImages bool `json:"requires_real_images"`
Reason string `json:"reason"`
DetectedSignals []string `json:"detected_signals"`
}
func ResolveDeliveryContract(title string, topic string, explicitTarget string) DeliveryContract {
raw := normalizeDeliveryContractText(title + " " + topic)
target := normalizeDeliveryTarget(explicitTarget)
if target == "" {
target = DeliveryTargetLocalPreview
}
signals := deliverySignals(raw)
onlineSignal := requestHasOnlineDeliverySignalText(raw)
if explicitTarget == "" && onlineSignal {
target = DeliveryTargetOnlineSlide
}
requiresOnline := target == DeliveryTargetOnlineSlide || target == DeliveryTargetBoth
return DeliveryContract{
DeliveryTarget: target,
RequiresOnlineSlide: requiresOnline,
RequiresLocalPreview: target == DeliveryTargetLocalPreview || target == DeliveryTargetBoth,
RequiresRealImages: requestHasRealImageSignalText(raw),
Reason: "resolved from explicit delivery target and request text",
DetectedSignals: signals,
}
}
func NormalizeDeliveryTarget(target string) string {
return normalizeDeliveryTarget(target)
}
func RequestHasOnlineDeliverySignal(title string, topic string) bool {
return requestHasOnlineDeliverySignalText(normalizeDeliveryContractText(title + " " + topic))
}
func DeliveryTargetConflictsWithOnlineSignal(title string, topic string, target string) bool {
return normalizeDeliveryTarget(target) == DeliveryTargetLocalPreview && RequestHasOnlineDeliverySignal(title, topic)
}
func normalizeDeliveryTarget(target string) string {
switch strings.TrimSpace(target) {
case "":
return ""
case DeliveryTargetLocalPreview:
return DeliveryTargetLocalPreview
case DeliveryTargetOnlineSlide:
return DeliveryTargetOnlineSlide
case DeliveryTargetBoth:
return DeliveryTargetBoth
default:
return strings.TrimSpace(target)
}
}
func deliverySignals(raw string) []string {
out := []string{}
for _, token := range []string{
"线上", "飞书", "lark", "feishu", "online", "share", "共享", "创建 slide", "创建slides",
"真实", "实际", "图片", "照片", "论文", "paper", "report", "pdf",
} {
if strings.Contains(raw, strings.ToLower(token)) {
out = appendUnique(out, token)
}
}
return out
}
func requestHasOnlineDeliverySignalText(raw string) bool {
if containsAny(raw, []string{"不需要线上", "无需线上", "不要线上", "本地预览即可", "local preview only"}) {
return false
}
return containsAny(raw, []string{
"线上", "飞书", "lark", "feishu", "online", "share", "共享",
"创建 slide", "创建slides", "真正创建", "线上 slide", "线上slides",
"download the report as pdf", "下载为 pdf", "导出 pdf",
})
}
func requestHasRealImageSignalText(raw string) bool {
if containsAny(raw, []string{"纯向量", "不要图片", "不使用图片", "no photos", "no images", "vector-only", "chart-only"}) {
return false
}
return containsAny(raw, []string{
"真实", "实际", "美观", "视觉冲击", "图片", "照片", "官网", "论文", "paper", "report",
"公司", "品牌", "人物", "地点", "赛事", "产品", "financial report", "deep dive",
})
}
func normalizeDeliveryContractText(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.Join(strings.Fields(value), " ")
return value
}
func readDeliveryContract(safeRoot string, run Run) (DeliveryContract, bool, error) {
raw, err := readRunRegularArtifact(safeRoot, deliveryContractPath)
if err != nil {
return ResolveDeliveryContract(run.Title, run.Intent.Topic, run.DeliveryTarget), false, nil
}
var file DeliveryContractFile
if err := json.Unmarshal(raw, &file); err != nil {
return DeliveryContract{}, true, fmt.Errorf("%s: invalid JSON: %w", deliveryContractPath, err)
}
contract := file.DeliveryContract
if strings.TrimSpace(contract.DeliveryTarget) == "" {
contract = ResolveDeliveryContract(run.Title, run.Intent.Topic, run.DeliveryTarget)
}
return contract, true, nil
}
func ValidateDeliveryContractGate(safeRoot string) error {
run, err := readRunFile(safeRoot)
if err != nil {
return err
}
contract, _, err := readDeliveryContract(safeRoot, run)
if err != nil {
return err
}
switch contract.DeliveryTarget {
case DeliveryTargetLocalPreview, DeliveryTargetOnlineSlide, DeliveryTargetBoth:
default:
return fmt.Errorf("delivery_contract_gate: unsupported delivery_target %q", contract.DeliveryTarget)
}
if contract.DeliveryTarget == DeliveryTargetLocalPreview && RequestHasOnlineDeliverySignal(run.Title, run.Intent.Topic) {
return fmt.Errorf("delivery_contract_gate: request asks for online delivery but delivery_target is local_preview")
}
if contract.RequiresOnlineSlide && contract.DeliveryTarget == DeliveryTargetLocalPreview {
return fmt.Errorf("delivery_contract_gate: requires_online_slide=true conflicts with local_preview target")
}
return nil
}
func writeDeliveryContractFile(writeRoot string, opts InitOptions) error {
contract := ResolveDeliveryContract(opts.Title, opts.Topic, opts.DeliveryTarget)
promptContract, err := promptContractForInitArtifact(StageRequestResolution)
if err != nil {
return err
}
return writeJSON(filepath.Join(writeRoot, deliveryContractPath), DeliveryContractFile{
PromptContract: promptContract,
DeliveryContract: contract,
})
}
func promptContractForInitArtifact(stage string) (StagePromptContract, error) {
requiredPromptIDs, err := CorePromptIDsForProfile(RouteProfileLocalSVGDeck)
if err != nil {
return StagePromptContract{}, err
}
return StagePromptContract{
Protocol: ProtocolAnyGenSVGSlides,
Stage: stage,
Orchestrator: "mode_system_prompt_svg",
ProtocolReference: "svg_reference",
RequiredPromptIDs: requiredPromptIDs,
}, nil
}

View File

@@ -1,57 +0,0 @@
package svglide
import (
"slices"
"testing"
)
func TestResolveDeliveryContractDetectsOnlineSlide(t *testing.T) {
got := ResolveDeliveryContract("DeepSeek V4", "生成真实美观的线上 svg ppt", "")
if got.DeliveryTarget != DeliveryTargetOnlineSlide {
t.Fatalf("DeliveryTarget = %q, want %q", got.DeliveryTarget, DeliveryTargetOnlineSlide)
}
if !got.RequiresOnlineSlide {
t.Fatal("RequiresOnlineSlide = false, want true")
}
if !got.RequiresRealImages {
t.Fatal("RequiresRealImages = false, want true")
}
}
func TestResolveDeliveryContractAllowsExplicitLocalPreview(t *testing.T) {
got := ResolveDeliveryContract("Demo", "生成本地 SVG preview不需要线上创建", DeliveryTargetLocalPreview)
if got.DeliveryTarget != DeliveryTargetLocalPreview {
t.Fatalf("DeliveryTarget = %q, want %q", got.DeliveryTarget, DeliveryTargetLocalPreview)
}
if got.RequiresOnlineSlide {
t.Fatal("RequiresOnlineSlide = true, want false")
}
}
func TestDeliveryTargetConflictsWithOnlineSignal(t *testing.T) {
if !DeliveryTargetConflictsWithOnlineSignal("Demo", "请创建线上飞书 slide", DeliveryTargetLocalPreview) {
t.Fatal("conflict = false, want true")
}
if DeliveryTargetConflictsWithOnlineSignal("Demo", "本地预览即可,不需要线上创建", DeliveryTargetLocalPreview) {
t.Fatal("conflict = true, want false")
}
}
func TestInitDeliveryContractUsesManifestPromptIDs(t *testing.T) {
contract, err := promptContractForInitArtifact(StageRequestResolution)
if err != nil {
t.Fatal(err)
}
want, err := CorePromptIDsForProfile(RouteProfileLocalSVGDeck)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(contract.RequiredPromptIDs, want) {
t.Fatalf("RequiredPromptIDs = %v, want manifest-derived %v", contract.RequiredPromptIDs, want)
}
for _, id := range []string{"mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding", "svglide_visual_quality_overlay", "slide_font_catalog"} {
if !slices.Contains(contract.RequiredPromptIDs, id) {
t.Fatalf("RequiredPromptIDs missing %q: %v", id, contract.RequiredPromptIDs)
}
}
}

View File

@@ -1,229 +0,0 @@
package svglide
import (
"fmt"
"strings"
)
const editorialQualityReportPath = "receipts/editorial_quality.json"
type EditorialQualityReport struct {
Status string `json:"status"`
Score int `json:"score"`
Metrics EditorialQualityMetrics `json:"metrics"`
Issues []EditorialQualityIssue `json:"issues"`
Target editorialQualityTarget `json:"target"`
}
type EditorialQualityMetrics struct {
Slides int `json:"slides"`
MediaPressureIssueCount int `json:"media_pressure_issue_count"`
DominantRealImagePages int `json:"dominant_real_image_pages"`
MaxConsecutiveInfographicPages int `json:"max_consecutive_infographic_pages"`
CardDominantRatioBP int `json:"card_dominant_ratio_bp"`
ShapeLanguageMaxRatioBP int `json:"shape_language_max_ratio_bp"`
CreativeErrorCount int `json:"creative_error_count"`
ContentPayloadIssueCount int `json:"content_payload_issue_count"`
SparseLabelListCount int `json:"sparse_label_list_count"`
MissingEvidencePayloadCount int `json:"missing_evidence_payload_count"`
MissingVisualDataItemsCount int `json:"missing_visual_data_items_count"`
IssueCount int `json:"issue_count"`
}
type EditorialQualityIssue struct {
Code string `json:"code"`
Path string `json:"path"`
Message string `json:"message"`
Severity string `json:"severity"`
}
type editorialQualityTarget struct {
MinimumScore int `json:"minimum_score"`
RequireMediaPressurePassed bool `json:"require_media_pressure_passed"`
RequireCoverDominantRealImage bool `json:"require_cover_dominant_real_image"`
MaxConsecutiveInfographicOnlyPages int `json:"max_consecutive_infographic_only_pages"`
MaxCardDominantRatioBP int `json:"max_card_dominant_ratio_bp"`
MaxShapeLanguageMaxRatioBP int `json:"max_shape_language_max_ratio_bp"`
}
func EvaluateEditorialQualityRun(contract qualityVisualContract, media MediaPressureReport, creative CreativeQualityReport, contentPayloadReports ...ContentPayloadReport) EditorialQualityReport {
target := resolveEditorialQualityTarget(contract)
contentPayload := ContentPayloadReport{}
if len(contentPayloadReports) > 0 {
contentPayload = contentPayloadReports[0]
}
report := EditorialQualityReport{
Status: "passed",
Score: 100,
Metrics: editorialMetrics(media, creative, contentPayload),
Issues: []EditorialQualityIssue{},
Target: target,
}
if target.RequireMediaPressurePassed && media.Status != "passed" {
report.Score -= 35
addEditorialIssue(&report, mediaPressureReportPath, "svglide.editorial_quality.media_pressure", "media pressure must pass before the deck can be considered visually ready")
}
if target.RequireCoverDominantRealImage && media.Metrics.CoverDominantRealImagePages == 0 {
report.Score -= 25
addEditorialIssue(&report, mediaPressureReportPath, "svglide.editorial_quality.cover_hero", "topic archetype requires a cover-level dominant real image")
}
if target.MaxConsecutiveInfographicOnlyPages > 0 && media.Metrics.MaxConsecutiveInfographicPages > target.MaxConsecutiveInfographicOnlyPages {
report.Score -= 20
addEditorialIssue(&report, mediaPressureReportPath, "svglide.editorial_quality.infographic_run", fmt.Sprintf("consecutive infographic-only run is %d, want <= %d", media.Metrics.MaxConsecutiveInfographicPages, target.MaxConsecutiveInfographicOnlyPages))
}
if target.MaxCardDominantRatioBP > 0 && report.Metrics.CardDominantRatioBP > target.MaxCardDominantRatioBP {
report.Score -= 15
addEditorialIssue(&report, creativeQualityReportPath, "svglide.editorial_quality.card_overuse", fmt.Sprintf("card-dominant slide ratio is %d bp, want <= %d bp", report.Metrics.CardDominantRatioBP, target.MaxCardDominantRatioBP))
}
if target.MaxShapeLanguageMaxRatioBP > 0 && report.Metrics.ShapeLanguageMaxRatioBP > target.MaxShapeLanguageMaxRatioBP {
report.Score -= 15
addEditorialIssue(&report, creativeQualityReportPath, "svglide.editorial_quality.shape_language_overuse", fmt.Sprintf("shape language max ratio is %d bp, want <= %d bp", report.Metrics.ShapeLanguageMaxRatioBP, target.MaxShapeLanguageMaxRatioBP))
}
if creative.Status != "passed" {
report.Score -= minPositive(creativeErrorCount(creative)*5, 25)
}
if contentPayload.Metrics.IssueCount > 0 {
report.Score -= minPositive(contentPayload.Metrics.IssueCount*6, 30)
if contentPayload.Metrics.SparseLabelListCount > 0 {
addEditorialIssue(&report, contentPayloadReportPath, "svglide.editorial.content_sparse_label_list", fmt.Sprintf("sparse label-list slides: %d", contentPayload.Metrics.SparseLabelListCount))
}
if contentPayload.Metrics.MissingCentralClaimCount+contentPayload.Metrics.MissingSupportingPointsCount+contentPayload.Metrics.MissingSourceBoundFactCount+contentPayload.Metrics.SourceBindingIssueCount > 0 {
addEditorialIssue(&report, contentPayloadReportPath, "svglide.editorial.content_missing_evidence_payload", "substantive slides need central claims, supporting points, and source-bound facts")
}
if contentPayload.Metrics.MissingVisualDataItemsCount > 0 {
addEditorialIssue(&report, contentPayloadReportPath, "svglide.editorial.content_visual_data_mismatch", fmt.Sprintf("visual forms missing data items: %d", contentPayload.Metrics.MissingVisualDataItemsCount))
}
}
if target.MinimumScore > 0 && report.Score < target.MinimumScore {
addEditorialIssue(&report, editorialQualityReportPath, "svglide.editorial_quality.score", fmt.Sprintf("editorial quality score is %d, want >= %d", report.Score, target.MinimumScore))
}
if report.Score < 0 {
report.Score = 0
}
report.Metrics.IssueCount = len(report.Issues)
if report.Metrics.IssueCount > 0 {
report.Status = "failed"
}
return report
}
func EvaluateEditorialQualityExecutionFailure(contract qualityVisualContract, media MediaPressureReport, err error) EditorialQualityReport {
target := resolveEditorialQualityTarget(contract)
report := EditorialQualityReport{
Status: "failed",
Score: 0,
Metrics: EditorialQualityMetrics{
Slides: media.Metrics.Slides,
MediaPressureIssueCount: media.Metrics.IssueCount,
DominantRealImagePages: media.Metrics.DominantRealImagePages,
MaxConsecutiveInfographicPages: media.Metrics.MaxConsecutiveInfographicPages,
CreativeErrorCount: 1,
IssueCount: 1,
},
Issues: []EditorialQualityIssue{{
Code: "svglide.editorial_quality.creative_unavailable",
Path: creativeQualityReportPath,
Message: err.Error(),
Severity: "error",
}},
Target: target,
}
return report
}
func writeEditorialQualityReport(safeRoot string, report EditorialQualityReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, editorialQualityReportPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func resolveEditorialQualityTarget(contract qualityVisualContract) editorialQualityTarget {
target := defaultEditorialQualityTarget(strings.TrimSpace(contract.TopicArchetype))
explicit := contract.EditorialQualityTarget
if explicit.MinimumScore > 0 {
target.MinimumScore = explicit.MinimumScore
}
if explicit.RequireMediaPressurePassed {
target.RequireMediaPressurePassed = true
}
if explicit.RequireCoverDominantRealImage {
target.RequireCoverDominantRealImage = true
}
if explicit.MaxConsecutiveInfographicOnlyPages > 0 {
target.MaxConsecutiveInfographicOnlyPages = explicit.MaxConsecutiveInfographicOnlyPages
}
if explicit.MaxCardDominantRatioBP > 0 {
target.MaxCardDominantRatioBP = explicit.MaxCardDominantRatioBP
}
if explicit.MaxShapeLanguageMaxRatioBP > 0 {
target.MaxShapeLanguageMaxRatioBP = explicit.MaxShapeLanguageMaxRatioBP
}
return target
}
func defaultEditorialQualityTarget(archetype string) editorialQualityTarget {
switch archetype {
case "financial_company_report", "named_company_report":
return editorialQualityTarget{MinimumScore: 75, RequireMediaPressurePassed: true, RequireCoverDominantRealImage: true, MaxConsecutiveInfographicOnlyPages: 3, MaxCardDominantRatioBP: 5000, MaxShapeLanguageMaxRatioBP: 8000}
case "premium_product_brand", "brand_official_site":
return editorialQualityTarget{MinimumScore: 80, RequireMediaPressurePassed: true, RequireCoverDominantRealImage: true, MaxConsecutiveInfographicOnlyPages: 2, MaxCardDominantRatioBP: 3500, MaxShapeLanguageMaxRatioBP: 7500}
case "sports_editorial", "event_editorial":
return editorialQualityTarget{MinimumScore: 78, RequireMediaPressurePassed: true, RequireCoverDominantRealImage: true, MaxConsecutiveInfographicOnlyPages: 2, MaxCardDominantRatioBP: 4000, MaxShapeLanguageMaxRatioBP: 7000}
default:
return editorialQualityTarget{}
}
}
func editorialMetrics(media MediaPressureReport, creative CreativeQualityReport, contentPayload ContentPayloadReport) EditorialQualityMetrics {
slides := media.Metrics.Slides
if slides == 0 {
slides = creative.Metrics.Slides
}
cardRatio := 0
if creative.Metrics.Slides > 0 {
cardRatio = creative.Metrics.CardDominantSlideCount * 10000 / creative.Metrics.Slides
}
return EditorialQualityMetrics{
Slides: slides,
MediaPressureIssueCount: media.Metrics.IssueCount,
DominantRealImagePages: media.Metrics.DominantRealImagePages,
MaxConsecutiveInfographicPages: media.Metrics.MaxConsecutiveInfographicPages,
CardDominantRatioBP: cardRatio,
ShapeLanguageMaxRatioBP: creative.Metrics.ShapeLanguageMaxRatioBP,
CreativeErrorCount: creativeErrorCount(creative),
ContentPayloadIssueCount: contentPayload.Metrics.IssueCount,
SparseLabelListCount: contentPayload.Metrics.SparseLabelListCount,
MissingEvidencePayloadCount: contentPayload.Metrics.MissingCentralClaimCount + contentPayload.Metrics.MissingSupportingPointsCount + contentPayload.Metrics.MissingSourceBoundFactCount + contentPayload.Metrics.SourceBindingIssueCount,
MissingVisualDataItemsCount: contentPayload.Metrics.MissingVisualDataItemsCount,
}
}
func creativeErrorCount(report CreativeQualityReport) int {
count := 0
for _, issue := range report.Issues {
if issue.Severity == "error" {
count++
}
}
return count
}
func addEditorialIssue(report *EditorialQualityReport, path, code, message string) {
report.Issues = append(report.Issues, EditorialQualityIssue{
Code: code,
Path: path,
Message: message,
Severity: "error",
})
}
func isZeroEditorialQualityTarget(target editorialQualityTarget) bool {
return target.MinimumScore == 0 &&
!target.RequireMediaPressurePassed &&
!target.RequireCoverDominantRealImage &&
target.MaxConsecutiveInfographicOnlyPages == 0 &&
target.MaxCardDominantRatioBP == 0 &&
target.MaxShapeLanguageMaxRatioBP == 0
}

View File

@@ -1,118 +0,0 @@
package svglide
import (
"errors"
"testing"
)
func TestEditorialQualityRejectsPassedMechanicalGateWithoutMediaPressure(t *testing.T) {
contract := qualityVisualContract{TopicArchetype: "financial_company_report"}
media := MediaPressureReport{
Status: "failed",
Metrics: MediaPressureMetrics{Slides: 5, IssueCount: 2, DominantRealImagePages: 0, CoverDominantRealImagePages: 0, MaxConsecutiveInfographicPages: 5},
Issues: []MediaPressureIssue{{Code: "svglide.media_pressure.cover_dominant_real_image", Severity: "error"}},
Policy: defaultMediaPressurePolicy("financial_company_report", 5),
}
creative := CreativeQualityReport{
Status: "passed",
Metrics: CreativeQualityMetrics{Slides: 5, ShapeLanguageMaxRatioBP: 10000},
}
report := EvaluateEditorialQualityRun(contract, media, creative)
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !editorialIssueCodesContain(report.Issues, "svglide.editorial_quality.media_pressure") {
t.Fatalf("issues = %+v, want media pressure issue", report.Issues)
}
if !editorialIssueCodesContain(report.Issues, "svglide.editorial_quality.cover_hero") {
t.Fatalf("issues = %+v, want cover hero issue", report.Issues)
}
}
func TestEditorialQualityPassesWhenArchetypeFloorIsMet(t *testing.T) {
contract := qualityVisualContract{TopicArchetype: "sports_editorial"}
media := MediaPressureReport{
Status: "passed",
Metrics: MediaPressureMetrics{Slides: 8, IssueCount: 0, DominantRealImagePages: 3, CoverDominantRealImagePages: 1, MaxConsecutiveInfographicPages: 2},
Policy: defaultMediaPressurePolicy("sports_editorial", 8),
}
creative := CreativeQualityReport{
Status: "passed",
Metrics: CreativeQualityMetrics{Slides: 8, CardDominantSlideCount: 2, ShapeLanguageMaxRatioBP: 6250},
}
report := EvaluateEditorialQualityRun(contract, media, creative)
if report.Status != "passed" {
t.Fatalf("status = %q, want passed: %+v", report.Status, report.Issues)
}
if report.Score < report.Target.MinimumScore {
t.Fatalf("score = %d, want >= %d", report.Score, report.Target.MinimumScore)
}
}
func TestEditorialQualityExecutionFailureWritesActionableIssue(t *testing.T) {
contract := qualityVisualContract{TopicArchetype: "financial_company_report"}
media := MediaPressureReport{
Status: "passed",
Metrics: MediaPressureMetrics{Slides: 3, IssueCount: 0, DominantRealImagePages: 2, CoverDominantRealImagePages: 1},
}
report := EvaluateEditorialQualityExecutionFailure(contract, media, errors.New("creative report missing"))
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if report.Metrics.CreativeErrorCount != 1 {
t.Fatalf("creative errors = %d, want 1", report.Metrics.CreativeErrorCount)
}
if !editorialIssueCodesContain(report.Issues, "svglide.editorial_quality.creative_unavailable") {
t.Fatalf("issues = %+v, want creative unavailable issue", report.Issues)
}
}
func TestEditorialQualityFailsContentPayloadIssues(t *testing.T) {
contract := qualityVisualContract{}
media := MediaPressureReport{
Status: "passed",
Metrics: MediaPressureMetrics{Slides: 3, IssueCount: 0},
}
creative := CreativeQualityReport{
Status: "passed",
Metrics: CreativeQualityMetrics{Slides: 3},
}
payload := ContentPayloadReport{
Status: "failed",
Metrics: ContentPayloadMetrics{
Slides: 3,
SubstantiveSlides: 2,
SparseLabelListCount: 1,
MissingSupportingPointsCount: 1,
MissingSourceBoundFactCount: 1,
MissingVisualDataItemsCount: 1,
IssueCount: 4,
},
}
report := EvaluateEditorialQualityRun(contract, media, creative, payload)
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if report.Metrics.SparseLabelListCount != 1 {
t.Fatalf("sparse count = %d, want 1", report.Metrics.SparseLabelListCount)
}
if !editorialIssueCodesContain(report.Issues, "svglide.editorial.content_sparse_label_list") {
t.Fatalf("issues = %+v, want content_sparse_label_list", report.Issues)
}
if !editorialIssueCodesContain(report.Issues, "svglide.editorial.content_visual_data_mismatch") {
t.Fatalf("issues = %+v, want content_visual_data_mismatch", report.Issues)
}
}
func editorialIssueCodesContain(issues []EditorialQualityIssue, want string) bool {
for _, issue := range issues {
if issue.Code == want {
return true
}
}
return false
}

View File

@@ -1,172 +0,0 @@
package svglide
import (
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
)
const (
slideSupportedFontsPath = "font_catalog/slide_supported_fonts.json"
slideFontThemePresetsPath = "font_catalog/slide_font_theme_presets.json"
typographyFontSourcePreset = "slide_font_theme_presets"
)
//go:embed font_catalog/slide_supported_fonts.json font_catalog/slide_font_theme_presets.json font_catalog/slide_font_tags.json
var slideFontCatalogFS embed.FS
type slideSupportedFontsFile struct {
Version string `json:"version,omitempty"`
Counts struct {
Total int `json:"total"`
} `json:"counts"`
Fonts []slideSupportedFont `json:"fonts"`
}
type slideSupportedFont struct {
FontFamily string `json:"font_family"`
Source string `json:"source"`
Lang string `json:"lang"`
Display map[string]string `json:"display_name"`
}
type slideFontThemePresetsFile struct {
Version string `json:"version"`
Status string `json:"status"`
VisualMoodPresets map[string]slideFontMoodPreset `json:"visual_mood_presets"`
}
type slideFontMoodPreset struct {
Themes []string `json:"themes"`
Intent string `json:"intent"`
Roles map[string][]string `json:"roles"`
Notes []string `json:"notes,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
type typographyCatalogValidation struct {
MissingSource bool
MissingSelectedMood bool
UnknownMoods []string
UnsupportedRoles map[string]string
StackRoles map[string]string
PresetMismatchRoles map[string]string
}
func loadSlideSupportedFonts() (slideSupportedFontsFile, error) {
raw, err := slideFontCatalogFS.ReadFile(slideSupportedFontsPath)
if err != nil {
return slideSupportedFontsFile{}, err
}
var catalog slideSupportedFontsFile
if err := json.Unmarshal(raw, &catalog); err != nil {
return slideSupportedFontsFile{}, fmt.Errorf("%s: invalid JSON: %w", slideSupportedFontsPath, err)
}
return catalog, nil
}
func loadSlideFontThemePresets() (slideFontThemePresetsFile, error) {
raw, err := slideFontCatalogFS.ReadFile(slideFontThemePresetsPath)
if err != nil {
return slideFontThemePresetsFile{}, err
}
var presets slideFontThemePresetsFile
if err := json.Unmarshal(raw, &presets); err != nil {
return slideFontThemePresetsFile{}, fmt.Errorf("%s: invalid JSON: %w", slideFontThemePresetsPath, err)
}
return presets, nil
}
func validateTypographyAgainstFontCatalog(contract typographyContractFile) (typographyCatalogValidation, error) {
catalog, err := loadSlideSupportedFonts()
if err != nil {
return typographyCatalogValidation{}, err
}
presets, err := loadSlideFontThemePresets()
if err != nil {
return typographyCatalogValidation{}, err
}
knownFonts := make(map[string]bool, len(catalog.Fonts))
for _, font := range catalog.Fonts {
if strings.TrimSpace(font.FontFamily) != "" {
knownFonts[font.FontFamily] = true
}
}
result := typographyCatalogValidation{
UnsupportedRoles: map[string]string{},
StackRoles: map[string]string{},
PresetMismatchRoles: map[string]string{},
}
if strings.TrimSpace(contract.FontSource) != typographyFontSourcePreset {
result.MissingSource = true
}
selectedMoods := nonEmptyStrings(contract.SelectedMoods)
if len(selectedMoods) == 0 {
result.MissingSelectedMood = true
}
allowedByRole := map[string]map[string]bool{}
for _, mood := range selectedMoods {
preset, ok := presets.VisualMoodPresets[mood]
if !ok {
result.UnknownMoods = append(result.UnknownMoods, mood)
continue
}
for role, candidates := range preset.Roles {
if allowedByRole[role] == nil {
allowedByRole[role] = map[string]bool{}
}
for _, candidate := range candidates {
allowedByRole[role][candidate] = true
}
}
}
sort.Strings(result.UnknownMoods)
for _, role := range []string{"display", "body", "number", "label"} {
font := contract.Roles[role]
family := strings.TrimSpace(font.Family)
if strings.Contains(family, ",") {
result.StackRoles[role] = family
continue
}
if !knownFonts[family] {
result.UnsupportedRoles[role] = family
continue
}
if len(selectedMoods) > 0 && len(result.UnknownMoods) == 0 {
allowed := allowedByRole[role]
if len(allowed) > 0 && !allowed[family] {
result.PresetMismatchRoles[role] = family
}
}
}
return result, nil
}
func (v typographyCatalogValidation) OK() bool {
return !v.MissingSource &&
!v.MissingSelectedMood &&
len(v.UnknownMoods) == 0 &&
len(v.UnsupportedRoles) == 0 &&
len(v.StackRoles) == 0 &&
len(v.PresetMismatchRoles) == 0
}
func sortedRoleFontPairs(values map[string]string) string {
if len(values) == 0 {
return ""
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%q", key, values[key]))
}
return strings.Join(parts, ", ")
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +0,0 @@
{
"version": "2026-07-07",
"status": "initial",
"source_font_catalog": "internal/svglide/font_catalog/slide_supported_fonts.json",
"principles": [
"Only use canonical Slide font_family values from slide_supported_fonts.json.",
"Do not emit arbitrary CSS font stacks for online Slide creation.",
"Choose by deck topic, visual mood, language, and text role instead of by topic alone.",
"A preset is a ranked candidate pool, not a hard one-font mapping.",
"Chinese decks should prefer zh-capable display/body candidates unless the page is intentionally Latin-led."
],
"role_definitions": {
"display": "Cover title, section title, editorial headline, hero phrase.",
"body": "Readable paragraph and bullets.",
"number": "Financial numbers, scores, tables, axes, labels with numeric density.",
"label": "Tags, captions, source notes, chart labels, small annotations."
},
"visual_mood_presets": {
"corporate_neutral": {
"themes": ["business report", "strategy", "organization", "operations", "meeting summary"],
"intent": "稳健、中性、清晰,避免强烈装饰。",
"roles": {
"display": ["Montserrat", "Inter", "Lato", "Noto Sans SC", "SimHei"],
"body": ["Inter", "Lato", "Open Sans", "Noto Sans SC", "Arial"],
"number": ["Roboto Mono", "Inconsolata", "Source Code Pro", "Roboto"],
"label": ["Inter", "Roboto", "Noto Sans SC", "Arial"]
}
},
"finance_institutional": {
"themes": ["financial report", "earnings", "investor update", "audit", "valuation"],
"intent": "机构化、可信、数字清晰,弱装饰、强层级。",
"roles": {
"display": ["IBM Plex Sans", "Montserrat", "Libre Franklin", "Noto Sans SC", "SimHei"],
"body": ["IBM Plex Sans", "Inter", "Roboto", "Noto Sans SC"],
"number": ["Roboto Mono", "Source Code Pro", "Inconsolata"],
"label": ["IBM Plex Sans", "Roboto Condensed", "Noto Sans SC"]
}
},
"tech_ai_precision": {
"themes": ["AI", "chip", "cloud", "engineering", "developer platform", "cybersecurity"],
"intent": "精密、现代、工程感,适合深色和高对比图表。",
"roles": {
"display": ["Exo 2", "Rajdhani SemiBold", "Titillium Web SemiBold", "LogoSC Unbounded Sans", "ChillDINGothic SemiBold"],
"body": ["IBM Plex Sans", "Inter", "Roboto", "Noto Sans SC"],
"number": ["Roboto Mono", "Source Code Pro", "Inconsolata"],
"label": ["Rajdhani SemiBold", "Roboto Condensed", "Noto Sans SC"]
}
},
"data_scientific": {
"themes": ["research", "science", "lab", "data analysis", "methodology", "benchmark"],
"intent": "理性、克制、可读,强调图表和注释。",
"roles": {
"display": ["IBM Plex Sans", "Source Code Pro", "Roboto Slab", "Noto Serif SC"],
"body": ["IBM Plex Sans", "Noto Sans", "Noto Sans SC", "Open Sans"],
"number": ["Source Code Pro", "Roboto Mono", "Inconsolata"],
"label": ["IBM Plex Sans", "Noto Sans", "Noto Sans SC"]
}
},
"luxury_editorial": {
"themes": ["luxury", "premium brand", "jewelry", "eyewear", "watch", "gallery", "boutique"],
"intent": "高级、克制、留白、杂志感,标题与正文需明显分化。",
"roles": {
"display": ["Playfair Display", "EB Garamond", "Libre Baskerville", "ChillJinshuSongMedium", "Songti SC"],
"body": ["Lora", "Libre Baskerville", "Noto Serif SC", "Songti SC", "Alegreya"],
"number": ["Montserrat", "Lato", "Roboto"],
"label": ["Josefin Sans", "Montserrat", "ChillDINGothic SemiBold", "Noto Sans SC"]
}
},
"fashion_high_contrast": {
"themes": ["fashion", "lookbook", "art direction", "campaign", "magazine"],
"intent": "高反差、大片感、强标题,适合图文融合。",
"roles": {
"display": ["Abril Fatface", "Playfair Display", "Bebas Neue", "Poiret One", "ChillJinshuSongMedium"],
"body": ["Raleway", "Lato", "Lora", "Noto Serif SC"],
"number": ["Bebas Neue", "Montserrat", "Roboto Condensed"],
"label": ["Raleway", "Josefin Sans", "Montserrat"]
}
},
"culture_heritage": {
"themes": ["tea", "museum", "heritage", "history", "traditional craft", "architecture"],
"intent": "文化感、书卷气、东方审美,但正文仍要可读。",
"roles": {
"display": ["ChillJinshuSongMedium", "Noto Serif SC", "Songti SC", "ZCOOL XiaoWei", "Kaiti SC"],
"body": ["Noto Serif SC", "Songti SC", "Noto Sans SC"],
"number": ["Noto Sans SC", "SimHei", "Roboto"],
"label": ["ChillDuanHeiSong_CompactRegular", "Noto Sans SC", "Songti SC"]
}
},
"calligraphy_poetic": {
"themes": ["poetry", "calligraphy", "literature", "festival", "ceremony", "classical culture"],
"intent": "诗性、手写、仪式感;只适合少量标题或视觉锚点,不适合长正文。",
"roles": {
"display": ["Ma Shan Zheng", "Liu Jian Mao Cao", "Long Cang", "Zhi Mang Xing", "Kaiti SC"],
"body": ["Noto Serif SC", "Songti SC", "Kaiti SC"],
"number": ["Noto Sans SC", "SimHei"],
"label": ["Kaiti SC", "Songti SC", "Noto Sans SC"]
}
},
"sports_broadcast": {
"themes": ["sports", "football", "basketball", "athlete profile", "match report", "tournament"],
"intent": "转播感、速度、冲击、比分清晰。",
"roles": {
"display": ["Anton", "Bebas Neue", "Oswald", "Teko", "Fjalla One"],
"body": ["Barlow Condensed", "Roboto Condensed", "Noto Sans SC", "ChillDuanSans WideSemiBold"],
"number": ["Anton", "Bebas Neue", "Teko", "Roboto Condensed"],
"label": ["Oswald", "Barlow Condensed", "ChillDuanSans WideSemiBold"]
}
},
"youth_pop": {
"themes": ["youth", "social media", "entertainment", "creator economy", "consumer brand", "music"],
"intent": "年轻、活泼、强识别,允许更高饱和度和圆润字体。",
"roles": {
"display": ["DouyinSans", "ZCOOL KuaiLe", "Righteous", "Comfortaa", "ChillRoundF"],
"body": ["Nunito Sans", "Quicksand", "Noto Sans SC", "Resource Han Rounded CN"],
"number": ["DouyinSans", "Montserrat", "Roboto"],
"label": ["DouyinSans", "Comfortaa", "Resource Han Rounded CN"]
}
},
"warm_lifestyle": {
"themes": ["travel", "food", "home", "wellness", "coffee", "daily life", "local guide"],
"intent": "温暖、轻松、有亲和力,正文要舒适。",
"roles": {
"display": ["Quicksand", "Nunito", "Lora", "975Maru SC", "Resource Han Rounded CN"],
"body": ["Nunito Sans", "Lora", "Noto Sans SC", "Songti SC"],
"number": ["Nunito Sans", "Roboto", "Montserrat"],
"label": ["Quicksand", "Nunito Sans", "Resource Han Rounded CN"]
}
},
"education_readable": {
"themes": ["course", "training", "explainer", "knowledge base", "tutorial", "school"],
"intent": "高可读、低噪声、层级明确。",
"roles": {
"display": ["Nunito Sans", "Source Code Pro", "Noto Sans SC", "SimHei"],
"body": ["Noto Sans SC", "Open Sans", "Roboto", "Arial"],
"number": ["Roboto Mono", "Roboto", "Source Code Pro"],
"label": ["Noto Sans SC", "Roboto", "Arial"]
}
},
"government_formal": {
"themes": ["policy", "government", "public affairs", "official report", "regulation"],
"intent": "正式、稳重、低风险,避免娱乐化。",
"roles": {
"display": ["Songti SC Black", "Noto Serif SC", "SimHei"],
"body": ["Songti SC", "Noto Serif SC", "Noto Sans SC"],
"number": ["SimHei", "Noto Sans SC", "Roboto"],
"label": ["SimHei", "Noto Sans SC", "Songti SC"]
}
},
"medical_clean": {
"themes": ["medical", "healthcare", "biotech", "clinical", "pharma", "public health"],
"intent": "清洁、可信、轻科技,避免过强装饰。",
"roles": {
"display": ["IBM Plex Sans", "Lato", "Noto Sans SC", "Source Code Pro"],
"body": ["IBM Plex Sans", "Open Sans", "Noto Sans SC", "Roboto"],
"number": ["Roboto Mono", "Source Code Pro", "Roboto"],
"label": ["IBM Plex Sans", "Noto Sans SC", "Roboto"]
}
},
"industrial_engineering": {
"themes": ["manufacturing", "automotive", "energy", "infrastructure", "supply chain", "construction"],
"intent": "硬朗、工程、结构感,适合流程图和技术剖面。",
"roles": {
"display": ["Barlow Condensed", "Archivo Narrow", "Roboto Condensed", "ChillDuanSans WideSemiBold"],
"body": ["Barlow", "Roboto", "Noto Sans SC", "ChillReunion_Sans"],
"number": ["Roboto Mono", "Teko", "Source Code Pro"],
"label": ["Archivo Narrow", "Roboto Condensed", "ChillDINGothic SemiBold"]
}
},
"startup_product": {
"themes": ["product launch", "SaaS", "growth", "startup pitch", "mobile app", "platform"],
"intent": "现代、清爽、产品化,适合界面截图和指标叙事。",
"roles": {
"display": ["Poppins", "Montserrat", "DM Sans 9pt", "DouyinSans", "Noto Sans SC"],
"body": ["DM Sans 9pt", "Inter", "Noto Sans SC", "Open Sans"],
"number": ["Roboto Mono", "DM Sans 9pt", "Montserrat"],
"label": ["DM Sans 9pt", "Inter", "Noto Sans SC"]
}
},
"gaming_sci_fi": {
"themes": ["gaming", "esports", "sci-fi", "future city", "metaverse", "virtual event"],
"intent": "未来、竞技、强风格;需要避免全页难读。",
"roles": {
"display": ["Exo", "Exo 2", "Play", "Rajdhani SemiBold", "LogoSC Unbounded Sans"],
"body": ["Exo 2", "Rajdhani SemiBold", "Noto Sans SC", "Roboto"],
"number": ["Rajdhani SemiBold", "Roboto Mono", "Teko"],
"label": ["Exo 2", "Rajdhani SemiBold", "LogoSC Unbounded Sans"]
},
"notes": ["Orbitron would be a strong sci-fi candidate, but it is not currently present in slide_supported_fonts.json; gate should reject it until the font catalog contains it."]
}
},
"topic_to_mood_examples": {
"NVIDIA Q4 financial report": ["finance_institutional", "tech_ai_precision"],
"Kaneko Optical brand introduction": ["luxury_editorial", "culture_heritage"],
"2026 World Cup Germany or Norway Haaland": ["sports_broadcast", "youth_pop"],
"Chinese tea introduction": ["culture_heritage", "calligraphy_poetic", "warm_lifestyle"],
"Apple product strategy": ["startup_product", "tech_ai_precision", "corporate_neutral"],
"public policy explainer": ["government_formal", "education_readable"],
"biotech market report": ["medical_clean", "finance_institutional", "data_scientific"]
},
"draft_quality_rules": [
"At least two different font families should be used across display/body/number/label unless the deck is a plain operational report.",
"Decorative script fonts are display-only by default and should not be used for body text.",
"Numeric-heavy decks should select number fonts from monospace, condensed, or tabular-looking candidates.",
"Chinese body text should prefer Noto Sans SC, Noto Serif SC, Songti SC, SimHei, or Resource Han Rounded CN depending on mood.",
"A generated deck should include the chosen mood preset names and role-level font choices in typography_contract.json.",
"If any selected font_family is not present in slide_supported_fonts.json, the run should fail before online Slide creation."
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,220 +0,0 @@
package svglide
import (
"encoding/json"
"fmt"
"strings"
)
const imageCandidatesPath = "assets/image_candidates.json"
type imageCandidatesFile struct {
RequiresRealImages bool `json:"requires_real_images"`
NoImageReason string `json:"no_image_reason"`
AttemptedSources []string `json:"attempted_sources"`
FailureReasonCode string `json:"failure_reason_code"`
Candidates []imageCandidate `json:"candidates"`
}
type imageCandidate struct {
ID string `json:"id"`
Query string `json:"query"`
SourceURL string `json:"source_url"`
SourceClass string `json:"source_class"`
Format string `json:"format"`
Width int `json:"width"`
Height int `json:"height"`
HasAlpha bool `json:"has_alpha"`
AssetRole string `json:"asset_role"`
FitRole string `json:"fit_role"`
LocalPath string `json:"local_path"`
ScoreBP int `json:"score_bp"`
Selected bool `json:"selected"`
SelectionReason string `json:"selection_reason"`
FormatExceptionReason string `json:"format_exception_reason"`
RejectionReason string `json:"rejection_reason"`
EvidenceRole string `json:"evidence_role"`
}
func readImageCandidates(safeRoot string) (imageCandidatesFile, error) {
raw, err := readRunRegularArtifact(safeRoot, imageCandidatesPath)
if err != nil {
return imageCandidatesFile{}, fmt.Errorf("read image candidates %q: %w", imageCandidatesPath, err)
}
var file imageCandidatesFile
if err := json.Unmarshal(raw, &file); err != nil {
return imageCandidatesFile{}, fmt.Errorf("%s: invalid JSON: %w", imageCandidatesPath, err)
}
return file, nil
}
func ValidateImageCandidatesGate(safeRoot string) error {
manifest, err := readAssetsManifest(safeRoot)
if err != nil {
return err
}
inventory, err := readAssetInventory(safeRoot)
if err != nil {
return err
}
candidates, err := readImageCandidates(safeRoot)
if err != nil {
return err
}
run, _ := readRunFile(safeRoot)
deliveryContract, _, _ := readDeliveryContract(safeRoot, run)
requiresRealImages := candidates.RequiresRealImages || deliveryContract.RequiresRealImages
hasRasterAsset := false
for _, asset := range manifest.Assets {
if isRasterImageAsset(asset) {
hasRasterAsset = true
break
}
}
if !requiresRealImages && len(candidates.Candidates) == 0 {
if strings.TrimSpace(candidates.NoImageReason) == "" {
return fmt.Errorf("image_candidates_gate: no real image candidates; set no_image_reason when requires_real_images=false")
}
if hasRasterAsset {
return fmt.Errorf("image_candidates_gate: ready raster image assets require selected candidates even when requires_real_images=false")
}
return nil
}
if requiresRealImages {
if err := validateImageCandidateSearchBreadth(candidates); err != nil {
return err
}
}
selectedByID := selectedImageCandidatesByID(candidates)
selectedByPath := selectedImageCandidatesByLocalPath(candidates)
inventoryByPath := inventoryItemByPath(inventory)
for _, asset := range manifest.Assets {
if !isRasterImageAsset(asset) {
continue
}
path := assetPath(asset)
item, ok := inventoryByPath[path]
if !ok {
return fmt.Errorf("image_candidates_gate: ready image asset %q path %q has no asset_inventory entry", assetID(asset), path)
}
candidate, ok := selectedCandidateForInventoryItem(item, selectedByID, selectedByPath)
if !ok {
return fmt.Errorf("image_candidates_gate: ready image asset %q path %q has no selected candidate", assetID(asset), path)
}
if strings.TrimSpace(item.CandidateID) != "" && strings.TrimSpace(candidate.LocalPath) != "" && strings.TrimSpace(candidate.LocalPath) != path {
return fmt.Errorf("image_candidates_gate: asset %q candidate_id %q points to local_path %q, want %q", assetID(asset), item.CandidateID, candidate.LocalPath, path)
}
if strings.TrimSpace(candidate.SourceURL) == "" {
return fmt.Errorf("image_candidates_gate: selected candidate for asset %q has empty source_url", assetID(asset))
}
if strings.TrimSpace(candidate.SelectionReason) == "" {
return fmt.Errorf("image_candidates_gate: selected candidate for asset %q has empty selection_reason", assetID(asset))
}
}
return nil
}
func selectedImageCandidatesByID(file imageCandidatesFile) map[string]imageCandidate {
out := make(map[string]imageCandidate)
for _, candidate := range file.Candidates {
if !candidate.Selected {
continue
}
id := strings.TrimSpace(candidate.ID)
if id != "" {
out[id] = candidate
}
}
return out
}
func selectedImageCandidatesByLocalPath(file imageCandidatesFile) map[string]imageCandidate {
out := make(map[string]imageCandidate)
for _, candidate := range file.Candidates {
if !candidate.Selected {
continue
}
localPath := strings.TrimSpace(candidate.LocalPath)
if localPath != "" {
out[localPath] = candidate
}
}
return out
}
func selectedCandidateForInventoryItem(item assetInventoryItem, byID, byPath map[string]imageCandidate) (imageCandidate, bool) {
candidateID := strings.TrimSpace(item.CandidateID)
if candidateID != "" {
candidate, ok := byID[candidateID]
return candidate, ok
}
candidate, ok := byPath[strings.TrimSpace(item.Path)]
return candidate, ok
}
func validateImageCandidateSearchBreadth(file imageCandidatesFile) error {
selectedCount := 0
coverHeroCandidates := 0
selectedCoverHeroFromUser := false
roleCandidateCount := map[string]int{}
selectedImportantRoles := map[string]bool{}
userProvidedImportantRoles := map[string]bool{}
for _, candidate := range file.Candidates {
role := strings.TrimSpace(candidate.AssetRole)
if role != "" {
roleCandidateCount[role]++
}
if role == "hero_photo" && strings.TrimSpace(candidate.FitRole) == "full_bleed" {
coverHeroCandidates++
if candidate.Selected && strings.TrimSpace(candidate.SourceClass) == "user_provided" {
selectedCoverHeroFromUser = true
}
}
if candidate.Selected {
selectedCount++
if isImportantImageRole(role) {
selectedImportantRoles[role] = true
if strings.TrimSpace(candidate.SourceClass) == "user_provided" {
userProvidedImportantRoles[role] = true
}
}
} else if strings.TrimSpace(candidate.RejectionReason) == "" {
return fmt.Errorf("image_candidates_gate: rejected candidate %q has empty rejection_reason", candidate.ID)
}
}
if selectedCount == 0 {
return fmt.Errorf("image_candidates_gate: requires_real_images=true but no selected image candidate exists")
}
if coverHeroCandidates > 0 && coverHeroCandidates < 3 && !selectedCoverHeroFromUser {
return fmt.Errorf("image_candidates_gate: cover hero search needs at least 3 candidates, got %d", coverHeroCandidates)
}
for role := range selectedImportantRoles {
if userProvidedImportantRoles[role] {
continue
}
if roleCandidateCount[role] < 2 {
return fmt.Errorf("image_candidates_gate: role %q needs at least 2 candidates, got %d", role, roleCandidateCount[role])
}
}
return nil
}
func isImportantImageRole(role string) bool {
switch role {
case "hero_photo", "scene_photo", "factory_photo", "store_photo", "people_photo", "transparent_subject", "floating_product", "logo", "chip_device", "ui_screenshot", "product_screen", "paper_screenshot", "paper_figure", "repo_screenshot", "official_logo", "source_page_screenshot":
return true
default:
return false
}
}
func inventoryItemByPath(inventory assetInventoryFile) map[string]assetInventoryItem {
out := make(map[string]assetInventoryItem)
for _, item := range inventory.Items {
if path := strings.TrimSpace(item.Path); path != "" {
out[path] = item
}
}
return out
}

View File

@@ -1,53 +0,0 @@
package svglide
import (
"strings"
"testing"
)
func TestValidateImageCandidatesGateRejectsReadyImageWithoutSelectedCandidate(t *testing.T) {
initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageAssets)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":""}]}`)
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
err := ValidateStageOutputs("demo")
if err == nil {
t.Fatal("expected ready image without selected candidate to be rejected")
}
if !strings.Contains(err.Error(), "image_candidates_gate") {
t.Fatalf("error = %v, want image_candidates_gate", err)
}
}
func TestValidateImageCandidatesGateRejectsCandidateIDPathMismatch(t *testing.T) {
initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageAssets)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"c1","query":"brand hero photo","source_url":"https://example.com/other.jpg","source_class":"user_provided","format":"jpg","width":1600,"height":900,"has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","local_path":"assets/images/other.jpg","score_bp":9200,"selected":true,"selection_reason":"user-provided high-resolution hero photo","format_exception_reason":"","rejection_reason":""}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","candidate_id":"c1","selection_reason":"user-provided high-resolution hero photo"}]}`)
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
err := ValidateStageOutputs("demo")
if err == nil || !strings.Contains(err.Error(), "candidate_id") {
t.Fatalf("expected candidate_id/path mismatch failure, got %v", err)
}
}
func TestValidateImageCandidatesGateAllowsExplicitNoImageDeck(t *testing.T) {
initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageAssets)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[]}`)
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"chart-only deck; no real raster image required","candidates":[]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
mustWriteNoChartAssetsForTest(t)
if err := ValidateStageOutputs("demo"); err != nil {
t.Fatalf("explicit no-image deck rejected: %v", err)
}
}

View File

@@ -1,338 +0,0 @@
package svglide
import (
"bytes"
"encoding/xml"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strconv"
"strings"
)
const imageUsageReportPath = "receipts/image_usage.json"
type ImageUsageReport struct {
Status string `json:"status"`
Slides []ImageUsageSlide `json:"slides"`
Issues []ImageUsageIssue `json:"issues"`
}
type ImageUsageSlide struct {
SlideID string `json:"slide_id"`
CanvasWidth float64 `json:"canvas_width"`
CanvasHeight float64 `json:"canvas_height"`
Assets []ImageUsageAsset `json:"assets"`
}
type ImageUsageAsset struct {
AssetID string `json:"asset_id"`
Path string `json:"path"`
Href string `json:"href"`
AssetRole string `json:"asset_role"`
FitRole string `json:"fit_role"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
AreaBP int `json:"area_bp"`
UsageStatus string `json:"usage_status"`
}
type ImageUsageIssue struct {
Code string `json:"code"`
Path string `json:"path"`
Message string `json:"message"`
}
func EvaluateImageUsageRun(safeRoot string, deck authorDeck, manifest deckAssetsFile, inventory assetInventoryFile) ImageUsageReport {
report := ImageUsageReport{Status: "passed", Slides: []ImageUsageSlide{}, Issues: []ImageUsageIssue{}}
inventoryByPath := inventoryItemByPath(inventory)
readyAssetsByPath := readyAssetsByPath(manifest)
usageByPath := map[string]ImageUsageAsset{}
for _, slide := range deck.Slides {
slideUsage := ImageUsageSlide{SlideID: strings.TrimSpace(slide.ID), Assets: []ImageUsageAsset{}}
raw, err := readRunRegularArtifact(safeRoot, strings.TrimSpace(slide.Path))
if err != nil {
report.Status = "failed"
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.image_usage.read_slide", Path: slide.Path, Message: err.Error()})
report.Slides = append(report.Slides, slideUsage)
continue
}
canvasWidth, canvasHeight := imageUsageCanvasSize(raw)
slideUsage.CanvasWidth = canvasWidth
slideUsage.CanvasHeight = canvasHeight
usages, issues := extractSlideImageUsages(slide.Path, raw, canvasWidth, canvasHeight, readyAssetsByPath, inventoryByPath)
if len(issues) > 0 {
report.Status = "failed"
report.Issues = append(report.Issues, issues...)
}
for _, usage := range usages {
usageByPath[usage.Path] = usage
slideUsage.Assets = append(slideUsage.Assets, usage)
}
report.Slides = append(report.Slides, slideUsage)
}
for _, asset := range manifest.Assets {
if !isRasterImageAsset(asset) {
continue
}
path := assetPath(asset)
if issue, ok := validateReadyRasterImageAsset(safeRoot, asset); ok {
report.Status = "failed"
report.Issues = append(report.Issues, issue)
}
item, ok := inventoryByPath[path]
if !ok {
continue
}
usage, used := usageByPath[path]
if !used {
report.Status = "failed"
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.quality.image_usage_missing", Path: path, Message: fmt.Sprintf("ready image asset %q is not referenced by any slide SVG", assetID(asset))})
continue
}
if item.FitRole == "full_bleed" && usage.AreaBP < 4500 {
report.Status = "failed"
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.quality.image_usage_area", Path: path, Message: fmt.Sprintf("asset %q fit_role=full_bleed but SVG area is only %d bp", assetID(asset), usage.AreaBP)})
}
}
return report
}
func validateReadyRasterImageAsset(safeRoot string, asset deckAsset) (ImageUsageIssue, bool) {
path := assetPath(asset)
if !shouldDecodeReadyRasterImagePath(path) {
return ImageUsageIssue{}, false
}
if strings.TrimSpace(path) == "" {
return ImageUsageIssue{
Code: "svglide.quality.image_asset_not_decodable",
Path: path,
Message: fmt.Sprintf("ready image asset %q has an empty local path", assetID(asset)),
}, true
}
raw, err := readRunRegularArtifact(safeRoot, path)
if err != nil {
if isRunSymlink(safeRoot, path) {
return ImageUsageIssue{}, false
}
return ImageUsageIssue{
Code: "svglide.quality.image_asset_not_decodable",
Path: path,
Message: fmt.Sprintf("ready image asset %q cannot be read or decoded: %v", assetID(asset), err),
}, true
}
if err := validateRasterImageBytes(path, raw); err != nil {
return ImageUsageIssue{
Code: "svglide.quality.image_asset_not_decodable",
Path: path,
Message: fmt.Sprintf("ready image asset %q is not a decodable image: %v", assetID(asset), err),
}, true
}
return ImageUsageIssue{}, false
}
func shouldDecodeReadyRasterImagePath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return true
}
lower := strings.ToLower(path)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:") {
return false
}
if isAbsoluteRunPath(path) {
return false
}
return true
}
func isRunSymlink(safeRoot string, rel string) bool {
path, err := safeRunPath(safeRoot, rel)
if err != nil {
return false
}
info, err := os.Lstat(path)
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
func validateRasterImageBytes(path string, raw []byte) error {
if len(raw) < 16 {
return fmt.Errorf("file is only %d bytes", len(raw))
}
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path)))
switch ext {
case ".webp":
if isWebPImage(raw) {
return nil
}
case ".avif":
if isAVIFImage(raw) {
return nil
}
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return err
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return fmt.Errorf("decoded dimensions are %dx%d", cfg.Width, cfg.Height)
}
return nil
}
func isWebPImage(raw []byte) bool {
return len(raw) >= 12 && string(raw[:4]) == "RIFF" && string(raw[8:12]) == "WEBP"
}
func isAVIFImage(raw []byte) bool {
if len(raw) < 12 {
return false
}
brand := string(raw[4:12])
return strings.HasPrefix(brand, "ftypavif") || strings.HasPrefix(brand, "ftypavis")
}
func writeImageUsageReport(safeRoot string, report ImageUsageReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, imageUsageReportPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func extractSlideImageUsages(slidePath string, raw []byte, canvasWidth float64, canvasHeight float64, readyAssetsByPath map[string]deckAsset, inventoryByPath map[string]assetInventoryItem) ([]ImageUsageAsset, []ImageUsageIssue) {
out := []ImageUsageAsset{}
issues := []ImageUsageIssue{}
decoder := xml.NewDecoder(strings.NewReader(string(raw)))
for {
token, err := decoder.Token()
if err != nil {
break
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "image" {
continue
}
attrs := parseSVGAttrs(start.Attr)
href := attrs["href"]
normalized := normalizeSlideAssetHref(slidePath, href)
asset, assetOK := readyAssetsByPath[normalized]
if !assetOK {
issues = append(issues, ImageUsageIssue{
Code: "svglide.quality.image_usage_unregistered",
Path: strings.TrimSpace(slidePath),
Message: fmt.Sprintf("slide SVG image href %q resolves to %q, which is not registered as a ready asset", href, normalized),
})
continue
}
if !isRasterImageAsset(asset) {
continue
}
item, ok := inventoryByPath[normalized]
if !ok {
issues = append(issues, ImageUsageIssue{
Code: "svglide.quality.image_usage_missing_inventory",
Path: normalized,
Message: fmt.Sprintf("ready image asset %q is referenced by SVG but missing from asset_inventory", assetID(asset)),
})
continue
}
width := parseImageUsageFloatAttr(attrs["width"])
height := parseImageUsageFloatAttr(attrs["height"])
out = append(out, ImageUsageAsset{
AssetID: item.ID,
Path: normalized,
Href: href,
AssetRole: item.AssetRole,
FitRole: item.FitRole,
X: parseImageUsageFloatAttr(attrs["x"]),
Y: parseImageUsageFloatAttr(attrs["y"]),
Width: width,
Height: height,
AreaBP: areaBPForCanvas(width, height, canvasWidth, canvasHeight),
UsageStatus: "matched",
})
}
return out, issues
}
func imageUsageCanvasSize(raw []byte) (float64, float64) {
width, height := svgViewBoxSize(string(raw))
if width <= 0 {
width = defaultSlideWidth
}
if height <= 0 {
height = defaultSlideHeight
}
return width, height
}
func parseSVGAttrs(attrs []xml.Attr) map[string]string {
out := map[string]string{}
for _, attr := range attrs {
out[attr.Name.Local] = attr.Value
if attr.Name.Space != "" {
out[attr.Name.Space+":"+attr.Name.Local] = attr.Value
}
}
return out
}
func normalizeSlideAssetHref(slidePath, href string) string {
href = strings.TrimSpace(href)
if href == "" || strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || filepath.IsAbs(href) {
return strings.TrimPrefix(filepath.ToSlash(href), "./")
}
base := filepath.Dir(filepath.ToSlash(slidePath))
normalized := filepath.Clean(filepath.Join(base, href))
return strings.TrimPrefix(filepath.ToSlash(normalized), "./")
}
func parseImageUsageFloatAttr(raw string) float64 {
raw = strings.TrimSpace(strings.TrimSuffix(raw, "px"))
value, _ := strconv.ParseFloat(raw, 64)
return value
}
func areaBP(width, height float64) int {
return areaBPForCanvas(width, height, 1280, 720)
}
func areaBPForCanvas(width, height float64, canvasWidth float64, canvasHeight float64) int {
if width <= 0 || height <= 0 {
return 0
}
if canvasWidth <= 0 {
canvasWidth = defaultSlideWidth
}
if canvasHeight <= 0 {
canvasHeight = defaultSlideHeight
}
canvasArea := canvasWidth * canvasHeight
if canvasArea <= 0 {
return 0
}
return int(width * height * 10000 / canvasArea)
}
func readyAssetsByPath(manifest deckAssetsFile) map[string]deckAsset {
out := map[string]deckAsset{}
for _, asset := range manifest.Assets {
if assetStatus(asset) != "ready" {
continue
}
if path := strings.TrimSpace(assetPath(asset)); path != "" {
out[path] = asset
}
}
return out
}

View File

@@ -1,135 +0,0 @@
package svglide
import "testing"
func TestImageUsageRejectsSelectedImageNotReferencedBySVG(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="80" y="120">No image</text></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing") {
t.Fatalf("expected selected image missing from SVG to fail: %#v", report.Issues)
}
}
func TestImageUsageRejectsFullBleedHeroUsedAsThumbnail(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="900" y="500" width="180" height="100"/></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_area") {
t.Fatalf("expected full-bleed hero thumbnail usage to fail: %#v", report.Issues)
}
}
func TestImageUsageAreaUsesActualSVGCanvas(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="0" y="0" width="960" height="540"/></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if report.Metrics.CoverHeroAreaBP != 10000 {
t.Fatalf("cover hero area = %d, want 10000 for full 960x540 canvas", report.Metrics.CoverHeroAreaBP)
}
}
func TestImageUsageParsesSingleQuotedAndXLinkHrefImage(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" xlink:href='../assets/images/hero.jpg' x='0' y='0' width='1280' height='720'/><text x="80" y="120">Cover</text></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing") {
t.Fatalf("xlink:href image should be detected as used: %#v", report.Issues)
}
}
func TestImageUsageRejectsUnregisteredSVGImageHref(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/temporary.jpg" x="0" y="0" width="1280" height="720"/></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_unregistered") {
t.Fatalf("expected unregistered SVG image href to fail: %#v", report.Issues)
}
}
func TestImageUsageRejectsReferencedReadyImageMissingInventory(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="0" y="0" width="1280" height="720"/></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing_inventory") {
t.Fatalf("expected referenced ready image missing inventory to fail: %#v", report.Issues)
}
}
func TestImageUsageRejectsReadyImageAssetNotDecodable(t *testing.T) {
initStatusTestRun(t)
root := "demo"
writeMinimalImageQualityDeckForTest(t)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
mustWriteTestFile(t, "demo/assets/images/hero.jpg", "fixture image bytes")
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_asset_not_decodable") {
t.Fatalf("expected undecodable ready image asset to fail: %#v", report.Issues)
}
}
func writeMinimalImageQualityDeckForTest(t *testing.T) {
t.Helper()
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.jpg" x="0" y="0" width="1280" height="720"/><text x="80" y="120">Cover</text></svg>`)
mustWriteTestPNGFile(t, "demo/assets/images/hero.jpg")
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_photo_title")
}

View File

@@ -1,237 +0,0 @@
package svglide
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type InitOptions struct {
Title string
Input string
Topic string
Language string
Audience string
DeliveryMode string
DeliveryTarget string
Pages int
Now time.Time
Overwrite bool
AgentRuntime string
AgentID string
RouteProfile string
ExecutionProfile string
}
func InitRun(root string, opts InitOptions) error {
root = strings.TrimSpace(root)
opts.Title = strings.TrimSpace(opts.Title)
opts.Input = strings.TrimSpace(opts.Input)
opts.Topic = strings.TrimSpace(opts.Topic)
opts.Language = strings.TrimSpace(opts.Language)
opts.DeliveryTarget = strings.TrimSpace(opts.DeliveryTarget)
opts.AgentRuntime = strings.TrimSpace(opts.AgentRuntime)
opts.AgentID = strings.TrimSpace(opts.AgentID)
opts.RouteProfile = strings.TrimSpace(opts.RouteProfile)
opts.ExecutionProfile = strings.TrimSpace(opts.ExecutionProfile)
if root == "" {
return fmt.Errorf("out path is required")
}
if opts.Title == "" {
return fmt.Errorf("title is required")
}
if (opts.Input == "") == (opts.Topic == "") {
return fmt.Errorf("exactly one of input or topic is required")
}
safeRoot, err := validate.SafeOutputPath(root)
if err != nil {
return err
}
if err := validateRunRoot(root, safeRoot); err != nil {
return err
}
if opts.Input != "" {
safeInput, err := validate.SafeInputPath(opts.Input)
if err != nil {
return err
}
if err := validateInputOutsideRunRoot(safeRoot, safeInput); err != nil {
return err
}
opts.Input = safeInput
}
if opts.Overwrite {
return initOverwrite(safeRoot, opts)
}
return initNoReplace(safeRoot, opts)
}
func validateRunRoot(root string, safeRoot string) error {
if filepath.Clean(root) == "." {
return fmt.Errorf("out path must be a child directory, got %q", root)
}
cwd, err := vfs.Getwd()
if err != nil {
return fmt.Errorf("cannot determine working directory: %w", err)
}
canonicalCwd, err := vfs.EvalSymlinks(cwd)
if err != nil {
return fmt.Errorf("cannot resolve working directory: %w", err)
}
if filepath.Clean(safeRoot) == filepath.Clean(canonicalCwd) {
return fmt.Errorf("out path must be a child directory, got %q", root)
}
return nil
}
func validateInputOutsideRunRoot(safeRoot string, safeInput string) error {
root := filepath.Clean(safeRoot)
input := filepath.Clean(safeInput)
rel, err := filepath.Rel(root, input)
if err != nil {
return fmt.Errorf("cannot compare input and output paths: %w", err)
}
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
return fmt.Errorf("input path %q must be outside output run directory %q", safeInput, safeRoot)
}
return nil
}
func initNoReplace(safeRoot string, opts InitOptions) error {
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
return err
}
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
return fmt.Errorf("%s already exists or cannot be created; refusing to overwrite: %w", safeRoot, err)
}
return writeClaimedRunDirectory(safeRoot, opts)
}
func initOverwrite(safeRoot string, opts InitOptions) error {
if err := vfs.RemoveAll(safeRoot); err != nil {
return err
}
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
return err
}
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
return err
}
return writeClaimedRunDirectory(safeRoot, opts)
}
func writeClaimedRunDirectory(safeRoot string, opts InitOptions) error {
cleanup := true
defer func() {
if cleanup {
_ = vfs.RemoveAll(safeRoot)
}
}()
if err := writeRunDirectory(safeRoot, safeRoot, opts); err != nil {
return err
}
cleanup = false
return nil
}
func writeRunDirectory(writeRoot string, runRoot string, opts InitOptions) error {
for _, dir := range []string{
"request",
"research",
"brief",
"outline",
"content",
"assets/images",
"publish",
"slides",
"schemas",
"receipts",
} {
if err := vfs.MkdirAll(filepath.Join(writeRoot, dir), 0o755); err != nil {
return err
}
}
run := NewRun(NewRunConfig{
Title: opts.Title,
Input: opts.Input,
Topic: opts.Topic,
Language: opts.Language,
Audience: opts.Audience,
DeliveryMode: opts.DeliveryMode,
DeliveryTarget: opts.DeliveryTarget,
Pages: opts.Pages,
Out: runRoot,
Now: opts.Now,
AgentRuntime: opts.AgentRuntime,
AgentID: opts.AgentID,
RouteProfile: opts.RouteProfile,
ExecutionProfile: opts.ExecutionProfile,
})
run.Policy.Overwrite = opts.Overwrite
if err := writeJSON(filepath.Join(writeRoot, "run.json"), run); err != nil {
return err
}
request := map[string]any{
"title": opts.Title,
"audience": opts.Audience,
"delivery_mode": opts.DeliveryMode,
"delivery_target": run.DeliveryTarget,
"pages": opts.Pages,
"intent": run.Intent,
"agent": run.Agent,
}
if opts.Input != "" {
request["input"] = opts.Input
}
if opts.Topic != "" {
request["topic"] = opts.Topic
}
if opts.Language != "" {
request["language"] = opts.Language
}
if err := writeJSON(filepath.Join(writeRoot, "request", "request.json"), request); err != nil {
return err
}
source := map[string]string{"type": "topic", "topic": opts.Topic}
if opts.Input != "" {
source = map[string]string{"path": opts.Input, "type": "local"}
}
if err := writeJSON(filepath.Join(writeRoot, "request", "source_manifest.json"), map[string]any{
"sources": []map[string]string{source},
}); err != nil {
return err
}
if err := writeDeliveryContractFile(writeRoot, opts); err != nil {
return err
}
return writeStaticFiles(writeRoot)
}
func writeStaticFiles(root string) error {
if err := writeText(filepath.Join(root, "README.md"), renderRunREADME()); err != nil {
return err
}
if err := writePromptManifest(root); err != nil {
return err
}
for name, schema := range DefaultSchemas() {
if err := writeText(filepath.Join(root, "schemas", name), schema); err != nil {
return err
}
}
return nil
}
func renderRunREADME() string {
var b bytes.Buffer
b.WriteString("# SVGlide Local Run\n\n")
b.WriteString("This directory is a local agent-neutral SVG slides runtime. It does not publish to Feishu Slides.\n")
return b.String()
}

View File

@@ -1,564 +0,0 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestInitRunWritesDirectoryContract(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
canonicalCwd, err := filepath.EvalSymlinks(cwd)
if err != nil {
t.Fatal(err)
}
root := "demo"
wantInput := filepath.Join(canonicalCwd, "source.md")
err = InitRun(root, InitOptions{
Title: "Demo",
Input: "source.md",
Audience: "产品负责人",
DeliveryMode: "self_read",
Pages: 8,
Now: time.Date(2026, 7, 2, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)),
})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{
"run.json",
"README.md",
"prompt_manifest.json",
"request/request.json",
"request/source_manifest.json",
"request/delivery_contract.json",
"research",
"brief",
"outline",
"content",
"schemas/request.schema.json",
"schemas/deck.schema.json",
"receipts",
"slides",
"assets/images",
"publish",
} {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Fatalf("missing %s: %v", name, err)
}
}
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
if err != nil {
t.Fatal(err)
}
var run Run
if err := json.Unmarshal(raw, &run); err != nil {
t.Fatal(err)
}
if run.Title != "Demo" || run.CurrentStage != StageRequest {
t.Fatalf("unexpected run: %+v", run)
}
if run.Input != wantInput {
t.Fatalf("run.Input = %q, want %q", run.Input, wantInput)
}
requestRaw, err := os.ReadFile(filepath.Join(root, "request", "request.json"))
if err != nil {
t.Fatal(err)
}
var request map[string]any
if err := json.Unmarshal(requestRaw, &request); err != nil {
t.Fatal(err)
}
if request["title"] != "Demo" || request["input"] != wantInput || request["audience"] != "产品负责人" || request["delivery_mode"] != "self_read" || request["delivery_target"] != DeliveryTargetLocalPreview || request["pages"] != float64(8) {
t.Fatalf("unexpected request.json: %+v", request)
}
manifestRaw, err := os.ReadFile(filepath.Join(root, "request", "source_manifest.json"))
if err != nil {
t.Fatal(err)
}
var manifest struct {
Sources []struct {
Path string `json:"path"`
Type string `json:"type"`
} `json:"sources"`
}
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
t.Fatal(err)
}
if len(manifest.Sources) != 1 || manifest.Sources[0].Path != wantInput || manifest.Sources[0].Type != "local" {
t.Fatalf("unexpected source_manifest.json: %+v", manifest)
}
if _, err := os.Stat(filepath.Join(root, "prompts")); !os.IsNotExist(err) {
t.Fatalf("prompts directory should not be generated per run, stat err = %v", err)
}
promptRaw, err := os.ReadFile(filepath.Join(root, "prompt_manifest.json"))
if err != nil {
t.Fatal(err)
}
prompt := string(promptRaw)
for _, want := range []string{"mode_system_prompt_svg", "svg_reference", "tools/slides_edit.md", "tools/resolve_delivery_contract.md", "tools/resolve_image_assets.md", "tools/generate_svg_chart.md", "tools/generate_vega_lite_chart.md", "tools/publish_online.md"} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt manifest missing %q:\n%s", want, prompt)
}
}
schemaRaw, err := os.ReadFile(filepath.Join(root, "schemas", "deck.schema.json"))
if err != nil {
t.Fatal(err)
}
var deckSchema map[string]any
if err := json.Unmarshal(schemaRaw, &deckSchema); err != nil {
t.Fatal(err)
}
if _, ok := deckSchema["properties"]; !ok || !strings.Contains(string(schemaRaw), "key_message") {
t.Fatalf("deck schema missing properties/key_message: %s", string(schemaRaw))
}
if !strings.Contains(string(schemaRaw), `"minItems": 1`) || !strings.Contains(string(schemaRaw), `^slides/[^/]+\\.svg$`) {
t.Fatalf("deck schema missing minItems/path pattern: %s", string(schemaRaw))
}
for _, name := range []string{
"source_manifest.schema.json",
"delivery_contract.schema.json",
"sources.schema.json",
"slide_content.schema.json",
"slide_copy_plan.schema.json",
"assets_plan.schema.json",
"assets_manifest.schema.json",
"image_candidates.schema.json",
"asset_inventory.schema.json",
"chart_manifest.schema.json",
"image_usage.schema.json",
"chart_quality.schema.json",
"typography_contract.schema.json",
"quality.schema.json",
"receipt.schema.json",
"lint.schema.json",
"preview.schema.json",
"svg_publish_request_evidence.schema.json",
"online_slide.schema.json",
"publish_online.schema.json",
} {
raw, err := os.ReadFile(filepath.Join(root, "schemas", name))
if err != nil {
t.Fatalf("missing schema %s: %v", name, err)
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("schema %s is not valid JSON: %v", name, err)
}
if schema["type"] == nil {
t.Fatalf("schema %s missing type: %s", name, string(raw))
}
}
for _, tc := range []struct {
name string
want []string
}{
{name: "request.schema.json", want: []string{`"purpose"`, `"language"`, `"visual_style_query"`}},
{name: "delivery_contract.schema.json", want: []string{`"delivery_target"`, `"requires_online_slide"`, `"requires_real_images"`}},
{name: "design_brief.schema.json", want: []string{`"visual_system"`, `"narrative_spine"`, `"depth"`, `"tone"`}},
{name: "deck.schema.json", want: []string{`"main_title"`, `"style_instruction"`, `"aesthetic_direction"`}},
{name: "sources.schema.json", want: []string{`"retrieval"`}},
{name: "slide_content.schema.json", want: []string{`"source_refs"`, `"central_claim"`, `"supporting_points"`, `"source_bound_facts"`, `"visuals"`, `"chart"`, `"table"`, `"crop"`}},
{name: "slide_copy_plan.schema.json", want: []string{`"audience_copy"`, `"production_instruction"`}},
{name: "assets_plan.schema.json", want: []string{`"experiment_unrestricted_assets"`, `"slide_id"`, `"status"`, `"deferred"`, `"chart"`, `"table"`, `"crop"`}},
{name: "image_candidates.schema.json", want: []string{`"requires_real_images"`, `"format_exception_reason"`, `"selection_reason"`, `"paper_screenshot"`}},
{name: "asset_inventory.schema.json", want: []string{`"large_ok"`, `"candidate_id"`, `"format_exception_reason"`}},
{name: "image_usage.schema.json", want: []string{`"area_bp"`, `"usage_status"`}},
{name: "chart_manifest.schema.json", want: []string{`"vega-lite"`, `"spec_path"`, `"svg_path"`}},
{name: "chart_quality.schema.json", want: []string{`"missing_unit_count"`, `"missing_source_count"`, `"decorative_chart_count"`}},
{name: "typography_contract.schema.json", want: []string{`"display"`, `"number"`, `"label"`}},
{name: "quality.schema.json", want: []string{`"metrics"`, `"real_image_assets"`, `"vega_lite_spec_assets"`, `"content_payload_issue_count"`}},
{name: "svg_publish_request_evidence.schema.json", want: []string{`"content_type"`, `"content_root"`, `"forbidden_format_detected"`}},
{name: "online_slide.schema.json", want: []string{`"presentation_id"`, `"blocked_reason_code"`}},
{name: "content_payload.schema.json", want: []string{`"sparse_label_list_count"`, `"source_binding_issue_count"`}},
} {
raw, err := os.ReadFile(filepath.Join(root, "schemas", tc.name))
if err != nil {
t.Fatalf("missing schema %s: %v", tc.name, err)
}
text := string(raw)
for _, want := range tc.want {
if !strings.Contains(text, want) {
t.Fatalf("schema %s missing %s: %s", tc.name, want, text)
}
}
}
}
func TestLocalRuntimeBindingMentionsCopyPlanAndAssetInventory(t *testing.T) {
assets, err := LoadAnyGenPromptAssets()
if err != nil {
t.Fatal(err)
}
var binding PromptAssetContract
found := false
for _, asset := range assets {
if asset.ID == "svglide_local_runtime_binding" {
binding = asset
found = true
break
}
}
if !found {
t.Fatal("missing svglide_local_runtime_binding")
}
raw, err := readPromptAssetFile(binding.Path)
if err != nil {
t.Fatal(err)
}
text := string(raw)
for _, want := range []string{"image_candidates", "asset_inventory", "image_usage", "chart_manifest", "typography_contract", "slide_copy_plan", "audience_copy", "production_instruction"} {
if !strings.Contains(text, want) {
t.Fatalf("runtime binding missing %q:\n%s", want, text)
}
}
}
func TestInitRunRefusesExistingRunJSON(t *testing.T) {
t.Chdir(t.TempDir())
root := "demo"
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "run.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
if err == nil {
t.Fatal("expected overwrite refusal")
}
}
func TestInitRunRefusesExistingRootWithoutRunJSON(t *testing.T) {
t.Chdir(t.TempDir())
root := "demo"
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
wantREADME := "keep this readme\n"
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(wantREADME), 0o644); err != nil {
t.Fatal(err)
}
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
gotREADME, readErr := os.ReadFile(filepath.Join(root, "README.md"))
if readErr != nil {
t.Fatal(readErr)
}
if string(gotREADME) != wantREADME {
t.Fatalf("README overwritten: got %q, want %q", string(gotREADME), wantREADME)
}
if err == nil {
t.Fatal("expected existing root refusal")
}
}
func TestInitRunOverwriteReplacesOldRunDirectory(t *testing.T) {
t.Chdir(t.TempDir())
root := "demo"
if err := os.MkdirAll(filepath.Join(root, "slides"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "slides", "old.svg"), []byte("<svg/>"), 0o644); err != nil {
t.Fatal(err)
}
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "slides", "old.svg")); !os.IsNotExist(err) {
t.Fatalf("old slide should be removed, stat err = %v", err)
}
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
if err != nil {
t.Fatal(err)
}
var run Run
if err := json.Unmarshal(raw, &run); err != nil {
t.Fatal(err)
}
if !run.Policy.Overwrite {
t.Fatalf("Policy.Overwrite = false, want true: %+v", run.Policy)
}
}
func TestInitRunRejectsOverlappingInputAndOutput(t *testing.T) {
tests := []struct {
name string
root string
input string
overwrite bool
}{
{name: "same path overwrite", root: "source.md", input: "source.md", overwrite: true},
{name: "input under output overwrite", root: "demo", input: "demo/source.md", overwrite: true},
{name: "input under output no overwrite", root: "demo", input: "demo/source.md", overwrite: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.MkdirAll(filepath.Dir(tt.input), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(tt.input, []byte("source"), 0o644); err != nil {
t.Fatal(err)
}
err := InitRun(tt.root, InitOptions{Title: "Demo", Input: tt.input, Overwrite: tt.overwrite})
if err == nil {
t.Fatal("expected overlapping input/output refusal")
}
got, readErr := os.ReadFile(tt.input)
if readErr != nil {
t.Fatalf("source should remain readable: %v", readErr)
}
if string(got) != "source" {
t.Fatalf("source content changed: got %q", string(got))
}
})
}
}
func TestInitRunRejectsUnsafePaths(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
tests := []struct {
name string
root string
opts InitOptions
}{
{name: "absolute root", root: filepath.Join(cwd, "demo"), opts: InitOptions{Title: "Demo", Input: "source.md"}},
{name: "escaping root", root: "../escape", opts: InitOptions{Title: "Demo", Input: "source.md"}},
{name: "escaping input", root: "demo", opts: InitOptions{Title: "Demo", Input: "../source.md"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := InitRun(tt.root, tt.opts); err == nil {
t.Fatal("expected unsafe path refusal")
}
})
}
}
func TestInitRunRejectsRootResolvingToCWDWhenOverwrite(t *testing.T) {
for _, root := range []string{".", "./", "subdir/.."} {
t.Run(root, func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
markerPath := filepath.Join(cwd, "keep.txt")
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
if err == nil {
t.Fatal("expected root resolving to CWD to be rejected")
}
got, readErr := os.ReadFile(markerPath)
if readErr != nil {
t.Fatalf("marker should remain readable: %v", readErr)
}
if string(got) != "keep" {
t.Fatalf("marker content changed: got %q", string(got))
}
})
}
}
func TestDefaultPromptManifestContracts(t *testing.T) {
manifest, err := ResolvedPromptManifest()
if err != nil {
t.Fatal(err)
}
if manifest.Source != anyGenPromptRoot {
t.Fatalf("Source = %q, want %q", manifest.Source, anyGenPromptRoot)
}
if manifest.Runtime != "agent" {
t.Fatalf("Runtime = %q, want agent", manifest.Runtime)
}
entries := map[string]PromptManifestEntry{}
for _, entry := range manifest.Entries {
entries[entry.Name] = entry
}
for _, want := range []string{"anygen_source_full", "anygen_svg_readme", "mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding", "svglide_visual_quality_overlay", "slide_font_catalog", "resolve_theme_contract", "plan_research", "resolve_design_brief", "slide_outline", "activate_slides_edit", "slides_edit", "finish_slides_edit", "resolve_image_assets", "generate_vega_lite_chart", "generate_svg_chart", "slides_convert", "slides_parse_template"} {
if entries[want].Path == "" {
t.Fatalf("manifest missing %q: %+v", want, manifest.Entries)
}
}
if entries["anygen_source_full"].Path != "docs/vendor/anygen-svg/source.full.md" || entries["anygen_source_full"].Always || entries["anygen_source_full"].SHA256 == "" {
t.Fatalf("anygen_source_full entry = %+v, want hashed provenance-only source.full.md path", entries["anygen_source_full"])
}
if entries["anygen_svg_readme"].Path != "skills/lark-slides/references/anygen-svg/README.md" || !entries["anygen_svg_readme"].Always {
t.Fatalf("anygen_svg_readme entry = %+v, want always README path", entries["anygen_svg_readme"])
}
if !entries["mode_system_prompt_svg"].Always || !entries["svg_reference"].Always {
t.Fatalf("core prompt entries must be always available: %+v", manifest.Entries)
}
if entries["svglide_local_runtime_binding"].Role != "runtime_binding" || !entries["svglide_local_runtime_binding"].Always {
t.Fatalf("runtime binding entry = %+v, want always runtime_binding", entries["svglide_local_runtime_binding"])
}
if entries["svglide_visual_quality_overlay"].Role != "runtime_binding" || !entries["svglide_visual_quality_overlay"].Always {
t.Fatalf("visual quality overlay entry = %+v, want always runtime_binding", entries["svglide_visual_quality_overlay"])
}
if entries["slide_font_catalog"].Role != "runtime_binding" || !entries["slide_font_catalog"].Always {
t.Fatalf("slide font catalog entry = %+v, want always runtime_binding", entries["slide_font_catalog"])
}
if entries["resolve_theme_contract"].Path != "skills/lark-slides/references/anygen-svg/tools/resolve_theme_contract.md" {
t.Fatalf("resolve_theme_contract path = %q", entries["resolve_theme_contract"].Path)
}
if entries["resolve_theme_contract"].Stage != StageRequestResolution {
t.Fatalf("resolve_theme_contract stage = %q, want %q", entries["resolve_theme_contract"].Stage, StageRequestResolution)
}
if !stringSliceContains(entries["resolve_theme_contract"].Produces, "request/theme_contract.json") {
t.Fatalf("resolve_theme_contract produces = %v, want request/theme_contract.json", entries["resolve_theme_contract"].Produces)
}
if entries["plan_research"].Stage != StageResearch {
t.Fatalf("plan_research stage = %q, want %q", entries["plan_research"].Stage, StageResearch)
}
if !stringSliceContains(entries["plan_research"].Produces, "research/research_plan.json") || !stringSliceContains(entries["plan_research"].Produces, "research/queries.json") {
t.Fatalf("plan_research produces = %v, want research plan and queries", entries["plan_research"].Produces)
}
if entries["activate_slides_edit"].Stage != StageSVGAuthor {
t.Fatalf("activate_slides_edit stage = %q, want %q", entries["activate_slides_edit"].Stage, StageSVGAuthor)
}
if entries["slides_edit"].Stage != StageSVGAuthor {
t.Fatalf("slides_edit stage = %q, want %q", entries["slides_edit"].Stage, StageSVGAuthor)
}
if entries["generate_svg_chart"].Stage != StageAssets {
t.Fatalf("generate_svg_chart stage = %q, want %q", entries["generate_svg_chart"].Stage, StageAssets)
}
if entries["generate_vega_lite_chart"].Stage != StageAssets {
t.Fatalf("generate_vega_lite_chart stage = %q, want %q", entries["generate_vega_lite_chart"].Stage, StageAssets)
}
if entries["resolve_image_assets"].Stage != StageAssets {
t.Fatalf("resolve_image_assets stage = %q, want %q", entries["resolve_image_assets"].Stage, StageAssets)
}
promptPaths, err := PromptPathsForStage(StageSVGAuthor)
if err != nil {
t.Fatal(err)
}
paths := strings.Join(promptPaths, "\n")
if strings.Contains(paths, "source.full.md") {
t.Fatalf("SVG author prompt paths should not require source snapshot:\n%s", paths)
}
for _, want := range []string{"README.md", "mode_system_prompt_svg.md", "svg_reference.md", "svglide_local_runtime_binding.md", "svglide_visual_quality_overlay.md", "slide_font_catalog.md", "tools/activate_slides_edit.md", "tools/slides_edit.md", "tools/compute_custom_shape_bbox.md"} {
if !strings.Contains(paths, want) {
t.Fatalf("SVG author prompt paths missing %q:\n%s", want, paths)
}
}
if strings.Contains(paths, "tools/slides_convert.md") || strings.Contains(paths, "tools/slides_parse_template.md") {
t.Fatalf("local SVG author prompt paths must not expose legacy tool prompts:\n%s", paths)
}
}
func TestInitRunRejectsBlankRequiredFields(t *testing.T) {
blankRoot := " "
t.Chdir(t.TempDir())
tests := []struct {
name string
root string
opts InitOptions
}{
{name: "root", root: blankRoot, opts: InitOptions{Title: "Demo", Input: "source.md"}},
{name: "title", root: "title", opts: InitOptions{Title: " \t", Input: "source.md"}},
{name: "input", root: "input", opts: InitOptions{Title: "Demo", Input: " \t"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := InitRun(tt.root, tt.opts); err == nil {
t.Fatal("expected blank field refusal")
}
})
}
}
func TestInitRunAcceptsTopicOnlyIntent(t *testing.T) {
t.Chdir(t.TempDir())
opts := InitOptions{
Title: "电影介绍",
Now: time.Date(2026, 7, 3, 10, 0, 0, 0, time.FixedZone("CST", 8*3600)),
}
setStringInitOptionField(t, &opts, "Topic", "介绍一部电影")
setStringInitOptionField(t, &opts, "Language", "zh")
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
setStringInitOptionField(t, &opts, "AgentID", "test-agent-1")
if err := InitRun("demo", opts); err != nil {
t.Fatalf("topic-only init should succeed without --input: %v", err)
}
runRaw, err := os.ReadFile(filepath.Join("demo", "run.json"))
if err != nil {
t.Fatal(err)
}
var run map[string]any
if err := json.Unmarshal(runRaw, &run); err != nil {
t.Fatal(err)
}
if run["runtime"] == "codex" || run["runtime"] == "fake-agent" {
t.Fatalf("run.runtime = %v, want agent-neutral protocol runtime separate from agent runtime", run["runtime"])
}
agent, ok := run["agent"].(map[string]any)
if !ok {
t.Fatalf("run.agent missing or invalid: %+v", run)
}
if agent["runtime"] != "fake-agent" || agent["id"] != "test-agent-1" {
t.Fatalf("run.agent = %+v, want fake-agent/test-agent-1", agent)
}
intent, ok := run["intent"].(map[string]any)
if !ok {
t.Fatalf("run.intent missing or invalid: %+v", run)
}
if intent["source_mode"] != "topic" || intent["topic"] != "介绍一部电影" || intent["language"] != "zh" {
t.Fatalf("run.intent = %+v, want topic-only zh intent", intent)
}
requestRaw, err := os.ReadFile(filepath.Join("demo", "request", "request.json"))
if err != nil {
t.Fatal(err)
}
var request map[string]any
if err := json.Unmarshal(requestRaw, &request); err != nil {
t.Fatal(err)
}
if input, ok := request["input"]; ok && input != "" {
t.Fatalf("topic-only request.json input = %v, want absent or empty", input)
}
if request["intent"] == nil || request["agent"] == nil {
t.Fatalf("request.json missing intent/agent: %+v", request)
}
manifestRaw, err := os.ReadFile(filepath.Join("demo", "request", "source_manifest.json"))
if err != nil {
t.Fatal(err)
}
var manifest struct {
Sources []map[string]string `json:"sources"`
}
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
t.Fatal(err)
}
if len(manifest.Sources) != 1 || manifest.Sources[0]["type"] != "topic" || manifest.Sources[0]["topic"] != "介绍一部电影" {
t.Fatalf("source_manifest.json = %+v, want one topic source", manifest)
}
}
func setStringInitOptionField(t *testing.T, opts *InitOptions, name string, value string) {
t.Helper()
field := reflect.ValueOf(opts).Elem().FieldByName(name)
if !field.IsValid() {
t.Fatalf("InitOptions missing %s field required by agent runtime protocol", name)
}
if field.Kind() != reflect.String || !field.CanSet() {
t.Fatalf("InitOptions.%s = %s canSet=%v, want settable string", name, field.Kind(), field.CanSet())
}
field.SetString(value)
}

View File

@@ -1,20 +0,0 @@
package svglide
import (
"encoding/json"
"github.com/larksuite/cli/internal/validate"
)
func writeJSON(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return validate.AtomicWrite(path, raw, 0o644)
}
func writeText(path string, content string) error {
return validate.AtomicWrite(path, []byte(content), 0o644)
}

Some files were not shown because too many files have changed in this diff Show More