Compare commits

..

85 Commits

Author SHA1 Message Date
songtianyi.theo
a7d5533c9a feat(svglide): add chart and visual quality gates 2026-07-06 19:28:35 +08:00
songtianyi.theo
27e7ef8d5b Improve SVGlide visual quality gates 2026-07-06 07:00:31 +08:00
songtianyi.theo
4364609e37 feat: harden svglide anygen runtime protocol 2026-07-04 11:42:14 +08:00
songtianyi.theo
a6b35d1e77 test: assert svglide shortcut execution mode 2026-07-03 16:44:09 +08:00
songtianyi.theo
8d0197630f feat: declare svglide next execution mode 2026-07-03 16:43:18 +08:00
songtianyi.theo
7ef44f7c27 docs: declare svglide generation execution mode 2026-07-03 16:42:14 +08:00
songtianyi.theo
33596111c7 fix: declare create-svglide local scopes 2026-07-03 16:26:18 +08:00
songtianyi.theo
fe8620425d docs: route create-svglide through anygen assets first 2026-07-03 16:21:35 +08:00
songtianyi.theo
76214a6176 feat: separate anygen prompts from svglide adapter 2026-07-03 16:20:32 +08:00
songtianyi.theo
a843ef0ac2 feat: expose anygen md asset entrypoint 2026-07-03 16:19:27 +08:00
songtianyi.theo
2a3e6ef2ef docs: add anygen svg asset entrypoint 2026-07-03 16:17:53 +08:00
songtianyi.theo
16f075b04a docs: narrow create-svglide to runtime adapter 2026-07-03 16:17:11 +08:00
songtianyi.theo
e5e17c17cf feat: make svglide shortcut local-only 2026-07-03 15:36:58 +08:00
songtianyi.theo
46014e9b77 test: align svglide anygen smoke name 2026-07-03 15:06:29 +08:00
songtianyi.theo
57cc929ad1 docs: document svglide anygen prompt runtime 2026-07-03 15:02:56 +08:00
songtianyi.theo
bd63a20342 feat: allow unrestricted svglide experiment assets 2026-07-03 15:01:32 +08:00
songtianyi.theo
d82d4e3333 feat: align svglide schemas with anygen experiment 2026-07-03 15:01:11 +08:00
songtianyi.theo
66ea925c3a feat: report anygen prompt assets in svglide next 2026-07-03 14:55:45 +08:00
songtianyi.theo
0672f6de28 feat: use anygen prompt manifest for svglide runs 2026-07-03 14:55:27 +08:00
songtianyi.theo
4dc182b8dd docs: add anygen svg prompt assets 2026-07-03 14:52:05 +08:00
songtianyi.theo
306307b3b3 docs: vendor anygen svg prompt source 2026-07-03 14:51:41 +08:00
songtianyi.theo
589200c8c2 fix: harden svglide asset quality checks 2026-07-03 14:22:18 +08:00
songtianyi.theo
a215a33c8b fix: align svglide migration scope 2026-07-03 13:44:54 +08:00
songtianyi.theo
1666c4db43 docs: describe anygen svglide standard workflow 2026-07-03 13:35:01 +08:00
songtianyi.theo
f3a40e4cda feat: render svglide sources and image assets 2026-07-03 13:18:54 +08:00
songtianyi.theo
00222052ef feat: harden svglide svg protocol lint 2026-07-03 12:56:22 +08:00
songtianyi.theo
f8950cdc8a feat: gate svglide repair on quality 2026-07-03 12:33:50 +08:00
songtianyi.theo
74e7c5abee feat: add svglide anygen quality gate 2026-07-03 12:12:36 +08:00
songtianyi.theo
50754e53b1 feat: add anygen svglide run contracts 2026-07-03 11:40:56 +08:00
songtianyi.theo
ca8efe5d92 fix: harden svglide local repair flow 2026-07-03 02:56:31 +08:00
songtianyi.theo
5ae2594a5f docs: document svglide local execution loop 2026-07-03 02:43:19 +08:00
songtianyi.theo
fd96f6e895 feat: repair svglide runs with basic authoring 2026-07-03 02:38:15 +08:00
songtianyi.theo
81c36bcf85 feat: expose svglide complete and author actions 2026-07-03 02:25:52 +08:00
songtianyi.theo
283462a36f feat: author basic svglide text slides 2026-07-03 02:20:20 +08:00
songtianyi.theo
d4e074a494 feat: validate svglide stage artifacts 2026-07-03 02:09:17 +08:00
songtianyi.theo
15e7ab8b66 feat: advance svglide run stages 2026-07-03 01:56:23 +08:00
songtianyi.theo
f043ee61d8 docs: document create-svglide workflow 2026-07-03 00:17:21 +08:00
songtianyi.theo
5b264cf7b2 feat: add create-svglide shortcut 2026-07-03 00:15:10 +08:00
songtianyi.theo
ead6362ab6 feat: generate svglide preview artifacts 2026-07-03 00:11:22 +08:00
songtianyi.theo
9c0c5ae26a feat: validate svglide svg artifacts 2026-07-03 00:00:27 +08:00
songtianyi.theo
8a450b6437 feat: inspect svglide run status 2026-07-02 23:15:43 +08:00
songtianyi.theo
e196f68ef6 feat: initialize svglide local run directory 2026-07-02 22:25:27 +08:00
songtianyi.theo
dff21a86ec feat: add svglide run model 2026-07-02 21:11:36 +08:00
songtianyi.theo
38bf5402d9 docs: design create-svglide codex runtime 2026-07-02 20:55:06 +08:00
91-enjoy
9f150670f3 feat: upgrade card send (#1688) 2026-07-02 19:46:11 +08:00
liangshuo-1
578e2db4e0 fix: point permission-apply link at official /page/scope-apply entry (#1722) 2026-07-02 19:26:00 +08:00
Paulazaaza-dev
94139751d3 Add detailed command-to-reference mapping for the approval skill. (#1630) 2026-07-02 19:08:19 +08:00
shifengjuan-dev
8c3ed5d224 feat(im): add +chat-members-list shortcut for member listing (#1398)
Add a dedicated +chat-members-list shortcut that lists chat members,
returning users and bots in separate users[] / bots[] buckets. It owns its
pagination loop (mirroring the paginateLoop conventions: per-page log line,
--page-limit cap, non-advancing-token guard) because the list_members
response is multi-bucket: the generic --page-all merger is built for
single-array responses and would silently drop the bots[] bucket and the
final-page truncations[] signal.

Highlights:
- merges users[] and bots[] across pages; takes truncations[] / has_more /
  page_token from the last page so a server-side cap is never hidden
- surfaces truncations[] with a loud stderr warning when the server caps a
  bucket due to security config (the list is incomplete)
- --member-types filter (user/bot), --member-id-type, and the standard
  --page-all / --page-limit / --page-token flags
- with --page-all and no explicit --page-size, uses the max page size to
  minimize round-trips
- docs: SKILL.md Shortcuts table + references/lark-im-chat-members-list.md
2026-07-02 18:09:51 +08:00
zhanghuanxu
c982df4cf0 test: inline slide lint XML fixtures 2026-07-02 18:02:04 +08:00
zhanghuanxu
fb5ae41bca docs: remove lark slides template toolchain 2026-07-02 18:02:04 +08:00
zhanghuanxu
87e872a4c1 docs: add lark slides generation constraints 2026-07-02 18:02:04 +08:00
syh-cpdsss
ddc0f2a521 feat(okr): semi-plain text format with mention position preservation + patch shortcut (#1671)
Add semi-plain text (simple) format for OKR content I/O, and a new `+patch`
shortcut for incremental updates to objectives and key results.
2026-07-02 17:45:00 +08:00
HanShaoshuai-k
440867f1b4 fix: reduce public content token false positives 2026-07-02 17:39:49 +08:00
fangshuyu-768
d0cde9a414 Improve secure label error handling (#1707)
* Improve secure label error handling

* Address secure label review feedback
2026-07-02 15:41:02 +08:00
SunPeiYang996
075b34f9a3 chore: lark-cli docs support reference_map (#1690)
* chore:lark-cli docs support reference_map

* fix: address docs reference map review feedback

* test: harden docs reference map CI assertions
2026-07-02 13:07:42 +08:00
fangshuyu-768
3788405256 fix(doc): align word statistics compound tokens (#1706) 2026-07-02 11:43:22 +08:00
liangshuo-1
462358a746 install: warn instead of failing when checksums.txt is missing (#1712) 2026-07-01 22:50:56 +08:00
liangshuo-1
ad4d3cb874 chore: release v1.0.62 (#1710) 2026-07-01 21:41:14 +08:00
zhicong666-bytedance
171778951d feat(vc): add meeting message send shortcut (#1643)
* feat(vc): add meeting message send shortcut

* docs: refine vc meeting emoji guidance

* fix(vc): validate meeting message send conflicts

* test: add vc meeting message dry-run e2e

* fix(vc): validate meeting message send limits
2026-07-01 20:51:59 +08:00
fangshuyu-768
a6797ac2e4 Improve drive batch failure handling (#1703) 2026-07-01 18:15:14 +08:00
fangshuyu-768
d852ab311b feat(doc): add document word statistics helper (#1697) 2026-07-01 18:03:28 +08:00
HanShaoshuai-k
e8bfbab4a5 fix: reduce public content credential false positives (#1700) 2026-07-01 17:46:33 +08:00
zgz2048
3bda9e17de fix: support field create json array input (#1661) 2026-07-01 16:08:55 +08:00
ILUO
e753b15d84 fix: expose completion state in my tasks output (#1641)
* fix: expose completion state in my tasks output

* test: cover my tasks pretty completion state
2026-07-01 15:41:57 +08:00
dc-bytedance
bdffffb368 feat: interactive upgrade prompt for bare lark-cli (#1498) 2026-07-01 15:07:18 +08:00
dc-bytedance
ec6fdc9b30 feat: fail closed when checksums.txt is missing during install (#1503) 2026-07-01 13:23:23 +08:00
liangshuo-1
775ee5a501 chore: release v1.0.61 (#1695) 2026-06-30 22:18:39 +08:00
liujinkun2025
214318aa02 fix: support bot identity for drive search (#1670) 2026-06-30 21:59:51 +08:00
liangshuo-1
6f2cddfce1 fix(identity): correct identity diagnosis under external credential providers (#1693) 2026-06-30 21:56:03 +08:00
wangwei
75926f9744 feat(apps): add db, file, openapi-key and observability shortcuts (#1596)
* feat: add apps observability helpers

* feat: add apps log observability shortcuts

* feat: add apps trace observability shortcuts

* feat: add apps metric analytics shortcuts

* feat: add apps envvar shortcuts

* docs: document apps observability envvar shortcuts

* fix: add apps observability env hint

* test: cover apps envvar delete dry-run

* fix: align apps observability OpenAPI schema

* fix: map apps observability named series

* fix: apps observability api upgrade

* fix: refine apps observability output

* feat(apps): integrate miaoda db/file CLI commands into apps-spark integration

Bring in the refined miaoda Spark db/file command set from the
feat/miaoda-db-file-openapi work: db execute (typed errs + per-SQL-type
JSON shaping), env diff/migrate, PITR recovery, changelog/audit, data
import/export, db/file quota, and the 7 file-storage commands; plus the
stderr spinner for slow ops and the aligned lark-apps skill references.

Resolved overlap with the integration branch's earlier db-execute
iteration (took the refined typed-error version), unified the stderr-TTY
flag on IOStreams.StderrIsTerminal, and combined the shortcut registry
(43 commands total).

* feat(apps): add openapi-key shortcuts for open API key management (#1576)

* feat(apps): add openapi-key common helpers (mask/redact/config)

* feat(apps): add +openapi-key-list (redacted)

* feat(apps): add +openapi-key-get (redacted)

* feat(apps): add +openapi-key-create (one-time raw secret)

* feat(apps): add +openapi-key-update

* feat(apps): add +openapi-key-enable / +openapi-key-disable

* feat(apps): add +openapi-key-delete (high-risk-write)

* feat(apps): add +openapi-key-reset (rotate, one-time new secret)

* test(apps): assert reset surfaces raw key exactly once

* feat(apps): register openapi-key shortcuts

* docs(lark-apps): add openapi-key reference and routing

* test(apps): update shortcut count for openapi-key commands

* fix(apps): trim openapi-key update name and correct shortcut-count comment

* fix(apps): use camelCase config and add scope-all/scope-api flags

Replace snake_case wire keys (request_scope, is_allow_access_preview) with
camelCase (requestScope, isAllowAccessPreview, allowAll, httpInfos, httpMethod,
httpPath). Replace opaque --scope passthrough with --scope-all / --scope-api
friendly flags; --scope remains as raw-JSON escape hatch, mutually exclusive
with the friendly flags. Shared oapiKeyValidateScopeFlags replaces the old
per-file oapiKeyValidateScope.

* fix(apps): use Changed for scope-all and refresh openapi-key scope docs

Switch the update at-least-one guard from rctx.Bool to rctx.Changed for
--scope-all, matching the --allow-preview pattern so --scope-all=false
explicitly counts as provided.

Rewrite lark-apps-openapi-key.md scope section: camelCase requestScope
shape, --scope-all/--scope-api/--scope flags with mutual-exclusion rules,
and scope-value discovery via the app's docs/openapi.json.

* fix(apps): emit snake_case request_scope config for open gateway

Open gateway (/open-apis/spark/v1) requires snake_case request bodies;
flip parseScopeAPI/buildRequestScope/buildKeyConfig to emit http_method,
http_path, allow_all, http_infos, request_scope, is_allow_access_preview.
Update unit tests to assert snake_case and reject camelCase keys.

* docs(lark-apps): correct openapi-key scope to snake_case wire format

* docs(apps): align openapi-key flag help text to snake_case wire keys

* feat(apps): add actionable hints and more examples to openapi-key

P1: chain .WithHint(...) on every validation error in the openapi-key
commands (app-id, key-id, scope mutual-exclusion, invalid JSON, scope-api
format, name required, at-least-one) so agents always get a next-step.
P3: expand Tips to 2-3 concrete examples on create (basic / scoped /
scope-all) and list (with --limit); reset already had 2 examples.
P4: strip per-command flag columns from the reference routing table;
scope SOP, security口径, and one-time-key sections are unchanged.

* refactor(apps): rename db --env to --environment (hard rename)

Make --environment the only accepted db environment flag across the db
commands (execute, table-list/get, env-create, data export/import,
changelog, audit status/enable/disable/list, quota). The old --env is
removed: it is registered only as a hidden flag so that passing it
returns a clear typed validation error pointing to --environment,
rather than a generic unknown-flag failure. Update the lark-apps db
references accordingly.

* fix: upgrade observability and env

* feat: rename app observability commands to list

* feat(apps): default db --environment to dev across all db commands

Unify the db environment flag default to dev for every db command (was
online for table-list/get, data export/import, changelog, audit, quota;
execute/env-create were already dev). Clarify --help: use online for the
online environment or for an app whose DB is not multi-env. Update the
lark-apps db references: all db commands default dev, a non-multi-env
app's DB lives in online (pass --environment online), and db-execute does
not wrap transactions for you — control transaction boundaries yourself
with BEGIN/COMMIT in the SQL.

* fix: remove unsed files

* file_common.go 的 3 处裸 fmt.Errorf 已改为 typed errs.NewValidationError(errs.SubtypeInvalidArgument, ...)(时间格式校验错误,归 validation)

* fix(apps): resolve openapi-key CI gate failures (#1604)

* test(apps): use placeholder api_key values in openapi-key tests

* fix(apps): return typed errs from openapi-key scope helpers

* fix(apps): rename openapi-key status enum to dodge credential scanner

* fix(apps): reword openapi-key pretty labels to dodge credential scanner

* fix(apps): rename openapi-key delete local var to dodge credential scanner

* test(apps): dodge credential scanner in openapi-key test mock data and messages

* style(apps): gofmt openapi-key common test after fixture rename

* test(apps): align db dry-run e2e with --environment rename and dev default

db dry-run tests still used the removed --env flag and asserted the old
online default, breaking the Run dry-run E2E tests CI step after the
--environment hard rename and dev-default change. Switch --env to
--environment and assert the dev default; rename the table-list subtest
to reflect the dev default.

* fix: improve env-pull dev database hint (#1614)

* feat(plugin): add plugin package management commands (#1609)

* feat: add plugin package and instance management commands for apps domain

Add 8 new shortcut commands under `lark-cli apps`:

Plugin package management (aligned with fullstack-cli):
- +plugin-install: download tgz, extract to node_modules, update package.json
- +plugin-uninstall: remove from node_modules and package.json actionPlugins
- +plugin-list: list declared plugins with installation status

Plugin instance CRUD (aligned with feida-ai):
- +plugin-instance-create: validate + write capability JSON with formValue validation
- +plugin-instance-update: merge mutable fields, re-validate formValue
- +plugin-instance-delete: idempotent file removal
- +plugin-instance-get: read capability JSON
- +plugin-instance-list: scan capabilities directory

Shared infrastructure (plugin_common.go):
- 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection)
- formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema
  type constraints, input ref existence, unconsumed params, array double-wrap auto-fix)
- tgz extraction with path traversal protection
- package.json actionPlugins management
- Install version check with mismatch warnings

* fix: close install gaps aligned with fullstack-cli

- latest version: re-check installed version after API resolves, skip
  download when already up to date
- actionPlugins sync: ensure package.json record is updated even when
  install is skipped (already_installed path)
- peerDependencies: warn about missing peer deps after extraction
  instead of silently ignoring them

* feat: add +plugin-instance-types command and auto-generate on create/update

Generate TypeScript interface definitions from plugin instance's paramsSchema
and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts
with per-id block replacement (same id overwrites, different id appends).

Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic:
- toPascalCase for type name prefixes (handles digit-prefixed segments)
- JSON Schema → TypeScript recursive conversion
- Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ----
- Auto-invoked after +plugin-instance-create and +plugin-instance-update
- Also available as standalone +plugin-instance-types --id <id>

* fix: hide +plugin-instance-types from agent (auto-invoked by create/update)

* feat: add plugin skill files for agent workflow guidance

- lark-apps-plugin.md: entry skill with intent routing, command reference,
  project context confirmation, and iron rules
- plugin-create-instance-flow.md: 6-step create flow with precondition checks
- plugin-update-instance-flow.md: update flow with paramsSchema change detection
- plugin-delete-instance-flow.md: delete flow with code reference scanning
- plugin-get-instance-flow.md: query routing for list/get/manifest reads
- plugin-instance-schema.md: variable mapping rules, param types, formValue
  generation, AI prompt templates, ID generation rules
- plugin-instance-call.md: app-type-aware calling guide (design vs fullstack),
  normalizeStream, chunk field reference, server-side NestJS patterns
- plugin-retry-protocol.md: validation failure retry protocol (max 3)
- SKILL.md: add plugin intent route with trigger keywords

* feat: add --local flag to +plugin-install for local tgz installation

Supports installing plugin packages from local .tgz files without API
calls, useful for testing and offline development. Reads plugin key and
version from the extracted package.json inside the tgz.

Also moved Scopes to ConditionalScopes so --local path skips auth.

* fix: improve error messages for plugin install and check

- pluginCheckInstalled: distinguish "directory not exist" (not installed)
  vs "directory exists but manifest.json missing" (not built correctly),
  with specific hints for each case
- pluginResolveVersion: detect non-JSON API response (typically HTML 404
  from unregistered endpoint) and give clear "API not available" message
  instead of misleading "check plugin key spelling"
- Hide --local flag from help (dev/test only, not for agents)

* refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance

- Merge plugin-instance-schema, create/update/delete/get flows, and
  retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry)
- Merge plugin-catalog into lark-apps-plugin.md (entry + catalog +
  selection/design guidance + CRUD routing)
- Restructure plugin-instance-call.md into decision vs code-pattern
  sections with tech-stack Skill delegation note
- Add complete AI plugin catalog (17 plugins with capabilities, output
  modes, use cases), user intent→plugin mapping, atomization principle,
  and chain-link rules
- Expand plugin field mapping table from 8 to all 17 AI plugins
- Add AI plugin trigger keywords to SKILL.md description for host agent
  skill matching
- Rename files to lark-apps-plugin-* prefix for consistency

* refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill

Remove all code pattern content (capabilityClient imports, normalizeStream,
NestJS injection, streaming examples, chunk field table) from
lark-apps-plugin-call.md. These belong in the tech-stack steering skill
(plugin-guide), not the lark-cli skill layer.

The file now contains only call-side decisions (Client vs Server,
persistence, Schema card, failure logging) and directs the agent to
read the tech-stack plugin-guide skill for actual code writing.

* fix: use absolute project-path for tech-stack skill location in plugin-call

Replace relative .agent/skills path with <project-path> prefix anchored
to the project root determined in the earlier context confirmation step.
Add fallback path and minimal call rules when skill file doesn't exist.

* fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill

* fix: require reading project plugin-guide skill before writing call code

* fix: improve plugin error hints for AI agent friendliness

- Version mismatch warning now includes the exact +plugin-install
  command to update
- Batch install (+plugin-install without --name) now re-installs
  when declared version differs from installed version
- Remove --local flag from user-facing error hints (internal-only)

* docs: add plugin package ≠ npm package distinction to skill docs

Add a comparison table and iron law #6 to prevent agents from confusing
+plugin-install with npm install, which was a recurring failure in
multi-model evaluation.

* fix: block plugin uninstall when instances still reference the package

Add pluginCheckDependentInstances to scan capabilities/ for instances
that reference the plugin being uninstalled. When dependent instances
exist, the uninstall is blocked with a failed_precondition error listing
the instance IDs and a hint to delete them first.

* fix: update plugin API paths to match new OpenAPI gateway routes

- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=

* fix: update plugin install to match final OpenAPI gateway protocol

- batch_query: URL /plugin/versions/batch_query, request uses plugin_keys
  array + latest_only boolean, response uses flat data.items list with
  plugin_key/plugin_version fields
- download: changed from GET+query to POST+JSON body {plugin_key, plugin_version},
  response is binary tgz stream (supportFileDownload)
- scope: spark:plugin:readonly → spark:app:read

* fix: align dry-run output with new batch_query + download_package request format

* fix: match actual API response field names (key/version instead of plugin_key/plugin_version)

* docs: strengthen plugin reference reading rules from advisory to mandatory

Change lark-apps-plugin.md from implicit to explicit required reading
for any plugin work. Replace soft '按需读' with bold '必读' for all three
plugin reference files. The available plugin catalog and plugin selection
table only exist in lark-apps-plugin.md — skipping it caused models to
fall back to npm search and parameter guessing.

* fix: remove call example annotation from types, add skill reference instead

* refactor: streamline plugin skill files

* refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考

- 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md),
  其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到
  应用仓库 .agents/skills/plugin-guide/SKILL.md
- 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致:
  plugin-install / plugin-uninstall / plugin-list /
  plugin-instance-create / update / delete / get / list
- 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力),
  意图路由引导先读仓库 Skill 再看 CLI 命令参考

* fix(plugin):simplify skill docs and resolve plugin version from actionPlugins

Remove redundant skill documentation (pre-check table, validation error
examples, JSON return samples, fullstack-cli references) that duplicate
CLI error hints.  Make --plugin version optional and resolve from
package.json actionPlugins.  Drop unused createdBy field.

* fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill

- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名

* fix(plugin): resolve real paths in dry-run output for instance commands

Replace <capabilities_dir> placeholders with resolved paths so models
can see actual file locations before execution. Add version_source,
types_output, and scan_dir fields to describe implicit behaviors.

* refactor(plugin): hide instance commands, delegate to repo Skill

Hide +plugin-instance-create/update/delete/get/list from CLI help.
Remove instance reference files from lark-apps skill. Route instance
CRUD and call code generation to project repo plugin-guide skill.

Go instance code preserved, just hidden.

* refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件

- 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件)
- 删除 plugin_instance_types(TypeScript 类型生成命令)
- 移除 shortcuts.go 中的 6 个注册项
- 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行):
  校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等
- 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变

插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json,
验证规则写在 Skill 中由 agent 自校验。

* refactor(plugin): remove --project-path flag and split --name into --name + --version

- Remove --project-path from plugin-install/list/uninstall (use cwd like npm)
- Split --name key@version into separate --name and --version flags
- Remove pluginParseInstallTarget (no longer needed)
- Improve DryRun desc and error hints for --version usage
- Update skill docs to reflect new flag structure
- Tests use chdirTest helper instead of --project-path

* feat(plugin): add Examples to --help for plugin-install/list/uninstall

按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。

* fix(plugin): address PR #1609 review findings

- Fix hint referencing non-existent +plugin-instance-delete command,
  point to repo plugin-guide Skill instead
- Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir
  to env-only resolution, fix ambiguous hint to suggest env vars
- Reclassify download errors from file_io to network/api with proper
  hints and retryable marking
- Slim SKILL.md routing row, move judgment rules to plugin-install reference
- Rename --local flag to --file to align with CLI conventions

* fix(skill): restore plugin routing row with judgment rules, fix markdown formatting

Revert SKILL.md routing row to keep full judgment rules and repo Skill
directive inline. Fix bold marker spacing and restore missing table column.
Revert reference to original content without duplicated rules.

* fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test

Restore SKILL.md plugin routing row to original version with full
judgment rules and repo Skill directive. Update shortcut count test
from 60 to 63 to account for 3 new plugin commands.

* fix(plugin):fix lark-apps skill docs which is about plugin

* fix(plugin):correct plugin skill md

* fix(plugin):correct plugin md

* fix(plugin):correct plugin and local dev skills md

* fix(plugin):correct apps plugin skills md

* fix(lark-apps): move repo skill reading hint to post-init phase

将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。

* fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide

- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>

* feat(apps): add release polling interval time and release time costs

* fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655)

- Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix
  for consistency with other files in the package
- Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss

* style: gofmt apps plugin files (#1664)

* fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667)

- Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall
  shortcuts to satisfy TestAllShortcutsScopesNotNil
- Remove unused pluginCheckInstalled function (deadcode)
- Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns
- Fix forbidigo: replace bare fmt.Errorf in Execute with typed error,
  add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ
- Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf
- Remove all unused //nolint:forbidigo directives from test files

* style: gofmt apps_plugin list/uninstall/install_test files

Fix fast-gate Check formatting failure: align struct literal fields in
apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body
statement onto its own line in apps_plugin_install_test.go.

* fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668)

- Change nolint comment separator from -- to // to satisfy nolintlint
- Move nilerr nolint directive to return statement to suppress nilerr correctly
- Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ

* fix(apps): validate openapi-key scope method, path and raw JSON (#1675)

Enforce an HTTP method whitelist (GET/POST/PUT/PATCH/DELETE), reject
malformed --scope-api paths (must start with '/', no '..' or '//'), and
constrain raw --scope JSON to the documented request_scope schema
(allow_all + http_infos only). Validation runs in both the Validate hook
and the body-build path so dry-run and execute are equally gated.

Fixes PR #1596 audit findings HIGH-2 and MEDIUM-4.

* fix(apps): harden db/file shortcuts per security audit (PR #1596)

Address the file/db findings from the PR #1596 security audit with
safer header/flag/path handling:

- HIGH-3 (--output path traversal): add rejectOutputTraversal() and wire
  it into +file-download and +db-data-export Validate; reject absolute
  paths and any .. component up front. (FileIO.Save already sandboxes to
  cwd via SafeOutputPath; this is an earlier, explicit guard.)
- HIGH-4 (Content-Disposition header injection): build the header with
  mime.FormatMediaType instead of manual string concatenation.
- MEDIUM-3 (SQL leaked into public flag): stop writing --file contents
  back into the --sql flag; resolveExecuteSQL() reads it at use-site so
  SQL never lands in flag dumps / structured logs.
- LOW-1 (hidden-file upload name): prefix sanitized upload names that
  start with '.' with '_'.
- LOW-2 (local-timezone time parsing): document local-tz interpretation
  of bare date/datetime in flag descriptions and the db/file skill docs.

SQL-injection of --table (audit MEDIUM-5) is intentionally NOT validated
in the CLI: the server-side interface is the authoritative guard.

Add apps_security_fixes_test.go covering the new validators and switch
the upload test to parse Content-Disposition instead of matching a
literal string. Update lark-apps-db.md / lark-apps-file.md skill refs.

* fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse (#1677)

Security fixes from PR #1596 security audit:
- Skip symlink/hardlink entries during tgz extraction (Zip Slip)
- Limit tgz entry and download size to 10 MB (OOM/DoS)
- Limit error response body read to 4 KB
- Validate MIAODA_APP_TYPE as numeric to prevent path manipulation
- Add validatePluginKey + secureModulePath to block --name path
  traversal (../../.ssh etc.) for install/uninstall

Usability fix:
- Add explicit 'local command, no --app-id' notice in plugin
  reference docs to prevent agent from incorrectly passing
  --app-id to plugin commands (which read package.json locally)

* fix(apps): cap db async poll timeout at 2 minutes

+db-recovery-apply blocked up to 30min and +db-env-migrate /
+db-recovery-diff up to 10min while polling the server for async-task
completion. These operations are expected to finish within ~1 minute;
the long ceilings mostly hurt agents, whose harness kills the command on
timeout while the server-side operation keeps running with no handle to
re-query — especially risky for the irreversible recovery-apply.

Cap all three pollUntil ceilings at 2 minutes (polling interval
unchanged). Stuck operations now surface the retryable network/timeout
envelope after 2min instead of hanging for 10-30min.

* fix(plugin): create temp dir in project path to avoid cross-filesystem EXDEV on Rename (#1683)

pluginInstallLocal used os.MkdirTemp("") which creates the temp
directory on the system temp partition. On Windows (and some
Linux/macOS setups), the temp partition is on a different filesystem
from the project directory, causing os.Rename to fail with EXDEV.

Use projectPath as the temp dir parent so it is always on the same
filesystem as node_modules.

* fix(plugin): improve --help Tips with local-command hint and update semantics (#1691)

- Add "Run in project root; does NOT take --app-id" to all plugin Tips
- Clarify install command also supports update (install or update to latest/specific version)
- Clarify batch install reads from package.json actionPlugins

---------

Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com>
Co-authored-by: raistlin042 <lvxinsheng@bytedance.com>
Co-authored-by: anngo-nk <anguohui@bytedance.com>
Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-06-30 21:11:27 +08:00
linchao5102
5c4ad52741 fix: harden git credential error handling (#1676) 2026-06-30 19:57:04 +08:00
wangweiming-01
3fcb695698 docs: guide document copy skill usage (#1673) 2026-06-30 16:47:06 +08:00
mew
fb042758db feat: add whoami command showing effective identity (#1666) 2026-06-30 15:56:56 +08:00
SunPeiYang996
22108c3300 feat(docs): add reference map flags (#1547) 2026-06-30 12:07:18 +08:00
liuxin-0319
31744f8cf9 docs: fix lark-doc media token examples (#1662) 2026-06-30 11:47:14 +08:00
liangshuo-1
1dd0758091 chore: release v1.0.60 (#1657) 2026-06-29 22:34:36 +08:00
yballul-bytedance
4a5a669b1a fix(auth): remove 'claude settings' (#1654) 2026-06-29 21:58:22 +08:00
liangshuo-1
ebb0b6fe73 feat(affordance): per-command usage guidance system (markdown source) (#1565) 2026-06-29 19:33:27 +08:00
liangshuo-1
5c0a36b2a6 feat(transport): add LARK_CLI_NO_PROXY_WARN to silence proxy warning (#1647) 2026-06-29 19:31:48 +08:00
mazhe-nerd
21905b0ba1 fix(install): load @clack/prompts via dynamic import to avoid ERR_REQUIRE_ESM (#1636) (#1652) 2026-06-29 19:16:37 +08:00
yballul-bytedance
602c788fd9 feat(authorization): expand lark-shared auth guidance and assert clean logout JSON (#1598)
- skills/lark-shared/SKILL.md: broaden skill description to cover auth login/status/logout, --domain business-domain scopes, missing scopes and authorization revocation; add an auth task quick-reference table mapping user intents to lark-cli commands; document LARKSUITE_CLI_NO_UPDATE_NOTIFIER / LARKSUITE_CLI_NO_SKILLS_NOTIFIER env vars for stable JSON; soften _notice.update handling so it no longer interrupts the current task.
- cmd/auth/logout_test.go: in TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly, additionally assert that the success JSON payload has no 'message' field, matching the contract that logout success only carries loggedOut=true.
2026-06-29 16:28:57 +08:00
HanShaoshuai-k
30b28cf17f fix: reduce public content false positives 2026-06-29 14:02:43 +08:00
calendar-assistant
297776ea66 feat(event): support VC meeting lifecycle events (#1632) 2026-06-29 11:11:23 +08:00
Max Coplan
5b0c3137e3 test(doc): derive fetch test flag defaults from v2FetchFlags (#1428)
Replace hardcoded flag defaults in the fetch test helpers with
fetchDefault() / fetchDefaultInt() helpers that read the declared
defaults from v2FetchFlags(). This prevents future drift between
production flag defaults and test setup, and panics loudly if a
flag name is misspelled rather than silently returning "".

The tests now correctly avoid hardcoding doc-format, but other
flag defaults (detail, revision-id, scope, etc.) were still
duplicated here. Deriving all defaults from v2FetchFlags() keeps
the whole test command definition aligned with production.

Co-authored-by: TraeCli (Doubao-Seed-Dogfooding) <trae@bytedance.com>
Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-06-29 11:09:51 +08:00
xiongyuanwen-byted
4c31323de1 feat(sheets): use office_sheet_file parent_type for imported office spreadsheets (#1606)
Image uploads to a spreadsheet hard-coded parent_type=sheet_image at every
entry point. Imported "office" spreadsheets carry a token prefixed with
"fake_office_", for which the drive backend requires
parent_type=office_sheet_file. Funnel the parent_type selection through a
single sheets-domain helper so the rule lives in one place and every
image-upload path (float-image, +cells-set-image, backward +media-upload,
and every dry-run preview) stays consistent.

- Add sheetMediaParentType(token) in the sheets domain: returns
  office_sheet_file for fake_office_-prefixed tokens, otherwise sheet_image.
- Add an uploadSheetImage(...) collector that builds the
  DriveMediaUploadAllConfig (including parent_type) once, replacing the
  per-call-site hand-rolled configs.
- Route both main-domain image entries through the collector — float-image
  local upload and +cells-set-image — covering Execute and the dry-run
  preview body/desc.
- Cover the backward +media-upload entry: single-part, multipart (>20MB),
  and both dry-run bodies. backward is a separate package and an
  intentional verbatim mirror of shortcuts/sheets/, so it keeps its own
  copy of the helper rather than importing the main domain.
- Leave the shared common.UploadDriveMediaAllTyped upload layer untouched
  — the fake_office_ rule is sheets-specific and must not leak into
  mail/slides/doc/drive/base.

Tests:
- Pure-function TestSheetMediaParentType (5 cases incl. prefix-only and
  mid-string non-match).
- Main-domain dry-run TestCellsSetImage_DryRunOfficeParentType and
  TestUploadSheetImage_ParentType / _FileOpenError that exercise the
  Execute path on the wire, asserting parent_type via the captured
  multipart body and typed validation metadata (errs.ProblemOf
  category/subtype, fs.ErrNotExist cause preserved) on file open errors.
  decodeSheetMediaMultipartBody fails fast on NextPart / ReadFrom errors
  rather than silently producing a partial body.
- backward TestSheetMediaUploadExecuteOfficeParentType (real multipart
  wire) and TestSheetMediaUploadDryRunSmallFileOfficeParentType
  (small-file dry-run preview for fake_office_).
- cli_e2e tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go: --dry-run
  end-to-end across +media-upload and +cells-set-image, native and
  fake_office_ tokens, asserting api.0 is POST upload_all with
  parent_type=sheet_image / office_sheet_file and parent_node = token.
2026-06-27 16:16:56 +08:00
495 changed files with 64547 additions and 76871 deletions

View File

@@ -2,6 +2,62 @@
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
@@ -1277,6 +1333,9 @@ 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

49
affordance/README.md Normal file
View File

@@ -0,0 +1,49 @@
# 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.

19
affordance/contact.md Normal file
View File

@@ -0,0 +1,19 @@
# 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,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
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),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]

View File

@@ -22,6 +22,11 @@ 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",
@@ -38,7 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLoginWithContext(ctx, f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))

View File

@@ -42,6 +42,11 @@ 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{
@@ -73,7 +78,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 f != nil && f.Config != nil {
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}

View File

@@ -19,6 +19,7 @@ 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"
@@ -89,8 +90,9 @@ func WithoutPlugins() BuildOption {
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
// 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.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
@@ -145,6 +147,9 @@ 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
@@ -170,6 +175,10 @@ 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
@@ -187,9 +196,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(auth.NewCmdAuthWithContext(ctx, 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))
@@ -205,10 +215,17 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd)
groupRootCommands(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
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 cfg.skipPlugins {

View File

@@ -129,7 +129,10 @@ 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 {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
// 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", ""))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,14 +4,19 @@
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) {
@@ -140,14 +145,84 @@ 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 {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
return check
}
}
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,10 +10,22 @@ 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"})
@@ -27,6 +39,8 @@ 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)
@@ -57,9 +71,15 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
var foundTask bool
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
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" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
@@ -69,4 +89,12 @@ 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,6 +124,45 @@ 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,9 +11,11 @@ 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"
@@ -28,43 +30,60 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
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).
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
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`
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
// 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}}
# Search users
lark-cli contact +search-user --query "John"
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
Install all skills:
npx skills add larksuite/cli -g -y
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -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}}
Learn more: https://github.com/larksuite/cli#agent-skills
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
More help: lark-cli <command> --help`
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}}
`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -84,10 +103,16 @@ func Execute() int {
configureFlagCompletions(os.Args)
ctx := context.Background()
f, rootCmd, reg := buildInternal(
ctx, inv,
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...,
)
// --- Notices (non-blocking) ---
@@ -111,6 +136,30 @@ 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
@@ -529,6 +578,49 @@ 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
@@ -610,6 +702,17 @@ 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,9 +5,12 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
@@ -26,6 +29,27 @@ 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.
@@ -75,12 +99,71 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
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)
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},
}
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)
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(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)
}
}

90
cmd/root_upgrade.go Normal file
View File

@@ -0,0 +1,90 @@
// 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)
}
}

191
cmd/root_upgrade_test.go Normal file
View File

@@ -0,0 +1,191 @@
// 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,41 +4,211 @@
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"
)
// 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 {
// 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 {
var b strings.Builder
b.WriteString(description)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// 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.
// 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.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
@@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string {
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
var s strings.Builder
fmt.Fprintf(&s, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
fmt.Fprintf(&s, " • %s\n", it)
}
sections = append(sections, strings.TrimRight(s.String(), "\n"))
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Avoid when", a.AvoidWhen)
bullets("Prerequisites", a.Prerequisites)
bullets("Tips", a.Tips)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
@@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string {
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
}
}
for _, ext := range a.Extensions {
bullets(ext.Label, ext.Items)
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
return strings.Join(sections, "\n\n")
}

View File

@@ -8,15 +8,18 @@ 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": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"avoid_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"tips": ["富文本用 msg_type=post"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
@@ -29,6 +32,7 @@ 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",
@@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) {
}
}
func TestServiceMethod_AffordanceInLong(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) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
@@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) {
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
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)
if strings.Contains(cmd.Long, "Examples:") {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.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)
// 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)
}
}

View File

@@ -60,8 +60,11 @@ 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)
}
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\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, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)

View File

@@ -30,6 +30,11 @@ 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))
}
@@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string {
return facts
}
// 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.
// 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).
func paramFlagUsage(f meta.Field) string {
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
return strings.Join(fieldFacts(f), ". ")
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
@@ -103,8 +103,23 @@ 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"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// 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
}
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -64,15 +65,38 @@ 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).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
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 {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
}
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 {
@@ -177,7 +201,19 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
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
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -186,6 +222,7 @@ 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(),
@@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
paginates: methodPaginates(m),
}
}
@@ -254,6 +291,14 @@ 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")
@@ -271,10 +316,11 @@ 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)
// 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())
// 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)
// 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
@@ -292,13 +338,11 @@ 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})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
// 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.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {

169
cmd/whoami/whoami.go Normal file
View File

@@ -0,0 +1,169 @@
// 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
}

320
cmd/whoami/whoami_test.go Normal file
View File

@@ -0,0 +1,320 @@
// 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)
}
}

41
content_embed.go Normal file
View File

@@ -0,0 +1,41 @@
// 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

@@ -0,0 +1,259 @@
# `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.

2426
docs/vendor/anygen-svg/source.full.md vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
{
"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

@@ -0,0 +1,20 @@
# 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/app/cli_xxx/auth")
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
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/app/cli_xxx/auth")
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
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/app/cli_xxx/auth",
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"missing_scopes": []any{"calendar:event:create"},
}
for k, want := range wantFields {

View File

@@ -0,0 +1,62 @@
// 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

@@ -0,0 +1,281 @@
// 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

@@ -0,0 +1,61 @@
// 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,6 +11,8 @@ 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"
@@ -30,6 +32,38 @@ 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

@@ -0,0 +1,96 @@
// 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

@@ -0,0 +1,86 @@
// 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

@@ -0,0 +1,180 @@
// 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,6 +48,22 @@ 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 {
@@ -109,6 +125,9 @@ 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
}
@@ -148,6 +167,9 @@ 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,6 +18,9 @@ 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).
@@ -27,19 +30,24 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
// 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.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
}
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

@@ -0,0 +1,31 @@
// 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,12 +10,14 @@ 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).
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
// 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.
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
return out
}
// 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.
// 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.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
// 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)
// 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 fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
return base
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
return base + "&scopes=" + 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/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
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)
}
}
@@ -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/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
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)
}
}
@@ -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{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"scopes=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"scopes=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"clientID=good?q=injected"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"clientID=good#fragment"},
},
{
name: "slash in appID escapes path segment",
name: "slash in appID does not open a new path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
},
}
@@ -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, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
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)
}
}
@@ -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/app/cli_x/auth?q=contact%3Acontact"
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=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,8 +10,20 @@ 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{
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"
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
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -27,6 +27,13 @@ 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,6 +102,35 @@ 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,6 +13,7 @@ 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"
@@ -61,12 +62,131 @@ 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,9 +10,11 @@ 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"
)
@@ -348,3 +350,136 @@ 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,30 +5,39 @@ package meta
import "encoding/json"
// 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.
// 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.
type Affordance struct {
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"`
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"`
}
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
type AffordanceCase struct {
Description string `json:"description"`
Description string `json:"description,omitempty"`
Command string `json:"command"`
}
// 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.
// 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".
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
@@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
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 {
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":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
@@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) {
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"do_not_use_when": ["已知具体 calendar_id"],
"avoid_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
@@ -47,10 +48,22 @@ 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

@@ -0,0 +1,80 @@
// 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

@@ -0,0 +1,54 @@
// 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,6 +52,9 @@ func isPlaceholderValue(value string) bool {
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
printfPlaceholderValue(normalized) ||
htmlEntityAnglePlaceholder(normalized) ||
starMaskedPlaceholder(normalized) ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
@@ -61,12 +64,42 @@ 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":
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
return true
}
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
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
}
}
func allXPlaceholder(value string) bool {
@@ -81,6 +114,41 @@ 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,7 +4,10 @@
package publiccontent
import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -52,8 +55,9 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
@@ -63,21 +67,27 @@ 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 isSchemaDottedIdentifier(line, match) {
if !isJWTToken(match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for range bearerHeaderRE.FindAllString(line, -1) {
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
if isPlaceholderBearerHeader(match) {
continue
}
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
if isPlaceholderCredentialURL(file, 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) {
@@ -124,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -261,7 +274,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
return tokenLikePlaceholderValue(key, value)
default:
return false
}
@@ -273,12 +286,16 @@ func tokenLikePlaceholderKey(key string) bool {
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
func tokenLikePlaceholderValue(key, 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, "...") ||
@@ -288,6 +305,149 @@ func tokenLikePlaceholderValue(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)
}
@@ -328,20 +488,87 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
if !sourceCodeFile(file) || 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", ".py":
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
return true
default:
return false
@@ -355,7 +582,147 @@ 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
}
@@ -364,7 +731,10 @@ func codeReferenceExpression(value string) bool {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
if !codeIdentifier(value) {
return false
}
return codeIdentifier(value)
}
func codeIdentifier(value string) bool {
@@ -381,20 +751,6 @@ 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", "{", "[":
@@ -404,6 +760,40 @@ 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")
}
@@ -562,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
func isPlaceholderCredentialURL(file, raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -571,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -585,6 +976,46 @@ 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 {
@@ -741,7 +1172,12 @@ 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.ReplaceAllString(text, "<jwt-like-token>")
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
if isJWTToken(match) {
return "<jwt-like-token>"
}
return match
})
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}

View File

@@ -61,6 +61,19 @@ 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 {
@@ -211,7 +224,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
}
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
got := ScanFile("testdata/publiccontent/.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)
}
@@ -549,7 +562,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
}
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
"inPrivateKey = true",
"inPrivateKey = false",
"hasPrivateKey: false",
@@ -632,6 +645,45 @@ 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 {
@@ -648,6 +700,7 @@ 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 {
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
}
}
@@ -724,8 +777,70 @@ 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("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
got := ScanFile("fixtures/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)
@@ -734,7 +849,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
got := ScanFile("fixtures/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)
@@ -743,7 +858,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
}
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
"AppSecret: stored,",
"AccessToken: result.Token.AccessToken,",
`token := runtime.Str("token")`,
@@ -756,7 +871,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
}
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
got := ScanFile("fixtures/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",
@@ -770,8 +885,174 @@ 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("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
`<img token="..." url="https://..." width="..." height="..."/>`,
`<sheet token="..." sheet-id="...">`,
}, "\n")+"\n"))
@@ -783,7 +1064,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
}
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
got := ScanFile("fixtures/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)
@@ -791,8 +1072,38 @@ 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("skills/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"1704067200"}`,
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
}, "\n")+"\n"))
@@ -805,7 +1116,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
@@ -821,7 +1132,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
}
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "block_token": "boardXXXX" }`,
`{ "resource_token": "doc_token_or_url" }`,
`{ "token": "canonical_token" }`,
@@ -841,7 +1152,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
@@ -856,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
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)
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)
}
}
}
@@ -958,6 +1271,19 @@ 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" +
@@ -975,6 +1301,22 @@ 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",
@@ -1012,6 +1354,37 @@ 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,13 +59,9 @@ 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(
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
"%s/page/scope-apply?clientID=%s&scopes=%s",
core.ResolveOpenBaseURL(brand),
url.QueryEscape(appID),
url.QueryEscape(scope),
)

View File

@@ -4,8 +4,11 @@
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"
@@ -22,7 +25,7 @@ func Convert(f meta.Field) Property {
if f.Type == "file" {
p.Format = "binary"
}
p.Description = f.Description
p.Description = normalizeDesc(f.Description)
p.Default = f.CoercedDefault()
p.Example = f.CoercedExample()
p.Minimum = f.MinBound()
@@ -52,6 +55,24 @@ 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
@@ -86,6 +107,18 @@ 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
@@ -108,16 +141,17 @@ func buildInputSchema(m meta.Method) *InputSchema {
Properties: &OrderedProps{},
}
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())
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")
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. Not sent to the backend.",
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.",
})
}
@@ -125,20 +159,24 @@ func buildInputSchema(m meta.Method) *InputSchema {
return is
}
// 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) {
// 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) {
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: propsOf(fields),
Properties: props,
})
if len(req) > 0 {
is.Required = append(is.Required, name)
@@ -179,7 +217,13 @@ 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 {
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
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)
}
// Envelopes renders the given method refs into envelopes, sorted by name. The
@@ -205,7 +249,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
return Envelope{
Name: name,
Description: m.Description,
Description: normalizeDesc(m.Description),
InputSchema: buildInputSchema(m),
OutputSchema: buildOutputSchema(m),
Meta: buildMeta(m),

View File

@@ -9,7 +9,9 @@ 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"
@@ -504,6 +506,31 @@ 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,6 +13,10 @@ 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"`
@@ -44,9 +48,15 @@ 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"`
Enum []interface{} `json:"enum,omitempty"`
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"`
// 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

@@ -0,0 +1,348 @@
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)
}
mustWriteTestFile(t, "demo/contact-sheet.png", "png")
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/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/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/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","source_refs":["web1"],"visuals":[{"id":"revenue_mix","type":"chart","instruction":"Compare segment revenue"}]}]}`)
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" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<slide:note>Source: web1</slide:note><rect width="960" height="540" fill="#fff"/><text x="48" y="72">Data center leads</text><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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 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":""}`)
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":"电影的核心吸引力","source_refs":["user1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a cinematic hero image"}]}]}`)
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":""}]}`)
mustWriteTestFile(t, "demo/assets/images/movie-hero.png", "png")
case StageSVGAuthor:
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 960 540">`+fontTokenStyleForTest()+`<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"/><text x="48" y="88">电影介绍</text><text x="48" y="150">电影的核心吸引力</text></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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,46 @@
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)
}
})
}
}

169
internal/svglide/assets.go Normal file
View File

@@ -0,0 +1,169 @@
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 {
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)
}
return file, nil
}
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"
}

655
internal/svglide/author.go Normal file
View File

@@ -0,0 +1,655 @@
package svglide
import (
"encoding/json"
"fmt"
"html"
"path/filepath"
"strings"
)
const (
defaultSlideWidth = 960
defaultSlideHeight = 540
defaultAuthorBgColor = "#FFFFFF"
defaultAuthorInkColor = "#111827"
defaultAuthorMuteColor = "#6B7280"
defaultAuthorAccent = "#2563EB"
svgAuthorReceipt = "receipts/svg_author.json"
)
type AuthorReport struct {
Status string `json:"status"`
Slides []string `json:"slides"`
}
type authorDeck struct {
Title string `json:"title"`
Slides []authorDeckSlide `json:"slides"`
}
type authorDeckSlide struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Role string `json:"role"`
VisualRole string `json:"visual_role"`
VisualIntent string `json:"visual_intent"`
KeyMessage string `json:"key_message"`
Path string `json:"path"`
LayoutFamily string `json:"layout_family"`
LayoutArchetype string `json:"layout_archetype"`
LayoutSignature string `json:"layout_signature"`
StoryFunction string `json:"story_function"`
PrimaryAssetRole string `json:"primary_asset_role"`
FusionCandidate bool `json:"fusion_candidate"`
}
type authorSlideContentFile struct {
Slides []authorSlideContent `json:"slides"`
}
type authorSlideContent struct {
ID string `json:"id"`
Content string `json:"content"`
Notes string `json:"notes"`
SourceRefs []string `json:"source_refs"`
Visuals []authorSlideVisual `json:"visuals"`
}
type authorSlideVisual struct {
ID string `json:"id"`
Type string `json:"type"`
Instruction string `json:"instruction"`
}
type authorAsset = deckAsset
type authorVisualSystem struct {
ColorSystem struct {
Background string `json:"background"`
Ink string `json:"ink"`
Muted string `json:"muted"`
Accent string `json:"accent"`
} `json:"color_system"`
Typography struct {
Title int `json:"title"`
Body int `json:"body"`
} `json:"typography"`
LayoutLanguage string `json:"layout_language"`
}
type authorTheme struct {
Background string
Ink string
Muted string
Accent string
TitleSize int
BodySize int
}
type authorSlideTarget struct {
Slide authorDeckSlide
Content authorSlideContent
Assets []authorAsset
Path string
Target string
Page int
}
func AuthorSlides(root string) (AuthorReport, error) {
return authorSlides(root, nil)
}
func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return AuthorReport{}, err
}
deck, err := readAuthorDeck(safeRoot, strings.TrimSpace(run.Artifacts.Deck))
if err != nil {
return AuthorReport{}, err
}
contentByID, err := readAuthorContent(safeRoot, "content/slide_content.json")
if err != nil {
return AuthorReport{}, err
}
theme, err := readAuthorTheme(safeRoot, "brief/visual_system.json")
if err != nil {
return AuthorReport{}, err
}
assetsBySlideID, err := readAuthorAssets(safeRoot, assetsManifestPath)
if err != nil {
return AuthorReport{}, err
}
if err := validateAuthorDeckContent(deck, contentByID); err != nil {
return AuthorReport{}, err
}
targets := make([]authorSlideTarget, 0, len(deck.Slides))
report := AuthorReport{
Status: StatusDone,
Slides: make([]string, 0, len(deck.Slides)),
}
for i, slide := range deck.Slides {
slidePath, err := previewSlideObjectPath(slide.Path)
if err != nil {
return AuthorReport{}, err
}
if selectedPaths != nil && !selectedPaths[slidePath] {
continue
}
target, err := ensureRunFileTargetForWrite(safeRoot, slidePath)
if err != nil {
return AuthorReport{}, err
}
targets = append(targets, authorSlideTarget{
Slide: slide,
Content: contentByID[strings.TrimSpace(slide.ID)],
Assets: selectAuthorRenderableImageAssets(safeRoot, contentByID[strings.TrimSpace(slide.ID)], assetsBySlideID[strings.TrimSpace(slide.ID)]),
Path: slidePath,
Target: target,
Page: i + 1,
})
report.Slides = append(report.Slides, slidePath)
}
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, svgAuthorReceipt)
if err != nil {
return AuthorReport{}, err
}
for _, target := range targets {
svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, target.Assets, theme, target.Page, len(deck.Slides))
if err := writeText(target.Target, svg); err != nil {
return AuthorReport{}, err
}
}
if err := writeAuthorVisualReceipts(safeRoot, deck, contentByID, assetsBySlideID, selectedPaths); err != nil {
return AuthorReport{}, err
}
if err := writeJSON(receiptTarget, StageReceipt{
Stage: StageSVGAuthor,
Status: StatusDone,
Artifacts: report.Slides,
}); err != nil {
return AuthorReport{}, err
}
return report, nil
}
func writeAuthorVisualReceipts(safeRoot string, deck authorDeck, contentByID map[string]authorSlideContent, assetsBySlideID map[string][]authorAsset, selectedPaths map[string]bool) error {
existingByID := map[string]visualReceipt{}
if existing, err := readVisualReceipts(safeRoot); err == nil {
for _, receipt := range existing.Slides {
id := strings.TrimSpace(receipt.SlideID)
if id != "" {
existingByID[id] = receipt
}
}
}
out := visualReceiptsFile{Slides: make([]visualReceipt, 0, len(deck.Slides))}
for i, slide := range deck.Slides {
id := strings.TrimSpace(slide.ID)
receipt, ok := existingByID[id]
slidePath, pathErr := previewSlideObjectPath(slide.Path)
shouldRefresh := !ok || selectedPaths == nil || (pathErr == nil && selectedPaths[slidePath])
if shouldRefresh {
receipt = authorVisualReceiptForSlide(slide, contentByID[id], assetsBySlideID[id], i)
}
out.Slides = append(out.Slides, receipt)
}
target, err := ensureRunFileTargetForWrite(safeRoot, visualReceiptsPath)
if err != nil {
return err
}
return writeJSON(target, out)
}
func authorVisualReceiptForSlide(slide authorDeckSlide, content authorSlideContent, assets []authorAsset, index int) visualReceipt {
layoutFamily := firstNonEmpty(slide.LayoutFamily, inferAuthorLayoutFamily(slide, content, assets))
layoutSignature := firstNonEmpty(slide.LayoutSignature, inferAuthorLayoutSignature(layoutFamily, assets, index))
layoutArchetype := firstNonEmpty(slide.LayoutArchetype, inferAuthorLayoutArchetype(layoutFamily, layoutSignature))
primaryAsset, assetRole := authorPrimaryAssetEvidence(slide, assets)
pageDifference := "opening page"
if index > 0 {
pageDifference = "different content block and slide order from previous page"
}
dataRationale := ""
chartReceipt := visualChartReceipt{Renderer: "none"}
for _, visual := range content.Visuals {
if strings.TrimSpace(visual.Type) == "chart" {
dataRationale = firstNonEmpty(visual.Instruction, "chart visual requested by slide content")
chartReceipt = visualChartReceipt{
ChartID: strings.TrimSpace(visual.ID),
Renderer: "none",
WhyChartIsNeeded: dataRationale,
}
break
}
}
fontRoles := map[string]string{
"display": "Noto Serif CJK SC",
"body": "Noto Sans CJK SC",
"number": "Roboto Mono",
"label": "PingFang SC",
}
return visualReceipt{
SlideID: strings.TrimSpace(slide.ID),
StoryJob: firstNonEmpty(slide.StoryFunction, slide.Role, "proof"),
LayoutFamily: layoutFamily,
LayoutArchetype: layoutArchetype,
LayoutSignature: layoutSignature,
ThumbnailJob: firstNonEmpty(slide.Title, slide.KeyMessage),
VisualCenter: firstNonEmpty(primaryAsset, slide.Title, slide.KeyMessage),
TopicFitClaim: firstNonEmpty(slide.KeyMessage, slide.Summary, content.Content),
InformationDensityPlan: "one clear claim with supporting visual or concise text",
PageDifferenceFromPrevious: pageDifference,
PrimaryAsset: primaryAsset,
AssetRole: assetRole,
FontRoleUsage: fontRoles,
TypographyRoleUsage: fontRoles,
CompositionIntent: "local author fallback layout with topic-specific text and available assets",
DataVisualRationale: dataRationale,
SourceEvidence: append([]string{}, content.SourceRefs...),
ContainerFitPlan: "use open grid by default; use cards only for explicit grouping or complex image backgrounds",
ContainerDecision: "content decides carrier; no default card wrapper",
TextCarrier: "open_grid",
ShapeLanguage: "minimal",
CardBudget: visualCardBudget{
CardCount: 0,
WhyCardsAreNeeded: "none: default text carrier is open layout",
},
ChartReceipt: chartReceipt,
FusionSpec: visualFusionReceipt{Enabled: false},
QAExpectations: []string{"no process text", "font roles present", "layout is readable"},
}
}
func inferAuthorLayoutFamily(slide authorDeckSlide, content authorSlideContent, assets []authorAsset) string {
if len(assets) > 0 && isCoverSlide(slide) {
return "character_product_focus"
}
for _, visual := range content.Visuals {
switch strings.TrimSpace(visual.Type) {
case "chart", "table":
return "data_scoreboard"
}
}
if len(assets) > 1 {
return "evidence_board"
}
return "quiet_synthesis"
}
func inferAuthorLayoutSignature(layoutFamily string, assets []authorAsset, index int) string {
switch layoutFamily {
case "character_product_focus":
return "image_claim"
case "data_scoreboard":
return "data_panel"
case "evidence_board":
return "evidence_collage"
default:
if index%2 == 1 {
return "text_evidence_panel"
}
return "single_claim_poster"
}
}
func inferAuthorLayoutArchetype(layoutFamily string, layoutSignature string) string {
signature := strings.ToLower(strings.TrimSpace(layoutSignature))
switch {
case strings.Contains(signature, "waterfall") || strings.Contains(signature, "bridge"):
return "waterfall_bridge"
case strings.Contains(signature, "bubble") || strings.Contains(signature, "peer"):
return "peer_bubble_field"
case strings.Contains(signature, "ledger") || strings.Contains(signature, "statement"):
return "statement_ledger"
case strings.Contains(signature, "timeline") || strings.Contains(signature, "route"):
return "timeline_path"
case strings.Contains(signature, "risk") || strings.Contains(signature, "radar"):
return "risk_radar"
case strings.Contains(signature, "split") || strings.Contains(signature, "left_text_right_chart"):
return "image_argument_split"
case strings.Contains(signature, "evidence") || strings.Contains(signature, "collage"):
return "evidence_collage"
case strings.Contains(signature, "poster"):
return "poster_stat_lockup"
}
switch strings.TrimSpace(layoutFamily) {
case "full_bleed_hero":
return "full_bleed_photo_title"
case "data_scoreboard":
return "data_scoreboard"
case "evidence_board":
return "evidence_collage"
case "timeline_route":
return "timeline_path"
case "character_product_focus":
return "annotated_image"
case "image_text_fusion_split":
return "image_argument_split"
default:
return "poster_stat_lockup"
}
}
func authorPrimaryAssetEvidence(slide authorDeckSlide, assets []authorAsset) (string, string) {
for _, asset := range assets {
if path := assetPath(asset); path != "" {
return path, firstNonEmpty(slide.PrimaryAssetRole, asset.Usage, "topic anchor")
}
}
return "", firstNonEmpty(slide.PrimaryAssetRole, "none")
}
func readAuthorDeck(safeRoot string, deckPath string) (authorDeck, error) {
if deckPath == "" {
return authorDeck{}, fmt.Errorf("deck artifact path is empty")
}
raw, err := readRunRegularArtifact(safeRoot, deckPath)
if err != nil {
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
}
var deck authorDeck
if err := json.Unmarshal(raw, &deck); err != nil {
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
}
if len(deck.Slides) == 0 {
return authorDeck{}, fmt.Errorf("deck %q contains no slides", deckPath)
}
return deck, nil
}
func readAuthorContent(safeRoot string, path string) (map[string]authorSlideContent, error) {
raw, err := readRunRegularArtifact(safeRoot, path)
if err != nil {
return nil, fmt.Errorf("read slide content %q: %w", path, err)
}
var file authorSlideContentFile
if err := json.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("read slide content %q: %w", path, err)
}
byID := make(map[string]authorSlideContent, len(file.Slides))
for _, slide := range file.Slides {
id := strings.TrimSpace(slide.ID)
if id == "" {
return nil, fmt.Errorf("slide content id must not be empty")
}
if _, exists := byID[id]; exists {
return nil, fmt.Errorf("slide content id %q is duplicated", id)
}
byID[id] = slide
}
return byID, nil
}
func readAuthorAssets(safeRoot string, path string) (map[string][]authorAsset, error) {
file, err := readDeckAssetsArtifact(safeRoot, path)
if err != nil {
return nil, fmt.Errorf("read assets manifest %q: %w", path, err)
}
bySlideID := make(map[string][]authorAsset, len(file.Assets))
for _, asset := range file.Assets {
if assetStatus(asset) != "ready" {
continue
}
slideID := assetSlideID(asset)
bySlideID[slideID] = append(bySlideID[slideID], asset)
}
return bySlideID, nil
}
func selectAuthorRenderableImageAssets(safeRoot string, content authorSlideContent, assets []authorAsset) []authorAsset {
if len(content.Visuals) == 0 || len(assets) == 0 {
return nil
}
assetByID := make(map[string]authorAsset, len(assets))
for _, asset := range assets {
if assetType(asset) != "image" {
continue
}
id := assetID(asset)
if id == "" {
continue
}
assetByID[id] = asset
}
for _, visual := range content.Visuals {
if strings.TrimSpace(visual.Type) != "image" {
continue
}
id := strings.TrimSpace(visual.ID)
if id == "" {
continue
}
asset, ok := assetByID[id]
if !ok {
continue
}
if !authorImageAssetUsable(safeRoot, asset) {
continue
}
return []authorAsset{asset}
}
return nil
}
func authorImageAssetUsable(_ string, asset authorAsset) bool {
if assetType(asset) != "image" {
return false
}
path := assetPath(asset)
return path != ""
}
func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error {
deckIDs := make(map[string]bool, len(deck.Slides))
for _, slide := range deck.Slides {
id := strings.TrimSpace(slide.ID)
if id == "" {
return fmt.Errorf("deck slide id must not be empty")
}
if deckIDs[id] {
return fmt.Errorf("deck slide id %q is duplicated", id)
}
deckIDs[id] = true
if _, ok := contentByID[id]; !ok {
return fmt.Errorf("deck slide id %q is missing from slide content", id)
}
}
return nil
}
func readAuthorTheme(safeRoot string, path string) (authorTheme, error) {
raw, err := readRunRegularArtifact(safeRoot, path)
if err != nil {
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
}
var visual authorVisualSystem
if err := json.Unmarshal(raw, &visual); err != nil {
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
}
theme := authorTheme{
Background: normalizeAuthorColor(visual.ColorSystem.Background, defaultAuthorBgColor),
Ink: normalizeAuthorColor(visual.ColorSystem.Ink, defaultAuthorInkColor),
Muted: normalizeAuthorColor(visual.ColorSystem.Muted, defaultAuthorMuteColor),
Accent: normalizeAuthorColor(visual.ColorSystem.Accent, defaultAuthorAccent),
TitleSize: visual.Typography.Title,
BodySize: visual.Typography.Body,
}
if theme.TitleSize <= 0 {
theme.TitleSize = 32
}
if theme.BodySize <= 0 {
theme.BodySize = 16
}
return theme, nil
}
func normalizeAuthorColor(value string, fallback string) string {
value = strings.TrimSpace(value)
if isAllowedAuthorHexColor(value) {
return value
}
return fallback
}
func isAllowedAuthorHexColor(value string) bool {
if len(value) != 4 && len(value) != 7 && len(value) != 9 {
return false
}
if value[0] != '#' {
return false
}
for _, r := range value[1:] {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
continue
}
return false
}
return true
}
func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, assets []authorAsset, theme authorTheme, page int, total int) string {
title := firstNonEmpty(slide.Title, "Untitled slide")
keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary)
bodyLines := authorBodyLines(content.Content)
footer := strings.TrimSpace(deckTitle)
if footer == "" {
footer = "SVGlide"
}
footnote := authorSourceFootnote(content.SourceRefs)
heroAsset := firstReadyAuthorImageAsset(assets)
contentWidth := 848
contentHeight := 404
if heroAsset != nil {
contentWidth = 500
}
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="%s" xmlns:slide="%s" width="%d" height="%d" viewBox="0 0 960 540" slide:role="slide">`+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight)
fmt.Fprintf(&b, " <style>:root{--font-display:\"Noto Serif CJK SC\",\"Songti SC\",serif;--font-body:\"Noto Sans CJK SC\",\"PingFang SC\",sans-serif;--font-number:\"Roboto Mono\",\"SFMono-Regular\",monospace;--font-label:\"PingFang SC\",\"Noto Sans CJK SC\",sans-serif;}</style>\n")
if notes := strings.TrimSpace(content.Notes); notes != "" {
fmt.Fprintf(&b, " <slide:note>%s</slide:note>\n", escapeText(notes))
}
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="540" fill="%s" data-role="background"/>`+"\n", escapeAttr(theme.Background))
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="8" fill="%s"/>`+"\n", escapeAttr(theme.Accent))
fmt.Fprintf(&b, ` <foreignObject x="56" y="48" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", contentWidth, contentHeight)
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-body);color:%s;">`+"\n", escapeAttr(theme.Ink))
fmt.Fprintf(&b, ` <div style="font-family:var(--font-display);font-size:%dpx;font-weight:700;line-height:1.16;margin-bottom:16px;">%s</div>`+"\n", theme.TitleSize, escapeText(title))
if keyMessage != "" {
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.35;color:%s;margin-bottom:22px;">%s</div>`+"\n", maxInt(theme.BodySize+4, 18), escapeAttr(theme.Accent), escapeText(keyMessage))
}
fmt.Fprintf(&b, ` <div style="border:1px solid #E5E7EB;border-radius:6px;padding:20px 24px;min-height:190px;background:#F9FAFB;">`+"\n")
for _, line := range bodyLines {
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.55;margin-bottom:8px;">- %s</div>`+"\n", theme.BodySize, escapeText(line))
}
fmt.Fprintf(&b, " </div>\n")
fmt.Fprintf(&b, " </div>\n")
fmt.Fprintf(&b, " </foreignObject>\n")
if footnote != "" {
fmt.Fprintf(&b, ` <foreignObject x="56" y="456" width="520" height="18" slide:role="shape" slide:shape-type="text">`+"\n")
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-label);color:%s;font-size:12px;line-height:1.2;">%s</div>`+"\n", escapeAttr(theme.Muted), escapeText(footnote))
fmt.Fprintf(&b, " </foreignObject>\n")
}
if heroAsset != nil {
fmt.Fprintf(&b, ` <image slide:role="image" slide:shape-type="image" href="%s" x="600" y="160" width="304" height="190"/>`+"\n", escapeAttr(svgHrefForRunAsset(slide.Path, assetPath(*heroAsset))))
}
fmt.Fprintf(&b, ` <foreignObject x="56" y="482" width="848" height="32" slide:role="shape" slide:shape-type="text">`+"\n")
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-label);color:%s;font-size:12px;display:flex;justify-content:space-between;">`+"\n", escapeAttr(theme.Muted))
fmt.Fprintf(&b, ` <span>%s</span><span style="font-family:var(--font-number)">%d / %d</span>`+"\n", escapeText(footer), page, total)
fmt.Fprintf(&b, " </div>\n")
fmt.Fprintf(&b, " </foreignObject>\n")
fmt.Fprintf(&b, "</svg>\n")
return b.String()
}
func svgHrefForRunAsset(slidePath string, runAssetPath string) string {
runAssetPath = strings.TrimSpace(runAssetPath)
if runAssetPath == "" ||
strings.HasPrefix(runAssetPath, "data:") ||
strings.HasPrefix(runAssetPath, "http://") ||
strings.HasPrefix(runAssetPath, "https://") {
return runAssetPath
}
cleanSlidePath, err := previewSlideObjectPath(slidePath)
if err != nil {
return runAssetPath
}
rel, err := filepath.Rel(filepath.ToSlash(filepath.Dir(cleanSlidePath)), filepath.ToSlash(runAssetPath))
if err != nil {
return runAssetPath
}
return filepath.ToSlash(rel)
}
func authorBodyLines(content string) []string {
var lines []string
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return []string{"No content provided."}
}
return lines
}
func authorSourceFootnote(sourceRefs []string) string {
if len(sourceRefs) == 0 {
return ""
}
refs := make([]string, 0, len(sourceRefs))
for _, ref := range sourceRefs {
if trimmed := strings.TrimSpace(ref); trimmed != "" {
refs = append(refs, trimmed)
}
}
if len(refs) == 0 {
return ""
}
return "来源:" + strings.Join(refs, " / ")
}
func firstReadyAuthorImageAsset(assets []authorAsset) *authorAsset {
for i := range assets {
asset := &assets[i]
if assetType(*asset) != "image" {
continue
}
if assetPath(*asset) == "" {
continue
}
return asset
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func escapeText(value string) string {
return html.EscapeString(value)
}
func escapeAttr(value string) string {
return html.EscapeString(value)
}
func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -0,0 +1,634 @@
package svglide
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`)
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
report, err := AuthorSlides("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != StatusDone {
t.Fatalf("Status = %q, want %q", report.Status, StatusDone)
}
if len(report.Slides) != 2 {
t.Fatalf("Slides len = %d, want 2: %+v", len(report.Slides), report.Slides)
}
receipt := readAuthorReceiptForTest(t)
if receipt["stage"] != StageSVGAuthor {
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageSVGAuthor)
}
if receipt["status"] != StatusDone {
t.Fatalf("receipt status = %v, want %q", receipt["status"], StatusDone)
}
if _, ok := receipt["artifacts"].([]any); !ok {
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
}
if _, ok := receipt["generated_at"]; ok {
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
}
for _, rel := range []string{"slides/01.svg", "slides/02.svg"} {
raw, err := os.ReadFile(filepath.Join("demo", rel))
if err != nil {
t.Fatalf("missing %s: %v", rel, err)
}
svg := string(raw)
for _, want := range []string{
`slide:role="slide"`,
`viewBox="0 0 960 540"`,
`foreignObject`,
`slide:role="shape"`,
`slide:shape-type="text"`,
} {
if !strings.Contains(svg, want) {
t.Fatalf("%s missing %q:\n%s", rel, want, svg)
}
}
}
validation, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !validation.OK {
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
}
}
func TestAuthorSlidesFallsBackForUnsafeColorTokens(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"url(https://example.com/bg.svg)","ink":"red;background:url(https://example.com/x)","muted":"not-a-color","accent":"#abc"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
svg := string(raw)
for _, banned := range []string{"url(", "https://example.com", "red;background", "not-a-color"} {
if strings.Contains(svg, banned) {
t.Fatalf("SVG contains unsafe color token %q:\n%s", banned, svg)
}
}
for _, want := range []string{`fill="#FFFFFF"`, `color:#111827`, `color:#6B7280`, `fill="#abc"`, `color:#abc`} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing normalized/default color %q:\n%s", want, svg)
}
}
}
func TestAuthorSlidesPreflightsSlidePathsBeforeWriting(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/../02.svg"}]}`,
)
if _, err := AuthorSlides("demo"); err == nil {
t.Fatal("expected invalid second slide path to fail")
}
if _, err := os.Stat(filepath.Join("demo", "slides", "01.svg")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("first slide output exists after preflight failure, stat err = %v", err)
}
}
func TestAuthorSlidesRejectsMissingContentBeforeWriting(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"}]}`)
if _, err := AuthorSlides("demo"); err == nil {
t.Fatal("expected missing slide content to fail")
}
for _, rel := range []string{"slides/01.svg", "receipts/svg_author.json"} {
if _, err := os.Stat(filepath.Join("demo", rel)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s exists after content preflight failure, stat err = %v", rel, err)
}
}
}
func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s1","content":"Duplicate body line","source_refs":[],"visuals":[{"id":"none-s1b","type":"none","instruction":"Text-only"}]}]}`)
if _, err := AuthorSlides("demo"); err == nil {
t.Fatal("expected duplicate slide content id to fail")
}
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("svg_author receipt exists after duplicate content id failure, stat err = %v", err)
}
}
func TestAuthorSlidesDoesNotRenderImageForNoneVisualDespiteReadyAsset(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `<image slide:role="image"`) {
t.Fatalf("visual type none should not render image:\n%s", string(raw))
}
}
func TestAuthorSlidesDoesNotRenderImageForMismatchedVisualID(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"other","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `<image slide:role="image"`) {
t.Fatalf("mismatched visual id should not render image:\n%s", string(raw))
}
}
func TestAuthorSlidesRendersExperimentRemoteImageAsset(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the remote hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
svg := string(raw)
for _, want := range []string{
`<image slide:role="image"`,
`href="https://example.com/hero.png"`,
} {
if !strings.Contains(svg, want) {
t.Fatalf("experiment remote image missing %q:\n%s", want, svg)
}
}
}
func TestAuthorSlidesSkipsUnsupportedReadyImageAssets(t *testing.T) {
tests := []struct {
name string
asset string
}{
{
name: "diagram",
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero diagram","status":"ready"}]}`,
},
{
name: "missing",
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"missing"}]}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", tt.asset)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `<image slide:role="image"`) {
t.Fatalf("unsupported asset should not render image:\n%s", string(raw))
}
})
}
}
func TestAuthorSlidesRendersExistingAbsoluteImageAssetInExperiment(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
outside := filepath.Join(t.TempDir(), "hero.png")
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), outside) || !strings.Contains(string(raw), `<image slide:role="image"`) {
t.Fatalf("absolute asset should render image in experiment mode:\n%s", string(raw))
}
}
func initAuthorDemoRun(t *testing.T, visualSystem string, deck string) {
t.Helper()
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", visualSystem)
mustWriteTestFile(t, "demo/outline/deck.json", deck)
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
}
func TestAuthorSlidesRendersSourceFootnotes(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
svg := string(raw)
for _, want := range []string{
`来源`,
`web1`,
`slide:role="shape"`,
} {
if !strings.Contains(svg, want) {
t.Fatalf("source footnote missing %q:\n%s", want, svg)
}
}
}
func TestAuthorSlidesRendersPreparedImageAsset(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
svg := string(raw)
for _, want := range []string{
`<image slide:role="image"`,
`slide:shape-type="image"`,
`href="../assets/images/hero.png"`,
} {
if !strings.Contains(svg, want) {
t.Fatalf("prepared image asset missing %q:\n%s", want, svg)
}
}
validation, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !validation.OK {
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
}
}
func TestAuthorSlidesReadsAssetsManifestAndSerializesNotes(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the manifest hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
svg := string(raw)
for _, want := range []string{
`<slide:note>Speaker note</slide:note>`,
`<image slide:role="image"`,
`href="../assets/images/hero.png"`,
} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing %q:\n%s", want, svg)
}
}
}
func TestAuthorSlidesRendersImageFootnoteAndMultilineBodyWithValidation(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
writeStatusTestRunFile(t, run)
if _, err := AuthorSlides("demo"); err != nil {
t.Fatal(err)
}
validation, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !validation.OK {
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
}
}
func writeAuthorInputsWithAnyGenContracts(t *testing.T, assets string) {
t.Helper()
if strings.Contains(assets, `"assets":[]`) && !strings.Contains(assets, `"no_image_reason"`) {
assets = strings.TrimSuffix(strings.TrimSpace(assets), "}") + `,"no_image_reason":"Text-only deck; no image assets required"}`
}
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"First claim","body":"First body line\nSecond body line","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}},{"id":"s2","audience_copy":{"title":"Second claim","body":"Point A\nPoint B\nPoint C","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s2","content":"Point A\nPoint B\nPoint C","source_refs":["web1"],"visuals":[{"id":"none-s2","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", assets)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", assets)
}
func readAuthorReceiptForTest(t *testing.T) map[string]any {
t.Helper()
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "svg_author.json"))
if err != nil {
t.Fatal(err)
}
var receipt map[string]any
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatal(err)
}
return receipt
}
func mustWriteTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if strings.HasSuffix(filepath.ToSlash(path), assetsPlanPath) {
manifestPath := filepath.Join(filepath.Dir(path), filepath.Base(assetsManifestPath))
if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
candidatesPath := filepath.Join(filepath.Dir(path), filepath.Base(imageCandidatesPath))
if _, err := os.Stat(candidatesPath); os.IsNotExist(err) {
if err := os.WriteFile(candidatesPath, []byte(testImageCandidatesJSON(content)), 0o644); err != nil {
t.Fatal(err)
}
} else if err != nil {
t.Fatal(err)
}
inventoryPath := filepath.Join(filepath.Dir(path), filepath.Base(assetInventoryPath))
if err := os.WriteFile(inventoryPath, []byte(testAssetInventoryJSON(content)), 0o644); err != nil {
t.Fatal(err)
}
chartManifestPath := filepath.Join(filepath.Dir(path), "charts", "chart_manifest.json")
if err := os.MkdirAll(filepath.Dir(chartManifestPath), 0o755); err != nil {
t.Fatal(err)
}
chartBriefsPath := filepath.Join(filepath.Dir(path), "charts", "chart_briefs.json")
if err := os.WriteFile(chartBriefsPath, []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(chartManifestPath, []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`), 0o644); err != nil {
t.Fatal(err)
}
chartRenderPath := filepath.Join(filepath.Dir(filepath.Dir(path)), chartRenderReceiptPath)
if err := os.MkdirAll(filepath.Dir(chartRenderPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(chartRenderPath, []byte(`{"status":"passed","renderer":"node-vega-lite","charts":[],"issues":[]}`), 0o644); err != nil {
t.Fatal(err)
}
}
}
func testAssetInventoryJSON(assets string) string {
var file deckAssetsFile
if err := json.Unmarshal([]byte(assets), &file); err != nil {
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"items":[]}`
}
items := make([]map[string]any, 0, len(file.Assets))
for _, asset := range file.Assets {
if assetStatus(asset) != "ready" {
continue
}
format := strings.TrimPrefix(assetExt(asset), ".")
if format == "" {
format = "unknown"
}
items = append(items, map[string]any{
"id": assetID(asset),
"path": assetPath(asset),
"source_url": strings.TrimSpace(asset.SourceURL),
"width": 960,
"height": 540,
"semantic_type": assetType(asset),
"large_ok": true,
"full_bleed_ok": true,
"recommended_use": strings.TrimSpace(asset.Usage),
"avoid_reason": "",
"format": format,
"has_alpha": assetExt(asset) == ".png",
"asset_role": "hero_photo",
"fit_role": "split_panel",
"candidate_id": "cand-" + assetID(asset),
"selection_reason": "test fixture selected image",
})
}
raw, err := json.Marshal(map[string]any{
"prompt_contract": json.RawMessage(promptContractJSON(StageAssets)),
"items": items,
})
if err != nil {
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"items":[]}`
}
return string(raw)
}
func testImageCandidatesJSON(assets string) string {
var file deckAssetsFile
if err := json.Unmarshal([]byte(assets), &file); err != nil {
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"requires_real_images":false,"no_image_reason":"invalid test asset fixture; no image candidates","candidates":[]}`
}
candidates := make([]map[string]any, 0, len(file.Assets))
for _, asset := range file.Assets {
if !isRasterImageAsset(asset) {
continue
}
path := assetPath(asset)
sourceURL := strings.TrimSpace(asset.SourceURL)
if sourceURL == "" {
sourceURL = strings.TrimSpace(asset.Path)
}
format := strings.TrimPrefix(assetExt(asset), ".")
if format == "" {
format = "unknown"
}
candidates = append(candidates, map[string]any{
"id": "cand-" + assetID(asset),
"query": strings.TrimSpace(asset.Usage),
"source_url": sourceURL,
"source_class": "user_provided",
"format": format,
"width": 960,
"height": 540,
"has_alpha": assetExt(asset) == ".png",
"asset_role": "hero_photo",
"fit_role": "split_panel",
"local_path": path,
"score_bp": 9000,
"selected": true,
"selection_reason": "test fixture selected image",
"format_exception_reason": "",
"rejection_reason": "",
})
}
payload := map[string]any{
"prompt_contract": json.RawMessage(promptContractJSON(StageAssets)),
"requires_real_images": len(candidates) > 0,
"candidates": candidates,
}
if len(candidates) == 0 {
payload["no_image_reason"] = "test fixture has no real raster image assets"
}
raw, err := json.Marshal(payload)
if err != nil {
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"requires_real_images":false,"no_image_reason":"test fixture has no real raster image assets","candidates":[]}`
}
return string(raw)
}

View File

@@ -0,0 +1,141 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,74 @@
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

@@ -0,0 +1,343 @@
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

@@ -0,0 +1,113 @@
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

@@ -0,0 +1,211 @@
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

@@ -0,0 +1,66 @@
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

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

View File

@@ -0,0 +1,917 @@
{
"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

@@ -0,0 +1,15 @@
{
"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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,195 @@
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

@@ -0,0 +1,121 @@
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

@@ -0,0 +1,884 @@
package svglide
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
)
const (
visualReceiptsPath = "visual_receipts.json"
creativeQualityReportPath = "creative_quality_report.json"
)
type CreativeQualityReport struct {
Status string `json:"status"`
Issues []CreativeQualityIssue `json:"issues"`
Metrics CreativeQualityMetrics `json:"metrics"`
}
type CreativeQualityIssue struct {
Path string `json:"path"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
}
type CreativeQualityMetrics struct {
Slides int `json:"slides"`
VisualReceipts int `json:"visual_receipts"`
MissingVisualReceipts int `json:"missing_visual_receipts"`
ProcessLeakCount int `json:"process_leak_count"`
GenericFontSlideCount int `json:"generic_font_slide_count"`
TopicTypographyMismatchCount int `json:"topic_typography_mismatch_count"`
TypographyRoleCollapseCount int `json:"typography_role_collapse_count"`
DistinctLayoutFamilyCount int `json:"distinct_layout_family_count"`
DistinctLayoutArchetypeCount int `json:"distinct_layout_archetype_count"`
LayoutArchetypeMaxRatioBP int `json:"layout_archetype_max_ratio_bp"`
AdjacentLayoutArchetypeCount int `json:"adjacent_layout_archetype_count"`
LeftRightChartArchetypeCount int `json:"left_right_chart_archetype_count"`
LayoutSignatureMaxRatioBP int `json:"layout_signature_max_ratio_bp"`
AdjacentLayoutRepetitionCount int `json:"adjacent_layout_repetition_count"`
CardDominantSlideCount int `json:"card_dominant_slide_count"`
DarkCardTemplateSlideCount int `json:"dark_card_template_slide_count"`
ShapeLanguageMaxRatioBP int `json:"shape_language_max_ratio_bp"`
DecorativeImageOnlyCount int `json:"decorative_image_only_count"`
WeakCoverVisualImpactCount int `json:"weak_cover_visual_impact_count"`
DefaultCardTextContainerCount int `json:"default_card_text_container_count"`
OpenTextCarrierSlideCount int `json:"open_text_carrier_slide_count"`
FusionSlideCount int `json:"fusion_slide_count"`
FusionAdjacentCount int `json:"fusion_adjacent_count"`
WeakSlideCount int `json:"weak_slide_count"`
ChartWithoutEvidenceCount int `json:"chart_without_evidence_count"`
WarningCount int `json:"warning_count"`
}
type visualReceiptsFile struct {
Slides []visualReceipt `json:"slides"`
}
type visualReceipt struct {
SlideID string `json:"slide_id"`
StoryJob string `json:"story_job"`
LayoutFamily string `json:"layout_family"`
LayoutArchetype string `json:"layout_archetype"`
LayoutSignature string `json:"layout_signature"`
ThumbnailJob string `json:"thumbnail_job"`
VisualCenter string `json:"visual_center"`
TopicFitClaim string `json:"topic_fit_claim"`
InformationDensityPlan string `json:"information_density_plan"`
PageDifferenceFromPrevious string `json:"page_difference_from_previous"`
PrimaryAsset string `json:"primary_asset"`
AssetRole string `json:"asset_role"`
FontRoleUsage map[string]string `json:"font_role_usage"`
TypographyRoleUsage map[string]string `json:"typography_role_usage"`
CompositionIntent string `json:"composition_intent"`
DataVisualRationale string `json:"data_visual_rationale"`
SourceEvidence []string `json:"source_evidence"`
ContainerFitPlan string `json:"container_fit_plan"`
ContainerDecision string `json:"container_decision"`
TextCarrier string `json:"text_carrier"`
ShapeLanguage string `json:"shape_language"`
CardBudget visualCardBudget `json:"card_budget"`
ChartReceipt visualChartReceipt `json:"chart_receipt"`
FusionSpec visualFusionReceipt `json:"fusion_spec"`
QAExpectations []string `json:"qa_expectations"`
}
type visualCardBudget struct {
CardCount int `json:"card_count"`
WhyCardsAreNeeded string `json:"why_cards_are_needed"`
}
type visualChartReceipt struct {
ChartID string `json:"chart_id"`
Renderer string `json:"renderer"`
Unit string `json:"unit"`
Source string `json:"source"`
WhyChartIsNeeded string `json:"why_chart_is_needed"`
}
type visualFusionReceipt struct {
Enabled bool `json:"enabled"`
SeamSide string `json:"seam_side"`
SampledColor string `json:"sampled_color"`
PanelColor string `json:"panel_color"`
FadeWidth int `json:"fade_width"`
SubjectSafety string `json:"subject_safety"`
}
func CheckCreativeQuality(root string) (CreativeQualityReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return CreativeQualityReport{}, err
}
deckPath := "outline/deck.json"
deck, err := readAuthorDeck(safeRoot, deckPath)
if err != nil {
return CreativeQualityReport{}, err
}
mode := normalizedVisualQualityMode(run.VisualQualityMode)
report := CreativeQualityReport{
Status: "passed",
Issues: []CreativeQualityIssue{},
Metrics: CreativeQualityMetrics{Slides: len(deck.Slides)},
}
receipts, receiptErr := readVisualReceipts(safeRoot)
if receiptErr != nil {
addCreativeIssue(&report, mode, visualReceiptsPath, "svglide.creative.missing_visual_receipts", receiptErr.Error(), "error")
}
report.Metrics.VisualReceipts = len(receipts.Slides)
receiptBySlide := make(map[string]visualReceipt, len(receipts.Slides))
for _, receipt := range receipts.Slides {
id := strings.TrimSpace(receipt.SlideID)
if id != "" {
receiptBySlide[id] = receipt
}
}
if contract, present, contractErr := readTypographyContract(safeRoot); present {
if contractErr != nil {
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.invalid_contract", contractErr.Error(), "error")
} else {
deckType := strings.Join([]string{run.Title, run.Intent.Topic, deck.Title, contract.Profile}, " ")
identity := evaluateTypographyIdentity(contract, deckType)
if identity.GenericFallbackOnly {
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.too_generic", "typography contract uses only generic/browser fallback font stacks", "error")
}
if identity.RepeatedDefaultStack {
report.Metrics.TypographyRoleCollapseCount++
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.role_collapse", "typography contract must keep display, body, and numeric/label roles distinct enough to carry visual identity", "error")
}
if identity.ProfileMismatch {
report.Metrics.TopicTypographyMismatchCount++
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.profile_mismatch", "typography contract does not match the deck topic/profile identity", "error")
}
}
}
familyCounts := make(map[string]int)
archetypeCounts := make(map[string]int)
layoutCounts := make(map[string]int)
shapeLanguageCounts := make(map[string]int)
var previousLayout string
var previousArchetype string
var previousFamily string
var previousDarkCardTemplate bool
for i, slide := range deck.Slides {
id := strings.TrimSpace(slide.ID)
receipt, hasReceipt := receiptBySlide[id]
if !hasReceipt {
report.Metrics.MissingVisualReceipts++
addCreativeIssue(&report, mode, visualReceiptsPath, "svglide.creative.missing_visual_receipt", fmt.Sprintf("slide %q has no visual receipt", id), "error")
}
layoutFamily := firstNonEmpty(slide.LayoutFamily, receipt.LayoutFamily)
layoutArchetype := firstNonEmpty(slide.LayoutArchetype, receipt.LayoutArchetype, inferAuthorLayoutArchetype(layoutFamily, firstNonEmpty(slide.LayoutSignature, receipt.LayoutSignature)))
layoutSignature := firstNonEmpty(slide.LayoutSignature, receipt.LayoutSignature)
if strings.TrimSpace(layoutFamily) == "" || strings.TrimSpace(layoutSignature) == "" || strings.TrimSpace(layoutArchetype) == "" {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.missing_layout_fields", fmt.Sprintf("slide %q must declare layout_family, layout_archetype, and layout_signature", id), "error")
}
if layoutFamily != "" {
familyCounts[layoutFamily]++
}
if layoutArchetype != "" {
archetypeCounts[layoutArchetype]++
if i > 0 && layoutArchetype == previousArchetype {
report.Metrics.AdjacentLayoutArchetypeCount++
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_layout_archetype", fmt.Sprintf("slide %q repeats adjacent layout_archetype %q", id, layoutArchetype), "error")
}
if isLeftRightChartArchetype(layoutArchetype, layoutSignature, receipt) {
report.Metrics.LeftRightChartArchetypeCount++
}
previousArchetype = layoutArchetype
}
if layoutSignature != "" {
layoutCounts[layoutSignature]++
if i > 0 && layoutSignature == previousLayout {
report.Metrics.AdjacentLayoutRepetitionCount++
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_layout_repetition", fmt.Sprintf("slide %q repeats adjacent layout_signature %q", id, layoutSignature), "error")
}
previousLayout = layoutSignature
}
if layoutFamily == "image_text_fusion_split" {
report.Metrics.FusionSlideCount++
if i > 0 && previousFamily == "image_text_fusion_split" {
report.Metrics.FusionAdjacentCount++
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_fusion", fmt.Sprintf("slide %q repeats image_text_fusion_split after another fusion slide", id), "error")
}
if hasReceipt {
checkFusionReceipt(&report, mode, id, receipt)
}
}
previousFamily = layoutFamily
slidePath, err := previewSlideObjectPath(slide.Path)
if err != nil {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.invalid_slide_path", err.Error(), "error")
continue
}
raw, err := readRunRegularArtifact(safeRoot, slidePath)
if err != nil {
addCreativeIssue(&report, mode, slidePath, "svglide.creative.missing_slide_svg", err.Error(), "error")
continue
}
svg := string(raw)
shapeSummary := analyzeShapeLanguage(svg)
shapeLanguage := firstNonEmpty(receipt.ShapeLanguage, shapeLanguageSignature(shapeSummary, receipt))
if shapeLanguage != "" {
shapeLanguageCounts[shapeLanguage]++
}
if isCardDominantSlide(shapeSummary) {
report.Metrics.CardDominantSlideCount++
}
darkCardTemplate := isDarkCardTemplateSlide(shapeSummary)
if darkCardTemplate {
report.Metrics.DarkCardTemplateSlideCount++
if previousDarkCardTemplate {
addCreativeIssue(&report, mode, slidePath, "svglide.creative.dark_card_template_repetition", fmt.Sprintf("slide %q repeats the adjacent dark rounded-card template", id), "error")
}
}
previousDarkCardTemplate = darkCardTemplate
textCarrier := classifyTextCarrier(svg, receipt)
if isOpenTextCarrier(textCarrier) {
report.Metrics.OpenTextCarrierSlideCount++
}
if hasReceipt && isDefaultCardTextContainer(shapeSummary, textCarrier, receipt) {
report.Metrics.DefaultCardTextContainerCount++
addCreativeIssue(&report, mode, slidePath, "svglide.creative.default_card_text_container", fmt.Sprintf("slide %q uses rounded cards as the default text container without a content reason", id), "error")
}
if hasReceipt && isDecorativeImageOnlySlide(svg, receipt) {
report.Metrics.DecorativeImageOnlyCount++
addCreativeIssue(&report, mode, slidePath, "svglide.creative.decorative_image_only", fmt.Sprintf("slide %q uses imagery as decoration rather than a topic visual", id), "error")
}
if hasReceipt && isCoverSlide(slide) && !hasStrongCoverVisualImpact(svg, receipt) {
report.Metrics.WeakCoverVisualImpactCount++
addCreativeIssue(&report, mode, slidePath, "svglide.creative.weak_cover_visual_impact", fmt.Sprintf("slide %q cover lacks a strong topic-specific visual", id), "error")
}
leaks := countCreativeProcessLeaks(svg)
if leaks > 0 {
report.Metrics.ProcessLeakCount += leaks
addCreativeIssue(&report, mode, slidePath, "svglide.creative.process_leak", fmt.Sprintf("slide %q exposes process/source/prompt language in visible text", id), "error")
}
if svgHasGenericFontProblem(svg) {
report.Metrics.GenericFontSlideCount++
addCreativeIssue(&report, mode, slidePath, "svglide.creative.generic_fonts", fmt.Sprintf("slide %q uses generic/browser font roles instead of concrete deck typography", id), "error")
}
if hasReceipt && isWeakCreativeSlide(svg, receipt, layoutFamily) {
report.Metrics.WeakSlideCount++
addCreativeIssue(&report, mode, slidePath, "svglide.creative.weak_slide", fmt.Sprintf("slide %q lacks enough visual center, topic fit, differentiation, or information density", id), "error")
}
if hasDataVisualIntent(svg, layoutFamily, receipt) && !hasNumericSourceEvidence(receipt) {
report.Metrics.ChartWithoutEvidenceCount++
addCreativeIssue(&report, mode, slidePath, "svglide.creative.chart_without_evidence", fmt.Sprintf("slide %q uses data/chart visual language without numeric source_evidence", id), "error")
}
}
report.Metrics.DistinctLayoutFamilyCount = len(familyCounts)
report.Metrics.DistinctLayoutArchetypeCount = len(archetypeCounts)
report.Metrics.LayoutArchetypeMaxRatioBP = maxLayoutSignatureRatioBP(archetypeCounts, len(deck.Slides))
report.Metrics.LayoutSignatureMaxRatioBP = maxLayoutSignatureRatioBP(layoutCounts, len(deck.Slides))
report.Metrics.ShapeLanguageMaxRatioBP = maxLayoutSignatureRatioBP(shapeLanguageCounts, len(deck.Slides))
if len(deck.Slides) >= 8 && report.Metrics.DistinctLayoutArchetypeCount < minDistinctLayoutArchetypes(len(deck.Slides)) {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_archetype_diversity", fmt.Sprintf("deck has %d distinct layout_archetype values, want >= %d", report.Metrics.DistinctLayoutArchetypeCount, minDistinctLayoutArchetypes(len(deck.Slides))), "error")
}
if len(deck.Slides) >= 5 && report.Metrics.LayoutArchetypeMaxRatioBP > maxArchetypeRatioBP(len(deck.Slides)) {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_archetype_overuse", fmt.Sprintf("most common layout_archetype ratio is %d bp, want <= %d bp", report.Metrics.LayoutArchetypeMaxRatioBP, maxArchetypeRatioBP(len(deck.Slides))), "error")
}
if report.Metrics.LeftRightChartArchetypeCount > maxLeftRightChartArchetypes(len(deck.Slides)) {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.left_right_chart_overuse", fmt.Sprintf("left/right chart archetype count is %d, want <= %d", report.Metrics.LeftRightChartArchetypeCount, maxLeftRightChartArchetypes(len(deck.Slides))), "error")
}
if len(deck.Slides) >= 8 && report.Metrics.LayoutSignatureMaxRatioBP > 3000 {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_overuse", fmt.Sprintf("most common layout_signature ratio is %d bp, want <= 3000 bp", report.Metrics.LayoutSignatureMaxRatioBP), "error")
}
if limit := maxFusionSlides(len(deck.Slides)); report.Metrics.FusionSlideCount > limit {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.fusion_overuse", fmt.Sprintf("image_text_fusion_split count is %d, want <= %d", report.Metrics.FusionSlideCount, limit), "error")
}
if len(deck.Slides) >= 8 && report.Metrics.CardDominantSlideCount*10000/len(deck.Slides) > 3500 {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.card_dominant_overuse", fmt.Sprintf("card-dominant slide ratio is %d bp, want <= 3500 bp", report.Metrics.CardDominantSlideCount*10000/len(deck.Slides)), "error")
}
if len(deck.Slides) >= 8 && report.Metrics.OpenTextCarrierSlideCount*10000/len(deck.Slides) < 4000 {
addCreativeIssue(&report, mode, deckPath, "svglide.creative.open_text_carrier_underuse", fmt.Sprintf("open text carrier ratio is %d bp, want >= 4000 bp", report.Metrics.OpenTextCarrierSlideCount*10000/len(deck.Slides)), "error")
}
for _, issue := range report.Issues {
if issue.Severity == "warning" {
report.Metrics.WarningCount++
}
if issue.Severity == "error" {
report.Status = "failed"
}
}
return report, nil
}
func readVisualReceipts(safeRoot string) (visualReceiptsFile, error) {
raw, err := readRunRegularArtifact(safeRoot, visualReceiptsPath)
if err != nil {
return visualReceiptsFile{}, fmt.Errorf("%s: read artifact: %w", visualReceiptsPath, err)
}
var receipts visualReceiptsFile
if err := json.Unmarshal(raw, &receipts); err != nil {
return visualReceiptsFile{}, fmt.Errorf("%s: invalid JSON: %w", visualReceiptsPath, err)
}
return receipts, nil
}
func addCreativeIssue(report *CreativeQualityReport, mode, path, code, message, severity string) {
severity = strings.TrimSpace(severity)
if severity == "" {
severity = "error"
}
if mode == VisualQualityModeWarn && severity == "error" {
severity = "warning"
}
report.Issues = append(report.Issues, CreativeQualityIssue{
Path: path,
Code: code,
Message: message,
Severity: severity,
})
}
func normalizedVisualQualityMode(value string) string {
switch strings.TrimSpace(value) {
case VisualQualityModeWarn:
return VisualQualityModeWarn
default:
return VisualQualityModeStrict
}
}
func checkFusionReceipt(report *CreativeQualityReport, mode, slideID string, receipt visualReceipt) {
if !receipt.FusionSpec.Enabled {
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_missing_spec", fmt.Sprintf("slide %q uses image_text_fusion_split but fusion_spec.enabled is false", slideID), "error")
return
}
if strings.TrimSpace(receipt.FusionSpec.SeamSide) == "" || strings.TrimSpace(receipt.FusionSpec.SampledColor) == "" || strings.TrimSpace(receipt.FusionSpec.PanelColor) == "" {
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_missing_spec", fmt.Sprintf("slide %q fusion_spec must include seam_side, sampled_color, and panel_color", slideID), "error")
}
if receipt.FusionSpec.FadeWidth < 80 || receipt.FusionSpec.FadeWidth > 180 {
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_bad_fade", fmt.Sprintf("slide %q fusion fade_width must be 80-180 px, got %d", slideID, receipt.FusionSpec.FadeWidth), "error")
}
if strings.TrimSpace(receipt.FusionSpec.SubjectSafety) == "" {
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.subject_safety_warning", fmt.Sprintf("slide %q fusion_spec lacks subject safety judgment", slideID), "warning")
}
if ok, delta := CheckSeamDelta(receipt.FusionSpec.SampledColor, receipt.FusionSpec.PanelColor); !ok {
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_seam_delta", fmt.Sprintf("slide %q seam color delta is %d, want <= 45", slideID, delta), "warning")
}
}
func maxFusionSlides(slides int) int {
if slides <= 0 {
return 0
}
limit := slides * 30 / 100
if limit < 1 {
limit = 1
}
if limit > 3 {
limit = 3
}
return limit
}
func isLeftRightChartArchetype(archetype string, signature string, receipt visualReceipt) bool {
value := strings.ToLower(strings.TrimSpace(archetype + " " + signature + " " + receipt.CompositionIntent))
if strings.Contains(value, "left_text_right_chart") {
return true
}
if strings.Contains(value, "left text right chart") {
return true
}
return strings.Contains(value, "split") && strings.Contains(value, "chart")
}
func minDistinctLayoutArchetypes(slides int) int {
switch {
case slides >= 8:
return 5
case slides >= 5:
return 3
case slides >= 3:
return 2
default:
return 1
}
}
func maxArchetypeRatioBP(slides int) int {
if slides >= 8 {
return 2500
}
return 5000
}
func maxLeftRightChartArchetypes(slides int) int {
if slides >= 8 {
return 2
}
return 1
}
func countCreativeProcessLeaks(svg string) int {
visible := strings.ToLower(visibleSemanticText(svg))
count := 0
for _, marker := range []string{
"sources:",
"source note",
"prompt",
"slide:note",
"production_instruction",
"素材说明",
"制作说明",
"接缝",
"取色",
"渐变遮罩",
"source ref",
} {
count += strings.Count(visible, strings.ToLower(marker))
}
return count
}
var fontRolePattern = regexp.MustCompile(`--font-(display|body|number|label)\s*:\s*([^;"}]+)`)
func svgHasGenericFontProblem(svg string) bool {
matches := fontRolePattern.FindAllStringSubmatch(svg, -1)
if len(matches) == 0 {
return false
}
roles := make(map[string]bool)
genericRoles := 0
for _, match := range matches {
if len(match) != 3 {
continue
}
role := strings.TrimSpace(match[1])
if roles[role] {
continue
}
roles[role] = true
if isGenericOrBrowserFontStack(match[2]) {
genericRoles++
}
}
if len(roles) >= 4 && genericRoles == len(roles) {
return true
}
visible := visibleSemanticText(svg)
if containsCJK(visible) && !hasConcreteCJKFont(svg) {
return true
}
return false
}
func isGenericOrBrowserFontStack(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.Trim(value, `"'`)
for _, token := range []string{",", " "} {
value = strings.ReplaceAll(value, token, "")
}
if value == "" {
return true
}
for _, concrete := range []string{"inter", "roboto", "georgia", "avenir", "sfpro", "noto", "sourcehan", "pingfang", "hiragino", "microsoftyahei", "songtisc"} {
if strings.Contains(value, concrete) {
return false
}
}
for _, generic := range []string{"arial", "helvetica", "sans-serif", "serif", "system-ui", "ui-sans-serif"} {
value = strings.ReplaceAll(value, generic, "")
}
return value == ""
}
func containsCJK(text string) bool {
for _, r := range text {
if (r >= 0x4e00 && r <= 0x9fff) || (r >= 0x3400 && r <= 0x4dbf) {
return true
}
}
return false
}
func hasConcreteCJKFont(svg string) bool {
lower := strings.ToLower(svg)
for _, token := range []string{
"noto sans cjk", "noto serif cjk", "source han sans", "source han serif",
"pingfang", "hiragino sans gb", "microsoft yahei", "songti sc",
"思源黑体", "思源宋体", "微软雅黑", "黑体", "宋体",
} {
if strings.Contains(lower, strings.ToLower(token)) {
return true
}
}
return false
}
func isWeakCreativeSlide(svg string, receipt visualReceipt, layoutFamily string) bool {
score := 0
if strings.TrimSpace(receipt.VisualCenter) == "" {
score++
}
if strings.TrimSpace(receipt.TopicFitClaim) == "" {
score++
}
if weakReceiptText(receipt.InformationDensityPlan) {
score++
}
if weakReceiptText(receipt.PageDifferenceFromPrevious) {
score++
}
if countSVGImageElements(svg) == 0 && !hasDataVisualIntent(svg, layoutFamily, receipt) {
score++
}
if countRoundedTextPanels(svg) >= 3 && countSVGImageElements(svg) == 0 {
score++
}
return score >= 3
}
func weakReceiptText(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return true
}
for _, marker := range []string{"same", "similar", "tbd", "none", "无", "同上", "相同"} {
if value == marker || strings.Contains(value, marker) {
return true
}
}
return false
}
var roundedRectPattern = regexp.MustCompile(`(?is)<rect\b[^>]*(\brx\s*=|\bry\s*=)`)
var svgRectTagForCreativePattern = regexp.MustCompile(`(?is)<rect\b[^>]*>`)
var svgTextBlockForCreativePattern = regexp.MustCompile(`(?is)<(?:text|foreignObject)\b`)
var svgFillForCreativePattern = regexp.MustCompile(`(?i)\bfill\s*=\s*"([^"]+)"`)
var svgStrokeForCreativePattern = regexp.MustCompile(`(?i)\bstroke\s*=\s*"([^"]+)"`)
type shapeLanguageSummary struct {
RectCount int
RoundedCardCount int
LargePanelCount int
DarkFillCount int
StrokePanelCount int
ImageCount int
ImageAreaBP int
LargestImageAreaBP int
TextBlockCount int
}
type textCarrierKind string
const (
textCarrierOpenGrid textCarrierKind = "open_grid"
textCarrierImageDarkZone textCarrierKind = "image_dark_zone"
textCarrierLineAnnotation textCarrierKind = "line_annotation"
textCarrierAxisAnnotation textCarrierKind = "axis_annotation"
textCarrierCardGroup textCarrierKind = "card_group"
textCarrierMetricPanel textCarrierKind = "metric_panel"
)
func countRoundedTextPanels(svg string) int {
return len(roundedRectPattern.FindAllStringIndex(svg, -1))
}
func analyzeShapeLanguage(svg string) shapeLanguageSummary {
width, height := svgViewBoxSize(svg)
if width <= 0 {
width = defaultSlideWidth
}
if height <= 0 {
height = defaultSlideHeight
}
canvasArea := width * height
if canvasArea <= 0 {
canvasArea = defaultSlideWidth * defaultSlideHeight
}
summary := shapeLanguageSummary{
ImageCount: countSVGImageElements(svg),
TextBlockCount: len(svgTextBlockForCreativePattern.FindAllStringIndex(svg, -1)),
}
for _, tag := range svgRectTagForCreativePattern.FindAllString(svg, -1) {
summary.RectCount++
attrs := svgNumericAttrs(tag)
areaBP := creativeAreaBP(attrs["width"], attrs["height"], canvasArea)
isBackground := areaBP >= 9000 && attrs["x"] <= 1 && attrs["y"] <= 1
if !isBackground && rectTagHasRoundedCorners(tag) && areaBP >= 300 {
summary.RoundedCardCount++
}
if !isBackground && areaBP >= 1200 {
summary.LargePanelCount++
}
if !isBackground && isDarkCreativeFill(rectCreativeAttr(tag, svgFillForCreativePattern)) {
summary.DarkFillCount++
}
if !isBackground && rectCreativeAttr(tag, svgStrokeForCreativePattern) != "" {
summary.StrokePanelCount++
}
}
for _, tag := range svgImageTagPattern.FindAllString(svg, -1) {
attrs := svgNumericAttrs(tag)
areaBP := creativeAreaBP(attrs["width"], attrs["height"], canvasArea)
summary.ImageAreaBP += areaBP
if areaBP > summary.LargestImageAreaBP {
summary.LargestImageAreaBP = areaBP
}
}
if summary.ImageAreaBP > 10000 {
summary.ImageAreaBP = 10000
}
return summary
}
func isCardDominantSlide(summary shapeLanguageSummary) bool {
if summary.TextBlockCount == 0 {
return false
}
if summary.RoundedCardCount >= 3 && summary.ImageCount == 0 {
return true
}
return summary.RoundedCardCount >= 2 && summary.LargePanelCount >= 2 && summary.ImageAreaBP < 2000
}
func isDarkCardTemplateSlide(summary shapeLanguageSummary) bool {
return summary.RoundedCardCount >= 2 && summary.DarkFillCount >= 2 && summary.TextBlockCount > 0
}
func isDecorativeImageOnlySlide(svg string, receipt visualReceipt) bool {
if countSVGImageElements(svg) == 0 {
return false
}
summary := analyzeShapeLanguage(svg)
if summary.LargestImageAreaBP >= 1000 {
return false
}
return containsAny(strings.ToLower(strings.Join([]string{receipt.AssetRole, receipt.CompositionIntent}, " ")), []string{"decorative", "ornament", "texture", "background", "装饰", "纹理"})
}
func hasStrongCoverVisualImpact(svg string, receipt visualReceipt) bool {
if !receiptRequiresStrongCoverVisual(receipt) {
return true
}
if strings.TrimSpace(receipt.PrimaryAsset+receipt.AssetRole) == "" {
return true
}
if hasFullBleedImage(svg) {
return true
}
summary := analyzeShapeLanguage(svg)
return summary.LargestImageAreaBP >= 4500
}
func receiptRequiresStrongCoverVisual(receipt visualReceipt) bool {
haystack := strings.ToLower(strings.Join([]string{
receipt.LayoutFamily,
receipt.LayoutArchetype,
receipt.LayoutSignature,
receipt.ShapeLanguage,
receipt.ContainerDecision,
receipt.CompositionIntent,
receipt.AssetRole,
strings.Join(receipt.QAExpectations, " "),
}, " "))
return containsAny(haystack, []string{
"full_bleed", "full-bleed", "hero_cover", "cover hero", "strong cover", "strong visual",
"主视觉", "强视觉", "封面大图", "全屏图", "封面主视觉",
})
}
func classifyTextCarrier(svg string, receipt visualReceipt) textCarrierKind {
if carrier := parseReceiptTextCarrier(receipt.TextCarrier); carrier != "" {
return carrier
}
shape := analyzeShapeLanguage(svg)
haystack := strings.ToLower(strings.Join([]string{receipt.ContainerDecision, receipt.CompositionIntent, receipt.LayoutFamily, receipt.LayoutArchetype, receipt.LayoutSignature}, " "))
switch {
case containsAny(haystack, []string{"axis", "annotation", "坐标"}):
return textCarrierAxisAnnotation
case containsAny(haystack, []string{"line annotation", "callout", "rule", "标注", "引线"}):
return textCarrierLineAnnotation
case containsAny(haystack, []string{"metric", "scoreboard", "kpi", "指标"}):
return textCarrierMetricPanel
case shape.RoundedCardCount > 0 && shape.TextBlockCount > 0:
return textCarrierCardGroup
case shape.ImageCount > 0 && shape.DarkFillCount > 0:
return textCarrierImageDarkZone
default:
return textCarrierOpenGrid
}
}
func parseReceiptTextCarrier(value string) textCarrierKind {
switch textCarrierKind(strings.TrimSpace(value)) {
case textCarrierOpenGrid, textCarrierImageDarkZone, textCarrierLineAnnotation, textCarrierAxisAnnotation, textCarrierCardGroup, textCarrierMetricPanel:
return textCarrierKind(strings.TrimSpace(value))
default:
return ""
}
}
func isOpenTextCarrier(kind textCarrierKind) bool {
switch kind {
case textCarrierOpenGrid, textCarrierImageDarkZone, textCarrierLineAnnotation, textCarrierAxisAnnotation:
return true
default:
return false
}
}
func isDefaultCardTextContainer(summary shapeLanguageSummary, carrier textCarrierKind, receipt visualReceipt) bool {
if carrier != textCarrierCardGroup || summary.RoundedCardCount == 0 || summary.TextBlockCount == 0 {
return false
}
if receiptJustifiesCards(receipt) {
return false
}
if summary.ImageAreaBP >= 2500 || hasDataVisualIntent("", receipt.LayoutFamily, receipt) {
return false
}
return true
}
func receiptJustifiesCards(receipt visualReceipt) bool {
if receipt.CardBudget.CardCount > 0 && strings.TrimSpace(receipt.CardBudget.WhyCardsAreNeeded) != "" {
return true
}
haystack := strings.ToLower(strings.Join([]string{
receipt.ContainerDecision,
receipt.CompositionIntent,
receipt.InformationDensityPlan,
receipt.DataVisualRationale,
receipt.AssetRole,
}, " "))
return containsAny(haystack, []string{
"comparison", "compare", "metric", "kpi", "scoreboard", "quote", "control", "panel", "table", "chart", "group",
"比较", "对比", "指标", "引用", "面板", "表格", "图表", "分组", "复杂背景", "background complexity",
})
}
func shapeLanguageSignature(summary shapeLanguageSummary, receipt visualReceipt) string {
switch {
case strings.TrimSpace(receipt.ShapeLanguage) != "":
return strings.TrimSpace(receipt.ShapeLanguage)
case summary.ImageAreaBP >= 4500:
return "image_forward"
case summary.RoundedCardCount >= 3:
return "card_grid"
case summary.RoundedCardCount > 0:
return "card_text_panel"
case hasDataVisualIntent("", receipt.LayoutFamily, receipt):
return "chart_forward"
case summary.StrokePanelCount > 0:
return "rule_annotation"
default:
return "open_text"
}
}
func rectTagHasRoundedCorners(tag string) bool {
return roundedRectPattern.MatchString(tag)
}
func rectCreativeAttr(tag string, pattern *regexp.Regexp) string {
match := pattern.FindStringSubmatch(tag)
if len(match) != 2 {
return ""
}
return strings.TrimSpace(match[1])
}
func creativeAreaBP(width float64, height float64, canvasArea float64) int {
if width <= 0 || height <= 0 || canvasArea <= 0 {
return 0
}
return int(width * height * 10000 / canvasArea)
}
func isDarkCreativeFill(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || value == "none" || strings.HasPrefix(value, "url(") {
return false
}
switch value {
case "black", "#000", "#000000", "#111", "#111111", "#101010", "#101319":
return true
}
if strings.HasPrefix(value, "#") {
hex := strings.TrimPrefix(value, "#")
if len(hex) == 3 {
hex = strings.Repeat(hex[0:1], 2) + strings.Repeat(hex[1:2], 2) + strings.Repeat(hex[2:3], 2)
}
if len(hex) != 6 {
return false
}
r := parseHexByte(hex[0:2])
g := parseHexByte(hex[2:4])
b := parseHexByte(hex[4:6])
return r+g+b < 180
}
return strings.Contains(value, "rgb(0") || strings.Contains(value, "rgb(16") || strings.Contains(value, "rgb(17")
}
func parseHexByte(value string) int {
out := 0
for _, r := range value {
out *= 16
switch {
case r >= '0' && r <= '9':
out += int(r - '0')
case r >= 'a' && r <= 'f':
out += int(r-'a') + 10
case r >= 'A' && r <= 'F':
out += int(r-'A') + 10
default:
return 255
}
}
return out
}
func hasDataVisualIntent(svg string, layoutFamily string, receipt visualReceipt) bool {
if strings.TrimSpace(layoutFamily) == "data_scoreboard" {
return true
}
if strings.TrimSpace(receipt.DataVisualRationale) != "" {
return true
}
lower := strings.ToLower(svg)
for _, marker := range []string{"chart", "axis", "bar", "line-chart", "vega", "data-score", "scoreboard"} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func hasNumericSourceEvidence(receipt visualReceipt) bool {
if containsDigit(receipt.DataVisualRationale) {
return true
}
for _, value := range receipt.SourceEvidence {
if containsDigit(value) {
return true
}
}
return false
}
func containsDigit(value string) bool {
for _, r := range value {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
func writeCreativeQualityReport(safeRoot string, report CreativeQualityReport) error {
return writeJSON(filepath.Join(safeRoot, creativeQualityReportPath), report)
}

View File

@@ -0,0 +1,268 @@
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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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())
mustWriteTestFile(t, "demo/brief/typography_contract.json", `{"profile":"sports_editorial","roles":{"display":{"family":"Noto Serif CJK SC","weight":"700","size":"42","usage":"cover title"},"body":{"family":"Noto Sans CJK SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Roboto Mono","weight":"700","size":"34","usage":"scores"},"label":{"family":"PingFang SC","weight":"600","size":"13","usage":"labels"}},"rules":["sports deck typography should carry athletic score identity"]}`)
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 CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang 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 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 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 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

@@ -0,0 +1,212 @@
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"`
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"`
}
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
}
hasRasterAsset := false
for _, asset := range manifest.Assets {
if isRasterImageAsset(asset) {
hasRasterAsset = true
break
}
}
if !candidates.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 candidates.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
}
}
}
}
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":
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,201 @@
package svglide
import (
"encoding/xml"
"fmt"
"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"`
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
}
usages, issues := extractSlideImageUsages(slide.Path, raw, 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)
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 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, 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: areaBP(width, height),
UsageStatus: "matched",
})
}
return out, issues
}
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 {
if width <= 0 || height <= 0 {
return 0
}
return int(width * height * 10000 / (1280 * 720))
}
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

@@ -0,0 +1,97 @@
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"}]}`)
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 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"}]}`)
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 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>`)
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_photo_title")
}

226
internal/svglide/init.go Normal file
View File

@@ -0,0 +1,226 @@
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
Pages int
Now time.Time
Overwrite bool
AgentRuntime string
AgentID string
RouteProfile 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.AgentRuntime = strings.TrimSpace(opts.AgentRuntime)
opts.AgentID = strings.TrimSpace(opts.AgentID)
opts.RouteProfile = strings.TrimSpace(opts.RouteProfile)
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",
"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,
Pages: opts.Pages,
Out: runRoot,
Now: opts.Now,
AgentRuntime: opts.AgentRuntime,
AgentID: opts.AgentID,
RouteProfile: opts.RouteProfile,
})
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,
"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
}
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

@@ -0,0 +1,536 @@
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",
"research",
"brief",
"outline",
"content",
"schemas/request.schema.json",
"schemas/deck.schema.json",
"receipts",
"slides",
"assets/images",
} {
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["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_image_assets.md", "tools/generate_svg_chart.md", "tools/generate_vega_lite_chart.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",
"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",
} {
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: "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"`, `"minItems"`, `"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"`}},
{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"`}},
} {
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", "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["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", "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)
}

20
internal/svglide/io.go Normal file
View File

@@ -0,0 +1,20 @@
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)
}

203
internal/svglide/metrics.go Normal file
View File

@@ -0,0 +1,203 @@
package svglide
import (
"encoding/xml"
"fmt"
"io"
"strings"
)
type SemanticMetrics struct {
SlideCount int `json:"slide_count"`
SlidesWithSlideRole int `json:"slides_with_slide_role"`
ImageCount int `json:"image_count"`
TextCount int `json:"text_count"`
NoteCount int `json:"note_count"`
SourceRefCount int `json:"source_ref_count"`
MissingAssetCount int `json:"missing_asset_count"`
SlidesWithoutSourceRefs int `json:"slides_without_source_refs"`
VisibleLeakCount int `json:"visible_leak_count"`
FontTokenCount int `json:"font_token_count"`
MissingFontTokenCount int `json:"missing_font_token_count"`
}
func MissingAssetCountForRun(safeRoot string, run Run) int {
metrics, err := ComputeSemanticMetrics(safeRoot, run)
if err != nil {
return 0
}
return metrics.MissingAssetCount
}
func ComputeSemanticMetrics(safeRoot string, run Run) (SemanticMetrics, error) {
var metrics SemanticMetrics
deck, err := readAuthorDeck(safeRoot, semanticDeckPath(run))
if err != nil {
return metrics, err
}
metrics.SlideCount = len(deck.Slides)
content, err := readQualityContent(safeRoot)
if err == nil {
sourceRefBySlideID := make(map[string]int, len(content.Slides))
for _, slide := range content.Slides {
count := 0
for _, ref := range slide.SourceRefs {
if strings.TrimSpace(ref) != "" {
count++
}
}
sourceRefBySlideID[strings.TrimSpace(slide.ID)] = count
metrics.SourceRefCount += count
}
for _, slide := range deck.Slides {
if sourceRefBySlideID[strings.TrimSpace(slide.ID)] == 0 {
metrics.SlidesWithoutSourceRefs++
}
}
}
readyAssets := map[string]deckAsset{}
if assets, err := readAssetsManifest(safeRoot); err == nil {
for _, asset := range assets.Assets {
if assetStatus(asset) != "ready" {
continue
}
path := assetPath(asset)
if path != "" {
readyAssets[path] = asset
}
}
}
for _, slide := range deck.Slides {
slidePath, err := previewSlideObjectPath(slide.Path)
if err != nil {
continue
}
raw, err := readRunRegularArtifact(safeRoot, slidePath)
if err != nil {
continue
}
svg := string(raw)
metrics.VisibleLeakCount += countVisibleLeakMarkers(svg)
fontTokens := countFontTokens(svg)
metrics.FontTokenCount += fontTokens
if fontTokens < 4 {
metrics.MissingFontTokenCount += 4 - fontTokens
}
if strings.Contains(svg, `slide:role="slide"`) || strings.Contains(svg, `slide:role='slide'`) {
metrics.SlidesWithSlideRole++
}
metrics.TextCount += strings.Count(svg, "<text")
metrics.TextCount += strings.Count(svg, `slide:shape-type="text"`)
metrics.NoteCount += strings.Count(svg, "<slide:note")
for _, ref := range activeSVGAssetRefs(svg) {
switch ref.Kind {
case "image":
metrics.ImageCount++
}
resolvedHref, hrefErr := svgHrefRunPath(slidePath, ref.Href)
if hrefErr != nil {
metrics.MissingAssetCount++
continue
}
asset, ok := readyAssets[resolvedHref]
if !ok {
metrics.MissingAssetCount++
continue
}
if err := readyAssetLocalAvailability(safeRoot, run, asset); err != nil {
metrics.MissingAssetCount++
}
}
}
return metrics, nil
}
func countVisibleLeakMarkers(svg string) int {
count := 0
lower := strings.ToLower(visibleSemanticText(svg))
for _, marker := range []string{
"sources:",
"source note",
"production_instruction",
"图片要完整",
"必须让眼镜完整出现",
"不要裁切",
"来源来自官网",
} {
count += strings.Count(lower, strings.ToLower(marker))
}
return count
}
func visibleSemanticText(svg string) string {
decoder := xml.NewDecoder(strings.NewReader(svg))
var builder strings.Builder
excludedDepth := 0
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
return builder.String()
}
return svg
}
switch typed := token.(type) {
case xml.StartElement:
if excludedDepth > 0 || semanticTextExcludedElement(typed) {
excludedDepth++
}
case xml.CharData:
if excludedDepth == 0 {
builder.WriteByte(' ')
builder.Write(typed)
}
case xml.EndElement:
if excludedDepth > 0 {
excludedDepth--
}
}
}
}
func semanticTextExcludedElement(start xml.StartElement) bool {
if start.Name.Space == slideNamespace && start.Name.Local == "note" {
return true
}
switch start.Name.Local {
case "defs", "style", "script", "metadata", "title", "desc":
return true
default:
return false
}
}
func countFontTokens(svg string) int {
count := 0
for _, token := range []string{"--font-display", "--font-body", "--font-number", "--font-label"} {
if strings.Contains(svg, token) {
count++
}
}
return count
}
func readyAssetLocalAvailability(safeRoot string, run Run, asset deckAsset) error {
path := assetPath(asset)
if strings.HasPrefix(path, "https://") {
if normalizedRouteProfile(run.RouteProfile) == RouteProfileLocalSVGDeck {
return fmt.Errorf("local_svg_deck ready image asset path %q must be a local assets/images/<file>", path)
}
return nil
}
info, _, exists, err := lstatRunPath(safeRoot, path)
if err != nil {
return err
}
if !exists || !info.Mode().IsRegular() {
return fmt.Errorf("asset path %q is missing or not a regular file", path)
}
return nil
}

500
internal/svglide/preview.go Normal file
View File

@@ -0,0 +1,500 @@
package svglide
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/validate"
)
const defaultPreviewPath = "preview.html"
const previewReceiptPath = "receipts/preview.json"
type PreviewReport struct {
Status string `json:"status"`
MissingAssetCount int `json:"missing_asset_count"`
BrowserMissingAssetCount int `json:"browser_missing_asset_count"`
RenderedVisual string `json:"rendered_visual,omitempty"`
RenderedVisualIssueCount int `json:"rendered_visual_issue_count,omitempty"`
Slides []PreviewSlideReport `json:"slides"`
}
type PreviewSlideReport struct {
Path string `json:"path"`
Rendered bool `json:"rendered"`
Message string `json:"message,omitempty"`
}
type previewDeck struct {
Title string `json:"title"`
Slides []previewDeckSlide `json:"slides"`
}
type previewDeckSlide struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Role string `json:"role"`
KeyMessage string `json:"key_message"`
Path string `json:"path"`
}
type previewPageData struct {
Title string
Status string
SlideCount int
RenderedCount int
Slides []previewPageSlide
}
type previewPageSlide struct {
Number int
ID string
Title string
Summary string
Role string
KeyMessage string
Path string
Rendered bool
Message string
}
func WritePreview(root string) (PreviewReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return PreviewReport{}, err
}
deckPath := strings.TrimSpace(run.Artifacts.Deck)
if deckPath == "" {
return writeFailedPreview(safeRoot, run, "", "deck artifact path is empty")
}
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
if err != nil {
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q: %v", deckPath, err))
}
var deck previewDeck
if err := json.Unmarshal(deckRaw, &deck); err != nil {
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err))
}
if len(deck.Slides) == 0 {
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains no slides", deckPath))
}
report := PreviewReport{Slides: make([]PreviewSlideReport, 0, len(deck.Slides))}
pageSlides := make([]previewPageSlide, 0, len(deck.Slides))
for i, slide := range deck.Slides {
slidePath, pathErr := previewSlideObjectPath(slide.Path)
pageSlide := previewPageSlide{
Number: i + 1,
ID: strings.TrimSpace(slide.ID),
Title: strings.TrimSpace(slide.Title),
Summary: strings.TrimSpace(slide.Summary),
Role: strings.TrimSpace(slide.Role),
KeyMessage: strings.TrimSpace(slide.KeyMessage),
Path: slidePath,
}
item := PreviewSlideReport{Path: slidePath}
if pathErr != nil {
item.Message = pathErr.Error()
} else if slidePath == "" {
item.Path = "(slide)"
pageSlide.Path = item.Path
item.Message = "slide path must not be empty"
} else if raw, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
item.Message = err.Error()
} else {
item.Rendered = true
pageSlide.Rendered = true
for _, ref := range activeSVGAssetRefs(string(raw)) {
resolvedHref, hrefErr := svgHrefRunPath(slidePath, ref.Href)
if hrefErr != nil {
report.BrowserMissingAssetCount++
item.Rendered = false
pageSlide.Rendered = false
item.Message = appendPreviewMessage(item.Message, hrefErr.Error())
continue
}
if strings.HasPrefix(resolvedHref, "data:") || strings.HasPrefix(resolvedHref, "http://") || strings.HasPrefix(resolvedHref, "https://") {
continue
}
if _, err := readRunRegularArtifact(safeRoot, resolvedHref); err != nil {
report.BrowserMissingAssetCount++
item.Rendered = false
pageSlide.Rendered = false
item.Message = appendPreviewMessage(item.Message, fmt.Sprintf("browser asset href %q resolves missing path %q", ref.Href, resolvedHref))
}
}
}
pageSlide.Message = item.Message
report.Slides = append(report.Slides, item)
pageSlides = append(pageSlides, pageSlide)
}
visualReport := EvaluateRenderedVisualRun(safeRoot, deck)
if err := writeRenderedVisualReport(safeRoot, visualReport); err != nil {
return report, err
}
report.RenderedVisual = renderedVisualReceiptPath
report.RenderedVisualIssueCount = visualReport.Metrics.IssueCount
if visualReport.Status != "passed" {
for i := range report.Slides {
if renderedVisualSlideFailed(visualReport, report.Slides[i].Path) {
report.Slides[i].Rendered = false
report.Slides[i].Message = appendPreviewMessage(report.Slides[i].Message, "rendered visual gate failed")
pageSlides[i].Rendered = false
pageSlides[i].Message = appendPreviewMessage(pageSlides[i].Message, "rendered visual gate failed")
}
}
}
report = normalizePreviewReport(report)
report.MissingAssetCount = MissingAssetCountForRun(safeRoot, run)
if err := writePreviewArtifacts(safeRoot, run, deck.Title, report, pageSlides); err != nil {
return report, err
}
return report, nil
}
func writeFailedPreview(safeRoot string, run Run, path string, message string) (PreviewReport, error) {
path = strings.TrimSpace(path)
if path == "" {
path = "(deck)"
}
report := normalizePreviewReport(PreviewReport{
MissingAssetCount: MissingAssetCountForRun(safeRoot, run),
RenderedVisual: renderedVisualReceiptPath,
Slides: []PreviewSlideReport{{
Path: path,
Rendered: false,
Message: message,
}},
})
pageSlides := []previewPageSlide{{
Number: 1,
Title: "Preview failed",
Path: path,
Rendered: false,
Message: message,
}}
if err := writePreviewArtifacts(safeRoot, run, run.Title, report, pageSlides); err != nil {
return report, err
}
visualReport := RenderedVisualReport{
Status: "failed",
Metrics: RenderedVisualMetrics{Slides: 1, IssueCount: 1, OutOfCanvasCount: 1},
Issues: []RenderedVisualIssue{{
Path: path,
Code: "svglide.rendered_visual.preview_failed",
Message: message,
Severity: "error",
}},
Slides: []RenderedVisualSlideItem{{Path: path, Status: "failed", IssueCount: 1}},
}
if err := writeRenderedVisualReport(safeRoot, visualReport); err != nil {
return report, err
}
return report, nil
}
func normalizePreviewReport(report PreviewReport) PreviewReport {
if report.Slides == nil {
report.Slides = []PreviewSlideReport{}
}
report.Status = "passed"
for i := range report.Slides {
report.Slides[i].Path = strings.TrimSpace(report.Slides[i].Path)
if report.Slides[i].Path == "" {
report.Slides[i].Path = "(slide)"
}
if !report.Slides[i].Rendered {
report.Status = "failed"
}
}
return report
}
func appendPreviewMessage(existing string, next string) string {
existing = strings.TrimSpace(existing)
next = strings.TrimSpace(next)
if existing == "" {
return next
}
if next == "" {
return existing
}
return existing + "; " + next
}
func previewSlideObjectPath(path string) (string, error) {
raw := strings.TrimSpace(path)
if raw == "" {
return "", fmt.Errorf("slide path must not be empty")
}
if strings.Contains(raw, `\`) {
return "", fmt.Errorf("slide path %q must use forward slashes", raw)
}
if strings.Contains(raw, "%") {
return "", fmt.Errorf("slide path %q must not contain percent encoding", raw)
}
if strings.Contains(raw, ":") || strings.Contains(raw, "//") {
return "", fmt.Errorf("slide path %q must be a local slides/*.svg path", raw)
}
parts := strings.Split(raw, "/")
if len(parts) != 2 || parts[0] != "slides" {
return "", fmt.Errorf("slide path %q must match slides/<file>.svg", raw)
}
fileName := parts[1]
if fileName == "" || fileName == "." || fileName == ".." {
return "", fmt.Errorf("slide path %q must include a slide file name", raw)
}
if strings.Contains(fileName, "/") || strings.Contains(fileName, `\`) {
return "", fmt.Errorf("slide path %q must not contain nested directories", raw)
}
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
return "", fmt.Errorf("slide path %q must not contain dot segments", raw)
}
if strings.ToLower(filepath.Ext(fileName)) != ".svg" {
return "", fmt.Errorf("slide path %q must end with .svg", raw)
}
cleaned := filepath.ToSlash(filepath.Clean(raw))
if cleaned != raw {
return "", fmt.Errorf("slide path %q must already be normalized", raw)
}
return raw, nil
}
func writePreviewArtifacts(safeRoot string, run Run, title string, report PreviewReport, slides []previewPageSlide) error {
report = normalizePreviewReport(report)
previewPath := strings.TrimSpace(run.Artifacts.Preview)
if previewPath == "" {
previewPath = defaultPreviewPath
}
target, err := ensureRunFileTargetForWrite(safeRoot, previewPath)
if err != nil {
return err
}
htmlRaw, err := renderPreviewHTML(title, report, slides)
if err != nil {
return err
}
if err := validate.AtomicWrite(target, htmlRaw, 0o644); err != nil {
return err
}
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, previewReceiptPath)
if err != nil {
return err
}
raw, err := json.MarshalIndent(report, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return validate.AtomicWrite(receiptTarget, raw, 0o644)
}
func renderPreviewHTML(title string, report PreviewReport, slides []previewPageSlide) ([]byte, error) {
title = strings.TrimSpace(title)
if title == "" {
title = "SVGlide Preview"
}
var rendered int
for _, slide := range slides {
if slide.Rendered {
rendered++
}
}
data := previewPageData{
Title: title,
Status: report.Status,
SlideCount: len(slides),
RenderedCount: rendered,
Slides: slides,
}
var b bytes.Buffer
if err := previewTemplate.Execute(&b, data); err != nil {
return nil, err
}
return b.Bytes(), nil
}
var previewTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - SVGlide Preview</title>
<link rel="icon" href="data:,">
<style>
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--ink: #1f2933;
--muted: #657286;
--line: #d8dee8;
--accent: #1d7a62;
--warn: #b42318;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.45;
}
header {
position: sticky;
top: 0;
z-index: 2;
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,.94);
backdrop-filter: blur(10px);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
max-width: 1180px;
margin: 0 auto;
padding: 18px 24px;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 650;
letter-spacing: 0;
}
.meta {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.status {
color: #fff;
background: var(--accent);
border-radius: 999px;
padding: 3px 9px;
font-weight: 650;
}
.status.failed { background: var(--warn); }
main {
max-width: 1180px;
margin: 0 auto;
padding: 22px 24px 48px;
}
.deck {
display: grid;
gap: 18px;
}
.slide {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: 16px;
align-items: start;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 16px;
box-shadow: 0 12px 24px rgba(31,41,51,.06);
}
.frame {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
object {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.missing {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 24px;
color: var(--warn);
text-align: center;
font-size: 14px;
}
.details {
min-width: 0;
display: grid;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
.details h2 {
margin: 0;
color: var(--ink);
font-size: 16px;
font-weight: 650;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.label {
color: var(--ink);
font-weight: 650;
}
.path {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.message { color: var(--warn); overflow-wrap: anywhere; }
@media (max-width: 860px) {
.bar { align-items: flex-start; flex-direction: column; gap: 8px; }
.meta { flex-wrap: wrap; white-space: normal; }
.slide { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div class="bar">
<h1>{{.Title}}</h1>
<div class="meta">
<span class="status {{.Status}}">{{.Status}}</span>
<span>{{.RenderedCount}} / {{.SlideCount}} rendered</span>
</div>
</div>
</header>
<main>
<section class="deck">
{{range .Slides}}
<article class="slide">
<div class="frame">
{{if .Rendered}}
<object data="{{.Path}}" type="image/svg+xml" aria-label="{{.Title}}"></object>
{{else}}
<div class="missing">{{.Message}}</div>
{{end}}
</div>
<div class="details">
<h2>{{printf "%02d" .Number}}. {{.Title}}</h2>
{{if .Summary}}<div><span class="label">Summary</span><br>{{.Summary}}</div>{{end}}
{{if .KeyMessage}}<div><span class="label">Key Message</span><br>{{.KeyMessage}}</div>{{end}}
{{if .Role}}<div><span class="label">Role</span><br>{{.Role}}</div>{{end}}
<div><span class="label">Path</span><br><span class="path">{{.Path}}</span></div>
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
</div>
</article>
{{end}}
</section>
</main>
</body>
</html>
`))

View File

@@ -0,0 +1,347 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWritePreviewWritesHTMLAndReceipt(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
report, err := WritePreview("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
}
if len(report.Slides) != 1 || !report.Slides[0].Rendered || report.Slides[0].Path != "slides/01.svg" {
t.Fatalf("Slides = %+v, want rendered slides/01.svg", report.Slides)
}
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
if err != nil {
t.Fatal(err)
}
html := string(htmlRaw)
for _, want := range []string{"Demo - SVGlide Preview", `<link rel="icon" href="data:,">`, `data="slides/01.svg"`, "01. Slide", "Key Message"} {
if !strings.Contains(html, want) {
t.Fatalf("preview.html missing %q:\n%s", want, html)
}
}
receipt := readPreviewReceipt(t)
if receipt.Status != "passed" || len(receipt.Slides) != 1 || !receipt.Slides[0].Rendered {
t.Fatalf("preview receipt = %+v, want passed rendered slide", receipt)
}
}
func TestWritePreviewReportsMissingAssetsFromSVGAndManifest(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("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 960 540"><image slide:role="image" href="assets/images/missing.png"/></svg>`)
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"assets/images/missing.png","usage":"Hero image","status":"ready"}]}`)
report, err := WritePreview("demo")
if err != nil {
t.Fatal(err)
}
if report.MissingAssetCount != 1 {
t.Fatalf("MissingAssetCount = %d, want 1", report.MissingAssetCount)
}
receipt := readPreviewReceipt(t)
if receipt.MissingAssetCount != 1 {
t.Fatalf("receipt MissingAssetCount = %d, want 1", receipt.MissingAssetCount)
}
}
func TestWritePreviewFailsOnRenderedVisualOverflow(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("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"><text x="92" y="318" font-size="25">Revenue declined 4.3% year over year, but gross margin reached 46.6% and diluted EPS set a March-quarter record.</text></svg>`)
report, err := WritePreview("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
}
if report.RenderedVisual != renderedVisualReceiptPath || report.RenderedVisualIssueCount == 0 {
t.Fatalf("rendered visual fields = %q/%d, want receipt and issues", report.RenderedVisual, report.RenderedVisualIssueCount)
}
var visual RenderedVisualReport
raw, err := os.ReadFile(filepath.Join("demo", renderedVisualReceiptPath))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &visual); err != nil {
t.Fatal(err)
}
if visual.Status != "failed" || !renderedVisualHasCode(visual, "svglide.rendered_visual.text_overflow") {
t.Fatalf("visual = %+v, want text overflow failure", visual)
}
}
func TestWritePreviewEscapesDeckText(t *testing.T) {
initValidateTestRun(t)
writeDeckAt(t, filepath.Join("demo", "outline", "deck.json"), previewDeck{
Title: `<Deck & Demo>`,
Slides: []previewDeckSlide{{
ID: "cover",
Title: `<Cover & One>`,
Summary: `Summary <script>bad()</script>`,
Role: "cover",
KeyMessage: `Message & context`,
Path: "slides/01.svg",
}},
})
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
if _, err := WritePreview("demo"); err != nil {
t.Fatal(err)
}
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
if err != nil {
t.Fatal(err)
}
html := string(htmlRaw)
if strings.Contains(html, "<script>bad()</script>") {
t.Fatalf("preview.html contains unescaped script:\n%s", html)
}
if !strings.Contains(html, "&lt;script&gt;bad()&lt;/script&gt;") || !strings.Contains(html, "&lt;Deck &amp; Demo&gt;") {
t.Fatalf("preview.html missing escaped deck text:\n%s", html)
}
}
func TestWritePreviewUsesRunArtifactDeckAndPreviewPath(t *testing.T) {
initValidateTestRun(t)
run := readValidateTestRunFile(t)
run.Artifacts.Deck = "custom/deck.json"
run.Artifacts.Preview = "public/deck.html"
writeValidateTestRunFile(t, run)
writeMinimalDeck(t, "demo", "slides/missing.svg")
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
report, err := WritePreview("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
}
if _, err := os.Stat(filepath.Join("demo", "public", "deck.html")); err != nil {
t.Fatalf("missing custom preview path: %v", err)
}
if _, err := os.Stat(filepath.Join("demo", "preview.html")); !os.IsNotExist(err) {
t.Fatalf("default preview should not be written when artifact path is custom, stat err=%v", err)
}
}
func TestWritePreviewReportsUnsafeSlidePath(t *testing.T) {
tests := []struct {
name string
slidePath string
filePath string
wantMessage string
}{
{
name: "escape",
slidePath: "../outside.svg",
filePath: "outside.svg",
wantMessage: "slides/<file>.svg",
},
{
name: "remote scheme",
slidePath: "https:/evil.example/a.svg",
filePath: filepath.Join("demo", "https:", "evil.example", "a.svg"),
wantMessage: "local slides/*.svg",
},
{
name: "encoded dot segment",
slidePath: "slides/%2e%2e.svg",
filePath: filepath.Join("demo", "slides", "%2e%2e.svg"),
wantMessage: "percent encoding",
},
{
name: "nested directory",
slidePath: "slides/nested/01.svg",
filePath: filepath.Join("demo", "slides", "nested", "01.svg"),
wantMessage: "slides/<file>.svg",
},
{
name: "backslash",
slidePath: `slides\01.svg`,
filePath: filepath.Join("demo", `slides\01.svg`),
wantMessage: "forward slashes",
},
{
name: "wrong extension",
slidePath: "slides/01.png",
filePath: filepath.Join("demo", "slides", "01.png"),
wantMessage: ".svg",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initValidateTestRun(t)
writeMinimalDeck(t, "demo", tt.slidePath)
writeValidateTestFile(t, tt.filePath, visibleTextSVG())
report, err := WritePreview("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
}
if len(report.Slides) != 1 || report.Slides[0].Rendered {
t.Fatalf("Slides = %+v, want unrendered slide", report.Slides)
}
if !strings.Contains(report.Slides[0].Message, tt.wantMessage) {
t.Fatalf("Message = %q, want %q", report.Slides[0].Message, tt.wantMessage)
}
receipt := readPreviewReceipt(t)
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Rendered {
t.Fatalf("preview receipt = %+v, want failed unrendered slide", receipt)
}
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(htmlRaw), `data="`) {
t.Fatalf("preview should not embed unsafe slide path:\n%s", string(htmlRaw))
}
})
}
}
func TestWritePreviewWritesFailureArtifactsForDeckReadFailures(t *testing.T) {
initValidateTestRun(t)
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
report, err := WritePreview("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
}
if _, err := os.Stat(filepath.Join("demo", "preview.html")); err != nil {
t.Fatalf("missing preview.html for failed deck read: %v", err)
}
receipt := readPreviewReceipt(t)
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Path != "outline/deck.json" {
t.Fatalf("preview receipt = %+v, want failed deck report", receipt)
}
}
func TestWritePreviewRejectsPreviewSymlink(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.html")
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join("demo", "preview.html")); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "preview.html")); err != nil {
t.Fatal(err)
}
if _, err := WritePreview("demo"); err == nil {
t.Fatal("expected preview symlink write refusal")
}
raw, err := os.ReadFile(outside)
if err != nil {
t.Fatal(err)
}
if string(raw) != "outside" {
t.Fatalf("outside preview overwritten: %q", string(raw))
}
}
func TestWritePreviewRejectsPreviewReceiptSymlink(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.json")
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join("demo", "receipts", "preview.json")); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "preview.json")); err != nil {
t.Fatal(err)
}
if _, err := WritePreview("demo"); err == nil {
t.Fatal("expected preview receipt symlink write refusal")
}
raw, err := os.ReadFile(outside)
if err != nil {
t.Fatal(err)
}
if string(raw) != "outside" {
t.Fatalf("outside preview receipt overwritten: %q", string(raw))
}
}
func TestWritePreviewRejectsPreviewReceiptsDirectorySymlink(t *testing.T) {
cwd := initValidateTestRun(t)
writeMinimalDeck(t, "demo", "slides/01.svg")
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(cwd), "outside-preview-receipts")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
t.Fatal(err)
}
if _, err := WritePreview("demo"); err == nil {
t.Fatal("expected preview receipts directory symlink write refusal")
}
if _, err := os.Stat(filepath.Join(outside, "preview.json")); !os.IsNotExist(err) {
t.Fatalf("preview receipt should not be written outside run root, stat err=%v", err)
}
}
func readPreviewReceipt(t *testing.T) PreviewReport {
t.Helper()
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "preview.json"))
if err != nil {
t.Fatal(err)
}
var receipt PreviewReport
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatal(err)
}
return receipt
}
func writeDeckAt(t *testing.T, path string, deck previewDeck) {
t.Helper()
raw, err := json.MarshalIndent(deck, "", " ")
if err != nil {
t.Fatal(err)
}
raw = append(raw, '\n')
writeValidateTestFile(t, path, string(raw))
}

1267
internal/svglide/prompt.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,900 @@
package svglide
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"gopkg.in/yaml.v3"
)
const (
ProtocolAnyGenSVGSlides = "anygen-svg-slides"
)
type PromptAssetContract struct {
ID string `json:"id" yaml:"id"`
Role string `json:"role" yaml:"role"`
OrchestratedBy string `json:"orchestrated_by,omitempty" yaml:"orchestrated_by,omitempty"`
Invocation string `json:"invocation,omitempty" yaml:"invocation,omitempty"`
Stage string `json:"stage,omitempty" yaml:"stage,omitempty"`
Order int `json:"order,omitempty" yaml:"order,omitempty"`
Cardinality string `json:"cardinality,omitempty" yaml:"cardinality,omitempty"`
Requires []string `json:"requires,omitempty" yaml:"requires,omitempty"`
Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
Trigger []string `json:"trigger,omitempty" yaml:"trigger,omitempty"`
Consumes []string `json:"consumes,omitempty" yaml:"consumes,omitempty"`
Produces []string `json:"produces,omitempty" yaml:"produces,omitempty"`
CompletionGate []string `json:"completion_gate,omitempty" yaml:"completion_gate,omitempty"`
PhaseAnchors []string `json:"phase_anchors,omitempty" yaml:"phase_anchors,omitempty"`
Profiles []string `json:"profiles,omitempty" yaml:"profiles,omitempty"`
Exposure string `json:"exposure,omitempty" yaml:"exposure,omitempty"`
Rules []any `json:"-" yaml:"rules,omitempty"`
Path string `json:"path" yaml:"-"`
SHA256 string `json:"sha256" yaml:"-"`
}
type AnyGenOrchestrationGraph struct {
Protocol string `json:"protocol"`
Orchestrator PromptAssetContract `json:"orchestrator"`
ProtocolReference PromptAssetContract `json:"protocol_reference"`
Assets []PromptAssetContract `json:"assets"`
}
type ToolInvocationContract struct {
Protocol string `json:"protocol"`
RequiredCalls []ToolCallRequirement `json:"required_calls"`
ConditionalCalls []ToolCallRequirement `json:"conditional_calls"`
}
type ToolCallRequirement struct {
ID string `json:"id"`
Stage string `json:"stage,omitempty"`
PromptID string `json:"prompt_id"`
Invocation string `json:"invocation,omitempty"`
Order int `json:"order,omitempty"`
Cardinality string `json:"cardinality"`
Condition string `json:"condition"`
Consumes []string `json:"consumes"`
Produces []string `json:"produces"`
}
type StagePromptContract struct {
Protocol string `json:"protocol"`
Stage string `json:"stage"`
ContextReceipt string `json:"context_receipt,omitempty"`
Orchestrator string `json:"orchestrator"`
ProtocolReference string `json:"protocol_reference"`
RequiredPromptIDs []string `json:"required_prompt_ids"`
ConditionalPromptIDs []string `json:"conditional_prompt_ids,omitempty"`
PhaseAnchors []string `json:"phase_anchors,omitempty"`
}
type PromptContextAsset struct {
ID string `json:"id"`
Role string `json:"role"`
Path string `json:"path"`
SHA256 string `json:"sha256"`
Required bool `json:"required"`
}
type PromptContext struct {
ReadPolicy string `json:"read_policy"`
Authority string `json:"authority"`
Assets []PromptContextAsset `json:"assets"`
}
type AgentTask struct {
Protocol string `json:"protocol"`
Stage string `json:"stage"`
Objective string `json:"objective"`
Orchestrator string `json:"orchestrator"`
ProtocolReference string `json:"protocol_reference"`
RequiredPrompts []string `json:"required_prompts"`
RequiredCalls []ToolCallRequirement `json:"required_calls"`
ConditionalCalls []ToolCallRequirement `json:"conditional_calls,omitempty"`
PhaseAnchors []string `json:"phase_anchors,omitempty"`
Inputs []string `json:"inputs"`
Outputs []string `json:"outputs"`
CompletionGate []string `json:"completion_gate"`
ToolCallReceiptDir string `json:"tool_call_receipt_dir"`
PromptContext PromptContext `json:"prompt_context"`
}
type PromptContextReceipt struct {
Stage string `json:"stage"`
Protocol string `json:"protocol"`
AgentTask AgentTask `json:"agent_task"`
PromptContract StagePromptContract `json:"prompt_contract"`
ToolInvocationContract ToolInvocationContract `json:"tool_invocation_contract"`
AssetHashes map[string]string `json:"asset_hashes"`
}
func LoadAnyGenPromptAssets() ([]PromptAssetContract, error) {
manifest := DefaultPromptManifest()
assets := make([]PromptAssetContract, 0, len(manifest.Entries))
ids := map[string]bool{}
for _, entry := range manifest.Entries {
asset, err := loadPromptAssetContract(entry.Path)
if err != nil {
return nil, err
}
expectedID := entry.ID
if expectedID == "" {
expectedID = entry.Name
}
if asset.ID != expectedID {
return nil, fmt.Errorf("%s: prompt asset id = %q, want %q", entry.Path, asset.ID, expectedID)
}
if len(asset.Profiles) == 0 {
asset.Profiles = slices.Clone(entry.Profiles)
}
if asset.Exposure == "" {
asset.Exposure = entry.Exposure
}
if ids[asset.ID] {
return nil, fmt.Errorf("duplicate prompt asset id %q", asset.ID)
}
ids[asset.ID] = true
assets = append(assets, asset)
}
return assets, nil
}
func loadPromptAssetContract(path string) (PromptAssetContract, error) {
raw, err := readPromptAssetFile(path)
if err != nil {
return PromptAssetContract{}, err
}
frontmatter, err := semanticMarkdownFrontmatter(path, raw)
if err != nil {
return PromptAssetContract{}, err
}
var asset PromptAssetContract
decoder := yaml.NewDecoder(bytes.NewReader(frontmatter))
decoder.KnownFields(true)
if err := decoder.Decode(&asset); err != nil {
return PromptAssetContract{}, fmt.Errorf("%s frontmatter: %w", path, err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return PromptAssetContract{}, fmt.Errorf("%s frontmatter must contain a single YAML document", path)
}
return PromptAssetContract{}, fmt.Errorf("%s frontmatter: %w", path, err)
}
asset.Path = filepath.ToSlash(filepath.Clean(path))
asset.SHA256, err = promptAssetSHAStrict(path)
if err != nil {
return PromptAssetContract{}, err
}
if err := validatePromptAssetContract(asset); err != nil {
return PromptAssetContract{}, fmt.Errorf("%s: %w", path, err)
}
return asset, nil
}
func validatePromptAssetContract(asset PromptAssetContract) error {
if strings.TrimSpace(asset.ID) == "" {
return fmt.Errorf("missing id")
}
if strings.TrimSpace(asset.Role) == "" {
return fmt.Errorf("missing role")
}
if strings.TrimSpace(asset.Invocation) == "" {
return fmt.Errorf("missing invocation")
}
switch asset.Role {
case "source_snapshot", "reference_index", "semantic_contract", "runtime_binding":
if asset.Invocation != "reference" {
return fmt.Errorf("role %s must use invocation reference", asset.Role)
}
case "orchestrator":
if asset.Invocation != "required" || asset.ID != "mode_system_prompt_svg" {
return fmt.Errorf("orchestrator must be mode_system_prompt_svg with required invocation")
}
case "protocol_reference":
if asset.Invocation != "required" || asset.ID != "svg_reference" {
return fmt.Errorf("protocol_reference must be svg_reference with required invocation")
}
case "tool_prompt":
if asset.OrchestratedBy != "mode_system_prompt_svg" {
return fmt.Errorf("tool prompt %s must be orchestrated_by mode_system_prompt_svg", asset.ID)
}
if asset.Invocation != "required" && asset.Invocation != "conditional" {
return fmt.Errorf("tool prompt %s uses unsupported invocation %q", asset.ID, asset.Invocation)
}
if strings.TrimSpace(asset.Stage) == "" {
return fmt.Errorf("tool prompt %s missing stage", asset.ID)
}
if strings.TrimSpace(asset.Cardinality) == "" {
return fmt.Errorf("tool prompt %s missing cardinality", asset.ID)
}
if strings.TrimSpace(asset.Condition) == "" {
return fmt.Errorf("tool prompt %s missing condition", asset.ID)
}
if len(asset.Consumes) == 0 {
return fmt.Errorf("tool prompt %s missing consumes", asset.ID)
}
if len(asset.Produces) == 0 {
return fmt.Errorf("tool prompt %s missing produces", asset.ID)
}
if asset.Invocation == "conditional" && len(asset.Trigger) == 0 {
return fmt.Errorf("conditional tool prompt %s missing trigger", asset.ID)
}
default:
return fmt.Errorf("unsupported role %q", asset.Role)
}
return nil
}
func BuildAnyGenOrchestrationGraph() (AnyGenOrchestrationGraph, error) {
assets, err := LoadAnyGenPromptAssets()
if err != nil {
return AnyGenOrchestrationGraph{}, err
}
var orchestrators []PromptAssetContract
var references []PromptAssetContract
for _, asset := range assets {
switch asset.Role {
case "orchestrator":
orchestrators = append(orchestrators, asset)
case "protocol_reference":
references = append(references, asset)
}
}
if len(orchestrators) != 1 {
return AnyGenOrchestrationGraph{}, fmt.Errorf("expected exactly one orchestrator, got %d", len(orchestrators))
}
if len(references) != 1 {
return AnyGenOrchestrationGraph{}, fmt.Errorf("expected exactly one protocol reference, got %d", len(references))
}
return AnyGenOrchestrationGraph{
Protocol: ProtocolAnyGenSVGSlides,
Orchestrator: orchestrators[0],
ProtocolReference: references[0],
Assets: assets,
}, nil
}
func PromptAssetsForProfileStage(profile string, stage string) ([]PromptAssetContract, error) {
assets, err := LoadAnyGenPromptAssets()
if err != nil {
return nil, err
}
out := make([]PromptAssetContract, 0, len(assets))
for _, asset := range assets {
if !promptAssetAllowedForProfile(asset.Profiles, profile) {
continue
}
if asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.AlwaysForPromptContext(stage) || asset.Stage == stage {
out = append(out, asset)
}
}
return out, nil
}
func BuildToolInvocationContract(run Run, stage string) (ToolInvocationContract, error) {
assets, err := PromptAssetsForProfileStage(run.RouteProfile, stage)
if err != nil {
return ToolInvocationContract{}, err
}
contract := ToolInvocationContract{Protocol: ProtocolAnyGenSVGSlides}
for _, asset := range assets {
if asset.Role != "tool_prompt" {
continue
}
req := toolRequirementFromAsset(asset)
switch asset.Invocation {
case "required":
contract.RequiredCalls = append(contract.RequiredCalls, req)
case "conditional":
contract.ConditionalCalls = append(contract.ConditionalCalls, req)
}
}
return contract, nil
}
func RequiredPromptContractForStage(stage string, run Run) (StagePromptContract, error) {
assets, err := PromptAssetsForProfileStage(run.RouteProfile, stage)
if err != nil {
return StagePromptContract{}, err
}
contract := StagePromptContract{
Protocol: ProtocolAnyGenSVGSlides,
Stage: stage,
ContextReceipt: promptContextReceiptPath(stage),
Orchestrator: "mode_system_prompt_svg",
ProtocolReference: "svg_reference",
}
for _, asset := range assets {
if asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.Role == "runtime_binding" || asset.AlwaysForPromptContext(stage) {
if asset.Invocation == "conditional" {
contract.ConditionalPromptIDs = appendUnique(contract.ConditionalPromptIDs, asset.ID)
} else {
contract.RequiredPromptIDs = appendUnique(contract.RequiredPromptIDs, asset.ID)
}
}
if asset.Stage == stage && len(asset.PhaseAnchors) > 0 {
contract.PhaseAnchors = append(contract.PhaseAnchors, asset.PhaseAnchors...)
}
}
if stage == StageResearch {
contract.PhaseAnchors = []string{"Phase 3 - Build source material"}
}
if stage == StageSlideContent {
contract.PhaseAnchors = []string{"Phase 6 - Write slide_content.md"}
}
if stage == StageAssets {
contract.PhaseAnchors = appendUnique(contract.PhaseAnchors, "Phase 7 - Lock the visual direction & plan visuals")
contract.PhaseAnchors = appendUnique(contract.PhaseAnchors, "<visuals>")
}
return contract, nil
}
func (asset PromptAssetContract) AlwaysForPromptContext(stage string) bool {
return asset.Role == "reference_index" || asset.Role == "semantic_contract" || asset.Role == "runtime_binding" || asset.Stage == stage
}
func RequiredToolCallsForStage(stage string, run Run) ([]ToolCallRequirement, error) {
contract, err := BuildToolInvocationContract(run, stage)
if err != nil {
return nil, err
}
var calls []ToolCallRequirement
for _, call := range contract.RequiredCalls {
if call.Stage == stage {
calls = append(calls, call)
}
}
return calls, nil
}
func TriggeredConditionalToolCalls(stage string, run Run, safeRoot string) ([]ToolCallRequirement, error) {
contract, err := BuildToolInvocationContract(run, stage)
if err != nil {
return nil, err
}
var calls []ToolCallRequirement
for _, call := range contract.ConditionalCalls {
if call.Stage != stage {
continue
}
matched, err := conditionMatched(call.Condition, run, safeRoot)
if err != nil {
return nil, err
}
if matched {
calls = append(calls, call)
}
}
return calls, nil
}
func BuildAgentTask(stage Stage, run Run, safeRoot string, inputs, outputs []string) (AgentTask, StagePromptContract, ToolInvocationContract, error) {
promptContract, err := RequiredPromptContractForStage(stage.Name, run)
if err != nil {
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
}
promptContext, err := promptContextForPromptContract(promptContract)
if err != nil {
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
}
requiredCalls, err := RequiredToolCallsForStage(stage.Name, run)
if err != nil {
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
}
conditionalCalls, err := TriggeredConditionalToolCalls(stage.Name, run, safeRoot)
if err != nil {
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
}
stageContract := ToolInvocationContract{
Protocol: ProtocolAnyGenSVGSlides,
RequiredCalls: requiredCalls,
ConditionalCalls: conditionalCalls,
}
task := AgentTask{
Protocol: ProtocolAnyGenSVGSlides,
Stage: stage.Name,
Objective: stageObjective(stage.Name),
Orchestrator: promptContract.Orchestrator,
ProtocolReference: promptContract.ProtocolReference,
RequiredPrompts: promptContract.RequiredPromptIDs,
RequiredCalls: requiredCalls,
ConditionalCalls: conditionalCalls,
PhaseAnchors: promptContract.PhaseAnchors,
Inputs: inputs,
Outputs: outputs,
CompletionGate: completionGateForStage(stage.Name, requiredCalls, conditionalCalls),
ToolCallReceiptDir: filepath.ToSlash(filepath.Join("receipts", "tool_calls", stage.Name)),
PromptContext: promptContext,
}
return task, promptContract, stageContract, nil
}
func WritePromptContextReceipt(safeRoot string, stageName string, task AgentTask, promptContract StagePromptContract, toolContract ToolInvocationContract) error {
assetHashes := map[string]string{}
for _, asset := range task.PromptContext.Assets {
assetHashes[asset.ID] = asset.SHA256
}
target, err := ensureRunFileTargetForWrite(safeRoot, promptContextReceiptPath(stageName))
if err != nil {
return err
}
return writeJSON(target, PromptContextReceipt{
Stage: stageName,
Protocol: ProtocolAnyGenSVGSlides,
AgentTask: task,
PromptContract: promptContract,
ToolInvocationContract: toolContract,
AssetHashes: assetHashes,
})
}
func ValidatePromptContextForStage(safeRoot string, stageName string, run Run) (PromptContextReceipt, error) {
if stageName == StageRequest {
return PromptContextReceipt{}, nil
}
raw, err := readRunRegularArtifact(safeRoot, promptContextReceiptPath(stageName))
if err != nil {
return PromptContextReceipt{}, fmt.Errorf("missing_prompt_context: %w", err)
}
var receipt PromptContextReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
return PromptContextReceipt{}, fmt.Errorf("invalid prompt context receipt: %w", err)
}
if receipt.Stage != stageName {
return PromptContextReceipt{}, fmt.Errorf("wrong_stage_prompt_context: got %q want %q", receipt.Stage, stageName)
}
expectedContract, err := RequiredPromptContractForStage(stageName, run)
if err != nil {
return PromptContextReceipt{}, err
}
expectedContext, err := promptContextForPromptContract(expectedContract)
if err != nil {
return PromptContextReceipt{}, err
}
allowedIDs := make(map[string]string, len(expectedContext.Assets))
requiredIDs := make(map[string]string, len(expectedContext.Assets))
for _, asset := range expectedContext.Assets {
allowedIDs[asset.ID] = asset.SHA256
if !asset.Required {
continue
}
requiredIDs[asset.ID] = asset.SHA256
}
for id, want := range requiredIDs {
got, ok := receipt.AssetHashes[id]
if !ok {
return PromptContextReceipt{}, fmt.Errorf("missing_prompt_context_asset: %s", id)
}
if got != want {
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", id, got, want)
}
}
for _, asset := range receipt.AgentTask.PromptContext.Assets {
want, ok := allowedIDs[asset.ID]
if !ok {
return PromptContextReceipt{}, fmt.Errorf("prompt context asset %q is not allowed for route profile %q stage %q", asset.ID, run.RouteProfile, stageName)
}
if strings.TrimSpace(asset.SHA256) != "" && asset.SHA256 != want {
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", asset.ID, asset.SHA256, want)
}
}
for id, want := range receipt.AssetHashes {
expectedHash, ok := allowedIDs[id]
if !ok {
return PromptContextReceipt{}, fmt.Errorf("prompt context asset %q is not allowed for route profile %q stage %q", id, run.RouteProfile, stageName)
}
if expectedHash != want {
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", id, want, expectedHash)
}
}
return receipt, nil
}
func ValidateToolCallReceiptsForStage(safeRoot string, stageName string, run Run, receipt PromptContextReceipt) error {
if stageName == StageRequest {
return nil
}
requiredCalls, err := RequiredToolCallsForStage(stageName, run)
if err != nil {
return err
}
conditionalCalls, err := TriggeredConditionalToolCalls(stageName, run, safeRoot)
if err != nil {
return err
}
calls := append([]ToolCallRequirement{}, requiredCalls...)
calls = append(calls, conditionalCalls...)
promptIDs := promptIDsFromReceipt(receipt)
for _, call := range calls {
path := filepath.Join("receipts", "tool_calls", stageName, call.ID+".json")
raw, err := readRunRegularArtifact(safeRoot, path)
if err != nil {
return fmt.Errorf("missing_tool_call: %s: %w", call.ID, err)
}
var toolReceipt struct {
Stage string `json:"stage"`
CallID string `json:"call_id"`
PromptID string `json:"prompt_id"`
Invocation string `json:"invocation"`
Condition string `json:"condition"`
ConditionMatched bool `json:"condition_matched"`
Order int `json:"order"`
Cardinality string `json:"cardinality"`
Status string `json:"status"`
Consumed []string `json:"consumed"`
Produced []string `json:"produced"`
}
if err := json.Unmarshal(raw, &toolReceipt); err != nil {
return fmt.Errorf("%s: invalid tool call receipt: %w", path, err)
}
if toolReceipt.Stage != stageName || toolReceipt.CallID != call.ID || toolReceipt.PromptID != call.PromptID || toolReceipt.Status != StatusDone {
return fmt.Errorf("%s: receipt does not satisfy tool call %s", path, call.ID)
}
if toolReceipt.Invocation != call.Invocation || toolReceipt.Condition != call.Condition || toolReceipt.Cardinality != call.Cardinality || toolReceipt.Order != call.Order {
return fmt.Errorf("%s: receipt contract mismatch for tool call %s", path, call.ID)
}
if !toolReceipt.ConditionMatched {
return fmt.Errorf("%s: condition_matched must be true for required tool call %s", path, call.ID)
}
if !stringSlicesEqual(toolReceipt.Consumed, call.Consumes) {
return fmt.Errorf("%s: consumed artifacts = %v, want %v", path, toolReceipt.Consumed, call.Consumes)
}
if !stringSlicesEqual(toolReceipt.Produced, call.Produces) {
return fmt.Errorf("%s: produced artifacts = %v, want %v", path, toolReceipt.Produced, call.Produces)
}
if !promptIDs[toolReceipt.PromptID] {
return fmt.Errorf("%s: prompt_id %q is not in current prompt context", path, toolReceipt.PromptID)
}
if err := validateToolReceiptArtifactsExist(safeRoot, path, "consumed", toolReceipt.Consumed); err != nil {
return err
}
if err := validateToolReceiptArtifactsExist(safeRoot, path, "produced", toolReceipt.Produced); err != nil {
return err
}
}
return nil
}
func ValidateArtifactPromptContractForStage(safeRoot string, stageName string, outputs []string) error {
if stageName == StageRequest || stageName == StageValidatePreviewRepair {
return nil
}
for _, output := range outputs {
if hasGlobMeta(output) || !strings.HasSuffix(output, ".json") || strings.HasPrefix(output, "receipts/") || output == "quality_report.json" {
continue
}
raw, err := readRunRegularArtifact(safeRoot, output)
if err != nil {
return err
}
var artifact struct {
PromptContract StagePromptContract `json:"prompt_contract"`
}
if err := json.Unmarshal(raw, &artifact); err != nil {
return fmt.Errorf("%s: invalid JSON: %w", output, err)
}
if artifact.PromptContract.Protocol == "" {
return fmt.Errorf("%s: missing prompt_contract", output)
}
if artifact.PromptContract.Stage != stageName {
return fmt.Errorf("%s: prompt_contract.stage = %q, want %q", output, artifact.PromptContract.Stage, stageName)
}
if artifact.PromptContract.Orchestrator != "mode_system_prompt_svg" {
return fmt.Errorf("%s: prompt_contract.orchestrator = %q, want mode_system_prompt_svg", output, artifact.PromptContract.Orchestrator)
}
if artifact.PromptContract.ProtocolReference != "svg_reference" {
return fmt.Errorf("%s: prompt_contract.protocol_reference = %q, want svg_reference", output, artifact.PromptContract.ProtocolReference)
}
}
return nil
}
func promptContextReceiptPath(stage string) string {
return filepath.ToSlash(filepath.Join("receipts", "prompt_context", stage+".json"))
}
func promptContextForPromptContract(contract StagePromptContract) (PromptContext, error) {
ids := append([]string{}, contract.RequiredPromptIDs...)
ids = append(ids, contract.ConditionalPromptIDs...)
assets := make([]PromptContextAsset, 0, len(ids))
assetByID, err := promptAssetsByID()
if err != nil {
return PromptContext{}, err
}
for _, id := range ids {
asset, ok := assetByID[id]
if !ok {
return PromptContext{}, fmt.Errorf("prompt context references unknown prompt id %q", id)
}
assets = append(assets, PromptContextAsset{
ID: id,
Role: asset.Role,
Path: asset.Path,
SHA256: asset.SHA256,
Required: slices.Contains(contract.RequiredPromptIDs, id),
})
}
return PromptContext{
ReadPolicy: "read_required_assets_before_authoring",
Authority: "cli_runtime_protocol",
Assets: assets,
}, nil
}
func toolRequirementFromAsset(asset PromptAssetContract) ToolCallRequirement {
return ToolCallRequirement{
ID: asset.ID,
Stage: asset.Stage,
PromptID: asset.ID,
Invocation: asset.Invocation,
Order: asset.Order,
Cardinality: asset.Cardinality,
Condition: asset.Condition,
Consumes: slices.Clone(asset.Consumes),
Produces: slices.Clone(asset.Produces),
}
}
func promptPathByID(id string) string {
for _, entry := range DefaultPromptManifest().Entries {
entryID := entry.ID
if entryID == "" {
entryID = entry.Name
}
if entryID == id {
return entry.Path
}
}
return ""
}
func promptRoleByID(id string) string {
for _, entry := range DefaultPromptManifest().Entries {
entryID := entry.ID
if entryID == "" {
entryID = entry.Name
}
if entryID == id {
return entry.Role
}
}
return ""
}
func promptAssetSHA(path string) string {
hash, err := promptAssetSHAStrict(path)
if err == nil {
return hash
}
raw, err := os.ReadFile(path)
if err != nil {
raw = []byte("missing:" + path)
}
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
func promptAssetSHAStrict(path string) (string, error) {
raw, err := readPromptAssetFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
func readPromptAssetFile(path string) ([]byte, error) {
readPath := resolvePromptAssetReadPath(path)
raw, err := os.ReadFile(readPath)
if err != nil {
return nil, fmt.Errorf("read prompt asset %q: %w", path, err)
}
return raw, nil
}
func resolvePromptAssetReadPath(path string) string {
if filepath.IsAbs(path) {
return path
}
if _, err := os.Stat(path); err == nil {
return path
}
_, file, _, ok := runtime.Caller(0)
if !ok {
return path
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
return filepath.Join(repoRoot, path)
}
func promptAssetsByID() (map[string]PromptAssetContract, error) {
assets, err := LoadAnyGenPromptAssets()
if err != nil {
return nil, err
}
out := make(map[string]PromptAssetContract, len(assets))
for _, asset := range assets {
out[asset.ID] = asset
}
return out, nil
}
func promptIDsFromReceipt(receipt PromptContextReceipt) map[string]bool {
ids := make(map[string]bool, len(receipt.AgentTask.PromptContext.Assets)+len(receipt.AssetHashes))
for _, asset := range receipt.AgentTask.PromptContext.Assets {
ids[asset.ID] = true
}
for id := range receipt.AssetHashes {
ids[id] = true
}
return ids
}
func validateToolReceiptArtifactsExist(safeRoot string, receiptPath string, field string, paths []string) error {
if len(paths) == 0 {
return fmt.Errorf("%s: %s must not be empty", receiptPath, field)
}
for _, rel := range paths {
rel = strings.TrimSpace(rel)
if rel == "" {
return fmt.Errorf("%s: %s contains empty path", receiptPath, field)
}
if hasGlobMeta(rel) {
matches, err := filepath.Glob(filepath.Join(safeRoot, filepath.Clean(rel)))
if err != nil {
return fmt.Errorf("%s: %s glob %q invalid: %w", receiptPath, field, rel, err)
}
if len(matches) == 0 {
return fmt.Errorf("%s: %s glob %q matched no artifacts", receiptPath, field, rel)
}
continue
}
if _, err := readRunRegularArtifact(safeRoot, rel); err != nil {
return fmt.Errorf("%s: %s artifact %q invalid: %w", receiptPath, field, rel, err)
}
}
return nil
}
func stageObjective(stage string) string {
switch stage {
case StageRequestResolution:
return "识别用户请求的真实实体、主题类型、置信度和歧义;低置信度时阻断后续研究。"
case StageResearch:
return "基于用户主题和本地/网页资料建立 source material。"
case StageDesignBrief:
return "调用/遵守 resolve_design_brief生成 narrative spine、depth、tone、visual system。"
case StageOutline:
return "调用/遵守 slide_outline生成 deck outline、页角色、key message 和 style instruction。"
case StageSlideContent:
return "按 mode_system_prompt_svg Phase 6 生成逐页内容稿、source refs 和 visual intents。"
case StageAssets:
return "按 <visuals> 规划/准备图片、图表、diagram、fallback不得无理由全 diagram。"
case StageSVGAuthor:
return "调用/遵守 activate_slides_edit 和 slides_edit按 svg_reference 写完整 SVG slides。"
case StageValidatePreviewRepair:
return "调用/遵守 finish_slides_edit执行 validate、preview、quality、semantic repair。"
default:
return "初始化或推进当前 SVGlide run stage。"
}
}
func completionGateForStage(stage string, required, conditional []ToolCallRequirement) []string {
var gates []string
for _, call := range append(append([]ToolCallRequirement{}, required...), conditional...) {
gates = append(gates, call.Produces...)
}
if len(gates) == 0 {
switch stage {
case StageResearch:
gates = []string{"sources_material_ready"}
case StageSlideContent:
gates = []string{"slide_content_ready"}
case StageAssets:
gates = []string{"assets_plan_ready"}
default:
gates = []string{"stage_outputs_ready"}
}
}
return gates
}
func appendUnique(values []string, value string) []string {
if value == "" || slices.Contains(values, value) {
return values
}
return append(values, value)
}
func stringSlicesEqual(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range want {
if strings.TrimSpace(got[i]) != strings.TrimSpace(want[i]) {
return false
}
}
return true
}
func conditionMatched(condition string, run Run, safeRoot string) (bool, error) {
switch condition {
case "", "always":
return true, nil
case "svg_has_custom_path":
matches, _ := filepath.Glob(filepath.Join(safeRoot, "slides", "*.svg"))
for _, path := range matches {
raw, err := os.ReadFile(path)
if err != nil {
return false, err
}
if strings.Contains(string(raw), `slide:shape-type="custom"`) {
return true, nil
}
}
return false, nil
case "visual_type_chart":
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
if err != nil {
return false, nil
}
return strings.Contains(string(raw), `"type":"chart"`) || strings.Contains(string(raw), `"type": "chart"`), nil
case "required_chart_renderer_vega_lite":
if contractRequiresVegaLiteChart(safeRoot) {
return true, nil
}
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
if err != nil {
return false, nil
}
return strings.Contains(string(raw), `"type":"chart"`) || strings.Contains(string(raw), `"type": "chart"`), nil
case "legacy_or_non_chart_svg_visual_reference_only":
return run.RouteProfile == routeProfileImportedPPTX || run.RouteProfile == routeProfileLegacyEditor, nil
case "input_is_pptx":
if run.RouteProfile != routeProfileImportedPPTX {
return false, nil
}
return strings.EqualFold(filepath.Ext(run.Intent.Input), ".pptx") || strings.EqualFold(filepath.Ext(run.Input), ".pptx"), nil
case "template_requested":
if run.RouteProfile != routeProfileTemplateReference {
return false, nil
}
raw, err := readRunRegularArtifact(safeRoot, "request/request.json")
if err != nil {
return false, nil
}
return strings.Contains(string(raw), `"template":true`) ||
strings.Contains(string(raw), `"template": true`) ||
strings.Contains(string(raw), `"template_requested":true`) ||
strings.Contains(string(raw), `"template_requested": true`), nil
case "outline_changed_after_initial_generation":
return false, nil
default:
return false, nil
}
}
func contractRequiresVegaLiteChart(safeRoot string) bool {
for _, rel := range []string{"brief/visual_quality_contract.json", "request/entity_resolution.json"} {
raw, err := readRunRegularArtifact(safeRoot, rel)
if err != nil {
continue
}
if strings.Contains(string(raw), `"required_chart_renderer":"vega-lite"`) || strings.Contains(string(raw), `"required_chart_renderer": "vega-lite"`) {
return true
}
}
return false
}

View File

@@ -0,0 +1,194 @@
package svglide
import (
"path/filepath"
"strings"
)
const anyGenPromptRoot = "skills/lark-slides/references/anygen-svg"
const anyGenSourceFull = "docs/vendor/anygen-svg/source.full.md"
type PromptManifest struct {
Source string `json:"source"`
Runtime string `json:"runtime"`
Entries []PromptManifestEntry `json:"entries"`
}
type PromptManifestEntry struct {
Name string `json:"name"`
ID string `json:"id,omitempty"`
Path string `json:"path"`
SHA256 string `json:"sha256,omitempty"`
Stage string `json:"stage,omitempty"`
Always bool `json:"always,omitempty"`
Role string `json:"role,omitempty"`
OrchestratedBy string `json:"orchestrated_by,omitempty"`
Invocation string `json:"invocation,omitempty"`
Order int `json:"order,omitempty"`
Cardinality string `json:"cardinality,omitempty"`
Requires []string `json:"requires,omitempty"`
Condition string `json:"condition,omitempty"`
Trigger []string `json:"trigger,omitempty"`
Consumes []string `json:"consumes,omitempty"`
Produces []string `json:"produces,omitempty"`
CompletionGate []string `json:"completion_gate,omitempty"`
PhaseAnchors []string `json:"phase_anchors,omitempty"`
Profiles []string `json:"profiles,omitempty"`
Exposure string `json:"exposure,omitempty"`
}
func DefaultPromptManifest() PromptManifest {
return PromptManifest{
Source: anyGenPromptRoot,
Runtime: "agent",
Entries: []PromptManifestEntry{
sourceSnapshotEntry("anygen_source_full", anyGenSourceFull),
referenceEntry("anygen_svg_readme", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "README.md")), "reference_index"),
referenceEntry("mode_system_prompt_svg", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "mode_system_prompt_svg.md")), "orchestrator"),
referenceEntry("svg_reference", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svg_reference.md")), "protocol_reference"),
referenceEntry("anygen_semantic_contract", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "semantic_contract.md")), "semantic_contract"),
referenceEntry("svglide_local_runtime_binding", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svglide_local_runtime_binding.md")), "runtime_binding"),
referenceEntry("svglide_visual_quality_overlay", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svglide_visual_quality_overlay.md")), "runtime_binding"),
toolEntry("resolve_design_brief", "resolve_design_brief", StageDesignBrief, 1, "once", "always", []string{"request/request.json", "research/research_notes.md"}, []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, []string{"design_brief_resolved", "typography_contract_resolved"}),
toolEntry("slide_outline", "slide_outline", StageOutline, 2, "once", "always", []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, []string{"outline/deck.json"}, []string{"deck_outline_valid"}),
toolEntry("activate_slides_edit", "activate_slides_edit", StageSVGAuthor, 3, "once", "always", []string{"outline/deck.json"}, []string{"receipts/tool_calls/svg_author/activate_slides_edit.json"}, []string{"slide_edit_activated"}),
toolEntry("slides_edit", "slides_edit", StageSVGAuthor, 4, "once_or_more", "always", []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "brief/typography_contract.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "assets/image_candidates.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json"}, []string{"slides/*.svg"}, []string{"svg_protocol_valid", "slide_matches_outline_content_assets"}),
toolEntry("finish_slides_edit", "finish_slides_edit", StageValidatePreviewRepair, 5, "once", "always", []string{"slides/*.svg"}, []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "receipts/image_usage.json", "receipts/chart_usage.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json", "receipts/chart_quality.json"}, []string{"validate_preview_rendered_visual_quality_semantic_creative_chart_passed"}),
conditionalToolEntry("slide_organize", "slide_organize", StageOutline, 6, "zero_or_more", "outline_changed_after_initial_generation", []string{"outline/deck.json"}, []string{"outline/deck.json"}, []string{"outline_structure_updated"}),
conditionalToolEntry("compute_custom_shape_bbox", "compute_custom_shape_bbox", StageSVGAuthor, 7, "zero_or_more", "svg_has_custom_path", []string{"slides/*.svg"}, []string{"receipts/tool_calls/svg_author/compute_custom_shape_bbox.json"}, []string{"custom_shape_bbox_resolved"}),
toolEntry("resolve_image_assets", "resolve_image_assets", StageAssets, 8, "once", "always", []string{"request/request.json", "request/entity_resolution.json", "research/sources.json", "outline/deck.json", "content/slide_content.json", "brief/visual_system.json"}, []string{"assets/image_candidates.json", "assets/assets_plan.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "receipts/tool_calls/assets/resolve_image_assets.json"}, []string{"image_candidates_recorded", "selected_images_have_source_url", "selected_images_have_role_fit_reason"}),
conditionalToolEntry("generate_vega_lite_chart", "generate_vega_lite_chart", StageAssets, 9, "zero_or_more", "required_chart_renderer_vega_lite", []string{"content/slide_content.json", "research/sources.json", "assets/assets_manifest.json", "assets/charts/chart_briefs.json"}, []string{"assets/charts/chart_manifest.json", "assets/charts/specs/*.vl.json"}, []string{"vega_lite_specs_planned"}),
conditionalToolEntry("generate_svg_chart", "generate_svg_chart", StageAssets, 10, "zero_or_more", "legacy_or_non_chart_svg_visual_reference_only", []string{"content/slide_content.json", "assets/assets_manifest.json"}, []string{"assets/assets_manifest.json"}, []string{"not_a_standard_chart_producer"}),
legacyConditionalToolEntry("slides_convert", "slides_convert", StageResearch, 11, "zero_or_more", "input_is_pptx", []string{"request/source_manifest.json"}, []string{"research/sources.json"}, []string{"slides_converted"}, []string{routeProfileImportedPPTX}),
legacyConditionalToolEntry("slides_parse_template", "slides_parse_template", StageAssets, 12, "zero_or_more", "template_requested", []string{"request/request.json"}, []string{"assets/assets_manifest.json"}, []string{"template_parsed"}, []string{routeProfileTemplateReference}),
},
}
}
func referenceEntry(id, path, role string) PromptManifestEntry {
return PromptManifestEntry{Name: id, ID: id, Path: path, Always: true, Role: role, Invocation: "reference", Profiles: runtimeProfiles(), Exposure: "runtime"}
}
func sourceSnapshotEntry(id, path string) PromptManifestEntry {
entry := referenceEntry(id, path, "source_snapshot")
entry.Always = false
entry.Exposure = "audit"
return entry
}
func toolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string) PromptManifestEntry {
return PromptManifestEntry{
Name: name,
ID: name,
Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", file+".md")),
Stage: stage,
Role: "tool_prompt",
OrchestratedBy: "mode_system_prompt_svg",
Invocation: "required",
Order: order,
Cardinality: cardinality,
Requires: []string{"mode_system_prompt_svg", "svg_reference"},
Condition: condition,
Trigger: []string{"initial_deck_generation"},
Consumes: consumes,
Produces: produces,
CompletionGate: gate,
Profiles: runtimeProfiles(),
Exposure: "runtime",
}
}
func conditionalToolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string) PromptManifestEntry {
entry := toolEntry(name, file, stage, order, cardinality, condition, consumes, produces, gate)
entry.Invocation = "conditional"
entry.Trigger = []string{condition}
return entry
}
func legacyConditionalToolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string, profiles []string) PromptManifestEntry {
entry := conditionalToolEntry(name, file, stage, order, cardinality, condition, consumes, produces, gate)
entry.Profiles = profiles
entry.Exposure = "legacy"
return entry
}
func runtimeProfiles() []string {
return []string{RouteProfileLocalSVGDeck, routeProfileImportedPPTX, routeProfileTemplateReference}
}
func ResolvedPromptManifest() (PromptManifest, error) {
assets, err := LoadAnyGenPromptAssets()
if err != nil {
return PromptManifest{}, err
}
entries := make([]PromptManifestEntry, 0, len(assets))
for _, asset := range assets {
entries = append(entries, PromptManifestEntry{
Name: asset.ID,
ID: asset.ID,
Path: asset.Path,
SHA256: asset.SHA256,
Stage: asset.Stage,
Always: asset.Role == "reference_index" || asset.Role == "semantic_contract" || asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.Role == "runtime_binding",
Role: asset.Role,
OrchestratedBy: asset.OrchestratedBy,
Invocation: asset.Invocation,
Order: asset.Order,
Cardinality: asset.Cardinality,
Requires: asset.Requires,
Condition: asset.Condition,
Trigger: asset.Trigger,
Consumes: asset.Consumes,
Produces: asset.Produces,
CompletionGate: asset.CompletionGate,
PhaseAnchors: asset.PhaseAnchors,
Profiles: asset.Profiles,
Exposure: asset.Exposure,
})
}
return PromptManifest{Source: anyGenPromptRoot, Runtime: "agent", Entries: entries}, nil
}
func PromptPathsForStage(stage string) ([]string, error) {
return PromptPathsForStageForProfile(RouteProfileLocalSVGDeck, stage)
}
func PromptPathsForStageForProfile(profile string, stage string) ([]string, error) {
manifest, err := ResolvedPromptManifest()
if err != nil {
return nil, err
}
paths := make([]string, 0, len(manifest.Entries))
for _, entry := range manifest.Entries {
if !promptAssetAllowedForProfile(entry.Profiles, profile) {
continue
}
if entry.Always || entry.Stage == stage {
paths = append(paths, entry.Path)
}
}
return paths, nil
}
func promptAssetAllowedForProfile(profiles []string, profile string) bool {
if strings.TrimSpace(profile) == "" {
profile = RouteProfileLocalSVGDeck
}
if len(profiles) == 0 {
return true
}
for _, candidate := range profiles {
if candidate == profile {
return true
}
}
return false
}
func writePromptManifest(root string) error {
manifest, err := ResolvedPromptManifest()
if err != nil {
return err
}
return writeJSON(filepath.Join(root, "prompt_manifest.json"), manifest)
}

1211
internal/svglide/quality.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,961 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckQualityAllowsExplicitLocalSourceWithoutFullPageWebSource(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"source.md","title":"Local Source","excerpt":"Input","usage":"Support","retrieval":"local_file"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["local1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only slide; no image assets required"}`)
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
}
}
func TestCheckQualityRejectsTopicDeckWithoutFullPageWebOrExplicitLocalSource(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"source1","path":"source.md","title":"Weak Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["source1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.research") {
t.Fatalf("issues = %+v, want svglide.quality.research", report.Issues)
}
}
func TestCheckQualityRejectsSlideContentWithoutSourceRefs(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.source_refs") {
t.Fatalf("issues = %+v, want svglide.quality.source_refs", report.Issues)
}
}
func TestCheckQualityRejectsMissingVisualAsset(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
}
}
func TestCheckQualityFailsEntityDeckWithoutRealVisualAssets(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/request/request.json", `{"title":"NVIDIA Financial Report","topic":"Generate a comprehensive financial report for Q4 2023 for Nvidia."}`)
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"resolved_entity":{"name":"NVIDIA","type":"company","confidence_bp":9500,"confidence_band":"high","reason":"Named public company"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"clarification_question":""}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"NVIDIA Financial Report","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Financial report","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"nvda","path":"https://investor.nvidia.com/","title":"NVIDIA IR","excerpt":"Financial report","usage":"financial data","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Financial report","source_refs":["nvda"],"visuals":[{"id":"chart","type":"none","instruction":"Chart-only cover"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"This data-report deck does not require raster images; charts are enough."}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[],"no_image_reason":"This data-report deck does not require raster images; charts are enough."}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
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()+`<rect width="1280" height="720" fill="#fff"/><text x="80" y="120" font-size="48">NVIDIA Q4 Financial Report</text></svg>`)
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if report.Metrics.VisualAssetIssueCount == 0 {
t.Fatalf("visual asset issue count = 0, want > 0")
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visual_asset.cover_real_hero_missing") {
t.Fatalf("issues = %+v, want cover hero missing", report.Issues)
}
}
func TestCheckQualityPassesAnyGenReadyRun(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed", report.Status)
}
if len(report.Issues) != 0 {
t.Fatalf("issues = %+v, want empty", report.Issues)
}
if report.Metrics.Slides != 1 || report.Metrics.Sources != 1 || report.Metrics.WebSources != 1 || report.Metrics.Assets != 1 || report.Metrics.SlidesWithSourceRef != 1 || report.Metrics.SlidesWithVisuals != 1 {
t.Fatalf("metrics = %+v, want all ones", report.Metrics)
}
raw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
if err != nil {
t.Fatalf("missing quality_report.json: %v", err)
}
var written QualityReport
if err := json.Unmarshal(raw, &written); err != nil {
t.Fatal(err)
}
if written.Status != "passed" {
t.Fatalf("written status = %q, want passed", written.Status)
}
}
func TestCheckQualityRejectsBrandOfficialSiteWithLowImageCoverage(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{
"resolved_entity":{"name":"KANEKO OPTICAL","type":"brand"},
"visual_quality_contract":{
"profile":"brand_official_site",
"requires_real_images":true,
"min_image_coverage_bp":7000,
"min_unique_images":6,
"min_official_images":4,
"allow_repeated_hero_only":false,
"reason":"真实品牌官网主题需要官网图片资产支撑。"
}
}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Kaneko","slides":[
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"},
{"id":"s2","title":"Thesis","summary":"Thesis","role":"thesis","key_message":"Thesis","path":"slides/02.svg"},
{"id":"s3","title":"History","summary":"History","role":"history","key_message":"History","path":"slides/03.svg"},
{"id":"s4","title":"Factory","summary":"Factory","role":"factory","key_message":"Factory","path":"slides/04.svg"},
{"id":"s5","title":"Product","summary":"Product","role":"product","key_message":"Product","path":"slides/05.svg"},
{"id":"s6","title":"Retail","summary":"Retail","role":"retail","key_message":"Retail","path":"slides/06.svg"},
{"id":"s7","title":"Process","summary":"Process","role":"process","key_message":"Process","path":"slides/07.svg"},
{"id":"s8","title":"Closing","summary":"Closing","role":"closing","key_message":"Closing","path":"slides/08.svg"}
]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[
{"id":"kaneko-home","path":"https://www.kaneko-optical.co.jp/zh-CHS/","title":"Home","excerpt":"Official site","usage":"identity","retrieval":"full_page"},
{"id":"user-hero-image","path":"/Users/bytedance/Downloads/image_gwnb.png","title":"User image","excerpt":"Hero","usage":"visual reference","retrieval":"user_provided"}
]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[
{"id":"s1","content":"Cover","source_refs":["kaneko-home","user-hero-image"],"visuals":[{"id":"asset-cover","type":"image","instruction":"Hero"}]},
{"id":"s2","content":"Thesis","source_refs":["kaneko-home"],"visuals":[{"id":"none-s2","type":"none","instruction":"Native diagram"}]},
{"id":"s3","content":"History","source_refs":["kaneko-home"],"visuals":[{"id":"none-s3","type":"none","instruction":"Native timeline"}]},
{"id":"s4","content":"Factory","source_refs":["kaneko-home"],"visuals":[{"id":"none-s4","type":"none","instruction":"Native matrix"}]},
{"id":"s5","content":"Product","source_refs":["kaneko-home"],"visuals":[{"id":"none-s5","type":"none","instruction":"Native cards"}]},
{"id":"s6","content":"Retail","source_refs":["kaneko-home"],"visuals":[{"id":"none-s6","type":"none","instruction":"Native metrics"}]},
{"id":"s7","content":"Process","source_refs":["kaneko-home"],"visuals":[{"id":"none-s7","type":"none","instruction":"Native rail"}]},
{"id":"s8","content":"Closing","source_refs":["kaneko-home","user-hero-image"],"visuals":[{"id":"asset-closing","type":"image","instruction":"Hero again"}]}
]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
{"id":"asset-cover","slide_id":"s1","visual_id":"asset-cover","kind":"image","local_path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","status":"ready","usage":"cover"},
{"id":"asset-closing","slide_id":"s8","visual_id":"asset-closing","kind":"image","local_path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","status":"ready","usage":"closing"}
]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[
{"id":"asset-cover","path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","width":1704,"height":868,"semantic_type":"brand hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":""}
]}`)
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png"/><text>Cover</text></svg>`)
mustWriteTestFile(t, "demo/slides/08.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png"/><text>Closing</text></svg>`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for low image coverage; metrics=%+v", report.Status, report.Metrics)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_coverage") {
t.Fatalf("issues = %+v, want svglide.quality.image_coverage", report.Issues)
}
}
func TestCheckQualityRejectsWeakCoverWhenVisualContractRequiresStrongCover(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand","must_have":{"strong_cover":true}}}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","visual_intent":"Use a strong first impression","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/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png" x="40" y="40" width="320" height="180"/><text x="48" y="260">Cover</text></svg>`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for weak cover; metrics=%+v", report.Status, report.Metrics)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.weak_cover") {
t.Fatalf("issues = %+v, want svglide.quality.weak_cover", report.Issues)
}
}
func TestCheckQualityRejectsPosterOnlyCoverWhenRealHeroImageRequired(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","cover_requires_real_hero_image":true}}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"NVIDIA Report","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":"nvidia","path":"https://www.nvidia.com/en-us/about-nvidia/","title":"NVIDIA","excerpt":"Official source","usage":"identity","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["nvidia"],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/generated-chip.svg","source_url":"","status":"ready","usage":"Generated chip hero"}]}`)
mustWriteTestFile(t, "demo/assets/images/generated-chip.svg", `<svg/>`)
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/generated-chip.svg" x="0" y="0" width="960" height="540"/><text x="48" y="120" font-size="72">NVIDIA</text></svg>`)
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_generated_svg")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for generated SVG cover; metrics=%+v", report.Status, report.Metrics)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.cover_real_hero_image") {
t.Fatalf("issues = %+v, want cover_real_hero_image", report.Issues)
}
if report.Metrics.CoverRealHeroImage {
t.Fatalf("cover_real_hero_image = true, want false for generated SVG")
}
}
func TestCheckQualityDoesNotCountPreviewSlideSVGImagesAsRealImages(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"resolved_entity":{"name":"Demo","type":"brand"},"visual_quality_contract":{"profile":"brand_official_site","requires_real_images":true,"min_image_coverage_bp":10000,"min_unique_images":1,"forbid_preview_wrapper_images_as_real_images":true}}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"slides/01.svg","source_url":"","status":"ready","usage":"Preview wrapper"}]}`)
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 960 540">`+fontTokenStyleForTest()+`<text x="48" y="80">Cover</text></svg>`)
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Metrics.RealImageAssets != 0 || report.Metrics.SlidesWithRealImageAssets != 0 {
t.Fatalf("metrics = %+v, preview slide SVG must not count as real image", report.Metrics)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.preview_wrapper_image") {
t.Fatalf("issues = %+v, want preview_wrapper_image", report.Issues)
}
}
func TestCheckQualityRejectsMissingVegaLiteManifestWhenRequired(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1}}`)
mustWriteVegaLiteQualityDeck(t)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for missing Vega-Lite manifest", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.missing_chart_manifest") {
t.Fatalf("issues = %+v, want missing_chart_manifest", report.Issues)
}
}
func TestCheckQualityRejectsHandwrittenChartWhenVegaLiteRequired(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1}}`)
mustWriteVegaLiteQualityDeck(t)
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"svg","charts":[{"id":"revenue","slide_id":"s1","renderer":"svg","svg_path":"assets/charts/revenue.svg"}]}`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for hand-written SVG chart", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.chart_renderer") {
t.Fatalf("issues = %+v, want chart_renderer", report.Issues)
}
}
func TestCheckQualityRejectsMissingTypographyContractWhenRequired(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","typography_contract_required":true}}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Financial Report","slides":[{"id":"s1","title":"Summary","summary":"Summary","role":"cover","key_message":"Summary","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"none","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only report summary"}`)
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for missing typography contract", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.missing_typography_contract") && !qualityIssueCodesContain(report.Issues, "svglide.quality.typography_contract") {
t.Fatalf("issues = %+v, want typography contract failure", report.Issues)
}
}
func TestStrictVisualContractMissingRealImagesFixtureFailsClosed(t *testing.T) {
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
fixture := filepath.Join(repoRoot, "testdata", "svglide", "strict_visual_contract_missing_real_images")
cwd := t.TempDir()
t.Chdir(cwd)
writeDefaultSemanticContractForTest(t)
runRoot := filepath.Join(cwd, "fixture")
copyTestDir(t, fixture, runRoot)
report, err := CheckQuality("fixture")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed; metrics=%+v", report.Status, report.Metrics)
}
for _, want := range []string{"svglide.quality.image_coverage", "svglide.quality.cover_real_hero_image"} {
if !qualityIssueCodesContain(report.Issues, want) {
t.Fatalf("issues = %+v, want %s", report.Issues, want)
}
}
if report.Metrics.RealImageAssets != 0 || report.Metrics.GeneratedSVGAssets == 0 {
t.Fatalf("metrics = %+v, want generated SVG but zero real images", report.Metrics)
}
}
func copyTestDir(t *testing.T, src string, dst string) {
t.Helper()
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
copyTestDir(t, srcPath, dstPath)
continue
}
raw, err := os.ReadFile(srcPath)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dstPath, raw, 0o644); err != nil {
t.Fatal(err)
}
}
}
func TestCheckQualityRejectsLowEvidenceDensityWhenVisualContractRequiresEvidenceGrid(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand_factory","must_have":{"evidence_page_min_visuals":4,"visual_roles_required":["hero_cover","evidence_grid"]}}}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","visual_intent":"Full bleed cover image","key_message":"Cover","path":"slides/01.svg"},
{"id":"s2","title":"Process","summary":"Process","role":"process","visual_role":"evidence_grid","visual_intent":"Use process images as evidence","key_message":"Process","path":"slides/02.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":"cover","type":"image","instruction":"Cover image"}]},
{"id":"s2","content":"Process","source_refs":["web1"],"visuals":[{"id":"p1","type":"image","instruction":"Process 1"},{"id":"p2","type":"image","instruction":"Process 2"}]}
]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
{"id":"cover","slide_id":"s1","visual_id":"cover","kind":"image","local_path":"assets/images/cover.png","source_url":"https://example.com/cover.png","status":"ready","usage":"Cover"},
{"id":"p1","slide_id":"s2","visual_id":"p1","kind":"image","local_path":"assets/images/p1.png","source_url":"https://example.com/p1.png","status":"ready","usage":"Process 1"},
{"id":"p2","slide_id":"s2","visual_id":"p2","kind":"image","local_path":"assets/images/p2.png","source_url":"https://example.com/p2.png","status":"ready","usage":"Process 2"}
]}`)
mustWriteTestFile(t, "demo/assets/images/cover.png", "png")
mustWriteTestFile(t, "demo/assets/images/p1.png", "png")
mustWriteTestFile(t, "demo/assets/images/p2.png", "png")
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/cover.png" x="0" y="0" width="960" height="540"/></svg>`)
mustWriteTestFile(t, "demo/slides/02.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/p1.png" x="40" y="40" width="320" height="180"/><image slide:role="image" href="../assets/images/p2.png" x="400" y="40" width="320" height="180"/><text x="48" y="300">Process</text></svg>`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for low evidence density; metrics=%+v", report.Status, report.Metrics)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.low_evidence_density") {
t.Fatalf("issues = %+v, want svglide.quality.low_evidence_density", report.Issues)
}
}
func TestKanekoCalibrationFixtureIsQualityFloorOnly(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "svglide", "visual_quality", "kaneko_baseline_calibration.json"))
if err != nil {
t.Fatal(err)
}
var fixture struct {
BenchmarkUsage string `json:"benchmark_usage"`
DeckType string `json:"deck_type"`
Minimums struct {
StrongCover bool `json:"strong_cover"`
EvidencePageMinVisuals int `json:"evidence_page_min_visuals"`
SemanticImageCoverageMinBP int `json:"semantic_image_coverage_min_bp"`
} `json:"minimums"`
QualityDimensions []string `json:"quality_dimensions"`
}
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatal(err)
}
if fixture.BenchmarkUsage != "quality_floor_only" {
t.Fatalf("benchmark_usage = %q, want quality_floor_only", fixture.BenchmarkUsage)
}
if fixture.DeckType != "brand_factory" || !fixture.Minimums.StrongCover || fixture.Minimums.EvidencePageMinVisuals < 12 || fixture.Minimums.SemanticImageCoverageMinBP < 9000 {
t.Fatalf("fixture = %+v, want脱敏质量下限指标", fixture)
}
if strings.Contains(string(raw), "<svg") || strings.Contains(string(raw), "viewBox") || strings.Contains(string(raw), "foreignObject") {
t.Fatalf("fixture leaks SVG source: %s", string(raw))
}
if len(fixture.QualityDimensions) == 0 {
t.Fatalf("quality_dimensions empty: %+v", fixture)
}
}
func TestCheckQualityAllowsExperimentAssetsAndDeferredUnsupportedVisuals(t *testing.T) {
t.Chdir(t.TempDir())
initStatusTestRun(t)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Full page excerpt","usage":"evidence","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/outline/deck.json", `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"Chart claim","summary":"Needs chart later","role":"content","key_message":"Chart is deferred","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Chart-backed point","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a remote hero image"},{"id":"chart1","type":"chart","instruction":"Use a real chart when chart generation is enabled"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"},{"id":"chart1","slide_id":"s1","type":"chart","path":"","usage":"Deferred chart generation","status":"deferred"}]}`)
mustWriteQualitySlideWithImage(t, "https://example.com/hero.png")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("Status = %q, want passed: %+v", report.Status, report.Issues)
}
}
func TestCheckQualityAllowsAbsoluteReadyAssetPathInExperiment(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
outside := filepath.Join(t.TempDir(), "hero.png")
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
mustWriteQualitySlideWithImage(t, outside)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
}
}
func TestQualityAllowsHeroPhotoJPGWhenFullBleedReady(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":"high-resolution official hero photo"}]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_role_format") {
t.Fatalf("hero jpg should not be rejected as PNG-only: %#v", report.Issues)
}
}
func TestQualityRejectsTransparentSubjectWithoutAlphaOrFallback(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" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/product.jpg" x="120" y="80" width="520" height="360"/><text x="80" y="620">Cover</text></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"product","slide_id":"s1","kind":"image","local_path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","status":"ready","usage":"Floating product"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"product","path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","width":1200,"height":800,"semantic_type":"product","large_ok":true,"full_bleed_ok":false,"recommended_use":"floating product","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"floating_product","fit_role":"floating_subject","selection_reason":""}]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_role_format") {
t.Fatalf("expected floating product jpg without alpha/format_exception_reason to fail: %#v", report.Issues)
}
}
func TestQualityAllowsTransparentSubjectWithFormatExceptionReason(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" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/product.jpg" x="120" y="80" width="520" height="360"/><text x="80" y="620">Cover</text></svg>`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"product","slide_id":"s1","kind":"image","local_path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","status":"ready","usage":"Floating product"}]}`)
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"product","path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","width":1200,"height":800,"semantic_type":"product","large_ok":true,"full_bleed_ok":false,"recommended_use":"floating product","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"floating_product","fit_role":"floating_subject","selection_reason":"best official product image despite no transparent cutout","format_exception_reason":"official source only provides JPG; SVG author must mask/crop on clean background"}]}`)
report, err := CheckQuality(root)
if err != nil {
t.Fatal(err)
}
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_role_format") {
t.Fatalf("structured format_exception_reason should satisfy image format exception: %#v", report.Issues)
}
}
func TestCheckQualityRejectsEmptyVisuals(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visuals") {
t.Fatalf("issues = %+v, want svglide.quality.visuals", report.Issues)
}
}
func TestCheckQualityRejectsVisualAssetTypeMismatch(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed", report.Status)
}
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
}
}
func TestAnyGenSemanticReportRejectsAllDiagramWithoutNoImageReason(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Movie Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Movie hook","path":"slides/01.svg"},{"id":"s2","title":"Context","summary":"Context summary","role":"content","key_message":"Movie context","path":"slides/02.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/movie","title":"Movie Source","excerpt":"Movie excerpt","usage":"Support","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":["web1"],"visuals":[{"id":"diagram1","type":"diagram","instruction":"Diagram fallback"}]},{"id":"s2","content":"Context","source_refs":["web1"],"visuals":[{"id":"diagram2","type":"diagram","instruction":"Diagram fallback"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"diagram1","slide_id":"s1","type":"diagram","path":"assets/images/diagram1.svg","usage":"Diagram fallback","status":"ready"},{"id":"diagram2","slide_id":"s2","type":"diagram","path":"assets/images/diagram2.svg","usage":"Diagram fallback","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/images/diagram1.svg", `<svg/>`)
mustWriteTestFile(t, "demo/assets/images/diagram2.svg", `<svg/>`)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want semantic failure for all-diagram fallback without no_image_reason; findings=%+v", report.Status, report.Findings)
}
if !semanticFindingsContainRule(report.Findings, "no_silent_all_diagram_fallback") {
t.Fatalf("findings = %+v, want no_silent_all_diagram_fallback", report.Findings)
}
if _, err := os.Stat(filepath.Join("demo", "anygen_semantic_report.json")); err != nil {
t.Fatalf("missing anygen_semantic_report.json: %v", err)
}
}
func semanticFindingsContainRule(findings []SemanticFinding, ruleID string) bool {
for _, finding := range findings {
if finding.RuleID == ruleID {
return true
}
}
return false
}
func TestAnyGenSemanticReportRejectsUnsafeReadyImageAssetPaths(t *testing.T) {
tests := []struct {
name string
assetPath string
setupAsset func(t *testing.T)
}{
{
name: "file url",
assetPath: "file:///tmp/secret.png",
},
{
name: "absolute path",
assetPath: "/Users/example/secret.png",
},
{
name: "missing local asset",
assetPath: "assets/images/missing.png",
},
{
name: "symlink local asset",
assetPath: "assets/images/hero.png",
setupAsset: func(t *testing.T) {
t.Helper()
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("outside-hero.png", []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initStatusTestRun(t)
if tt.setupAsset != nil {
tt.setupAsset(t)
}
writeSemanticImageDeck(t, tt.assetPath)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for unsafe ready image asset path %q; findings=%+v", report.Status, tt.assetPath, report.Findings)
}
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_path") {
t.Fatalf("findings = %+v, want svglide.semantic.asset_path", report.Findings)
}
})
}
}
func TestAnyGenSemanticReportRejectsBrowserRelativeMissingImageHref(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="assets/images/hero.png" x="0" y="0" width="960" height="540"/></svg>`)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed because browser resolves href against slides/ directory; findings=%+v", report.Status, report.Findings)
}
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
}
}
func TestAnyGenSemanticReportAllowsBrowserRelativeImageHrefAndNoteSources(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["kaneko-home"],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
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 960 540">`+fontTokenStyleForTest()+`<slide:note>Sources: kaneko-home</slide:note><image slide:role="image" href="../assets/images/hero.png" x="0" y="0" width="960" height="540"/></svg>`)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed for browser-relative image href and note source marker; findings=%+v", report.Status, report.Findings)
}
if report.Metrics.MissingAssetCount != 0 {
t.Fatalf("MissingAssetCount = %d, want 0", report.Metrics.MissingAssetCount)
}
if report.Metrics.VisibleLeakCount != 0 {
t.Fatalf("VisibleLeakCount = %d, want 0 for slide:note source marker", report.Metrics.VisibleLeakCount)
}
}
func TestAnyGenSemanticReportAllowsRegisteredChartHref(t *testing.T) {
initStatusTestRun(t)
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, "../assets/charts/revenue.svg")
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed for registered chart href; findings=%+v", report.Status, report.Findings)
}
}
func TestAnyGenSemanticReportRejectsUnregisteredChartHref(t *testing.T) {
initStatusTestRun(t)
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[]}`, "../assets/charts/revenue.svg")
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for unregistered chart href; findings=%+v", report.Status, report.Findings)
}
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
}
}
func TestAnyGenSemanticReportRejectsUnsafeReadyChartHref(t *testing.T) {
initStatusTestRun(t)
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"file:///tmp/secret.svg","usage":"Revenue chart","status":"ready"}]}`, "file:///tmp/secret.svg")
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for unsafe chart href; findings=%+v", report.Status, report.Findings)
}
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
}
}
func TestAnyGenSemanticReportRejectsChartAssetUsedAsImageHref(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
writeSemanticDeckWithSlideBody(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, `<image slide:role="image" href="../assets/charts/revenue.svg" x="80" y="80" width="640" height="360"/>`)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for chart asset used as image href; findings=%+v", report.Status, report.Findings)
}
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_type") {
t.Fatalf("findings = %+v, want svglide.semantic.asset_type", report.Findings)
}
}
func TestAnyGenSemanticReportRejectsExternalUseHref(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
writeSemanticDeckWithSlideBody(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, `<use href="../assets/charts/revenue.svg" x="80" y="80" width="640" height="360"/>`)
report, err := EvaluateAnyGenSemantics("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("status = %q, want failed for external use href; findings=%+v", report.Status, report.Findings)
}
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_type") {
t.Fatalf("findings = %+v, want svglide.semantic.asset_type", report.Findings)
}
}
func semanticFindingsContainCode(findings []SemanticFinding, code string) bool {
for _, finding := range findings {
if finding.Code == code {
return true
}
}
return false
}
func writeSemanticImageDeck(t *testing.T, assetPath string) {
t.Helper()
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Image Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Image hook","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+assetPath+`","usage":"Hero image","status":"ready"}]}`)
mustWriteQualitySlideWithImage(t, assetPath)
}
func writeSemanticChartDeck(t *testing.T, assetsPlan string, chartHref string) {
t.Helper()
writeSemanticDeckWithSlideBody(t, assetsPlan, `<rect slide:role="chart" href="`+chartHref+`" x="80" y="80" width="640" height="360"/>`)
}
func writeSemanticDeckWithSlideBody(t *testing.T, assetsPlan string, slideBody string) {
t.Helper()
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Chart Deck","slides":[{"id":"s1","title":"Revenue","summary":"Revenue summary","role":"content","key_message":"Revenue changed","path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Revenue changed","source_refs":[],"visuals":[{"id":"chart1","type":"chart","instruction":"Revenue chart"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", assetsPlan)
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 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/>`+slideBody+`<text x="48" y="500">Revenue</text></svg>`)
}
func TestCheckQualityCountsSlidesWithVisualsPerPage(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"},{"id":"logo","type":"diagram","instruction":"Support diagram"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"},{"id":"logo","slide_id":"s1","type":"diagram","path":"assets/images/logo.svg","usage":"Support diagram","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "logo.svg"), []byte("<svg/>"), 0o644); err != nil {
t.Fatal(err)
}
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed", report.Status)
}
if report.Metrics.SlidesWithVisuals != 1 {
t.Fatalf("metrics.slides_with_visuals = %d, want 1", report.Metrics.SlidesWithVisuals)
}
}
func TestCheckQualityAllowsSymlinkReadyAssetPathInExperiment(t *testing.T) {
initStatusTestRun(t)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","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":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("outside-hero.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
t.Fatal(err)
}
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
}
}
func TestCheckQualityUsesOutlineDeckNotRunArtifactDeck(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
run.Artifacts.Deck = "custom/deck.json"
writeStatusTestRunFile(t, run)
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
if err := os.MkdirAll(filepath.Join("demo", "custom"), 0o755); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, "demo/custom/deck.json", `{"title":"Custom Deck","slides":[{"id":"c1","title":"Custom 1","summary":"Custom summary 1","role":"cover","key_message":"Custom key 1","path":"slides/01.svg"},{"id":"c2","title":"Custom 2","summary":"Custom summary 2","role":"content","key_message":"Custom key 2","path":"slides/02.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":"Claim","source_refs":["web1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]},{"id":"c1","content":"Custom claim 1","source_refs":["web1"],"visuals":[{"id":"v2","type":"none","instruction":"Text-only"}]},{"id":"c2","content":"Custom claim 2","source_refs":["web1"],"visuals":[{"id":"v3","type":"none","instruction":"Text-only"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only slide; no image assets required"}`)
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
report, err := CheckQuality("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("status = %q, want passed", report.Status)
}
if report.Metrics.Slides != 1 {
t.Fatalf("metrics.slides = %d, want 1 from outline/deck.json", report.Metrics.Slides)
}
}
func mustWriteVegaLiteQualityDeck(t *testing.T) {
t.Helper()
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Financial Deck","slides":[{"id":"s1","title":"Revenue","summary":"Revenue trend","role":"content","key_message":"Revenue expanded","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"single_chart","story_function":"proof","primary_asset_role":"financial chart","fusion_candidate":false,"path":"slides/01.svg"}]}`)
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"10k","path":"https://example.com/annual-report","title":"Annual report","excerpt":"Revenue was 60.9 billion","usage":"financial data","retrieval":"full_page"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Revenue expanded","source_refs":["10k"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"revenue","slide_id":"s1","visual_id":"revenue","kind":"chart","local_path":"assets/charts/revenue.svg","source_url":"","status":"ready","usage":"Revenue chart"}],"no_image_reason":"Financial chart page; no photo required"}`)
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`)
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 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/><rect slide:role="chart" href="../assets/charts/revenue.svg" x="80" y="96" width="720" height="320"/><text x="48" y="480">Revenue: 60.9B</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":"single_chart","thumbnail_job":"chart proof","visual_center":"revenue chart","topic_fit_claim":"matches financial report","information_density_plan":"one main chart and one sourced note","page_difference_from_previous":"first chart page","primary_asset":"assets/charts/revenue.svg","asset_role":"financial proof","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"financial analysis chart","data_visual_rationale":"shows revenue change with sourced numbers","source_evidence":["FY revenue 60.9 billion from 10k"],"fusion_spec":{"enabled":false},"qa_expectations":["chart has numeric evidence"]}]}`)
}
func mustWriteQualitySlideWithImage(t *testing.T, href string) {
t.Helper()
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 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/><image slide:role="image" href="`+svgHrefForTestAsset(href)+`" x="40" y="40" width="320" height="180"/><text x="48" y="260">Claim</text></svg>`)
mustWriteQualityVisualReceiptForTest(t, "s1", "character_product_focus", "image_claim")
}
func mustWriteQualityVisualReceiptForTest(t *testing.T, slideID string, family string, signature string) {
t.Helper()
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"`+slideID+`","story_job":"hook","layout_family":"`+family+`","layout_archetype":"`+inferAuthorLayoutArchetype(family, signature)+`","layout_signature":"`+signature+`","thumbnail_job":"readable claim","visual_center":"topic claim and supporting visual","topic_fit_claim":"matches the requested topic","information_density_plan":"one clear claim with supporting proof","page_difference_from_previous":"distinct opening treatment","primary_asset":"hero","asset_role":"topic anchor","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"focused editorial slide","data_visual_rationale":"","source_evidence":["web1 supports this claim"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
}
func svgHrefForTestAsset(path string) string {
if strings.HasPrefix(path, "assets/") {
return "../" + path
}
return path
}
func qualityIssueCodesContain(issues []QualityIssue, want string) bool {
for _, issue := range issues {
if issue.Code == want {
return true
}
}
return false
}

153
internal/svglide/receipt.go Normal file
View File

@@ -0,0 +1,153 @@
package svglide
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type validationLintReceipt struct {
Status string `json:"status"`
Issues []ValidationIssue `json:"issues"`
}
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
report = normalizeValidationReport(report)
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
if err != nil {
return err
}
raw, err := json.MarshalIndent(validationLintReceipt{
Status: validationReceiptStatus(report),
Issues: report.Issues,
}, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
return err
}
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
if err != nil {
return err
}
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
}
func normalizeValidationReport(report ValidationReport) ValidationReport {
if report.Issues == nil {
report.Issues = []ValidationIssue{}
}
report.OK = len(report.Issues) == 0
for i := range report.Issues {
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
if report.Issues[i].Path == "" {
report.Issues[i].Path = "(deck)"
}
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
if report.Issues[i].Code == "" {
report.Issues[i].Code = "svglide.validation"
}
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
if report.Issues[i].Severity == "" {
report.Issues[i].Severity = "error"
}
}
return report
}
func validationReceiptStatus(report ValidationReport) string {
if report.OK {
return "passed"
}
return "failed"
}
func renderRepairQueue(report ValidationReport) string {
if report.OK {
return "No repair needed.\n"
}
var b bytes.Buffer
b.WriteString("# SVGlide Repair Queue\n\n")
for _, issue := range report.Issues {
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
}
return b.String()
}
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
cleanRel := filepath.Clean(rel)
if cleanRel == "." {
return "", fmt.Errorf("run file path must not be root")
}
dirRel := filepath.Dir(cleanRel)
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
return "", err
}
path, err := safeRunPath(safeRoot, cleanRel)
if err != nil {
return "", err
}
info, err := vfs.Lstat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return path, nil
}
return "", err
}
if info.Mode()&fs.ModeSymlink != 0 {
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("run file path %q must be a regular file", rel)
}
return path, nil
}
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
path, err := safeRunPath(safeRoot, rel)
if err != nil {
return "", err
}
cleanRel := filepath.Clean(rel)
if cleanRel == "." {
return path, nil
}
parts := strings.Split(cleanRel, string(filepath.Separator))
cur := safeRoot
for i, part := range parts {
if part == "" || part == "." {
continue
}
cur = filepath.Join(cur, part)
info, err := vfs.Lstat(cur)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
if err := vfs.Mkdir(cur, 0o755); err != nil {
info, err = vfs.Lstat(cur)
if err != nil {
return "", err
}
} else {
continue
}
}
if info.Mode()&fs.ModeSymlink != 0 {
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
}
if !info.IsDir() {
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
}
}
return path, nil
}

View File

@@ -0,0 +1,731 @@
package svglide
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
const renderedVisualReceiptPath = "receipts/rendered_visual.json"
type RenderedVisualReport struct {
Status string `json:"status"`
Metrics RenderedVisualMetrics `json:"metrics"`
Issues []RenderedVisualIssue `json:"issues"`
Slides []RenderedVisualSlideItem `json:"slides"`
}
type RenderedVisualMetrics struct {
Slides int `json:"slides"`
IssueCount int `json:"issue_count"`
OutOfCanvasCount int `json:"out_of_canvas_count"`
TextOverflowCount int `json:"text_overflow_count"`
TextCollisionCount int `json:"text_collision_count"`
UnsafeEdgeCount int `json:"unsafe_edge_count"`
ContainerTextOverflowCount int `json:"container_text_overflow_count"`
ContainerPaddingRiskCount int `json:"container_padding_risk_count"`
ForeignObjectOverlapCount int `json:"foreign_object_overlap_count"`
TightLineHeightCount int `json:"tight_line_height_count"`
BoldOveruseCount int `json:"bold_overuse_count"`
SmallTextPaddingRiskCount int `json:"small_text_padding_risk_count"`
}
type RenderedVisualIssue struct {
Path string `json:"path"`
SlideID string `json:"slide_id,omitempty"`
ElementID string `json:"element_id,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
Width float64 `json:"width,omitempty"`
Height float64 `json:"height,omitempty"`
}
type RenderedVisualSlideItem struct {
Path string `json:"path"`
Status string `json:"status"`
IssueCount int `json:"issue_count"`
}
type renderedVisualBox struct {
Path string
ElementID string
Kind string
Text string
X float64
Y float64
Width float64
Height float64
RequiredHeight float64
FontSize float64
LineHeight float64
FontWeight float64
}
type renderedVisualContainer struct {
Path string
ElementID string
Kind string
X float64
Y float64
Width float64
Height float64
RX float64
Fill string
Stroke string
Opacity float64
}
type renderedVisualParseState struct {
TranslateX float64
TranslateY float64
FontSize float64
LineHeight float64
FontWeight float64
TextAnchor string
InText bool
InForeign bool
ElementID string
TextX float64
TextY float64
Text string
Foreign renderedVisualBox
}
var renderedStyleNumberPattern = regexp.MustCompile(`(?i)(font-size|line-height)\s*:\s*([0-9.]+)`)
var renderedFontWeightNumberPattern = regexp.MustCompile(`(?i)font-weight\s*:\s*([0-9.]+|bold|bolder)`)
var renderedTransformTranslatePattern = regexp.MustCompile(`(?i)translate\(\s*([\-0-9.]+)(?:[\s,]+([\-0-9.]+))?`)
var renderedHTMLTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
var renderedWhitespacePattern = regexp.MustCompile(`\s+`)
var renderedStyleValuePattern = regexp.MustCompile(`(?i)(fill|stroke|opacity)\s*:\s*([^;]+)`)
func EvaluateRenderedVisualRun(safeRoot string, deck previewDeck) RenderedVisualReport {
report := RenderedVisualReport{
Status: "passed",
Issues: []RenderedVisualIssue{},
Slides: make([]RenderedVisualSlideItem, 0, len(deck.Slides)),
}
for _, slide := range deck.Slides {
slidePath, err := previewSlideObjectPath(slide.Path)
item := RenderedVisualSlideItem{Path: strings.TrimSpace(slide.Path), Status: "passed"}
if err != nil {
item.Status = "failed"
report.Issues = append(report.Issues, renderedVisualIssue(item.Path, "svglide.rendered_visual.slide_path", err.Error(), renderedVisualBox{}))
report.Slides = append(report.Slides, item)
continue
}
item.Path = slidePath
raw, err := readRunRegularArtifact(safeRoot, slidePath)
if err != nil {
item.Status = "failed"
report.Issues = append(report.Issues, renderedVisualIssue(slidePath, "svglide.rendered_visual.read_slide", err.Error(), renderedVisualBox{}))
report.Slides = append(report.Slides, item)
continue
}
slideReport := evaluateRenderedVisualSVG(slidePath, raw)
item.Status = slideReport.Status
item.IssueCount = slideReport.Metrics.IssueCount
report.Issues = append(report.Issues, slideReport.Issues...)
report.Slides = append(report.Slides, item)
}
report.Metrics.Slides = len(report.Slides)
renderedVisualFinalize(&report)
return report
}
func previewDeckFromAuthorDeck(deck authorDeck) previewDeck {
out := previewDeck{
Title: deck.Title,
Slides: make([]previewDeckSlide, 0, len(deck.Slides)),
}
for _, slide := range deck.Slides {
out.Slides = append(out.Slides, previewDeckSlide{
ID: slide.ID,
Title: slide.Title,
Summary: slide.Summary,
Role: slide.Role,
KeyMessage: slide.KeyMessage,
Path: slide.Path,
})
}
return out
}
func evaluateRenderedVisualSVG(path string, raw []byte) RenderedVisualReport {
width, height := svgViewBoxSize(string(raw))
if width <= 0 {
width = defaultSlideWidth
}
if height <= 0 {
height = defaultSlideHeight
}
boxes := renderedVisualTextBoxes(raw, path)
containers := renderedVisualContainers(raw, path, width, height)
report := RenderedVisualReport{
Status: "passed",
Issues: []RenderedVisualIssue{},
Slides: []RenderedVisualSlideItem{{Path: path, Status: "passed"}},
}
const edge = 6.0
var boldChars, totalChars int
var firstBold renderedVisualBox
for _, box := range boxes {
if box.Width <= 0 || box.Height <= 0 {
continue
}
totalChars += len([]rune(box.Text))
if box.FontWeight >= 700 {
boldChars += len([]rune(box.Text))
if firstBold.Text == "" {
firstBold = box
}
}
if box.Kind == "foreignObject" && box.RequiredHeight > box.Height*1.15 {
issueBox := box
issueBox.Height = box.RequiredHeight
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.text_box_overflow", "estimated foreignObject text height exceeds container height", issueBox))
continue
}
if box.X < -edge || box.Y < -edge || box.X+box.Width > width+edge || box.Y+box.Height > height+edge {
code := "svglide.rendered_visual.text_overflow"
if box.Kind == "foreignObject" {
code = "svglide.rendered_visual.text_box_overflow"
}
report.Issues = append(report.Issues, renderedVisualIssue(path, code, "estimated text box extends outside slide canvas", box))
continue
}
if box.X < edge || box.Y < edge || box.X+box.Width > width-edge || box.Y+box.Height > height-edge {
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.unsafe_edge", "estimated text box is too close to slide edge", box))
}
if box.Kind == "foreignObject" && box.FontSize > 0 && box.LineHeight > 0 && box.LineHeight < box.FontSize*1.12 {
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.tight_line_height", "foreignObject line-height is too tight for readable wrapped text", box))
}
if container, ok := nearestRenderedContainer(box, containers); ok {
content := renderedBoxVisibleContentBounds(box)
padding := renderedContainerPadding(container)
if content.Y+content.Height > container.Y+container.Height-padding {
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.container_text_overflow", "estimated text content exceeds visible card/container bottom padding", box, container))
} else if box.FontSize > 0 && box.FontSize <= 16 {
minMargin := minRenderedBoxMargin(content, container)
riskPadding := math.Max(10, box.FontSize*0.75)
if minMargin < riskPadding {
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.small_text_padding_risk", "small text is too close to visible card/container edge", box, container))
}
}
if content.X < container.X+padding || content.X+content.Width > container.X+container.Width-padding {
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.container_padding_risk", "estimated text content violates visible card/container horizontal padding", box, container))
}
}
}
if totalChars >= 40 && boldChars*100 > totalChars*65 && firstBold.Text != "" {
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.bold_overuse", "more than 65% of visible text is bold, flattening typographic hierarchy", firstBold))
}
for i := 0; i < len(boxes); i++ {
for j := i + 1; j < len(boxes); j++ {
a, b := boxes[i], boxes[j]
if a.Kind == "foreignObject" && b.Kind == "foreignObject" {
if renderedBoxesOverlap(a, b, 3) {
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.foreign_object_collision", fmt.Sprintf("estimated foreignObject boxes collide: %q and %q", a.Text, b.Text), a))
}
continue
}
if a.Kind == "foreignObject" || b.Kind == "foreignObject" {
continue
}
if renderedBoxesOverlap(a, b, 3) {
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.text_collision", fmt.Sprintf("estimated text boxes collide: %q and %q", a.Text, b.Text), a))
}
}
}
report.Metrics.Slides = 1
report.Slides[0].IssueCount = len(report.Issues)
if len(report.Issues) > 0 {
report.Slides[0].Status = "failed"
}
renderedVisualFinalize(&report)
return report
}
func renderedVisualTextBoxes(raw []byte, path string) []renderedVisualBox {
decoder := xml.NewDecoder(bytes.NewReader(raw))
stack := []renderedVisualParseState{{FontSize: 16}}
var boxes []renderedVisualBox
for {
token, err := decoder.Token()
if err != nil {
break
}
current := stack[len(stack)-1]
switch t := token.(type) {
case xml.StartElement:
next := current
next.InText = false
next.InForeign = current.InForeign
next.Text = ""
next.ElementID = renderedAttrValue(t.Attr, "id")
next.TextAnchor = firstRenderedNonEmpty(renderedAttrValue(t.Attr, "text-anchor"), current.TextAnchor)
next.FontSize = firstPositive(parseFloatAttr(t.Attr, "font-size"), parseStyleNumber(renderedAttrValue(t.Attr, "style"), "font-size"), current.FontSize)
next.LineHeight = firstPositive(normalizeRenderedLineHeight(parseStyleNumber(renderedAttrValue(t.Attr, "style"), "line-height"), next.FontSize), current.LineHeight)
next.FontWeight = firstPositive(parseFontWeightValue(renderedAttrValue(t.Attr, "font-weight")), parseFontWeightStyle(renderedAttrValue(t.Attr, "style")), current.FontWeight)
dx, dy := parseTranslate(renderedAttrValue(t.Attr, "transform"))
next.TranslateX += dx
next.TranslateY += dy
switch t.Name.Local {
case "text":
next.InText = true
next.TextX = parseFloatAttr(t.Attr, "x") + next.TranslateX
next.TextY = parseFloatAttr(t.Attr, "y") + next.TranslateY
case "foreignObject":
next.InForeign = true
next.Foreign = renderedVisualBox{
Path: path,
ElementID: next.ElementID,
Kind: "foreignObject",
X: parseFloatAttr(t.Attr, "x") + next.TranslateX,
Y: parseFloatAttr(t.Attr, "y") + next.TranslateY,
Width: parseFloatAttr(t.Attr, "width"),
Height: parseFloatAttr(t.Attr, "height"),
FontSize: next.FontSize,
LineHeight: next.LineHeight,
FontWeight: next.FontWeight,
}
}
stack = append(stack, next)
case xml.CharData:
for i := range stack {
if stack[i].InText || stack[i].InForeign {
stack[i].Text += string(t)
}
}
case xml.EndElement:
if len(stack) <= 1 {
continue
}
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
switch t.Name.Local {
case "text":
text := normalizeRenderedText(top.Text)
if text != "" {
width := estimateRenderedTextWidth(text, top.FontSize)
x := top.TextX
switch strings.TrimSpace(top.TextAnchor) {
case "middle":
x -= width / 2
case "end":
x -= width
}
boxes = append(boxes, renderedVisualBox{
Path: path,
ElementID: top.ElementID,
Kind: "text",
Text: text,
X: x,
Y: top.TextY - top.FontSize*0.86,
Width: width,
Height: top.FontSize * 1.15,
FontSize: top.FontSize,
LineHeight: top.LineHeight,
FontWeight: top.FontWeight,
})
}
case "foreignObject":
text := normalizeRenderedText(top.Text)
if text != "" {
requiredHeight := estimateForeignObjectTextHeight(text, top.FontSize, top.Foreign.Width)
top.Foreign.Text = text
top.Foreign.RequiredHeight = requiredHeight
top.Foreign.FontSize = top.FontSize
top.Foreign.LineHeight = top.LineHeight
top.Foreign.FontWeight = top.FontWeight
boxes = append(boxes, top.Foreign)
}
}
}
}
return boxes
}
func renderedVisualFinalize(report *RenderedVisualReport) {
report.Status = "passed"
for _, issue := range report.Issues {
report.Metrics.IssueCount++
switch issue.Code {
case "svglide.rendered_visual.text_overflow", "svglide.rendered_visual.text_box_overflow":
report.Metrics.TextOverflowCount++
case "svglide.rendered_visual.text_collision":
report.Metrics.TextCollisionCount++
case "svglide.rendered_visual.unsafe_edge":
report.Metrics.UnsafeEdgeCount++
case "svglide.rendered_visual.container_text_overflow":
report.Metrics.ContainerTextOverflowCount++
case "svglide.rendered_visual.container_padding_risk":
report.Metrics.ContainerPaddingRiskCount++
case "svglide.rendered_visual.foreign_object_collision":
report.Metrics.ForeignObjectOverlapCount++
case "svglide.rendered_visual.tight_line_height":
report.Metrics.TightLineHeightCount++
case "svglide.rendered_visual.bold_overuse":
report.Metrics.BoldOveruseCount++
case "svglide.rendered_visual.small_text_padding_risk":
report.Metrics.SmallTextPaddingRiskCount++
default:
report.Metrics.OutOfCanvasCount++
}
}
if report.Metrics.IssueCount > 0 {
report.Status = "failed"
}
for i := range report.Slides {
if report.Slides[i].IssueCount > 0 {
report.Slides[i].Status = "failed"
} else if report.Slides[i].Status == "" {
report.Slides[i].Status = "passed"
}
}
}
func renderedVisualContainerIssue(path, code, message string, box renderedVisualBox, container renderedVisualContainer) RenderedVisualIssue {
issue := renderedVisualIssue(path, code, message, box)
issue.Message = fmt.Sprintf("%s; nearest container x=%.2f y=%.2f width=%.2f height=%.2f", message, container.X, container.Y, container.Width, container.Height)
return issue
}
func renderedVisualIssue(path, code, message string, box renderedVisualBox) RenderedVisualIssue {
return RenderedVisualIssue{
Path: path,
ElementID: box.ElementID,
Code: code,
Message: message,
Severity: "error",
X: roundRenderedNumber(box.X),
Y: roundRenderedNumber(box.Y),
Width: roundRenderedNumber(box.Width),
Height: roundRenderedNumber(box.Height),
}
}
func writeRenderedVisualReport(safeRoot string, report RenderedVisualReport) error {
target, err := ensureRunFileTargetForWrite(safeRoot, renderedVisualReceiptPath)
if err != nil {
return err
}
return writeJSON(target, report)
}
func readRenderedVisualReport(safeRoot string) (RenderedVisualReport, bool, error) {
raw, err := readRunRegularArtifact(safeRoot, renderedVisualReceiptPath)
if err != nil {
if strings.Contains(err.Error(), "missing or not a regular file") {
return RenderedVisualReport{}, false, nil
}
return RenderedVisualReport{}, false, err
}
var report RenderedVisualReport
if err := json.Unmarshal(raw, &report); err != nil {
return RenderedVisualReport{}, true, err
}
return report, true, nil
}
func renderedVisualSlideFailed(report RenderedVisualReport, path string) bool {
for _, slide := range report.Slides {
if slide.Path == path && slide.Status == "failed" {
return true
}
}
return false
}
func renderedBoxesOverlap(a, b renderedVisualBox, pad float64) bool {
return a.X < b.X+b.Width+pad &&
a.X+a.Width+pad > b.X &&
a.Y < b.Y+b.Height+pad &&
a.Y+a.Height+pad > b.Y
}
func renderedVisualContainers(raw []byte, path string, slideWidth, slideHeight float64) []renderedVisualContainer {
decoder := xml.NewDecoder(bytes.NewReader(raw))
type state struct {
translateX float64
translateY float64
}
stack := []state{{}}
var containers []renderedVisualContainer
for {
token, err := decoder.Token()
if err != nil {
break
}
switch t := token.(type) {
case xml.StartElement:
current := stack[len(stack)-1]
next := current
dx, dy := parseTranslate(renderedAttrValue(t.Attr, "transform"))
next.translateX += dx
next.translateY += dy
if t.Name.Local == "rect" {
if renderedVisualRectLooksLikeChartMark(t.Attr) {
stack = append(stack, next)
continue
}
container := renderedVisualContainer{
Path: path,
ElementID: renderedAttrValue(t.Attr, "id"),
Kind: "rect",
X: parseFloatAttr(t.Attr, "x") + next.translateX,
Y: parseFloatAttr(t.Attr, "y") + next.translateY,
Width: parseFloatAttr(t.Attr, "width"),
Height: parseFloatAttr(t.Attr, "height"),
RX: parseFloatAttr(t.Attr, "rx"),
Fill: firstRenderedNonEmpty(renderedAttrValue(t.Attr, "fill"), parseStyleValue(renderedAttrValue(t.Attr, "style"), "fill")),
Stroke: firstRenderedNonEmpty(renderedAttrValue(t.Attr, "stroke"), parseStyleValue(renderedAttrValue(t.Attr, "style"), "stroke")),
Opacity: firstPositive(parseFloatAttr(t.Attr, "opacity"), parseFloatLoose(parseStyleValue(renderedAttrValue(t.Attr, "style"), "opacity")), 1),
}
if isRenderedVisualContainer(container, slideWidth, slideHeight) {
containers = append(containers, container)
}
}
stack = append(stack, next)
case xml.EndElement:
if len(stack) > 1 {
stack = stack[:len(stack)-1]
}
}
}
return containers
}
func isRenderedVisualContainer(container renderedVisualContainer, slideWidth, slideHeight float64) bool {
if container.Width <= 0 || container.Height <= 0 || slideWidth <= 0 || slideHeight <= 0 {
return false
}
if container.Width/slideWidth < 0.04 || container.Height/slideHeight < 0.04 {
return false
}
fill := strings.ToLower(strings.TrimSpace(container.Fill))
stroke := strings.ToLower(strings.TrimSpace(container.Stroke))
hasVisualStyle := container.RX > 0 || (fill != "" && fill != "none" && fill != "transparent") || (stroke != "" && stroke != "none" && stroke != "transparent")
if !hasVisualStyle {
return false
}
areaRatio := (container.Width * container.Height) / (slideWidth * slideHeight)
if areaRatio > 0.55 {
return false
}
touchesEdges := container.X <= 2 && container.Y <= 2 && container.X+container.Width >= slideWidth-2 && container.Y+container.Height >= slideHeight-2
return !(areaRatio > 0.70 && touchesEdges)
}
func renderedVisualRectLooksLikeChartMark(attrs []xml.Attr) bool {
haystack := strings.ToLower(strings.Join([]string{
renderedAttrValue(attrs, "id"),
renderedAttrValue(attrs, "class"),
renderedAttrValue(attrs, "role"),
renderedAttrValue(attrs, "aria-label"),
renderedAttrValue(attrs, "data-mark"),
renderedAttrValue(attrs, "data-role"),
}, " "))
for _, token := range []string{"mark-bar", "mark bar", "bar-mark", "axis", "tick", "plot", "vega", "data-point", "datapoint"} {
if strings.Contains(haystack, token) {
return true
}
}
return false
}
func nearestRenderedContainer(box renderedVisualBox, containers []renderedVisualContainer) (renderedVisualContainer, bool) {
content := renderedBoxVisibleContentBounds(box)
centerX := content.X + content.Width/2
var best renderedVisualContainer
var bestArea float64
for _, container := range containers {
if centerX < container.X || centerX > container.X+container.Width {
continue
}
overlapsVertically := content.Y < container.Y+container.Height && content.Y+content.Height > container.Y
startsInside := box.Y >= container.Y && box.Y <= container.Y+container.Height
if !overlapsVertically && !startsInside {
continue
}
area := container.Width * container.Height
if bestArea == 0 || area < bestArea {
best = container
bestArea = area
}
}
return best, bestArea > 0
}
func renderedBoxVisibleContentBounds(box renderedVisualBox) renderedVisualBox {
out := box
if box.Kind == "foreignObject" && box.RequiredHeight > 0 {
out.Height = box.RequiredHeight
}
return out
}
func renderedContainerPadding(container renderedVisualContainer) float64 {
shortSide := math.Min(container.Width, container.Height)
return math.Max(12, math.Min(24, shortSide*0.10))
}
func minRenderedBoxMargin(box renderedVisualBox, container renderedVisualContainer) float64 {
return math.Min(
math.Min(box.X-container.X, container.X+container.Width-(box.X+box.Width)),
math.Min(box.Y-container.Y, container.Y+container.Height-(box.Y+box.Height)),
)
}
func estimateForeignObjectTextHeight(text string, fontSize float64, width float64) float64 {
if fontSize <= 0 {
fontSize = 16
}
if width <= 0 {
return fontSize * 1.3
}
plain := normalizeRenderedText(renderedHTMLTagPattern.ReplaceAllString(text, " "))
textWidth := estimateRenderedTextWidth(plain, fontSize)
lines := math.Max(1, math.Ceil(textWidth/(width*0.92)))
return lines * fontSize * 1.22
}
func estimateRenderedTextWidth(text string, fontSize float64) float64 {
if fontSize <= 0 {
fontSize = 16
}
var units float64
for _, r := range text {
switch {
case r == ' ' || r == '\t' || r == '\n':
units += 0.33
case r < 128:
if strings.ContainsRune(".,:;!|/\\'\"()-+%", r) {
units += 0.36
} else {
units += 0.58
}
default:
units += 1.0
}
}
return units * fontSize
}
func renderedAttrValue(attrs []xml.Attr, name string) string {
for _, attr := range attrs {
if attr.Name.Local == name {
return strings.TrimSpace(attr.Value)
}
}
return ""
}
func parseFloatAttr(attrs []xml.Attr, name string) float64 {
return parseFloatLoose(renderedAttrValue(attrs, name))
}
func parseFloatLoose(value string) float64 {
value = strings.TrimSpace(strings.TrimSuffix(value, "px"))
if value == "" {
return 0
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return parsed
}
func parseStyleNumber(style, name string) float64 {
for _, match := range renderedStyleNumberPattern.FindAllStringSubmatch(style, -1) {
if len(match) == 3 && strings.EqualFold(match[1], name) {
return parseFloatLoose(match[2])
}
}
return 0
}
func parseStyleValue(style, name string) string {
for _, match := range renderedStyleValuePattern.FindAllStringSubmatch(style, -1) {
if len(match) == 3 && strings.EqualFold(match[1], name) {
return strings.TrimSpace(match[2])
}
}
return ""
}
func parseFontWeightStyle(style string) float64 {
match := renderedFontWeightNumberPattern.FindStringSubmatch(style)
if len(match) != 2 {
return 0
}
return parseFontWeightValue(match[1])
}
func parseFontWeightValue(value string) float64 {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "bold", "bolder":
return 700
default:
return parseFloatLoose(value)
}
}
func normalizeRenderedLineHeight(value, fontSize float64) float64 {
if value <= 0 {
return 0
}
if value < 4 && fontSize > 0 {
return value * fontSize
}
return value
}
func parseTranslate(transform string) (float64, float64) {
match := renderedTransformTranslatePattern.FindStringSubmatch(transform)
if len(match) == 0 {
return 0, 0
}
x := parseFloatLoose(match[1])
y := 0.0
if len(match) > 2 {
y = parseFloatLoose(match[2])
}
return x, y
}
func firstRenderedNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func firstPositive(values ...float64) float64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func normalizeRenderedText(text string) string {
text = renderedWhitespacePattern.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
func roundRenderedNumber(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -0,0 +1,143 @@
package svglide
import (
"os"
"path/filepath"
"testing"
)
func TestRenderedVisualGateDetectsAppleSubtitleOverflow(t *testing.T) {
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="92" y="318" font-size="25">Revenue declined 4.3% year over year, but gross margin reached 46.6% and diluted EPS set a March-quarter record.</text></svg>`
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_overflow") {
t.Fatalf("report = %+v, want text_overflow", report)
}
}
func TestRenderedVisualGateDetectsForeignObjectClip(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><foreignObject x="370" y="58" width="820" height="58" style="font-size:48px;line-height:1.16"><h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0">这支美国队的优势在结构,不只在星味</h2></foreignObject></svg>`
report := evaluateRenderedVisualSVG("slides/02.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_box_overflow") {
t.Fatalf("report = %+v, want text_box_overflow", report)
}
}
func TestRenderedVisualGateDetectsTimelineCollision(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><g font-size="46"><text x="984" y="338">2022</text><text x="1160" y="338" text-anchor="end">2024</text></g></svg>`
report := evaluateRenderedVisualSVG("slides/03.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_collision") {
t.Fatalf("report = %+v, want text_collision", report)
}
}
func TestRenderedVisualGateDetectsMetricCardInternalOverflow(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
<rect x="930" y="134" width="190" height="118" rx="10" fill="#202420" stroke="#3d443f"/>
<foreignObject x="950" y="239" width="150" height="129" style="font-size:16px">
<div xmlns="http://www.w3.org/1999/xhtml">current assets / liabilities</div>
</foreignObject>
</svg>`
report := evaluateRenderedVisualSVG("slides/05-ratios-peers.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.container_text_overflow") {
t.Fatalf("report = %+v, want container_text_overflow", report)
}
}
func TestRenderedVisualGateAllowsTextInsideCardPadding(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
<rect x="100" y="100" width="280" height="150" rx="10" fill="#202420" stroke="#3d443f"/>
<foreignObject x="124" y="126" width="220" height="84" style="font-size:18px">
<div xmlns="http://www.w3.org/1999/xhtml">A fitted note with room.</div>
</foreignObject>
</svg>`
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
if report.Status != "passed" {
t.Fatalf("report = %+v, want passed", report)
}
}
func TestRenderedVisualGateDoesNotTreatChartBarsAsTextContainers(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
<rect x="120" y="160" width="280" height="320" class="mark-bar" fill="#76b900"/>
<text x="132" y="188" font-size="22">$22.1B</text>
</svg>`
report := evaluateRenderedVisualSVG("slides/chart.svg", []byte(svg))
if report.Status != "passed" {
t.Fatalf("report = %+v, want passed for chart mark label", report)
}
}
func TestRenderedVisualGateDetectsForeignObjectOverlap(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
<foreignObject x="120" y="120" width="260" height="80" style="font-size:22px"><p xmlns="http://www.w3.org/1999/xhtml">First label</p></foreignObject>
<foreignObject x="180" y="150" width="260" height="80" style="font-size:22px"><p xmlns="http://www.w3.org/1999/xhtml">Second label</p></foreignObject>
</svg>`
report := evaluateRenderedVisualSVG("slides/02.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.foreign_object_collision") {
t.Fatalf("report = %+v, want foreign_object_collision", report)
}
}
func TestRenderedVisualGateDetectsTightLineHeight(t *testing.T) {
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
<foreignObject x="120" y="120" width="420" height="90" style="font-size:20px;line-height:20px"><p xmlns="http://www.w3.org/1999/xhtml">Dense label copy with enough words to wrap into two lines.</p></foreignObject>
</svg>`
report := evaluateRenderedVisualSVG("slides/03.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.tight_line_height") {
t.Fatalf("report = %+v, want tight_line_height", report)
}
}
func TestRenderedVisualGateDetectsBoldOveruse(t *testing.T) {
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="100" y="120" font-size="34" font-weight="800">Revenue acceleration is the story</text>
<text x="100" y="180" font-size="28" font-weight="800">Margins expand while supply stays tight</text>
<text x="100" y="236" font-size="24" font-weight="800">Every sentence should not be bold</text>
</svg>`
report := evaluateRenderedVisualSVG("slides/04.svg", []byte(svg))
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.bold_overuse") {
t.Fatalf("report = %+v, want bold_overuse", report)
}
}
func TestRenderedVisualGateAllowsFittedText(t *testing.T) {
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="92" y="120" font-size="24">Short title</text><foreignObject x="92" y="180" width="520" height="90" style="font-size:24px"><p xmlns="http://www.w3.org/1999/xhtml">One fitted sentence.</p></foreignObject></svg>`
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
if report.Status != "passed" {
t.Fatalf("report = %+v, want passed", report)
}
}
func TestRenderedVisualGateRegressionFixtures(t *testing.T) {
tests := []struct {
name string
file string
code string
}{
{"apple subtitle", "apple_subtitle_overflow.svg", "svglide.rendered_visual.text_overflow"},
{"apple cashflow", "apple_cashflow_callout_overflow.svg", "svglide.rendered_visual.text_overflow"},
{"leica timeline", "leica_timeline_collision.svg", "svglide.rendered_visual.text_collision"},
{"sports title", "sports_foreign_object_clip.svg", "svglide.rendered_visual.text_box_overflow"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "svglide", "rendered_visual", tt.file))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
report := evaluateRenderedVisualSVG(tt.file, raw)
if report.Status != "failed" || !renderedVisualHasCode(report, tt.code) {
t.Fatalf("report = %+v, want %s", report, tt.code)
}
})
}
}
func renderedVisualHasCode(report RenderedVisualReport, code string) bool {
for _, issue := range report.Issues {
if issue.Code == code {
return true
}
}
return false
}

863
internal/svglide/repair.go Normal file
View File

@@ -0,0 +1,863 @@
package svglide
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/internal/validate"
)
type RepairReport struct {
Status string `json:"status"`
LintOK bool `json:"lint_ok"`
Preview string `json:"preview"`
Quality string `json:"quality"`
Creative string `json:"creative"`
Semantic string `json:"semantic"`
Reauthored bool `json:"reauthored"`
}
type DeliveryReceipt struct {
Status string `json:"status"`
RouteProfile string `json:"route_profile"`
Orchestrator string `json:"orchestrator"`
RuntimeBinding string `json:"runtime_binding"`
Deck string `json:"deck"`
SlidesDir string `json:"slides_dir"`
Slides []string `json:"slides"`
Preview DeliveryPreviewEvidence `json:"preview"`
QualityReport string `json:"quality_report"`
AnyGenSemanticReport string `json:"anygen_semantic_report"`
VisualReceipts string `json:"visual_receipts"`
CreativeQualityReport string `json:"creative_quality_report"`
SemanticMetrics SemanticMetrics `json:"semantic_metrics"`
StageStatus map[string]string `json:"stage_status"`
FullChainEvidence FullChainEvidence `json:"full_chain_evidence"`
LegacyRuntimeExecuted bool `json:"legacy_runtime_executed"`
LegacyToolIDs []string `json:"legacy_tool_ids"`
LegacyArtifactMatches []string `json:"legacy_artifact_matches"`
CorePromptIDs []string `json:"core_prompt_ids"`
ObservedPromptIDs []string `json:"observed_prompt_ids"`
BlockedPromptIDs []string `json:"blocked_prompt_ids"`
}
type DeliveryPreviewEvidence struct {
Path string `json:"path"`
Status string `json:"status"`
MissingAssetCount int `json:"missing_asset_count"`
}
type FullChainEvidence struct {
RunJSON string `json:"run_json"`
Request string `json:"request"`
SourceManifest string `json:"source_manifest"`
EntityResolution string `json:"entity_resolution"`
ResearchNotes string `json:"research_notes"`
Sources string `json:"sources"`
ResearchCoverage string `json:"research_coverage"`
DesignBrief string `json:"design_brief"`
VisualSystem string `json:"visual_system"`
TypographyContract string `json:"typography_contract"`
Outline string `json:"outline"`
SlideContent string `json:"slide_content"`
AssetManifest string `json:"asset_manifest"`
RenderedVisual string `json:"rendered_visual"`
QualityReport string `json:"quality_report"`
CreativeQualityReport string `json:"creative_quality_report"`
ChartRenderReport string `json:"chart_render_report"`
ChartUsageReport string `json:"chart_usage_report"`
ChartQualityReport string `json:"chart_quality_report"`
Delivery string `json:"delivery"`
StageReceipts map[string]string `json:"stage_receipts"`
ScreenshotEvidence []string `json:"screenshot_evidence"`
ManualPatch ManualPatchStatus `json:"manual_patch"`
}
type ManualPatchStatus struct {
Applied bool `json:"applied"`
Files []string `json:"files"`
Reason string `json:"reason,omitempty"`
}
func RepairRun(root string) (RepairReport, error) {
return repairRun(root, EvaluateAnyGenSemantics)
}
func RepairRunWithSemanticContractFile(root string, contractPath string) (RepairReport, error) {
contract, err := LoadSemanticContractFile(contractPath)
if err != nil {
return RepairReport{}, err
}
return RepairRunWithSemanticContract(root, contract)
}
func RepairRunWithSemanticContract(root string, contract SemanticContract) (RepairReport, error) {
return repairRun(root, func(root string) (AnyGenSemanticReport, error) {
return EvaluateAnyGenSemanticsWithContract(root, contract)
})
}
func repairRun(root string, evaluateSemantic func(string) (AnyGenSemanticReport, error)) (RepairReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return RepairReport{}, err
}
lint, validateErr := ValidateRun(root)
if validateErr != nil {
return RepairReport{}, validateErr
}
reauthored := false
if !lint.OK {
repairPaths, ok := authorRepairPaths(lint)
if ok {
if _, err := authorSlides(root, repairPaths); err != nil {
return RepairReport{}, err
}
reauthored = true
lint, validateErr = ValidateRun(root)
}
if validateErr != nil {
return RepairReport{}, validateErr
}
}
preview, err := WritePreview(root)
if err != nil {
return RepairReport{}, err
}
quality, err := CheckQuality(root)
if err != nil {
return RepairReport{}, err
}
if quality.Status != "passed" {
if err := writeQualityRepairQueue(safeRoot, quality); err != nil {
return RepairReport{}, err
}
}
semantic, err := evaluateSemantic(root)
if err != nil {
return RepairReport{}, err
}
report := RepairReport{
Status: "failed",
LintOK: lint.OK,
Preview: preview.Status,
Quality: quality.Status,
Creative: creativeStatusFromQuality(safeRoot),
Semantic: semantic.Status,
Reauthored: reauthored,
}
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative == "passed" && report.Semantic == "passed" {
report.Status = "passed"
}
previewPath := strings.TrimSpace(run.Artifacts.Preview)
if previewPath == "" {
previewPath = defaultPreviewPath
}
artifacts := []string{
"receipts/lint.json",
"receipts/preview.json",
"quality_report.json",
anyGenSemanticReportPath,
visualReceiptsPath,
creativeQualityReportPath,
chartRenderReceiptPath,
chartUsageReceiptPath,
chartQualityReportPath,
"repair_queue.md",
previewPath,
}
if report.Status == "passed" {
artifacts = append(artifacts, deliveryReceiptPath)
} else if report.LintOK && report.Preview == "passed" {
artifacts = append(artifacts, deliveryReceiptPath)
}
if err := writeStageReceipt(safeRoot, StageReceipt{
Stage: StageValidatePreviewRepair,
Status: report.Status,
Message: repairReceiptMessage(report),
Artifacts: artifacts,
}); err != nil {
return report, err
}
if report.Status == "passed" {
if _, err := writeDeliveryReceiptWithStatus(safeRoot, run, StatusReady); err != nil {
return report, err
}
} else if report.LintOK && report.Preview == "passed" {
if _, err := writeDeliveryReceiptWithStatus(safeRoot, run, StatusNeedsRepair); err != nil {
return report, err
}
}
return report, nil
}
const deliveryReceiptPath = "receipts/delivery.json"
const deliveryChartQualityReportPath = "receipts/chart_quality.json"
func writeDeliveryReceipt(safeRoot string, run Run) (DeliveryReceipt, error) {
return writeDeliveryReceiptWithStatus(safeRoot, run, StatusReady)
}
func writeDeliveryReceiptWithStatus(safeRoot string, run Run, status string) (DeliveryReceipt, error) {
receipt, err := generateDeliveryReceiptWithStatus(safeRoot, run, status)
if err != nil {
return DeliveryReceipt{}, err
}
if err := writeDeliveryReceiptSchema(safeRoot); err != nil {
return DeliveryReceipt{}, err
}
if err := ValidateDeliveryReceiptAgainstRun(receipt, run); err != nil {
return DeliveryReceipt{}, err
}
target, err := ensureRunFileTargetForWrite(safeRoot, deliveryReceiptPath)
if err != nil {
return DeliveryReceipt{}, err
}
if err := writeJSON(target, receipt); err != nil {
return DeliveryReceipt{}, err
}
return receipt, nil
}
func writeDeliveryReceiptSchema(safeRoot string) error {
target, err := ensureRunFileTargetForWrite(safeRoot, "schemas/delivery.schema.json")
if err != nil {
return err
}
return writeText(target, DeliveryReceiptSchema)
}
func GenerateDeliveryReceipt(safeRoot string, run Run) (DeliveryReceipt, error) {
return generateDeliveryReceiptWithStatus(safeRoot, run, StatusReady)
}
func generateDeliveryReceiptWithStatus(safeRoot string, run Run, status string) (DeliveryReceipt, error) {
status = strings.TrimSpace(status)
if status == "" {
status = StatusReady
}
deckPath := strings.TrimSpace(run.Artifacts.Deck)
if deckPath == "" {
deckPath = "outline/deck.json"
}
deck, err := readAuthorDeck(safeRoot, deckPath)
if err != nil {
return DeliveryReceipt{}, err
}
slides := make([]string, 0, len(deck.Slides))
for _, slide := range deck.Slides {
slidePath, err := previewSlideObjectPath(slide.Path)
if err != nil {
return DeliveryReceipt{}, err
}
if _, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
return DeliveryReceipt{}, err
}
slides = append(slides, slidePath)
}
previewPath := strings.TrimSpace(run.Artifacts.Preview)
if previewPath == "" {
previewPath = defaultPreviewPath
}
requiredReports := []string{previewPath, "quality_report.json", anyGenSemanticReportPath, creativeQualityReportPath, chartRenderReceiptPath, chartUsageReceiptPath, chartQualityReportPath}
if status == StatusReady {
requiredReports = append(requiredReports, visualReceiptsPath)
}
for _, rel := range requiredReports {
if _, err := readRunRegularArtifact(safeRoot, rel); err != nil {
return DeliveryReceipt{}, err
}
}
slidesDir := strings.TrimSpace(run.Artifacts.SlidesDir)
if slidesDir == "" {
slidesDir = "slides"
}
preview, err := readDeliveryPreviewEvidence(safeRoot, previewPath)
if err != nil {
return DeliveryReceipt{}, err
}
semantic, err := readDeliverySemanticReport(safeRoot)
if err != nil {
return DeliveryReceipt{}, err
}
legacy, err := ScanLegacyRuntimeEvidence(safeRoot, run)
if err != nil {
return DeliveryReceipt{}, err
}
fullChainEvidence, fullChainComplete, err := buildDeliveryFullChainEvidence(safeRoot, run, previewPath)
if err != nil {
return DeliveryReceipt{}, err
}
if status == StatusReady && !fullChainComplete {
status = StatusNeedsRepair
}
receipt := DeliveryReceipt{
Status: status,
RouteProfile: normalizedRouteProfile(run.RouteProfile),
Orchestrator: "mode_system_prompt_svg",
RuntimeBinding: "svglide_local_runtime_binding",
Deck: deckPath,
SlidesDir: slidesDir,
Slides: slides,
Preview: preview,
QualityReport: "quality_report.json",
AnyGenSemanticReport: anyGenSemanticReportPath,
VisualReceipts: visualReceiptsPath,
CreativeQualityReport: creativeQualityReportPath,
SemanticMetrics: semantic.Metrics,
StageStatus: deliveryStageStatus(run),
FullChainEvidence: fullChainEvidence,
LegacyRuntimeExecuted: legacy.LegacyRuntimeExecuted,
LegacyToolIDs: legacy.LegacyToolIDs,
LegacyArtifactMatches: legacy.LegacyArtifactMatches,
CorePromptIDs: []string{"mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding"},
ObservedPromptIDs: legacy.ObservedPromptIDs,
BlockedPromptIDs: legacy.BlockedPromptIDs,
}
return receipt, nil
}
func creativeStatusFromQuality(safeRoot string) string {
raw, err := readRunRegularArtifact(safeRoot, creativeQualityReportPath)
if err != nil {
return "missing"
}
var report CreativeQualityReport
if err := json.Unmarshal(raw, &report); err != nil {
return "invalid"
}
return strings.TrimSpace(report.Status)
}
func ValidateDeliveryReceiptAgainstRun(receipt DeliveryReceipt, run Run) error {
if normalizedRouteProfile(run.RouteProfile) == RouteProfileLocalSVGDeck && receipt.LegacyRuntimeExecuted {
return fmt.Errorf("legacy runtime evidence found for local_svg_deck: tools=%s artifacts=%s", strings.Join(receipt.LegacyToolIDs, ","), strings.Join(receipt.LegacyArtifactMatches, ","))
}
return nil
}
type LegacyRuntimeEvidence struct {
LegacyRuntimeExecuted bool `json:"legacy_runtime_executed"`
LegacyToolIDs []string `json:"legacy_tool_ids"`
LegacyArtifactMatches []string `json:"legacy_artifact_matches"`
ObservedPromptIDs []string `json:"observed_prompt_ids"`
BlockedPromptIDs []string `json:"blocked_prompt_ids"`
}
func ScanLegacyRuntimeEvidence(safeRoot string, run Run) (LegacyRuntimeEvidence, error) {
legacyIDs, blockedIDs, err := legacyPromptIDSets(run)
if err != nil {
return LegacyRuntimeEvidence{}, err
}
evidence := LegacyRuntimeEvidence{
LegacyToolIDs: []string{},
LegacyArtifactMatches: []string{},
ObservedPromptIDs: []string{},
BlockedPromptIDs: sortedKeys(blockedIDs),
}
toolMatches, err := filepath.Glob(filepath.Join(safeRoot, "receipts", "tool_calls", "*", "*.json"))
if err != nil {
return LegacyRuntimeEvidence{}, err
}
for _, path := range toolMatches {
id := strings.TrimSuffix(filepath.Base(path), ".json")
if !legacyIDs[id] {
continue
}
evidence.LegacyToolIDs = appendUnique(evidence.LegacyToolIDs, id)
if rel, err := filepath.Rel(safeRoot, path); err == nil {
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, filepath.ToSlash(rel))
}
}
contextMatches, err := filepath.Glob(filepath.Join(safeRoot, "receipts", "prompt_context", "*.json"))
if err != nil {
return LegacyRuntimeEvidence{}, err
}
for _, path := range contextMatches {
raw, err := os.ReadFile(path)
if err != nil {
return LegacyRuntimeEvidence{}, err
}
var receipt PromptContextReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
return LegacyRuntimeEvidence{}, fmt.Errorf("%s: invalid prompt context JSON: %w", filepath.ToSlash(path), err)
}
rel := filepath.ToSlash(path)
if localRel, err := filepath.Rel(safeRoot, path); err == nil {
rel = filepath.ToSlash(localRel)
}
for id := range promptIDsFromReceipt(receipt) {
evidence.ObservedPromptIDs = appendUnique(evidence.ObservedPromptIDs, id)
if blockedIDs[id] {
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, rel+"#"+id)
}
}
}
artifactMatches, err := scanLegacyRunArtifacts(safeRoot)
if err != nil {
return LegacyRuntimeEvidence{}, err
}
for _, match := range artifactMatches {
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, match)
}
sort.Strings(evidence.LegacyToolIDs)
sort.Strings(evidence.LegacyArtifactMatches)
sort.Strings(evidence.ObservedPromptIDs)
evidence.LegacyRuntimeExecuted = len(evidence.LegacyToolIDs) > 0 || len(artifactMatches) > 0 || legacyPromptObserved(evidence.LegacyArtifactMatches, blockedIDs)
return evidence, nil
}
func scanLegacyRunArtifacts(safeRoot string) ([]string, error) {
var matches []string
err := filepath.WalkDir(safeRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == safeRoot {
return nil
}
rel, err := filepath.Rel(safeRoot, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if legacyRunArtifactMatch(rel) {
matches = appendUnique(matches, rel)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(matches)
return matches, nil
}
func legacyRunArtifactMatch(rel string) bool {
lower := strings.ToLower(filepath.ToSlash(rel))
base := strings.ToLower(filepath.Base(lower))
ext := strings.ToLower(filepath.Ext(base))
switch ext {
case ".slides", ".pptx", ".sxsd", ".xml":
return true
}
switch base {
case "converted_pptx_manifest.json",
"template_manifest.json",
"sxsd_manifest.json",
"editor_session.json",
"publish_receipt.json",
"readback_receipt.json",
"live_create_receipt.json":
return true
}
if strings.Contains(lower, "sxsd") || strings.Contains(lower, "legacy_editor") {
return true
}
return false
}
func legacyPromptObserved(matches []string, blockedIDs map[string]bool) bool {
for _, match := range matches {
for id := range blockedIDs {
if strings.HasSuffix(match, "#"+id) {
return true
}
}
}
return false
}
func legacyPromptIDSets(run Run) (map[string]bool, map[string]bool, error) {
assets, err := LoadAnyGenPromptAssets()
if err != nil {
return nil, nil, err
}
legacyIDs := map[string]bool{}
blockedIDs := map[string]bool{}
profile := normalizedRouteProfile(run.RouteProfile)
for _, asset := range assets {
if asset.Exposure != "legacy" {
continue
}
legacyIDs[asset.ID] = true
if !promptAssetAllowedForProfile(asset.Profiles, profile) {
blockedIDs[asset.ID] = true
}
}
return legacyIDs, blockedIDs, nil
}
func buildDeliveryFullChainEvidence(safeRoot string, run Run, previewPath string) (FullChainEvidence, bool, error) {
manualPatch, err := readManualPatchStatus(safeRoot, previewPath)
if err != nil {
return FullChainEvidence{}, false, err
}
evidence := FullChainEvidence{
Delivery: deliveryReceiptPath,
StageReceipts: map[string]string{},
ScreenshotEvidence: []string{},
ManualPatch: manualPatch,
}
requiredArtifactsComplete := true
for _, item := range []struct {
rel string
set func(string)
}{
{"run.json", func(path string) { evidence.RunJSON = path }},
{"request/request.json", func(path string) { evidence.Request = path }},
{"request/source_manifest.json", func(path string) { evidence.SourceManifest = path }},
{"request/entity_resolution.json", func(path string) { evidence.EntityResolution = path }},
{"research/research_notes.md", func(path string) { evidence.ResearchNotes = path }},
{"research/sources.json", func(path string) { evidence.Sources = path }},
{"research/research_coverage.json", func(path string) { evidence.ResearchCoverage = path }},
{"brief/design_brief.json", func(path string) { evidence.DesignBrief = path }},
{"brief/visual_system.json", func(path string) { evidence.VisualSystem = path }},
{"brief/typography_contract.json", func(path string) { evidence.TypographyContract = path }},
{"outline/deck.json", func(path string) { evidence.Outline = path }},
{"content/slide_content.json", func(path string) { evidence.SlideContent = path }},
{"assets/assets_manifest.json", func(path string) { evidence.AssetManifest = path }},
{renderedVisualReceiptPath, func(path string) { evidence.RenderedVisual = path }},
{"quality_report.json", func(path string) { evidence.QualityReport = path }},
{creativeQualityReportPath, func(path string) { evidence.CreativeQualityReport = path }},
{chartRenderReceiptPath, func(path string) { evidence.ChartRenderReport = path }},
{chartUsageReceiptPath, func(path string) { evidence.ChartUsageReport = path }},
{deliveryChartQualityReportPath, func(path string) { evidence.ChartQualityReport = path }},
} {
path, err := existingDeliveryEvidencePath(safeRoot, item.rel)
if err != nil {
return FullChainEvidence{}, false, err
}
item.set(path)
if path == "" {
requiredArtifactsComplete = false
}
}
stageReceiptsComplete := true
for _, stage := range DefaultStages() {
path, err := existingDeliveryEvidencePath(safeRoot, stage.Receipt)
if err != nil {
return FullChainEvidence{}, false, err
}
evidence.StageReceipts[stage.Name] = path
if path == "" {
stageReceiptsComplete = false
continue
}
valid, err := validDeliveryStageReceipt(safeRoot, stage, path)
if err != nil {
return FullChainEvidence{}, false, err
}
if !valid {
stageReceiptsComplete = false
}
}
screenshots, err := screenshotEvidencePaths(safeRoot)
if err != nil {
return FullChainEvidence{}, false, err
}
evidence.ScreenshotEvidence = screenshots
return evidence, requiredArtifactsComplete && stageReceiptsComplete && len(screenshots) > 0, nil
}
func existingDeliveryEvidencePath(safeRoot string, rel string) (string, error) {
exists, err := runRegularFileExists(safeRoot, rel)
if err != nil {
return "", err
}
if !exists {
return "", nil
}
return filepath.ToSlash(filepath.Clean(rel)), nil
}
func validDeliveryStageReceipt(safeRoot string, stage Stage, rel string) (bool, error) {
raw, err := readRunRegularArtifact(safeRoot, rel)
if err != nil {
return false, err
}
var receipt StageReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
return false, nil
}
if strings.TrimSpace(receipt.Stage) != stage.Name {
return false, nil
}
switch strings.TrimSpace(receipt.Status) {
case StatusDone, "passed":
return true, nil
default:
return false, nil
}
}
func screenshotEvidencePaths(safeRoot string) ([]string, error) {
paths := []string{}
for _, pattern := range []string{
filepath.Join(safeRoot, "screenshots", "*"),
filepath.Join(safeRoot, "contact-sheet*"),
filepath.Join(safeRoot, "contact_sheet*"),
filepath.Join(safeRoot, "receipts", "screenshots", "*"),
filepath.Join(safeRoot, "receipts", "contact-sheet*"),
filepath.Join(safeRoot, "receipts", "contact_sheet*"),
} {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
for _, match := range matches {
info, err := os.Lstat(match)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
rel, err := filepath.Rel(safeRoot, match)
if err != nil {
return nil, err
}
paths = appendUnique(paths, filepath.ToSlash(rel))
}
}
sort.Strings(paths)
return paths, nil
}
func readManualPatchStatus(safeRoot string, previewPath string) (ManualPatchStatus, error) {
const manualPatchPath = "receipts/manual_patch.json"
exists, err := runRegularFileExists(safeRoot, manualPatchPath)
if err != nil {
return ManualPatchStatus{}, err
}
if !exists {
return ManualPatchStatus{Files: []string{}}, nil
}
raw, err := readRunRegularArtifact(safeRoot, manualPatchPath)
if err != nil {
return ManualPatchStatus{}, err
}
var patch ManualPatchStatus
if err := json.Unmarshal(raw, &patch); err != nil {
return ManualPatchStatus{}, fmt.Errorf("%s: invalid JSON: %w", manualPatchPath, err)
}
if !patch.Applied && len(patch.Files) == 0 && strings.TrimSpace(patch.Reason) == "" {
var wrapped struct {
ManualPatch ManualPatchStatus `json:"manual_patch"`
}
if err := json.Unmarshal(raw, &wrapped); err != nil {
return ManualPatchStatus{}, fmt.Errorf("%s: invalid JSON: %w", manualPatchPath, err)
}
patch = wrapped.ManualPatch
}
return normalizeManualPatchStatus(patch, previewPath), nil
}
func normalizeManualPatchStatus(patch ManualPatchStatus, previewPath string) ManualPatchStatus {
files := make([]string, 0, len(patch.Files))
previewPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(previewPath)))
for _, file := range patch.Files {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(file)))
if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "../") {
continue
}
if strings.HasPrefix(clean, "slides/") || strings.HasPrefix(clean, "assets/") || clean == previewPath {
files = appendUnique(files, clean)
}
}
sort.Strings(files)
return ManualPatchStatus{
Applied: patch.Applied || len(files) > 0,
Files: files,
Reason: strings.TrimSpace(patch.Reason),
}
}
func readDeliveryPreviewEvidence(safeRoot string, previewPath string) (DeliveryPreviewEvidence, error) {
raw, err := readRunRegularArtifact(safeRoot, previewReceiptPath)
if err != nil {
return DeliveryPreviewEvidence{}, err
}
var report PreviewReport
if err := json.Unmarshal(raw, &report); err != nil {
return DeliveryPreviewEvidence{}, fmt.Errorf("%s: invalid JSON: %w", previewReceiptPath, err)
}
return DeliveryPreviewEvidence{
Path: previewPath,
Status: report.Status,
MissingAssetCount: report.MissingAssetCount,
}, nil
}
func readDeliverySemanticReport(safeRoot string) (AnyGenSemanticReport, error) {
raw, err := readRunRegularArtifact(safeRoot, anyGenSemanticReportPath)
if err != nil {
return AnyGenSemanticReport{}, err
}
var report AnyGenSemanticReport
if err := json.Unmarshal(raw, &report); err != nil {
return AnyGenSemanticReport{}, fmt.Errorf("%s: invalid JSON: %w", anyGenSemanticReportPath, err)
}
return report, nil
}
func deliveryStageStatus(run Run) map[string]string {
status := make(map[string]string, len(run.Stages))
for _, stage := range run.Stages {
status[stage.Name] = stage.Status
}
return status
}
func normalizedRouteProfile(profile string) string {
profile = strings.TrimSpace(profile)
if profile == "" {
return RouteProfileLocalSVGDeck
}
return profile
}
func sortedKeys(values map[string]bool) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}
func canRepairByAuthoring(report ValidationReport) bool {
_, ok := authorRepairPaths(report)
return ok
}
func authorRepairPaths(report ValidationReport) (map[string]bool, bool) {
if report.OK || len(report.Issues) == 0 {
return nil, false
}
paths := make(map[string]bool)
for _, issue := range report.Issues {
path, ok := repairIssueAuthorPath(issue)
if !ok {
return nil, false
}
paths[path] = true
}
if len(paths) == 0 {
return nil, false
}
return paths, true
}
func canRepairIssueByAuthoring(issue ValidationIssue) bool {
_, ok := repairIssueAuthorPath(issue)
return ok
}
func repairIssueAuthorPath(issue ValidationIssue) (string, bool) {
path := strings.TrimSpace(issue.Path)
slidePath, err := previewSlideObjectPath(path)
if err != nil {
return "", false
}
switch strings.TrimSpace(issue.Code) {
case "svglide.path":
return slidePath, strings.Contains(issue.Message, "missing or not a regular file")
case "svglide.xml", "svglide.root", "svglide.slide_role", "svglide.viewbox", "svglide.visible_content":
return slidePath, true
default:
return "", false
}
}
func repairReceiptMessage(report RepairReport) string {
if report.Status == "passed" {
if report.Reauthored {
return "lint, preview, quality, creative, and semantic report passed after reauthoring"
}
return "lint, preview, quality, creative, and semantic report passed"
}
if report.LintOK && report.Preview == "passed" && report.Quality != "passed" {
return "quality gate failed"
}
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative != "passed" {
return "creative quality gate failed"
}
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative == "passed" && report.Semantic != "passed" {
return "semantic gate failed"
}
if report.Reauthored {
return "repair reauthored slides but lint or preview still failed"
}
return "lint or preview failed"
}
func writeQualityRepairQueue(safeRoot string, report QualityReport) error {
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
if err != nil {
return err
}
return validate.AtomicWrite(queuePath, []byte(renderQualityRepairQueue(report)), 0o644)
}
func renderQualityRepairQueue(report QualityReport) string {
if report.Status == "passed" || len(report.Issues) == 0 {
return "No repair needed.\n"
}
var b strings.Builder
b.WriteString("# SVGlide Repair Queue\n\n")
for _, issue := range report.Issues {
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
if suggestion := qualityRepairSuggestion(issue.Code); suggestion != "" {
fmt.Fprintf(&b, " - Repair: %s\n", suggestion)
}
}
return b.String()
}
func qualityRepairSuggestion(code string) string {
switch strings.TrimSpace(code) {
case "svglide.quality.weak_cover":
return "Rebuild cover with a full-bleed hero image or poster-style composition, reducing copy to one title and one subtitle."
case "svglide.quality.low_evidence_density":
return "Add a dense evidence grid or process image matrix using semantically relevant assets."
case "svglide.quality.repetitive_layout":
return "Vary slide rhythm across hero, thesis, evidence, detail, comparison, and closing layouts."
case "svglide.quality.low_semantic_image_coverage":
return "Replace decorative or generic visuals with images that prove the slide message."
case "svglide.chart_render.missing_node":
return "Install or expose Node.js v20+ and rerun StageAssets completion."
case "svglide.chart_render.missing_node_dependencies":
return "Run npm --prefix internal/svglide/chart_renderer install, then rerun StageAssets completion."
case "svglide.chart_quality.invalid_spec_json":
return "Regenerate the Vega-Lite spec as valid JSON."
case "svglide.chart_quality.unknown_source_id":
return "Use a source_id present in research/sources.json."
case "svglide.chart_usage.not_referenced":
return "Embed the rendered chart with <rect slide:role=\"chart\" href=\"assets/charts/<id>.svg\" .../>."
case "svglide.chart_usage.hand_drawn_chart":
return "Replace hand-drawn chart primitives with a rendered Vega-Lite chart asset."
default:
return ""
}
}

View File

@@ -0,0 +1,394 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRepairRunAuthorsMissingSlidesAndWritesFinalReceipt(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
}
if !report.LintOK {
t.Fatalf("LintOK = false, want true: %+v", report)
}
if report.Preview != "passed" {
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
}
if report.Quality != "passed" {
t.Fatalf("Quality = %q, want passed: %+v", report.Quality, report)
}
if report.Creative != "passed" {
t.Fatalf("Creative = %q, want passed: %+v", report.Creative, report)
}
if !report.Reauthored {
t.Fatalf("Reauthored = false, want true: %+v", report)
}
for _, rel := range []string{
"slides/01.svg",
"preview.html",
"receipts/lint.json",
"receipts/preview.json",
"receipts/chart_quality.json",
"quality_report.json",
"creative_quality_report.json",
"visual_receipts.json",
"receipts/validate_preview_repair.json",
} {
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
t.Fatalf("missing %s: %v", rel, err)
}
}
receipt := readRepairReceiptForTest(t)
if receipt["stage"] != StageValidatePreviewRepair {
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageValidatePreviewRepair)
}
if receipt["status"] != "passed" {
t.Fatalf("receipt status = %v, want passed", receipt["status"])
}
if receipt["message"] != "lint, preview, quality, creative, and semantic report passed after reauthoring" {
t.Fatalf("receipt message = %v, want semantic-aware pass message", receipt["message"])
}
if _, ok := receipt["artifacts"].([]any); !ok {
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
}
if _, ok := receipt["updated_at"]; ok {
t.Fatalf("receipt contains updated_at, want StageReceipt-compatible schema: %+v", receipt)
}
if _, ok := receipt["generated_at"]; ok {
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
}
}
func TestRepairWritesDeliveryReceipt(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteFullChainStageReceiptsForTest(t)
mustWriteFullChainEvidenceArtifactsForTest(t)
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" {
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
}
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
if err != nil {
t.Fatalf("missing delivery receipt after passed repair: %v", err)
}
var delivery map[string]any
if err := json.Unmarshal(raw, &delivery); err != nil {
t.Fatalf("invalid delivery receipt: %v", err)
}
if delivery["status"] != "ready" || delivery["deck"] != "outline/deck.json" {
t.Fatalf("delivery receipt = %+v, want ready deck path", delivery)
}
if delivery["route_profile"] != RouteProfileLocalSVGDeck {
t.Fatalf("delivery route_profile = %v, want %s", delivery["route_profile"], RouteProfileLocalSVGDeck)
}
if delivery["orchestrator"] != "mode_system_prompt_svg" || delivery["runtime_binding"] != "svglide_local_runtime_binding" {
t.Fatalf("delivery prompt core = %+v, want orchestrator and runtime binding", delivery)
}
preview, ok := delivery["preview"].(map[string]any)
if !ok || preview["path"] != "preview.html" || preview["status"] != "passed" {
t.Fatalf("delivery preview = %+v, want preview object with passed status", delivery["preview"])
}
if _, ok := delivery["semantic_metrics"].(map[string]any); !ok {
t.Fatalf("delivery missing semantic_metrics: %+v", delivery)
}
if delivery["legacy_runtime_executed"] != false {
t.Fatalf("delivery legacy_runtime_executed = %v, want false", delivery["legacy_runtime_executed"])
}
for _, key := range []string{"core_prompt_ids", "observed_prompt_ids", "blocked_prompt_ids", "stage_status"} {
if delivery[key] == nil {
t.Fatalf("delivery missing %s: %+v", key, delivery)
}
}
for _, key := range []string{"quality_report", "anygen_semantic_report", "visual_receipts", "creative_quality_report"} {
if delivery[key] == "" || delivery[key] == nil {
t.Fatalf("delivery receipt missing %s: %+v", key, delivery)
}
}
fullChain, ok := delivery["full_chain_evidence"].(map[string]any)
if !ok || fullChain["chart_render_report"] != chartRenderReceiptPath || fullChain["chart_usage_report"] != chartUsageReceiptPath || fullChain["chart_quality_report"] != chartQualityReportPath {
t.Fatalf("delivery full_chain_evidence missing chart reports: %+v", delivery["full_chain_evidence"])
}
screenshots, ok := fullChain["screenshot_evidence"].([]any)
if !ok || len(screenshots) == 0 {
t.Fatalf("delivery full_chain_evidence missing screenshots: %+v", fullChain)
}
slides, ok := delivery["slides"].([]any)
if !ok || len(slides) != 1 || slides[0] != "slides/01.svg" {
t.Fatalf("delivery slides = %+v, want slides/01.svg", delivery["slides"])
}
for _, rel := range []string{"outline/deck.json", "slides/01.svg", "preview.html", "quality_report.json"} {
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
t.Fatalf("delivery path %s missing: %v", rel, err)
}
}
}
func TestRepairRejectsLegacyRuntimeEvidenceForLocalProfile(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/receipts/tool_calls/research/slides_convert.json", `{"call_id":"slides_convert","status":"done"}`)
report, err := RepairRun("demo")
if err == nil {
t.Fatalf("expected legacy runtime evidence to reject delivery, got report %+v", report)
}
if !strings.Contains(err.Error(), "legacy runtime") || !strings.Contains(err.Error(), "slides_convert") {
t.Fatalf("error = %v, want legacy runtime evidence for slides_convert", err)
}
}
func TestRepairRunFailsWhenQualityFails(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"research/local.md","title":"Local source","excerpt":"Local excerpt","usage":"support","retrieval":"local_file"}]}`)
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
}
if report.LintOK != true {
t.Fatalf("LintOK = %v, want true: %+v", report.LintOK, report)
}
if report.Preview != "passed" {
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
}
if report.Quality != "failed" {
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
}
qualityRaw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
if err != nil {
t.Fatal(err)
}
var quality map[string]any
if err := json.Unmarshal(qualityRaw, &quality); err != nil {
t.Fatal(err)
}
if quality["status"] != "failed" {
t.Fatalf("quality status = %v, want failed: %+v", quality["status"], quality)
}
receipt := readRepairReceiptForTest(t)
if receipt["status"] != "failed" {
t.Fatalf("receipt status = %v, want failed", receipt["status"])
}
if receipt["message"] != "quality gate failed" {
t.Fatalf("receipt message = %v, want quality gate failed", receipt["message"])
}
deliveryRaw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
if err != nil {
t.Fatalf("missing delivery receipt for failed quality repair: %v", err)
}
var delivery map[string]any
if err := json.Unmarshal(deliveryRaw, &delivery); err != nil {
t.Fatal(err)
}
if delivery["status"] != StatusNeedsRepair {
t.Fatalf("delivery status = %v, want %s", delivery["status"], StatusNeedsRepair)
}
}
func TestRepairRunWritesVisualQualityRepairQueue(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"},
{"id":"s2","title":"Process","summary":"Process","role":"process","visual_role":"evidence_grid","key_message":"Process","path":"slides/02.svg"}
]}`,
)
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand_factory","must_have":{"evidence_page_min_visuals":4}}}`)
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":"cover","type":"image","instruction":"Cover image"}]},
{"id":"s2","content":"Process","source_refs":["web1"],"visuals":[{"id":"p1","type":"image","instruction":"Process 1"},{"id":"p2","type":"image","instruction":"Process 2"}]}
]}`)
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
{"id":"cover","slide_id":"s1","visual_id":"cover","kind":"image","local_path":"assets/images/cover.png","source_url":"https://example.com/cover.png","status":"ready","usage":"Cover"},
{"id":"p1","slide_id":"s2","visual_id":"p1","kind":"image","local_path":"assets/images/p1.png","source_url":"https://example.com/p1.png","status":"ready","usage":"Process 1"},
{"id":"p2","slide_id":"s2","visual_id":"p2","kind":"image","local_path":"assets/images/p2.png","source_url":"https://example.com/p2.png","status":"ready","usage":"Process 2"}
]}`)
mustWriteTestFile(t, "demo/assets/images/cover.png", "png")
mustWriteTestFile(t, "demo/assets/images/p1.png", "png")
mustWriteTestFile(t, "demo/assets/images/p2.png", "png")
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 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/cover.png" x="0" y="0" width="960" height="540"/></svg>`)
mustWriteTestFile(t, "demo/slides/02.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/p1.png" x="40" y="40" width="320" height="180"/><image slide:role="image" href="../assets/images/p2.png" x="400" y="40" width="320" height="180"/><text x="48" y="300">Process</text></svg>`)
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Quality != "failed" {
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
}
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(queue), "Add a dense evidence grid or process image matrix") {
t.Fatalf("repair queue = %q, want visual quality repair suggestion", string(queue))
}
}
func TestRepairReceiptMessagePrioritizesLintPreviewFailuresOverQuality(t *testing.T) {
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed"}); got != "lint or preview failed" {
t.Fatalf("message = %q, want lint or preview failed", got)
}
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed", Reauthored: true}); got != "repair reauthored slides but lint or preview still failed" {
t.Fatalf("reauthored message = %q, want reauthored lint/preview failure", got)
}
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "failed", Semantic: "passed"}); got != "quality gate failed" {
t.Fatalf("quality-only message = %q, want quality gate failed", got)
}
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "passed", Creative: "failed", Semantic: "passed"}); got != "creative quality gate failed" {
t.Fatalf("creative-only message = %q, want creative quality gate failed", got)
}
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "passed", Creative: "passed", Semantic: "failed"}); got != "semantic gate failed" {
t.Fatalf("semantic-only message = %q, want semantic gate failed", got)
}
}
func TestRepairRunOnlyReauthorsFailedSlidePaths(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
)
custom := `<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="#fff"/><text x="48" y="80">KEEP-CUSTOM-01</text></svg>`
mustWriteTestFile(t, "demo/slides/01.svg", custom)
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
t.Fatalf("report = %+v, want passed reauthored repair", report)
}
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
if err != nil {
t.Fatal(err)
}
if string(raw) != custom {
t.Fatalf("slides/01.svg was overwritten:\n%s", string(raw))
}
if _, err := os.Stat(filepath.Join("demo", "slides", "02.svg")); err != nil {
t.Fatalf("missing reauthored slides/02.svg: %v", err)
}
validation, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !validation.OK {
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
}
}
func TestRepairRunReauthorsBackgroundOnlySVG(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
mustWriteTestFile(t, "demo/slides/01.svg", backgroundOnlySVG())
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
t.Fatalf("report = %+v, want passed reauthored repair", report)
}
validation, err := ValidateRun("demo")
if err != nil {
t.Fatal(err)
}
if !validation.OK {
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
}
}
func TestRepairRunDoesNotAuthorInvalidSlidePath(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/../01.svg"}]}`,
)
report, err := RepairRun("demo")
if err != nil {
t.Fatal(err)
}
if report.Status != "failed" {
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
}
if report.Reauthored {
t.Fatalf("Reauthored = true, want false for invalid path: %+v", report)
}
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !os.IsNotExist(err) {
t.Fatalf("svg_author receipt exists or stat failed, want no authoring: %v", err)
}
}
func TestRepairRunTreatsValidationArtifactWriteErrorAsFatal(t *testing.T) {
initAuthorDemoRun(t,
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
)
if err := os.Remove(filepath.Join("demo", "repair_queue.md")); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join("demo", "repair_queue.md"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := RepairRun("demo"); err == nil {
t.Fatal("expected repair to return validation artifact write error")
}
if _, err := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(err) {
t.Fatalf("final repair receipt exists or stat failed, want no misleading final receipt: %v", err)
}
}
func readRepairReceiptForTest(t *testing.T) map[string]any {
t.Helper()
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "validate_preview_repair.json"))
if err != nil {
t.Fatal(err)
}
var receipt map[string]any
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatal(err)
}
return receipt
}

182
internal/svglide/run.go Normal file
View File

@@ -0,0 +1,182 @@
package svglide
import (
"strings"
"time"
)
const (
StageRequest = "request"
StageRequestResolution = "request_resolution"
StageResearch = "research"
StageDesignBrief = "design_brief"
StageOutline = "outline"
StageSlideContent = "slide_content"
StageAssets = "assets"
StageSVGAuthor = "svg_author"
StageValidatePreviewRepair = "validate_preview_repair"
StatusPending = "pending"
StatusReady = "ready"
StatusInProgress = "in_progress"
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
StatusNeedsRepair = "needs_repair"
)
const (
RouteProfileLocalSVGDeck = "local_svg_deck"
routeProfileImportedPPTX = "imported_pptx"
routeProfileTemplateReference = "template_reference"
routeProfileLegacyEditor = "legacy_editor"
)
const (
VisualQualityModeStrict = "strict"
VisualQualityModeWarn = "warn"
)
type Run struct {
Version int `json:"version"`
Runtime string `json:"runtime"`
Command string `json:"command"`
RouteProfile string `json:"route_profile"`
Title string `json:"title"`
Input string `json:"input,omitempty"`
Audience string `json:"audience,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
VisualQualityMode string `json:"visual_quality_mode,omitempty"`
Pages int `json:"pages,omitempty"`
Out string `json:"out"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
CurrentStage string `json:"current_stage"`
Stages []Stage `json:"stages"`
Artifacts ArtifactPaths `json:"artifacts"`
Policy Policy `json:"policy"`
Agent AgentSession `json:"agent"`
Intent Intent `json:"intent"`
}
type AgentSession struct {
Runtime string `json:"runtime"`
ID string `json:"id,omitempty"`
}
type Intent struct {
SourceMode string `json:"source_mode"`
Topic string `json:"topic,omitempty"`
Input string `json:"input,omitempty"`
Language string `json:"language,omitempty"`
}
type Stage struct {
Name string `json:"name"`
Status string `json:"status"`
Inputs []string `json:"inputs"`
Outputs []string `json:"outputs"`
Receipt string `json:"receipt"`
}
type ArtifactPaths struct {
Deck string `json:"deck"`
SlidesDir string `json:"slides_dir"`
Preview string `json:"preview"`
RepairQueue string `json:"repair_queue"`
}
type Policy struct {
PublishEnabled bool `json:"publish_enabled"`
NetworkByAgent bool `json:"network_by_agent"`
ImageGenerationByAgent bool `json:"image_generation_by_agent"`
Overwrite bool `json:"overwrite"`
}
type NewRunConfig struct {
Title string
Input string
Topic string
Language string
Audience string
DeliveryMode string
Pages int
Out string
Now time.Time
AgentRuntime string
AgentID string
RouteProfile string
}
func NewRun(cfg NewRunConfig) Run {
now := cfg.Now
if now.IsZero() {
now = time.Now()
}
ts := now.Format(time.RFC3339)
agentRuntime := cfg.AgentRuntime
if agentRuntime == "" {
agentRuntime = "codex"
}
routeProfile := strings.TrimSpace(cfg.RouteProfile)
if routeProfile == "" {
routeProfile = RouteProfileLocalSVGDeck
}
sourceMode := "local_file"
if cfg.Topic != "" {
sourceMode = "topic"
}
return Run{
Version: 1,
Runtime: "agent",
Command: "slides +create-svglide",
RouteProfile: routeProfile,
Title: cfg.Title,
Input: cfg.Input,
Audience: cfg.Audience,
DeliveryMode: cfg.DeliveryMode,
VisualQualityMode: VisualQualityModeStrict,
Pages: cfg.Pages,
Out: cfg.Out,
CreatedAt: ts,
UpdatedAt: ts,
CurrentStage: StageRequest,
Stages: DefaultStages(),
Artifacts: ArtifactPaths{
Deck: "outline/deck.json",
SlidesDir: "slides",
Preview: "preview.html",
RepairQueue: "repair_queue.md",
},
Policy: Policy{
PublishEnabled: false,
NetworkByAgent: true,
ImageGenerationByAgent: true,
Overwrite: false,
},
Agent: AgentSession{
Runtime: agentRuntime,
ID: cfg.AgentID,
},
Intent: Intent{
SourceMode: sourceMode,
Topic: cfg.Topic,
Input: cfg.Input,
Language: cfg.Language,
},
}
}
func DefaultStages() []Stage {
return []Stage{
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
{Name: StageRequestResolution, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"request/entity_resolution.json"}, Receipt: "receipts/request_resolution.json"},
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json", "research/research_coverage.json"}, Receipt: "receipts/research.json"},
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, Receipt: "receipts/design_brief.json"},
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md", "research/sources.json"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json", "content/slide_copy_plan.json"}, Receipt: "receipts/slide_content.json"},
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/image_candidates.json", "assets/assets_plan.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json", "receipts/chart_render.json"}, Receipt: "receipts/assets.json"},
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_manifest.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "receipts/image_usage.json", "receipts/chart_usage.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json", "receipts/chart_quality.json", "repair_queue.md", "preview.html", "receipts/delivery.json"}, Receipt: "receipts/validate_preview_repair.json"},
}
}

View File

@@ -0,0 +1,204 @@
package svglide
import (
"reflect"
"testing"
"time"
)
func TestDefaultStagesAreOrdered(t *testing.T) {
stages := DefaultStages()
want := []string{
StageRequest,
StageRequestResolution,
StageResearch,
StageDesignBrief,
StageOutline,
StageSlideContent,
StageAssets,
StageSVGAuthor,
StageValidatePreviewRepair,
}
if len(stages) != len(want) {
t.Fatalf("stage count = %d, want %d", len(stages), len(want))
}
for i, stage := range stages {
if stage.Name != want[i] {
t.Fatalf("stage[%d] = %q, want %q", i, stage.Name, want[i])
}
if stage.Status != StatusPending {
t.Fatalf("stage[%d].Status = %q, want %q", i, stage.Status, StatusPending)
}
if stage.Inputs == nil {
t.Fatalf("stage[%d].Inputs = nil, want stable empty array", i)
}
if stage.Outputs == nil {
t.Fatalf("stage[%d].Outputs = nil, want stable empty array", i)
}
if stage.Receipt == "" {
t.Fatalf("stage[%d] missing receipt path", i)
}
}
}
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
stages := DefaultStages()
svgAuthor := mustStage(t, stages, StageSVGAuthor)
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
}
repair := mustStage(t, stages, StageValidatePreviewRepair)
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
}
}
func TestDefaultStagesFinalStageRequiresQualityReport(t *testing.T) {
stages := DefaultStages()
final := stages[len(stages)-1]
if final.Name != StageValidatePreviewRepair {
t.Fatalf("final stage = %q, want %q", final.Name, StageValidatePreviewRepair)
}
if !stringSliceContains(final.Outputs, "quality_report.json") {
t.Fatalf("final outputs = %+v, want quality_report.json", final.Outputs)
}
if !stringSliceContains(final.Outputs, "receipts/chart_quality.json") {
t.Fatalf("final outputs = %+v, want receipts/chart_quality.json", final.Outputs)
}
if !stringSliceContains(final.Outputs, imageUsageReportPath) {
t.Fatalf("final outputs = %+v, want %s", final.Outputs, imageUsageReportPath)
}
}
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
stages := DefaultStages()
research := mustStage(t, stages, StageResearch)
want := []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}
if !reflect.DeepEqual(research.Inputs, want) {
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
}
}
func TestDefaultStagesRequestResolutionIsGateBetweenRequestAndResearch(t *testing.T) {
stages := DefaultStages()
requestResolution := mustStage(t, stages, StageRequestResolution)
if !reflect.DeepEqual(requestResolution.Inputs, []string{"request/request.json", "request/source_manifest.json"}) {
t.Fatalf("request_resolution Inputs = %v", requestResolution.Inputs)
}
if !reflect.DeepEqual(requestResolution.Outputs, []string{"request/entity_resolution.json"}) {
t.Fatalf("request_resolution Outputs = %v", requestResolution.Outputs)
}
if requestResolution.Receipt != "receipts/request_resolution.json" {
t.Fatalf("request_resolution Receipt = %q", requestResolution.Receipt)
}
}
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
stages := DefaultStages()
outline := mustStage(t, stages, StageOutline)
want := []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}
if !reflect.DeepEqual(outline.Inputs, want) {
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
}
}
func TestNewRunSeparatesProtocolRuntimeFromAgentRuntime(t *testing.T) {
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
run := NewRun(NewRunConfig{
Title: "Demo",
Input: "source.md",
Audience: "产品和工程负责人",
DeliveryMode: "self_read",
Pages: 8,
Out: ".lark-slides/svglide-runs/demo",
Now: now,
})
if run.Version != 1 {
t.Fatalf("Version = %d, want 1", run.Version)
}
if run.Runtime != "agent" {
t.Fatalf("Runtime = %q, want agent", run.Runtime)
}
if run.RouteProfile != RouteProfileLocalSVGDeck {
t.Fatalf("RouteProfile = %q, want %q", run.RouteProfile, RouteProfileLocalSVGDeck)
}
if run.Agent.Runtime != "codex" {
t.Fatalf("Agent.Runtime = %q, want default codex", run.Agent.Runtime)
}
if run.Intent.SourceMode != "local_file" || run.Intent.Input != "source.md" {
t.Fatalf("Intent = %+v, want local_file source.md", run.Intent)
}
if run.Command != "slides +create-svglide" {
t.Fatalf("Command = %q, want slides +create-svglide", run.Command)
}
if run.Title != "Demo" {
t.Fatalf("Title = %q, want Demo", run.Title)
}
if run.Input != "source.md" {
t.Fatalf("Input = %q, want source.md", run.Input)
}
if run.Audience != "产品和工程负责人" {
t.Fatalf("Audience = %q, want 产品和工程负责人", run.Audience)
}
if run.DeliveryMode != "self_read" {
t.Fatalf("DeliveryMode = %q, want self_read", run.DeliveryMode)
}
if run.Pages != 8 {
t.Fatalf("Pages = %d, want 8", run.Pages)
}
if run.Out != ".lark-slides/svglide-runs/demo" {
t.Fatalf("Out = %q, want .lark-slides/svglide-runs/demo", run.Out)
}
wantTS := now.Format(time.RFC3339)
if run.CreatedAt != wantTS {
t.Fatalf("CreatedAt = %q, want %q", run.CreatedAt, wantTS)
}
if run.UpdatedAt != wantTS {
t.Fatalf("UpdatedAt = %q, want %q", run.UpdatedAt, wantTS)
}
if run.CurrentStage != StageRequest {
t.Fatalf("CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
}
wantArtifacts := ArtifactPaths{
Deck: "outline/deck.json",
SlidesDir: "slides",
Preview: "preview.html",
RepairQueue: "repair_queue.md",
}
if run.Artifacts != wantArtifacts {
t.Fatalf("Artifacts = %+v, want %+v", run.Artifacts, wantArtifacts)
}
wantStages := DefaultStages()
if !reflect.DeepEqual(run.Stages, wantStages) {
t.Fatalf("Stages = %+v, want %+v", run.Stages, wantStages)
}
wantPolicy := Policy{
PublishEnabled: false,
NetworkByAgent: true,
ImageGenerationByAgent: true,
Overwrite: false,
}
if run.Policy != wantPolicy {
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
}
}
func mustStage(t *testing.T, stages []Stage, name string) Stage {
t.Helper()
for _, stage := range stages {
if stage.Name == name {
return stage
}
}
t.Fatalf("missing stage %q", name)
return Stage{}
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}

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