fix(activity-registry): 配方静态审补齐 spec §5.7 漏掉的几条

**`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 08:33:20 +08:00
co-authored by Claude Opus 5
parent a09acc7504
commit 647b86753a
2 changed files with 204 additions and 6 deletions
@@ -500,8 +500,9 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', ()
}); });
it('in: 与 result: 不是台账地址,不该被当成台账来审', () => { it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
/* 主键列照给 —— 少了它这份配方本来就是坏的(2026-08-29 新增的那条审读),与本条要验的事无关 */
const findings = autoRecipeFindings( 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([]); expect(findings).toEqual([]);
}); });
@@ -536,7 +537,112 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', ()
}); });
it('card 是最后一笔:不出这条 warn', () => { 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); 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);
});
}); });
@@ -8,6 +8,8 @@
* 本文件只做**纯**判定。 * 本文件只做**纯**判定。
*/ */
import type { ActivityManifest, StaticAuditFinding } from '@contracts'; import type { ActivityManifest, StaticAuditFinding } from '@contracts';
/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */
import { staticCheck } from '@sfeel';
export type ExternalChecks = { export type ExternalChecks = {
/** 声明引用但工具箱里没有的工具 */ /** 声明引用但工具箱里没有的工具 */
@@ -385,8 +387,21 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] { export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] {
const out: StaticAuditFinding[] = []; const out: StaticAuditFinding[] = [];
const ledgers = new Map( 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)); const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name));
/** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */ /** 台账地址:剥掉 `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<string>): 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 => { const walkExpr = (expr: unknown, at: string): void => {
if (typeof expr !== 'object' || expr === null) return; if (typeof expr !== 'object' || expr === null) return;
const node = expr as Record<string, unknown>; const node = expr as Record<string, unknown>;
if (typeof node['when'] === 'string') checkWhen(node['when'], at);
if (typeof node['lookup'] === 'string') { if (typeof node['lookup'] === 'string') {
checkAddress(node['lookup'], at); checkAddress(node['lookup'], at);
for (const value of Object.values((node['by'] ?? {}) as Record<string, unknown>)) walkExpr(value, at); for (const value of Object.values((node['by'] ?? {}) as Record<string, unknown>)) walkExpr(value, at);
@@ -436,19 +490,43 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
if (recipe === undefined) continue; if (recipe === undefined) continue;
const at = `assets.businessModel.steps[${step.item}].auto`; const at = `assets.businessModel.steps[${step.item}].auto`;
const consumed = new Set<string>(); const consumed = new Set<string>();
/* 配方级 `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()) { for (const [index, action] of recipe.do.entries()) {
const where = `${at}.do[${index}]`; const where = `${at}.do[${index}]`;
/* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */
if (action.when !== undefined) checkWhen(action.when, where, letNames);
if (action.kind === 'project') { if (action.kind === 'project') {
checkAddress(action.into, where); checkAddress(action.into, where);
walkSource(action.from, 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)) { for (const [name, expr] of Object.entries(action.fields)) {
walkExpr(expr, where); 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 }); 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') { if (action.kind === 'analyze') {
walkSource(action.input.from, where); walkSource(action.input.from, where);
@@ -462,6 +540,18 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
if (action.as === 'decide' && (action.options ?? []).length === 0) { if (action.as === 'decide' && (action.options ?? []).length === 0) {
out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where }); 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** * **`title`/`ask`/`standard`/`basis[].v` 都是 AutoExpr,合法地可以含 lookup**
* (2026-08-29 评审补,原实现只核了 `gateLedgers`,这四处从没被 `walkExpr` 走过 —— * (2026-08-29 评审补,原实现只核了 `gateLedgers`,这四处从没被 `walkExpr` 走过 ——
@@ -492,6 +582,8 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
if (action.kind === 'say') { if (action.kind === 'say') {
walkExpr(action.text, where); 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) { if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) {
out.push({ out.push({
code: 'schema-invalid', code: 'schema-invalid',