Archived
同一份配方 `[project, card, project]` 在两种局面下行为相反:人还在填(quiet)时 card 被 quiet 过滤 `continue` 掉,**第三笔照跑**;人交回之后(非 quiet、carded) 走到 carded 分支 `stop: true` break 掉整条 do,**第三笔不跑**。 也就是说**唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行**; 而人还没答完时它倒是跑了。写配方的人会很自然地写「铺格 → 发卡 → 收口整理」, 然后发现第三笔从来没生效过 —— 静默,没有痕。 「收拍」留给**真发出去了卡**那一次(两条带 stop 的 return 不动)。 配套一条装包 warn:card 后面还有 N 笔时说清它们什么时候跑 —— 顺序读起来像 「一拍连做」,实际要等人交回之后的那一拍。 auto-runner 3 测(carded 跑完 / 真发卡仍收拍 / quiet 与 carded 现在一致)+ static-audit 2 测。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
632 lines
29 KiB
TypeScript
632 lines
29 KiB
TypeScript
/**
|
|
* 安装静态审 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 11.2,AM 11.3)。
|
|
*
|
|
* 「静态审:声明齐备(契约 / 业务模型 / Harness)、工具调用契约核对(装不齐不上架)、
|
|
* schema 校验、无越权代码(无环境能力、无动态执行)、**悬空监听拒绝**、模版无脚本。」
|
|
*
|
|
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
|
|
* 本文件只做**纯**判定。
|
|
*/
|
|
import type { ActivityManifest, StaticAuditFinding } from '@contracts';
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* **配方静态审**(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}`, new Set(l.fields.map((f) => f.name))]),
|
|
);
|
|
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,
|
|
});
|
|
}
|
|
};
|
|
|
|
/** 表达式里也可能藏着 lookup 地址,递归找一遍 */
|
|
const walkExpr = (expr: unknown, at: string): void => {
|
|
if (typeof expr !== 'object' || expr === null) return;
|
|
const node = expr as Record<string, unknown>;
|
|
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>();
|
|
|
|
for (const [index, action] of recipe.do.entries()) {
|
|
const where = `${at}.do[${index}]`;
|
|
if (action.kind === 'project') {
|
|
checkAddress(action.into, where);
|
|
walkSource(action.from, where);
|
|
const columns = ledgers.get(action.into);
|
|
for (const [name, expr] of Object.entries(action.fields)) {
|
|
walkExpr(expr, where);
|
|
if (columns !== undefined && !columns.has(name)) {
|
|
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, 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 });
|
|
}
|
|
}
|
|
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 });
|
|
}
|
|
/*
|
|
* **`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);
|
|
}
|
|
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 };
|
|
}
|