This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts
T
xcsandClaude Opus 5 6636abfc39 fix(auto): 配方字段按依赖拓扑求值 —— jsonb 会重排对象键,声明顺序靠不住
真跑 e2e 逼出来的地基缺陷。活动清单存在 sys.activity_package.manifest 这个
**jsonb 列**里,而 PG 按「先短后长、同长字典序」重排所有嵌套对象的键。
auto-runner 原先逐字段**按声明顺序**求值、后面的字段靠 ctx.computed 看前面的值 ——
顺序一乱,依赖就断,而且**一声不响**。

W05 实证:judgement_class(15B)被排到它依赖的 difference_status(17B)前面,
求值时后者恒 undefined、when 三级全落空,摔进 constraint_type 兜底档 ——
7 行差异结论全判错,与实际状态无关;而 difference_status 走
lookup:'result:items' 不依赖字段序,所以它是对的。「一个对一个全错」由此而来。
隔离测试复现不出来,因为它直接读 dist/manifest.json,从不经 DB 往返。

修法:字段求值改为**依赖拓扑排序**(Kahn),不用迭代到不动点 ——
后者的数值收敛判据会漏掉「A/B 互相 when 为假、恰好在错误状态下稳定」这种
结构有环却数值收敛的配方;拓扑是结构性判定,不会假阴性。
通用图算法 topoOrder 提进 @utils(契约新增,非破坏);
AutoExpr→依赖名提取因 L4 不能 import L5,在 loop-engine 与 activity-registry
各留一份并互相指路。真正的环由装包静态审当场拒装 —— 不兜底,兜底正是这次出事的形态。

变异验证:git stash 只撤 auto-runner.ts 那一处,两条新测试当场变红,
数值与专查报告的 SQL 转录逐字吻合(使能阈值/match 被误判 hard_mismatch/medium);
stash pop 后 37/37 绿。E04 92/92、I01 55/55 全绿;全仓 2447 测通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 07:05:48 +08:00

821 lines
39 KiB
TypeScript

/**
* 安装静态审 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 11.2,AM 11.3)。
*
* 「静态审:声明齐备(契约 / 业务模型 / Harness)、工具调用契约核对(装不齐不上架)、
* schema 校验、无越权代码(无环境能力、无动态执行)、**悬空监听拒绝**、模版无脚本。」
*
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
* 本文件只做**纯**判定。
*/
import type { ActivityManifest, AutoExpr, StaticAuditFinding } from '@contracts';
/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */
import { staticCheck } from '@sfeel';
import { topoOrder } from '@utils';
export type ExternalChecks = {
/** 声明引用但工具箱里没有的工具 */
readonly missingTools: readonly string[];
/** 声明引用但未绑定四元的模型名 */
readonly unboundModels: readonly string[];
/** 台账声明合并后语义互斥的字段 */
readonly ledgerConflicts: readonly string[];
/** 视图模版的净化门结果 */
readonly unsafeTemplates: readonly string[];
/** 代码产物里出现的环境能力引用 */
readonly environmentCapabilities: readonly string[];
/** 代码产物里出现的动态执行 */
readonly dynamicEval: boolean;
/** 签名与校验和是否可信 */
readonly signatureValid: boolean;
/** SDK 版本是否兼容 */
readonly sdkCompatible: boolean;
};
/**
* 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。
* 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里,
* 传全份清单照旧过(结构子类型),而读了什么一目了然。
*/
export type AcceptanceAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
};
// tools 也要:判据能读「这一单调了几次某工具」(`tool_..._calls`,基座按留痕数)
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> &
Partial<Pick<ActivityManifest['declarations'], 'tools'>>;
readonly assets: {
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
};
};
export type LedgerAuditInput = { readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> };
export type DeliverableAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'requiredDeliverables'>;
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
};
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
/**
* 悬空监听禁止(AM 4.4):
* 「凡 onPart 监听的数据项,必须存在至少一条**可达的写入来源**;凡等环外处,必须配时间监听。
* 无来源又无时限的监听即『幽灵等待』—— 永不苏醒的支线,安装静态审必须拒绝。」
*/
export function danglingListeners(manifest: ActivityManifest): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const [index, listener] of manifest.declarations.listeners.entries()) {
const at = `declarations.listeners[${index}]`;
if (listener.deadline.trim() === '' || !DURATION.test(listener.deadline)) {
out.push({
code: 'dangling-listener',
severity: 'error',
message: `监听时限缺失或非法(${listener.deadline || '空'})—— 无死等,悬空监听拒绝上架`,
at,
});
continue;
}
if (Object.keys(listener.keyFilter).length === 0 && listener.condition === undefined) {
out.push({
code: 'dangling-listener',
severity: 'error',
message: '监听既无关联键过滤也无内容条件 —— 认不出「就是它」,拒绝上架',
at,
});
}
}
return out;
}
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const declared = boundNames(manifest);
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
const at = `contract.acceptanceCriteria[${index}]`;
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
out.push({
code: 'acceptance-level-misreported',
severity: 'error',
message: `判据「${criterion.label}」标注为 ${criterion.level}(机检)却没有可机检的谓词或模型引用`,
at,
});
}
/*
* **谓词里的名字必须在台账上**(2026-07-31 立)。
*
* A2 的判据写的是 `candidatesConfirmed >= 2`,而台账里从来没有这个名字 ——
* S-FEEL 拿未绑定的名字比大小求值为假,于是这条判据**永远达不了标**:
* 活办得再干净也过不了 submit 门,Agent 连拒五拍熔断,整条流卡死在 A2。
* A3/A4/A6/A7 全是同一个毛病,一个都没跑通过。
*
* 这种错光看清单是看不出来的(谓词本身语法没问题),只有跑到放行那一刻才炸,
* 而那时人已经签完字了。所以它必须在**安装期**挡下:装不上,好过装上了跑死。
*/
for (const name of predicateNames(criterion.predicate)) {
if (declared.has(name)) continue;
out.push({
code: 'acceptance-predicate-unbound',
severity: 'error',
message:
`判据「${criterion.label}」的谓词引用了 \`${name}\`,而台账与案卷里都没有这个名字 —— ` +
'S-FEEL 对未绑定的名字求值为假,这条判据将永远达不了标,放行门永远拒。' +
'把它改成活动真写得出的列(通常是汇总表里的 _total 计数列)。',
at,
});
}
}
return out;
}
/**
* 必交付物的**产出地址**必须指向真实存在的台账表(2026-07-31 立)。
*
* 预检数的是「本单在这张表上写了几行」。地址不给,它退回拿 key 去任务视图里找 ——
* 而 key 是 `benchmark-table` 这样的短横线别名,视图里永远没有这个键,
* 于是「必交付物缺一不得提交放行」**永远拒**:活办完了也放不了行,连拒五拍熔断。
* 地址写错(指向不存在的表)是同一个下场,一并挡在安装期。
*/
export function deliverableAddressFindings(manifest: DeliverableAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const tables = new Set(manifest.declarations.ledger.map((t) => `${t.domain}.${t.object}`));
for (const [index, item] of manifest.contract.requiredDeliverables.entries()) {
const at = `contract.requiredDeliverables[${index}]`;
if (item.ledger === undefined || item.ledger.trim() === '') {
out.push({
code: 'deliverable-address-unbound',
severity: 'error',
message:
`必交付物「${item.label}」没给 \`ledger\` 产出地址 —— ` +
'预检会退回拿 key 去任务视图里找,而 key 是短横线别名、视图里没有这个键,' +
'这一件将永远算「未产出」,放行门永远拒。给它写上产出落在哪张表。',
at,
});
continue;
}
if (!tables.has(item.ledger)) {
out.push({
code: 'deliverable-address-unbound',
severity: 'error',
message:
`必交付物「${item.label}」的产出地址 \`${item.ledger}\` 不是本活动声明的台账表 —— ` +
`声明过的是:${[...tables].join('、') || '(一张都没有)'}`,
at,
});
}
}
return out;
}
/**
* 台账对象的性质要**核得住**(2026-07-31 立)。
*
* 起因是一次真实的数据丢失:A4 的报价草案 `quote_pricing.draft` 本该一单一份,
* 主键却只有 `pn` —— 第二单报同一个料号就 upsert 覆盖了上一单。
* 跑了四轮 A4,表里只剩最后一轮的 3 行;而主键带单号的 `quote_intake.record`
* 是 61 行 / 20 单,一单不少。**作者没决定这张表是单据还是主数据,主键就写歪了。**
*
* 光加一个 `kind` 字段治不了 —— 声明能填错。所以每一类各核一条:
* · `record` 必须指明单标识,且它得真在主键里(否则「一单一份」是空话);
* · `master` 不该有单标识(有了说明它其实是单据,归错类了);
* · 任何表都得有主键 —— 没主键的台账合并语义无从谈起。
*/
export function ledgerKindFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const [index, table] of manifest.declarations.ledger.entries()) {
const at = `declarations.ledger[${index}]`;
const where = `${table.domain}.${table.object}`;
const pk = table.fields.filter((f) => f.primaryKey).map((f) => f.name);
const names = new Set(table.fields.map((f) => f.name));
if (pk.length === 0) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message: `台账 \`${where}\` 一个主键列都没有 —— 合并与去重都无从谈起,写进去的行会越堆越多且认不出重复`,
at,
});
}
if (table.kind === 'record') {
if (table.caseKey === undefined) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`单据表 \`${where}\` 没指明单标识(\`caseKey\`)—— 一单一份就成了空话:` +
'主键里没有「单」,第二单碰到同一个业务键会直接覆盖上一单(实测丢过数据)。' +
'指明哪一列是单标识,或者它其实是主数据就改 `kind: \'master\'`。',
at,
});
} else if (!names.has(table.caseKey)) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message: `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` 不在它自己的字段里`,
at,
});
} else if (!pk.includes(table.caseKey)) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`单据表 \`${where}\` 的单标识 \`${table.caseKey}\` **不是主键的一部分**(现主键:${pk.join(', ')})—— ` +
'单标识不进主键,等于没有单标识:同一个业务键的第二单照样覆盖第一单。',
at,
});
}
}
if (table.kind !== 'record' && table.caseKey !== undefined) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`\`${where}\` 标成 ${table.kind} 却带着单标识 \`${table.caseKey}\` —— ` +
'跨单的数据不该按单分行;它多半其实是单据,`kind` 归错了类。',
at,
});
}
}
return out;
}
/** S-FEEL 保留字与字面量 —— 它们不是台账名,不该被当成未绑定 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/**
* 从谓词里摘出**标识符**(粗筛,宁可漏报不可误报)。
* 只认「字母开头、由字母数字下划线点组成」的片段,去掉数字、字符串字面量与保留字;
* 带点的取根名(`a.b` 的绑定看 `a`)。
*/
function predicateNames(predicate: string | undefined): string[] {
if (predicate === undefined || predicate.trim() === '') return [];
const withoutStrings = predicate.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
const found = withoutStrings.match(/[A-Za-z_][A-Za-z0-9_.]*/g) ?? [];
const out = new Set<string>();
for (const raw of found) {
const root = raw.split('.')[0] ?? raw;
if (SFEEL_WORDS.has(root.toLowerCase())) continue;
out.add(root);
}
return [...out];
}
/**
* 这个活动**求值时能绑到的名字**:台账所有列名 + 案卷项 id + 起单入参的字段名。
* 任务视图正是由这三样摊平出来的,所以判据能读到的也就这三样。
*/
function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
const out = new Set<string>();
for (const table of manifest.declarations.ledger) {
for (const field of table.fields) out.add(field.name);
out.add(table.object);
/*
* **基座数出来的行数读数** `域_对象_rows` —— 活动写不了它,正因如此它才有用:
* 判据的两边不能都由活动自报。实测过一次「对账过关而账是错的」——
* 条目数报 9、表里躺着 10 行,三类条数与派单数同源同错,`2 = 2` 顺利过关。
* 它只对**本活动声明过的表**成立,所以在这儿按声明生成:
* 对象名手滑写错,照旧当场拒装。
*/
out.add(`${table.domain}_${table.object}_rows`);
/*
* 另两类基座读数,与 `_rows` 同一个道理 —— **能由数据算出来的,别让活动自报**:
* · `域_对象_missing` —— 缺必填几行(按 `fields[].required` 现数);
* · `域_对象_<列>_<值>` —— 按值现数几行(取值域由 `fields[].tally` 声明)。
* 后一类是三分类条数的落点:人在放行页改了类标,分布条、去向谓词、派单对账
* **全都跟着重算** —— 手工修改本来就是为了纠正 AI 的判断,改了却不影响结果,
* 那个兜底功能就是假的。
*/
if (table.fields.some((f) => f.required === true)) out.add(`${table.domain}_${table.object}_missing`);
if (table.fields.some((f) => f.expect === true)) out.add(`${table.domain}_${table.object}_gaps`);
for (const field of table.fields) {
for (const value of field.tally ?? []) {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
}
/*
* 不同值计数读数 `域_对象_distinct_<列>`(2026-08-24 起)——
* 「每家候选客户都进了计划」这类**覆盖闭合**判据的落点:
* 计划行数 ≠ 客户数,闭合要成为机检就得有「有几家不同客户」这个基座数。
*/
if (field.distinct === true) out.add(`${table.domain}_${table.object}_distinct_${field.name}`);
}
}
/*
* **工具调用读数** `tool_<工具名去符号>_calls`(2026-08-03 起)——
* 基座按留痕数「这一单成功调了几次某工具」,声明过的工具一律先铺 0。
*
* 它存在的理由与 `_rows` 一模一样:**判据的两边不能都由活动自报**。
* A2 实测约十次一次 —— 判稳那一步压根没调行情工具就把料判成「稳」转 A,
* 而自报的依据列写得漂漂亮亮,去向就此从寻源走成定价。
* 有了这个名字,「转 A 的依据来自本单亲查的行情」才是一条真机检。
*/
// 旧调用方(与老测试)只传 ledger —— 缺 tools 就当没声明工具,别掀翻整份审计
for (const tool of manifest.declarations.tools ?? []) {
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
}
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
}
const data = manifest.contract.input.data;
if (typeof data === 'object' && data !== null) {
const props = (data as { properties?: Record<string, unknown> }).properties;
if (props !== undefined) for (const key of Object.keys(props)) out.add(key);
}
return out;
}
/** loop 判据:双表示同版本,不允许分叉(AM 12.2) */
export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
const model = manifest.assets.businessModel;
const out: StaticAuditFinding[] = [];
if (model.cmmnXml.trim() === '') {
out.push({ code: 'schema-invalid', severity: 'error', message: '案例模型缺 CMMN XML', at: 'assets.businessModel.cmmnXml' });
}
if (model.narrative.trim() === '') {
out.push({
code: 'schema-invalid',
severity: 'error',
message: '案例说明书缺失 —— XML 与说明书同版本发布,不允许分叉',
at: 'assets.businessModel.narrative',
});
}
/*
* 剧本与阶段引的计划项 id **必须在 CMMN 里真存在** —— 对不上就在装的时候拒。
*
* 拼错一个 id 的后果是**静默失效**,而且两处症状都不像「id 写错了」:
* · 剧本对不上 → 那一步永远拿不到自己的做法与工具,模型每拍读整本说明书;
* · 阶段对不上 → 那一格永远收不完,进度条卡在那儿不动,人以为单卡住了。
* 从 XML 里认 id 用的是属性正则:这里不解析 CMMN(解析在引擎那侧),
* 只要认得出「有没有这个 id」就够。
*/
const declared = new Set([...model.cmmnXml.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
for (const step of model.steps ?? []) {
if (!declared.has(step.item)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `剧本引的计划项在 CMMN 里不存在:${step.item}`,
at: 'assets.businessModel.steps',
});
}
}
for (const phase of model.phases ?? []) {
for (const item of phase.items) {
if (!declared.has(item)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `阶段「${phase.name}」引的计划项在 CMMN 里不存在:${item}`,
at: 'assets.businessModel.phases',
});
}
}
}
return out;
}
/**
* 递归找出表达式里全部 `when` 字符串引用的根路径名(用于判「这个字段依赖同笔内的哪几个字段」)。
*
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `checkWhen` 同一个理由:
* 正则会把 `count(...)` 这类内建函数名也当成字段名。`when` 语法本该被 `checkWhen` 先一步
* 拦住,这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
*
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `whenReferences` 是同一个算法,
* 写了两遍**:装包静态审(L4 `activity-registry`)不能 import 配方执行器(L5
* `loop-engine`,模块分层只准低层被高层引用,§4),这一段小小的、纯粹依附 `AutoExpr`
* 形状的逻辑只能各自留一份;真正通用、不含业务语义的图算法(`topoOrder`)已经
* 提到 `@utils` 共用了。改动这个函数时,记得看看那边要不要一起改。
*/
function fieldReferences(expr: AutoExpr): readonly string[] {
if (typeof expr !== 'object' || expr === null) return [];
if ('when' in expr) {
const verdict = staticCheck(expr.when);
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
return [...names, ...fieldReferences(expr.then), ...fieldReferences(expr.else)];
}
if ('lookup' in expr) {
const byNames = Object.values(expr.by).flatMap((value) => fieldReferences(value));
return expr.else === undefined ? byNames : [...byNames, ...fieldReferences(expr.else)];
}
if ('cat' in expr) return expr.cat.flatMap((part) => fieldReferences(part));
if ('fmt' in expr) return fieldReferences(expr.fmt);
return [];
}
/**
* `fields` 内部字段求值依赖能不能排出安全序(与运行时 `orderFields` 同一件事,见上面
* `fieldReferences` 的头注)。排得出来给 `undefined`;排不出来(真正的环)把牵涉的字段名
* 摆出来 —— **这条错误 2026-08-30 才补上**:jsonb 会把 `fields` 对象的键序打乱,
* `judgement_class`/`risk_level` 这类依赖同笔内另一个字段的写法一旦被排到依赖对象前面,
* 求值时读到的是 `undefined`,`when` 链全部落空却不报错(详见
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`);
* 运行时已经改成按依赖排序求值,但**真正的环**(A 依赖 B、B 又依赖 A,排不出任何安全序)
* 是配方写错了 —— 别留到运行时才炸,装包这一刻就能看出来。
*/
function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
const entries = Object.entries(fields);
const names = new Set(entries.map(([name]) => name));
const deps = new Map<string, readonly string[]>(
entries.map(([name, expr]) => [name, [...new Set(fieldReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
);
const result = topoOrder(
entries.map(([name]) => name),
(name) => deps.get(name) ?? [],
);
return result.ok ? undefined : result.cycle;
}
/**
* **配方静态审**(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}`,
{
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:` 不是台账,跳过 */
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,
});
}
};
/**
* **`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 => {
if (typeof expr !== 'object' || expr === null) return;
const node = expr as Record<string, unknown>;
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<string, unknown>)) 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<string, unknown>;
if (typeof node['each'] === 'string') checkAddress(node['each'], at);
for (const value of Object.values((node['where'] ?? {}) as Record<string, unknown>)) 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<string, unknown>)) 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<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()) {
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 target = ledgers.get(action.into);
for (const [name, expr] of Object.entries(action.fields)) {
walkExpr(expr, where);
if (target !== undefined && !target.columns.has(name)) {
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
}
}
/*
* **prune 的表不该是人在件上能加行的表**(2026-08-29 终审 F3)。
* `prune` 说的是「这一笔投影出来的行就是全部,多出来的撤掉」;而 `humanWrites`
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
*/
if (action.prune === true && (step.humanWrites ?? []).includes(action.into)) {
out.push({
code: 'schema-invalid',
severity: 'warn',
message:
`${action.into} 既被这一笔 prune 投影、又列在这一步的 humanWrites 里 —— ` +
'人在件上加的行配方的数据源里没有,下一拍对齐会把它撤掉(静默);' +
'要么别 prune 这张表,要么它就不该是人能加行的表',
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,
});
}
}
/*
* **字段求值依赖不能成环**(2026-08-30)。`fields` 存进 jsonb 之后键序不再保证,
* 运行时已经按依赖排出安全序求值(见上面 `fieldsCycle` 的头注),但 A 依赖 B、
* B 又依赖 A 排不出任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
*/
{
const cycle = fieldsCycle(action.fields);
if (cycle !== undefined) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `${action.into} 的 fields 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
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 });
}
{
const cycle = fieldsCycle(action.input.fields);
if (cycle !== undefined) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `analyze 入参字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
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 });
}
/*
* **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` 走过 ——
* 卡面上的一处笔误因此能一声不响地装进来,只在这一步第一次真跑起来时才现形)。
*/
walkExpr(action.title, where);
if (action.ask !== undefined) walkExpr(action.ask, where);
if (action.standard !== undefined) walkExpr(action.standard, where);
for (const b of action.basis ?? []) walkExpr(b.v, where);
/*
* **card 后面还有笔:说清它们什么时候跑**(2026-08-29 终审 I3 附带的一条 warn)。
* 卡真发出去那一拍到此为止(`stop`);排在后面的笔要等**人交回之后**的那一拍才跑
* (那一拍这一笔 `card` 因为 `carded` 被跳过)。写配方的人常按顺序读成
* 「铺格 → 发卡 → 收口整理」一拍连做 —— 那是读错的,提前说一声。
*/
if (index < recipe.do.length - 1) {
out.push({
code: 'schema-invalid',
severity: 'warn',
message:
`card 后面还有 ${recipe.do.length - index - 1} 笔:卡发出去那一拍到此为止,` +
'这几笔要等人交回之后的那一拍才跑(那一拍 card 这一笔被跳过)—— 确认这就是你要的次序',
at: where,
});
}
}
/* `say.text` 同样是 AutoExpr,同一个理由要核(2026-08-29 评审补) */
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',
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[] = [];
if (!external.signatureValid) {
out.push({ code: 'signature-invalid', severity: 'error', message: '签名或校验和不可信' });
}
if (!external.sdkCompatible) {
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
}
for (const tool of external.missingTools) {
/*
* 工具缺口是**警告,不是拒装**(2026-07-30 降级)。
*
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」——
* 声明了系统里没有,那是系统的缺口,不是活动的错误。运行时已有出路:
* 未命中即**转人代办**(§6.2,人是工具的一种实现),这一步照样做得下去。
* 从前判 error 拒装,等于要求「先把工具全实现完才允许装活动」——
* 与声明式工具正面冲突,也让契约先行成了空话。
*
* 仍然如实报出来:治理台看得见「这件活动有几件工具要靠人顶着」。
*/
out.push({ code: 'tool-contract-missing', severity: 'warn', message: `工具调用契约未注册,运行时转人代办:${tool}` });
}
for (const model of external.unboundModels) {
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
}
for (const field of external.ledgerConflicts) {
out.push({ code: 'ledger-type-conflict', severity: 'error', message: `台账字段语义互斥,交数据管理裁决:${field}` });
}
for (const name of external.unsafeTemplates) {
out.push({ code: 'template-has-script', severity: 'error', message: `视图模版含脚本或外部资源:${name}` });
}
for (const capability of external.environmentCapabilities) {
out.push({ code: 'environment-capability', severity: 'error', message: `活动包不得持有环境能力:${capability}` });
}
if (external.dynamicEval) {
out.push({ code: 'dynamic-eval', severity: 'error', message: '活动包不得含动态执行(eval / new Function)' });
}
if (manifest.samples.length === 0) {
out.push({ code: 'schema-invalid', severity: 'error', message: '样例集为空 —— 首装审以静态审 + 样例集替代回放审' });
}
out.push(...danglingListeners(manifest));
out.push(...deliverableAddressFindings(manifest));
out.push(...referenceFindings(manifest));
out.push(...ledgerKindFindings(manifest));
out.push(...acceptanceLevelFindings(manifest));
out.push(...loopFindings(manifest));
out.push(...autoRecipeFindings(manifest));
return out;
}
/**
* `refersTo` 的引用目标必须是**本活动声明过的对象与列**(2026-08-24 立)。
*
* 引用检查在写入那一刻执行 —— 目标地址写错(表名手滑、列名不存在)的话,
* 那道墙就成了「永远查无此值」的死墙,活动装上就跑不动。与判据谓词绑定同一个道理:
* 这种错只有跑到写入那一刻才炸,必须拦在安装期。
*/
export function referenceFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const table of manifest.declarations.ledger) {
for (const field of table.fields) {
if (field.refersTo === undefined) continue;
const [rd, ro, rc] = field.refersTo.split('.');
const referenced = manifest.declarations.ledger.find((l) => l.domain === rd && l.object === ro);
const column = referenced?.fields.find((f) => f.name === rc);
if (referenced !== undefined && column !== undefined) continue;
out.push({
code: 'reference-target-unbound',
severity: 'error',
message:
`${table.domain}.${table.object}.${field.name} 的 refersTo 指向 \`${field.refersTo}\`,` +
`而本活动的台账声明里${referenced === undefined ? '没有这张表' : '这张表没有这一列'} —— ` +
'引用检查会变成「永远查无此值」的死墙,装上就跑不动。改成声明里真实存在的 域.对象.列。',
at: `declarations.ledger ${table.domain}.${table.object}.${field.name}`,
});
}
}
return out;
}
export function auditPassed(findings: readonly StaticAuditFinding[]): boolean {
return !findings.some((f) => f.severity === 'error');
}
/** 产物里的环境能力与动态执行扫描(ADR-004 静态审扫描产物 import 与全局引用) */
const FORBIDDEN_GLOBALS = [
'process.env',
'require(',
'node:fs',
'node:net',
'node:child_process',
'globalThis.fetch',
'XMLHttpRequest',
];
export function scanArtifact(code: string): { capabilities: string[]; dynamicEval: boolean } {
const capabilities = FORBIDDEN_GLOBALS.filter((token) => code.includes(token));
if (/\bfetch\s*\(/.test(code)) capabilities.push('fetch(');
const dynamicEval = /\beval\s*\(/.test(code) || /new\s+Function\s*\(/.test(code);
return { capabilities: [...new Set(capabilities)], dynamicEval };
}