Archived
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:
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user