From 363649677471014c30cd7b755fb963469bd58c0f Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 05:29:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(activity-registry):=20=E9=85=8D=E6=96=B9?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=AE=A1=20=E2=80=94=E2=80=94=20=E5=86=99?= =?UTF-8?q?=E9=94=99=E5=9C=B0=E5=9D=80=E5=9C=A8=E8=A3=85=E5=8C=85=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=88=BB=E5=B0=B1=E6=8B=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 refersTo 目标检查同款:代码错是必然的错,而必然的错只有在装的时候被拦下才算好查。 台账地址 / 列名 / 模型名 / 视图模版 / submit 的验收清单逐条核; analyze 的出参没人消费报 warning —— 那是白烧一次模型调用。 顺带(R8,Task 7 评审挖出):全 auto 的步没有 guide 是正常形态, guide.min(1) 放宽为 z.string() + superRefine —— 没 auto 又不写 guide 照旧拒装。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/acceptance-predicate.test.ts | 113 +++++++++++++++++ .../internal/domain/static-audit.ts | 120 ++++++++++++++++++ .../src/__tests__/auto-recipe.test.ts | 29 +++++ packages/contracts/src/manifest.ts | 19 ++- 4 files changed, 279 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts index 6fafd06..ad9facd 100644 --- a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts +++ b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts @@ -348,3 +348,116 @@ describe('refersTo 目标要在本活动的声明里 —— 写错的地址会 expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']); }); }); + +/** + * ── 配方静态审(2026-08-29)────────────────────────────────────────── + * + * `autoRecipeFindings` 读的是**整份** `ActivityManifest`(不像本文件其余审读只收窄到 + * 字段级),所以这里不能再用局部的 `manifest()` / `table()` 这类窄类型 helper —— + * 借 `activityManifestSchema.parse()` 把六区的默认值一次性填满,只手写真正必填的那几处。 + */ +import { activityManifestSchema } from '@contracts'; +import { autoRecipeFindings } from '../internal/domain/static-audit.ts'; + +/** 台账地址与列名全部真实声明过 —— `demo.record` 只有 `case_ref` / `note` 两列 */ +function manifestFixture(): ActivityManifest { + return activityManifestSchema.parse({ + identity: { + activityId: 'demo-activity', + name: '演示活动', + packageVersion: '1.0.0', + category: '演示', + sdkVersion: '1.0.0', + checksum: 'chk', + }, + contract: { + activityId: 'demo-activity', + version: '1.0.0', + input: { data: { type: 'object', properties: {} } }, + output: {}, + authorization: { humanCallBudget: 0 }, + resources: { budget: { steps: 10, tokens: 10000, activeMinutes: 10, humanCalls: 0 } }, + }, + declarations: { + models: ['m'], + ledger: [ + { + domain: 'demo', + object: 'record', + kind: 'record', + caseKey: 'case_ref', + label: '演示记录', + fields: [ + { name: 'case_ref', type: 'text', primaryKey: true }, + { name: 'note', type: 'text' }, + ], + }, + ], + harness: {}, + }, + assets: { + businessModel: { + cmmnXml: '', + narrative: '演示说明书', + dmn: { drd: [], tables: [] }, + steps: [], + }, + }, + samples: [{ name: '样例', input: {}, expect: { ports: ['done'] } }], + entry: { file: 'index.js', checksum: 'chk' }, + }); +} + +describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => { + const withRecipe = (auto: unknown) => { + const base = manifestFixture(); + return { + ...base, + assets: { + ...base.assets, + businessModel: { + ...base.assets.businessModel, + steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }], + }, + }, + } as never; + }; + + it('台账地址不在 declarations.ledger 里 → error', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] })); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true); + }); + + it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }), + ); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true); + }); + + it('each / lookup 的地址同样要在声明里', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'mine:nope.nope' }, fields: {} }] }), + ); + expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true); + }); + + it('in: 与 result: 不是台账地址,不该被当成台账来审', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: {} }] }), + ); + expect(findings).toEqual([]); + }); + + it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }), + ); + expect(findings.some((f) => f.severity === 'warn' && f.message.includes('items'))).toBe(true); + }); + + it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] })); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true); + }); +}); diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index a4cfe3a..c56862d 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -376,6 +376,125 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] { return out; } +/** + * **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。 + * + * 代码错是必然的错,模型错是概率的错。必然的错好查 —— 前提是它在装包那一刻就被拦下, + * 而不是等到第一单跑起来才现形。 + */ +export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] { + const out: StaticAuditFinding[] = []; + const ledgers = new Map( + manifest.declarations.ledger.map((l) => [`${l.domain}.${l.object}`, new Set(l.fields.map((f) => f.name))]), + ); + const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name)); + + /** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */ + const checkAddress = (raw: string, at: string): void => { + if (raw.startsWith('in:') || raw.startsWith('result:')) return; + const address = raw.startsWith('mine:') ? raw.slice('mine:'.length) : raw; + if (!ledgers.has(address)) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: `配方引的台账地址不在声明里:${address}(写的授权面是台账声明)`, + at, + }); + } + }; + + /** 表达式里也可能藏着 lookup 地址,递归找一遍 */ + const walkExpr = (expr: unknown, at: string): void => { + if (typeof expr !== 'object' || expr === null) return; + const node = expr as Record; + if (typeof node['lookup'] === 'string') { + checkAddress(node['lookup'], at); + for (const value of Object.values((node['by'] ?? {}) as Record)) walkExpr(value, at); + if (node['else'] !== undefined) walkExpr(node['else'], at); + } + if (Array.isArray(node['cat'])) for (const part of node['cat'] as unknown[]) walkExpr(part, at); + if (node['fmt'] !== undefined) walkExpr(node['fmt'], at); + if (node['then'] !== undefined) walkExpr(node['then'], at); + if (typeof node['when'] === 'string' && node['else'] !== undefined) walkExpr(node['else'], at); + }; + + const walkSource = (source: unknown, at: string): void => { + if (typeof source !== 'object' || source === null) return; + const node = source as Record; + if (typeof node['each'] === 'string') checkAddress(node['each'], at); + for (const value of Object.values((node['where'] ?? {}) as Record)) walkExpr(value, at); + if (typeof node['then'] === 'object' && node['then'] !== null) walkSource(node['then'], at); + if (Array.isArray(node['rows'])) { + for (const row of node['rows'] as unknown[]) { + for (const value of Object.values(row as Record)) walkExpr(value, at); + } + } + }; + + for (const step of manifest.assets.businessModel.steps ?? []) { + const recipe = step.auto; + if (recipe === undefined) continue; + const at = `assets.businessModel.steps[${step.item}].auto`; + const consumed = new Set(); + + for (const [index, action] of recipe.do.entries()) { + const where = `${at}.do[${index}]`; + if (action.kind === 'project') { + checkAddress(action.into, where); + walkSource(action.from, where); + const columns = ledgers.get(action.into); + for (const [name, expr] of Object.entries(action.fields)) { + walkExpr(expr, where); + if (columns !== undefined && !columns.has(name)) { + out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where }); + } + } + } + if (action.kind === 'analyze') { + walkSource(action.input.from, where); + for (const expr of Object.values(action.input.fields)) walkExpr(expr, where); + if (!manifest.declarations.models.includes(action.model)) { + out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where }); + } + } + if (action.kind === 'card') { + for (const address of action.gateLedgers ?? []) checkAddress(address, where); + if (action.as === 'decide' && (action.options ?? []).length === 0) { + out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where }); + } + } + if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: 'submit 那一步必须声明人工验收清单(acceptanceChecklist)—— 系统提交不比模型提交宽松', + at: where, + }); + } + /* 找出 `result:xxx` 被谁消费了 —— analyze 的出参没人用,就是白烧一次模型调用 */ + const text = JSON.stringify(action); + for (const hit of text.matchAll(/"result:([A-Za-z0-9_]+)"/g)) consumed.add(hit[1] ?? ''); + } + + for (const action of recipe.do) { + if (action.kind !== 'analyze') continue; + if (!consumed.has(action.into)) { + out.push({ + code: 'schema-invalid', + severity: 'warn', + message: `analyze 的出参 result:${action.into} 没有任何一笔消费它 —— 白烧一次模型调用`, + at, + }); + } + } + + if (step.view !== undefined && step.view !== '' && !templates.has(step.view)) { + out.push({ code: 'schema-invalid', severity: 'error', message: `剧本引的视图模版不存在:${step.view}`, at }); + } + } + return out; +} + export function staticAudit(manifest: ActivityManifest, external: ExternalChecks): StaticAuditFinding[] { const out: StaticAuditFinding[] = []; @@ -425,6 +544,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks out.push(...ledgerKindFindings(manifest)); out.push(...acceptanceLevelFindings(manifest)); out.push(...loopFindings(manifest)); + out.push(...autoRecipeFindings(manifest)); return out; } diff --git a/packages/contracts/src/__tests__/auto-recipe.test.ts b/packages/contracts/src/__tests__/auto-recipe.test.ts index 223f135..4d070f7 100644 --- a/packages/contracts/src/__tests__/auto-recipe.test.ts +++ b/packages/contracts/src/__tests__/auto-recipe.test.ts @@ -33,3 +33,32 @@ describe('auto 配方契约', () => { expect(parsed.success).toBe(false); }); }); + +import { planStepSchema } from '../manifest.ts'; + +describe('全 auto 的步可以没有 guide', () => { + /* brief 原样缺了 intent(required,非空)—— 缺了它 safeParse 恒失败,前两条测试就都验不到「guide 允不允许空」这件事本身 */ + const base = { item: 'S1', intent: '这一步交出什么', guide: '', writes: [], reads: [], tools: [] }; + + it('声明了 auto 就允许 guide 为空 —— 那一步压根不问模型', () => { + const parsed = planStepSchema.safeParse({ + ...base, + auto: { do: [{ kind: 'say', text: '办完了' }] }, + }); + expect(parsed.success).toBe(true); + }); + + it('没有 auto 又不写 guide → 拒 —— 「作者忘了写做法」这个老错照旧要拦', () => { + const parsed = planStepSchema.safeParse(base); + expect(parsed.success).toBe(false); + }); + + it('有 auto 但 guide 写了内容 → 照收(半 auto:确定的先做完,剩下的仍问模型)', () => { + const parsed = planStepSchema.safeParse({ + ...base, + guide: '把结论写实', + auto: { do: [{ kind: 'say', text: 'x' }] }, + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/packages/contracts/src/manifest.ts b/packages/contracts/src/manifest.ts index 16f3fa5..3c368f5 100644 --- a/packages/contracts/src/manifest.ts +++ b/packages/contracts/src/manifest.ts @@ -119,7 +119,7 @@ export const planStepSchema = z.object({ /** 一句话:这一步交出什么 */ intent: z.string().min(1), /** 这一步的做法(从说明书里搬过来的那一段)—— 只在轮到它时进上下文 */ - guide: z.string().min(1), + guide: z.string(), /** 这一步可写哪几个台账地址「域.对象」;空 = 这一步不写台账 */ writes: z.array(z.string()).default([]), /** @@ -200,7 +200,22 @@ export const planStepSchema = z.object({ * **老活动一个字不用改** —— 不写就是不写,不是缺件。 */ auto: autoRecipeSchema.optional(), -}); +}) + .superRefine((step, ctx) => { + /* + * **全 auto 的步没有 guide 是正常形态**(2026-08-29):引擎按 `guide.trim() === ''` + * 分「半 auto(确定的先做完,剩下的仍问模型)」与「全 auto(这一步一次都不问模型)」。 + * 而 `.min(1)` 让后者压根写不出来 —— 四步全 auto 的活动会在装包那一刻被拒。 + * 非空这条要求仍留给没有 auto 的步:**「作者忘了写做法」是个老错,照旧要拦**。 + */ + if (step.auto === undefined && step.guide.trim() === '') { + ctx.addIssue({ + code: 'custom', + path: ['guide'], + message: '这一步的做法不能为空 —— 只有声明了 auto 配方的步才允许没有 guide', + }); + } + }); export type PlanStep = z.infer; /**