From 647b86753a33863bcbf8549a99fcf9157918c9d4 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 08:33:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(activity-registry):=20=E9=85=8D=E6=96=B9?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=AE=A1=E8=A1=A5=E9=BD=90=20spec=20=C2=A75.?= =?UTF-8?q?7=20=E6=BC=8F=E6=8E=89=E7=9A=84=E5=87=A0=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`when` 从前一条都没审**(§5.7 第三条明写要审)。运行时两种下场都不好: 嵌在表达式里的 `{when}` 出错 → `evalExpr` catch 掉、**静默走 else**; 动作级的 `when` 出错 → 抛 → 整步交人。现在: · 所有 `when`(含嵌套在表达式里的)审语法,过 `@sfeel` 的 `staticCheck`; · **只有动作级**那一层连名字绑不绑得上一起审 —— 嵌套的 `when` 还看得见行内的 `col`/`outer`/`computed`,拿 `boundNames` 去卡会误报。 名字取 AST 的 `references` 而不是本文件那个正则:正则会把 `count(...)` 这类内建函数名也报成未绑定的台账名。 另三处「装包期判得出、却留到运行时」的: · `submit.rationale` 与 `recipe.let` 的表达式从没被 `walkExpr` 走过 (与这轮已修的 card/say 那个洞同族); · `project.fields` 不含目标表的某个主键列 —— 运行时那一行**静默 continue**, 一行不写、一条痕都没有,表里少了行谁也查不出为什么; · `card` 没有 `basis` —— 基座拒发 basis 为空的工单(契约注释自己写着 「别指望兜底」),运行时那一拒是抛,抛出去就是整步交人。 10 测。既有那条 `toEqual([])` 的用例把主键列补进了 fixture(那份配方本来就是坏的), 断言一个字没弱化。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/acceptance-predicate.test.ts | 110 +++++++++++++++++- .../internal/domain/static-audit.ts | 100 +++++++++++++++- 2 files changed, 204 insertions(+), 6 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 ed2597a..22e8bc0 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 @@ -500,8 +500,9 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () }); it('in: 与 result: 不是台账地址,不该被当成台账来审', () => { + /* 主键列照给 —— 少了它这份配方本来就是坏的(2026-08-29 新增的那条审读),与本条要验的事无关 */ const findings = autoRecipeFindings( - withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: {} }] }), + withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: { case_ref: { col: 'pn' } } }] }), ); expect(findings).toEqual([]); }); @@ -536,7 +537,112 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () }); it('card 是最后一笔:不出这条 warn', () => { - const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] })); + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] })); expect(findings.some((f) => f.message.includes('card 后面还有'))).toBe(false); }); + + /* ── 2026-08-29 终审 I4:spec §5.7 明列却一条都没审的几处 ────────────────── */ + + it('**动作级 when 写不成 S-FEEL → error**(运行时它会抛,整步交人)', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'say', when: 'note >>= (', text: 'x' }] }), + ); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true); + }); + + it('**嵌在表达式里的 when 也审语法** —— 它出错是静默走 else,更难查', () => { + const findings = autoRecipeFindings( + withRecipe({ + do: [ + { + kind: 'project', + into: 'demo.record', + from: { once: true }, + fields: { case_ref: 'a', note: { when: 'note = = 1', then: 'x', else: 'y' } }, + }, + ], + }), + ); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true); + }); + + it('动作级 when 引用了台账上没有的名字 → error(S-FEEL 求值为假,这一笔永远被跳过)', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'candidatesConfirmed > 0', text: 'x' }] })); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('candidatesConfirmed'))).toBe(true); + }); + + it('内建函数名不算未绑定的台账名 —— 名字取 AST 的 references,不是正则扫标识符', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'count(note) > 0', text: 'x' }] })); + expect(findings).toEqual([]); + }); + + it('动作级 when 引用台账列 / 基座读数 / 配方 let 都算绑得上', () => { + const findings = autoRecipeFindings( + withRecipe({ + let: { evalRef: 'W05-1' }, + do: [{ kind: 'say', when: 'demo_record_rows > 0 and note != "" and evalRef != ""', text: 'x' }], + }), + ); + expect(findings).toEqual([]); + }); + + it('**嵌套的 when 不审名字** —— 它看得见行内的列,拿 boundNames 去卡会误报', () => { + const findings = autoRecipeFindings( + withRecipe({ + do: [ + { + kind: 'project', + into: 'demo.record', + from: { each: 'demo.record' }, + fields: { case_ref: { col: 'case_ref' }, note: { when: '本行的列 = "x"', then: 'a', else: 'b' } }, + }, + ], + }), + ); + expect(findings).toEqual([]); + }); + + it('submit.rationale 里藏的 lookup 地址也要核', () => { + const base = manifestFixture(); + const manifest = { + ...base, + assets: { + ...base.assets, + businessModel: { + ...base.assets.businessModel, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + acceptanceChecklist: ['核一遍'], + auto: { do: [{ kind: 'submit', rationale: { lookup: 'rationale.nope', by: {}, take: 'x' } }] }, + }, + ], + }, + }, + } as never; + expect(autoRecipeFindings(manifest).some((f) => f.message.includes('rationale.nope'))).toBe(true); + }); + + it('recipe.let 里藏的 lookup 地址也要核(那一圈从前根本没被 walkExpr 走过)', () => { + const findings = autoRecipeFindings( + withRecipe({ let: { ref: { lookup: 'let.nope', by: {}, take: 'x' } }, do: [{ kind: 'say', text: 'x' }] }), + ); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('let.nope'))).toBe(true); + }); + + it('project.fields 少了目标表的主键列 → error(运行时那一行会被静默跳过)', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { note: 'x' } }] }), + ); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('没给主键列:case_ref'))).toBe(true); + }); + + it('card 没有 basis → error(基座拒发 basis 为空的工单,跑到那儿就停了)', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] })); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('没有 basis'))).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 30e23cb..3f15147 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 @@ -8,6 +8,8 @@ * 本文件只做**纯**判定。 */ import type { ActivityManifest, StaticAuditFinding } from '@contracts'; +/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */ +import { staticCheck } from '@sfeel'; export type ExternalChecks = { /** 声明引用但工具箱里没有的工具 */ @@ -385,8 +387,21 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] { 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))]), + manifest.declarations.ledger.map((l) => [ + `${l.domain}.${l.object}`, + { + columns: new Set(l.fields.map((f) => f.name)), + /* 主键列 —— 与运行时 `writable()` 同一口径(`fields.filter(primaryKey)`) */ + keys: l.fields.filter((f) => f.primaryKey).map((f) => f.name), + }, + ]), ); + /** + * 动作级 `when` 求值时的作用域 = 任务视图 + 基座读数 + 起单入参(`boundNames`)+ 配方的 `let`。 + * **不含**行内的列(`col` / `outer` / `computed`)—— 那些只有嵌在表达式里的 `when` 看得见, + * 所以名字绑定只审动作级那一层,嵌套的那些只审语法(见下面 `checkWhen`)。 + */ + const declaredNames = boundNames(manifest); const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name)); /** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */ @@ -403,10 +418,49 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi } }; - /** 表达式里也可能藏着 lookup 地址,递归找一遍 */ + /** + * **`when` 的 S-FEEL 要能解析**(spec §5.7 第三条,2026-08-29 终审 I4 补 —— 从前一条都没审)。 + * 运行时两种下场都不好:嵌在表达式里的 `{when}` 出错 → `evalExpr` catch 掉、**静默走 else**; + * 动作级的 `when` 出错 → 抛 → 整步交人。写错一个字的代价不该是一单跑不动。 + * + * 给了 `lets` 就连**名字绑得上**一起审(只有动作级那一层能这么审:嵌套的 `when` + * 还看得见行内的 `col` / `outer` / `computed`,拿 `boundNames` 去卡会误报)。 + */ + const checkWhen = (expr: string, at: string, lets?: ReadonlySet): void => { + const verdict = staticCheck(expr); + if (!verdict.ok) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: `配方的 when 过不了 S-FEEL 静态检(写不成 S-FEEL):${expr} —— ${verdict.issues.map((i) => i.message).join(';')}`, + at, + }); + return; + } + if (lets === undefined) return; + /* + * 名字取 `staticCheck` 的 `references`(AST 里的路径节点),不用本文件那个正则 `predicateNames` —— + * 正则会把 `count(...)` 这类**内建函数名**也当成台账名报未绑定;AST 分得清函数与路径。 + */ + for (const path of verdict.references) { + const name = path.split('.')[0] ?? path; + if (declaredNames.has(name) || lets.has(name)) continue; + out.push({ + code: 'schema-invalid', + severity: 'error', + message: + `配方的 when 引用了 \`${name}\`,而台账、案卷、起单入参与本配方的 let 里都没有这个名字 —— ` + + 'S-FEEL 对未绑定的名字求值为假,这一笔将**永远被跳过**(而且一声不响)', + at, + }); + } + }; + + /** 表达式里也可能藏着 lookup 地址与 when,递归找一遍 */ const walkExpr = (expr: unknown, at: string): void => { if (typeof expr !== 'object' || expr === null) return; const node = expr as Record; + if (typeof node['when'] === 'string') checkWhen(node['when'], at); if (typeof node['lookup'] === 'string') { checkAddress(node['lookup'], at); for (const value of Object.values((node['by'] ?? {}) as Record)) walkExpr(value, at); @@ -436,19 +490,43 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi if (recipe === undefined) continue; const at = `assets.businessModel.steps[${step.item}].auto`; const consumed = new Set(); + /* 配方级 `let`:名字进动作级 `when` 的作用域;**它自己的表达式也是 AutoExpr,一样要走一遍** */ + const letNames = new Set(Object.keys(recipe.let ?? {})); + for (const [name, expr] of Object.entries(recipe.let ?? {})) walkExpr(expr, `${at}.let.${name}`); for (const [index, action] of recipe.do.entries()) { const where = `${at}.do[${index}]`; + /* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */ + if (action.when !== undefined) checkWhen(action.when, where, letNames); if (action.kind === 'project') { checkAddress(action.into, where); walkSource(action.from, where); - const columns = ledgers.get(action.into); + const target = ledgers.get(action.into); for (const [name, expr] of Object.entries(action.fields)) { walkExpr(expr, where); - if (columns !== undefined && !columns.has(name)) { + if (target !== undefined && !target.columns.has(name)) { out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where }); } } + /* + * **主键列一个都不能少**(2026-08-29 终审 I4)。运行时 `auto-runner` 拿 + * `decl.keys` 逐列取值,取不到就当空主键 —— 那一行**静默 `continue`**: + * 一行不写、一条痕都没有。表里少了行,人和判据都查不出为什么。 + * 装包时判得出来的事,不留到运行时。 + */ + if (target !== undefined) { + const missing = target.keys.filter((k) => !(k in action.fields)); + if (missing.length > 0) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: + `配方往 ${action.into} 投影,却没给主键列:${missing.join('、')}(主键:${target.keys.join('、')})—— ` + + '运行时主键算不出来的行会被静默跳过,一行不写、一条痕都没有', + at: where, + }); + } + } } if (action.kind === 'analyze') { walkSource(action.input.from, where); @@ -462,6 +540,18 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi if (action.as === 'decide' && (action.options ?? []).length === 0) { out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where }); } + /* + * **basis 空着的卡基座直接拒发**(契约 `auto-recipe.ts` 的注释自己写着「别指望兜底」)—— + * 运行时那一拒是抛,抛出去就是整步交人。装包时看得见的事不留到运行时。 + */ + if ((action.basis ?? []).length === 0) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: '配方发的卡没有 basis(调用必附上下文)—— 基座拒发 basis 为空的工单,这一步跑到这儿就停了', + at: where, + }); + } /* * **`title`/`ask`/`standard`/`basis[].v` 都是 AutoExpr,合法地可以含 lookup** * (2026-08-29 评审补,原实现只核了 `gateLedgers`,这四处从没被 `walkExpr` 走过 —— @@ -492,6 +582,8 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi if (action.kind === 'say') { walkExpr(action.text, where); } + /* `submit.rationale` 同样是 AutoExpr,合法地可以含 lookup —— 从前这一支只查 acceptanceChecklist */ + if (action.kind === 'submit') walkExpr(action.rationale, where); if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) { out.push({ code: 'schema-invalid',