feat(activity-registry): 配方静态审 —— 写错地址在装包那一刻就拒

与 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 05:29:47 +08:00
co-authored by Claude Opus 5
parent f59e237035
commit 3636496774
4 changed files with 279 additions and 2 deletions
@@ -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: '<caseFileItem id="S1" name="x"/>',
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);
});
});