/** * 安装静态审 —— 纯判定,零 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 & { readonly input: Pick; }; // tools 也要:判据能读「这一单调了几次某工具」(`tool_..._calls`,基座按留痕数) readonly declarations: Pick & Partial>; readonly assets: { readonly businessModel: Pick; }; }; export type LedgerAuditInput = { readonly declarations: Pick }; export type DeliverableAuditInput = { readonly contract: Pick; readonly declarations: Pick; }; /** 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(); 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 { const out = new Set(); 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 }).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>): readonly string[] | undefined { const entries = Object.entries(fields); const names = new Set(entries.map(([name]) => name)); const deps = new Map( 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): 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; 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)) 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; if (typeof node['each'] === 'string') checkAddress(node['each'], at); for (const value of Object.values((node['where'] ?? {}) as Record)) 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)) 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(); /* 配方级 `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 }; }