feat(w05): 比对基准合一卡 + prune:'live' —— 类型与字段跟手,只显当前那一种

2026-08-31 用户第二轮:「芯片类型列表还是有问题…和核心参数字段不能合并成一个卡片吗…
两个芯片都选一样的就应该只显示一种,现在只会跟着变多不会跟着变少」+
「卡片下面那个容器黑色和页面背景不搭」。

① 比对基准卡(类型 + 字段合一,只读、跟手):不再列 mine: 草稿镜像(镜像里积着改选前的
  旧类型 —— 撤行只在确认那一拍跑,F3 不许在人编辑时静默删行),按案卷锚
  (sub_eval.case.chip_type_id,配方每拍现算)嵌套查字典:卡上改选、按保存,quiet 拍
  project 照跑,这张卡当场换脸 —— 两颗料同类型时恰好只有那一种;没人选时空态指路。
  新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。

② 契约新增 prune:'live'(quiet 拍也撤):只给**人不会在件上加行**的表用 —— F3 防的是
  「人刚加的行被撤」,人加不了行的表没有这个风险,而「改了类型,旧类型的参数行还摆在
  屏上」是另一种撒谎。W05 参数骨架 ⑤ 改 'live':改选类型,参数行跟手换。
  static-audit 的 prune×humanWrites 审计盖住 'live';规范 §4.6 纪律 5 补这条反面特例;
  两档都跳过已落地的行并出声(skipLanded)。

③ 板底改浅色蓝图:深色容器与纸面色阶断裂(用户点名),换强调色极浅晕染 + 淡格线,
  走线与信号点用强调色 —— 电路语汇留着,整页只剩 hero 一处深色。

测:契约 'live' 解析 + runner quiet-live 对齐(变异验证)+ 基准卡锚(变异验证)+
steps ⑤ 钉 'live';108 包内 + 全仓四门绿;2.8.1 装库 SQL 核过;:8787 已跑新构建
(watcher 又断,按应急程序手起 node dist/main.js)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-31 14:47:25 +08:00
co-authored by Claude Fable 5
parent 5a7bb9b58f
commit 11375eaf93
13 changed files with 172 additions and 27 deletions
@@ -581,6 +581,31 @@ describe('件① 评估输入补全', () => {
expect(html).not.toContain(':has(+ select.jc-cell:not(:has('); expect(html).not.toContain(':has(+ select.jc-cell:not(:has(');
}); });
/*
* 2026-08-31 用户第二轮:「芯片类型列表和核心参数字段,不能合并成一个卡片吗…两个芯片都
* 选一样的,就应该只显示一种」。合并 = 比对基准卡(类型名 + 字段清单一张只读卡);
* 「只显示一种、跟手」= 不列 mine: 草稿镜像(会积旧选择),按案卷锚(case.chip_type_id,
* 配方每拍现算)嵌套查字典 —— quiet 拍 project 照跑,保存即换脸。
* 新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。
*/
it('比对基准卡:case 锚嵌套查字典,类型与字段合一、全只读;编辑表收进抽屉', () => {
expect(html).toContain('data-rows="mine:sub_eval.case"');
const basis = /<article class="basis"[\s\S]*?<\/article>/.exec(html)?.[0] ?? '';
expect(basis).not.toBe('');
expect(basis).toContain('data-rows="parts.chip_type" data-by="type_id=chip_type_id"');
expect(basis).toContain('data-rows="parts.type_param" data-by="type_id=chip_type_id"');
/* 全只读:选类型在档案卡,建类型在抽屉 —— 基准卡上一个编辑面都不许有 */
expect(basis).not.toContain('data-col');
expect(basis).not.toContain('data-edit');
/* 抽屉存在,且两张编辑表都住在里面 */
const labAt = html.indexOf('<details class="lab">');
const labEnd = html.indexOf('</details>');
expect(labAt).toBeGreaterThanOrEqual(0);
expect(html.indexOf('<table data-edit="parts.chip_type">')).toBeGreaterThan(labAt);
expect(html.indexOf('<table data-edit="parts.type_param">')).toBeGreaterThan(labAt);
expect(labEnd).toBeGreaterThan(html.indexOf('<table data-edit="parts.type_param">'));
});
it('三级占位:必填红 / 建议橘 —— 空格子直说要不要填(档案卡与参数值表两处)', () => { it('三级占位:必填红 / 建议橘 —— 空格子直说要不要填(档案卡与参数值表两处)', () => {
expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"'); expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"');
expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"'); expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"');
@@ -744,14 +769,14 @@ describe('件① 评估输入补全', () => {
* 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则, * 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则,
* 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。 * 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。
*/ */
it('三张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => { it('四张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => {
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s*,\s*\.tbl-wrap\s+table\s*\{\s*counter-reset:\s*rn;?\s*\}/); expect(html).toMatch(/\.jc-tbl-wrap\s+table\s*,\s*\.tbl-wrap\s+table\s*\{\s*counter-reset:\s*rn;?\s*\}/);
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s*\{\s*counter-increment:\s*rn;?\s*\}/); expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s*\{\s*counter-increment:\s*rn;?\s*\}/);
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*\{\s*content:\s*counter\(rn\);?\s*\}/); expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*\{\s*content:\s*counter\(rn\);?\s*\}/);
// 这张件的三张表都走 .jc-tbl-wrap(没有 .tbl-wrap)—— 2026-08-31 块① 换成档案卡(不是表),4→3 // 四张:比对基准卡的字段表 + 抽屉里两张编辑表 + 参数值表(2026-08-31 类型/字段并成基准卡后的账)
expect(html).not.toContain('class="tbl-wrap"'); expect(html).not.toContain('class="tbl-wrap"');
const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0; const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0;
expect(jcTblWrapCount).toBe(3); expect(jcTblWrapCount).toBe(4);
}); });
}); });
@@ -214,7 +214,8 @@ describe('S1 配方', () => {
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[4]; const params = acts[4];
expect(params?.kind === 'project' && params.prune).toBe(true); /* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */
expect(params?.kind === 'project' && params.prune).toBe('live');
const from = params?.kind === 'project' ? params.from : undefined; const from = params?.kind === 'project' ? params.from : undefined;
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */ /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } }); expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
@@ -492,7 +492,13 @@ export const ASSESSMENT_STEPS = [
{ {
kind: 'project' as const, kind: 'project' as const,
into: 'parts.parameter', into: 'parts.parameter',
prune: true, /*
* **`prune: 'live'`(2026-08-31 用户:「切换料号类型时…只会跟着变多,不会跟着变少」)**:
* 参数骨架的行集全由这一笔按「料 × 类型字段」铺,人在件上**加不了行**(没有加行钮),
* F3 那条「quiet 拍不撤行」防的风险在这张表上不存在 —— 人改了类型、按保存,旧类型的
* 参数行当场撤掉,屏上跟手。已落地的行照旧跳过并出声(skipLanded)。
*/
prune: 'live' as const,
from: { from: {
each: 'mine:sub_eval.part', each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估', name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.7.0', packageVersion: '2.8.1',
category: '需求分析与方案匹配', category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment', checksum: 'dev-checksum-part-substitution-assessment',
@@ -204,6 +204,43 @@ const DOSSIERS = `<section class="board"><article class="dossiers" data-rows="mi
</div> </div>
</section></article></section>`; </section></article></section>`;
/**
* 比对基准卡 —— 芯片类型与它的核心参数字段**合成一张只读卡**(2026-08-31 用户:
* 「芯片类型列表和核心参数字段,不能合并成一个卡片吗…两个芯片都选一样的,就应该只显示一种」)。
*
* **显示永远跟手、永远只有当前那一种**:不列 `mine:parts.chip_type` 草稿(镜像里会积着
* 改选前的旧类型 —— 撤行只在确认工单那一拍跑,F3 不许在人编辑时静默删行),而是按**案卷锚**
* (`sub_eval.case.chip_type_id`,配方 ⑥ 每拍从原 PN 的选择现算)嵌套查字典:人卡上改选、
* 按保存,quiet 拍照样跑 project,锚跟着动,这张卡当场换脸 —— 两颗料选同一类型时,这里
* 恰好就是那一种;还没人选类型时零行,空态一句话指路。
*
* 全只读(一个 data-col 都没有)—— 选类型在档案卡上,建类型在下面的工作抽屉里。
* 嵌套三层:case(恰一行)→ chip_type 按锚查名 → type_param 按锚列字段;
* 宿主标签各不与行模版里的标签撞(article / span / tbody)。
*/
const BASIS_CARD = `<article class="basis" data-rows="mine:${D}.case"><section class="basis-in">
<header class="basis-hd">
<span class="basis-lb">比对基准</span>
<span class="basis-chip" data-rows="parts.chip_type" data-by="type_id=chip_type_id"><b data-slot="type_name" data-blank></b><i class="basis-desc" data-slot="description" data-blank></i></span>
</header>
<div class="jc-tbl-wrap"><table>
<thead><tr>
<th style="width:38px">#</th>
<th data-h="参数名">参数名</th>
<th data-h="参数说明">参数说明</th>
<th data-h="约束类型">约束(硬/软)</th>
<th data-h="参考单位">参考单位</th>
</tr></thead>
<tbody data-rows="parts.type_param" data-by="type_id=chip_type_id"><tr>
<td class="rn"><span class="jc-rn-num"></span></td>
<td class="cust" data-slot="param_name" data-blank></td>
<td class="desc" data-slot="param_desc" data-blank></td>
<td><span class="ct" data-class-of="constraint_type"></span></td>
<td data-slot="param_unit" data-blank></td>
</tr></tbody>
</table></div>
</section></article>`;
/** /**
* 块②a 新建芯片类型 —— `parts.chip_type`。类型下拉(块①)里没有合适的,人在这里现造一个。 * 块②a 新建芯片类型 —— `parts.chip_type`。类型下拉(块①)里没有合适的,人在这里现造一个。
* `type_id` 是自增主键 —— **人不填它**,自增列不问人;已有行只读态摆 `ro()`。 * `type_id` 是自增主键 —— **人不填它**,自增列不问人;已有行只读态摆 `ro()`。
@@ -352,16 +389,19 @@ export const INPUT_SUPPLEMENT = doc({
editGate('parts.profile', '两张档案卡上的主数据核对 / 补齐,点「编辑」开卡') + editGate('parts.profile', '两张档案卡上的主数据核对 / 补齐,点「编辑」开卡') +
gapbar('parts_profile', '必填') + gapbar('parts_profile', '必填') +
DOSSIERS + DOSSIERS +
zone('芯片类型', '本单选用 / 新建的类型;卡上改选之后,旧选择在你确认工单那一刻自动清掉(已入库的留档)', ZIC_TABLE) + zone('比对基准 · 芯片类型与核心参数字段', '两颗料归到哪个类型,就按哪个类型的字段逐项比 —— 档案卡上改选,这张卡当场跟着换', ZIC_TABLE) +
editGate('parts.chip_type', '新建一个类型:表尾那一行填类型名称与说明,保存后立刻出现在上面的下拉里') + BASIS_CARD +
gapbar('parts_chip_type', '必填') + gapbar('parts_chip_type', '必填') +
gapbar('parts_type_param', '必填') +
`<details class="lab"><summary class="lab-sm"><b>类型库里没有合适的?</b>展开新建一个类型,并把它的核心参数字段定下来 —— 定完回到上面的档案卡挑它<span class="lab-caret"></span></summary><div class="lab-bd">` +
editGate('parts.chip_type', '新建一个类型:表尾那一行填类型名称与说明,保存后立刻出现在档案卡的下拉里') +
CHIP_TYPE_TABLE + CHIP_TYPE_TABLE +
addrow('parts.chip_type', '+ 再建一个类型', '两颗料若都要新建、且类型不同,先各自保存,「加一行」能再建一个') + addrow('parts.chip_type', '+ 再建一个类型', '两颗料若都要新建、且类型不同,先各自保存,「加一行」能再建一个') +
zone('核心参数字段', '这个类型逐条比对时要看哪些参数(新建类型时必填,*为必填列)', ZIC_TABLE) + `<p class="lab-sub">这个类型逐条比对时要看哪些参数 —— 名字 / 说明 / 硬软约束(*为必填列)</p>` +
editGate('parts.type_param', '字段名字 / 说明 / 硬软约束,点「编辑」核对;表尾那一行 / 「加一行」补字段') + editGate('parts.type_param', '字段名字 / 说明 / 硬软约束,点「编辑」核对;表尾那一行 / 「加一行」补字段') +
gapbar('parts_type_param', '必填') +
TYPE_PARAM_TABLE + TYPE_PARAM_TABLE +
addrow('parts.type_param', '+ 加一行(补一个核心参数字段)', '所属类型 / 参数名 / 参数说明 / 约束类型必须写;参考单位选填') + addrow('parts.type_param', '+ 加一行(补一个核心参数字段)', '所属类型 / 参数名 / 参数说明 / 约束类型必须写;参考单位选填') +
`</div></details>` +
zone('参数值', '逐条核心参数的实测 / 规格值(一字段一行,*为必填列;单位由字典定,这里不改)', ZIC_TABLE) + zone('参数值', '逐条核心参数的实测 / 规格值(一字段一行,*为必填列;单位由字典定,这里不改)', ZIC_TABLE) +
editGate('parts.parameter', '逐条参数值核对 / 补齐,点「编辑」开表') + editGate('parts.parameter', '逐条参数值核对 / 补齐,点「编辑」开表') +
gapbar('parts_parameter', '必填') + gapbar('parts_parameter', '必填') +
@@ -246,18 +246,19 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va
*{animation:none !important;transition:none !important} *{animation:none !important;transition:none !important}
} }
/* ================= 电路板工作台(件① 的两张档案卡)================= */ /* ================= 电路蓝图工作台(件① 的两张档案卡)=================
.board{position:relative;margin:0 0 6px;padding:20px 16px 18px;border-radius:16px; 2026-08-31 用户:「卡片下面那个容器黑色和页面背景不搭」—— 深色板底退场,换**浅色蓝图**:
底是强调色的极浅晕染 + 淡格线,走线与信号点用强调色 —— 电路语汇留着,纸面色阶不断裂。 */
.board{position:relative;margin:0 0 6px;padding:18px 14px 16px;border-radius:16px;border:1px solid var(--acc-line);
background: background:
repeating-linear-gradient(0deg, rgba(255,255,255,.03) 0 1px, transparent 1px 26px), repeating-linear-gradient(0deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 26px), repeating-linear-gradient(90deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
radial-gradient(700px 260px at 16% -30%, var(--hero-tint), transparent 62%), linear-gradient(158deg, var(--acc-wash) 0%, #fff 62%, var(--acc-wash) 100%)}
linear-gradient(158deg, #13212f 0%, var(--board) 58%, #0f1d2b 100%)} .board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--acc-line)}
.board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--glow-line)} .board::after{content:"";position:absolute;top:50%;left:4%;width:8px;height:8px;margin-top:-4px;border-radius:999px;background:var(--acc);box-shadow:0 0 8px 2px var(--acc-soft);animation:sig 3.4s linear infinite}
.board::after{content:"";position:absolute;top:50%;left:4%;width:9px;height:9px;margin-top:-4px;border-radius:999px;background:var(--glow);box-shadow:0 0 14px 3px var(--glow-line),0 0 4px 1px var(--glow);animation:sig 3.4s linear infinite}
.dossiers{position:relative;z-index:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(272px,1fr));gap:16px} .dossiers{position:relative;z-index:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(272px,1fr));gap:16px}
.dossier{display:flex;flex-direction:column;gap:11px;background:rgba(255,255,255,.97);border-radius:16px;padding:15px 16px 14px;box-shadow:0 14px 30px rgba(3,10,18,.5),inset 0 1px 0 rgba(255,255,255,.7);transition:box-shadow .18s,transform .18s} .dossier{display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);transition:box-shadow .18s,transform .18s}
.dossier:hover{transform:translateY(-2px);box-shadow:0 18px 36px rgba(3,10,18,.55),inset 0 1px 0 rgba(255,255,255,.7)} .dossier:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(18,38,63,.13)}
.dz-top{display:flex;align-items:center;gap:12px} .dz-top{display:flex;align-items:center;gap:12px}
.dz-top .csvg{width:64px;flex:none} .dz-top .csvg{width:64px;flex:none}
.dz-id{display:flex;flex-direction:column;gap:4px;min-width:0} .dz-id{display:flex;flex-direction:column;gap:4px;min-width:0}
@@ -297,6 +298,31 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va
.jc-tbl-wrap td.pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px} .jc-tbl-wrap td.pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
.jc-tbl-wrap td.sg .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px} .jc-tbl-wrap td.sg .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px}
/* ================= 比对基准卡(类型 + 字段合一,只读、跟手)================= */
.basis{display:block;margin:0 0 10px}
.basis:empty{border:1.5px dashed var(--line);border-radius:12px;padding:16px;display:block}
.basis:empty::after{content:"还没定比对基准 —— 先在上面任一张档案卡上挑一个芯片类型,或展开下面的抽屉新建一个。";font-size:12.5px;color:var(--ink-3)}
.basis-in{display:flex;flex-direction:column;gap:10px;border:1px solid var(--acc-line);border-radius:12px;background:var(--acc-wash);padding:12px 14px 14px}
.basis-hd{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.basis-lb{flex:none;font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--acc-deep)}
.basis-chip b{display:inline-block;font-size:13px;font-weight:800;color:#fff;background:var(--acc);border-radius:999px;padding:2px 14px;margin-right:8px}
.basis-desc{font-style:normal;font-size:11.5px;color:var(--ink-3)}
.basis-hd:has(.basis-chip:empty)::after{content:"待选择";font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
.basis-in:has(tbody:empty) .jc-tbl-wrap{display:none}
.basis-in .jc-tbl-wrap{background:#fff}
/* ================= 新建类型工作抽屉(details,零脚本)================= */
.lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)}
.lab[open]{border-style:solid;background:#fff}
.lab-sm{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;padding:11px 14px;font-size:12.5px;color:var(--ink-3);user-select:none}
.lab-sm::-webkit-details-marker{display:none}
.lab-sm b{color:var(--ink-2)}
.lab-sm:hover{color:var(--acc-deep)}
.lab-caret{margin-left:auto;flex:none;width:8px;height:8px;border-right:2px solid var(--ink-4);border-bottom:2px solid var(--ink-4);transform:rotate(45deg);transition:transform .16s}
.lab[open] .lab-caret{transform:rotate(-135deg)}
.lab-bd{padding:2px 14px 14px}
.lab-sub{margin:16px 0 8px;font-size:11.5px;color:var(--ink-3)}
/* ================= 窄屏 ================= */ /* ================= 窄屏 ================= */
@media(max-width:620px){ @media(max-width:620px){
.card-head{padding:16px 14px 18px} .card-head{padding:16px 14px 18px}
+10 -2
View File
@@ -72,8 +72,16 @@ export type AutoAction = {
readonly when?: string | undefined; readonly when?: string | undefined;
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */ /** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
readonly into: string; readonly into: string;
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */ /**
readonly prune?: boolean | undefined; * 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
* `true`:只在非 quiet 拍撤 —— 人正对着件编辑的那几拍不动他的行(2026-08-29 终审 F3:
* 人刚在件上加的行,配方的数据源里当然没有,quiet 撤行就是静默数据丢失)。
* `'live'`(2026-08-31):quiet 拍也撤 —— **只给人不会在件上加行的表用**(行集全由配方铺,
* 如 W05 的参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表没有这个风险,
* 而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎 —— 跟手比守候更诚实。
* 已落地的行两档都跳过并出声(draftDrop 的 skipLanded)。
*/
readonly prune?: boolean | 'live' | undefined;
readonly from: AutoSource; readonly from: AutoSource;
readonly fields: Readonly<Record<string, AutoExpr>>; readonly fields: Readonly<Record<string, AutoExpr>>;
} | { } | {
@@ -628,7 +628,7 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有, * 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。 * 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
*/ */
if (action.prune === true && (step.humanWrites ?? []).includes(action.into)) { if ((action.prune === true || action.prune === 'live') && (step.humanWrites ?? []).includes(action.into)) {
out.push({ out.push({
code: 'schema-invalid', code: 'schema-invalid',
severity: 'warn', severity: 'warn',
@@ -795,6 +795,23 @@ describe('prune:人在填的时候不撤行(F3)', () => {
expect(out.dropped).toBe(1); expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
}); });
/*
* `prune: 'live'`(2026-08-31):quiet 拍也撤 —— 只给**人不会在件上加行**的表用
* (行集全由配方铺,如 W05 参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表
* 没有这个风险;而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎(用户实测点名
* 「只会跟着变多,不会跟着变少」)—— 跟手比守候更诚实。
*/
it("prune:'live' 在 quiet 拍也对齐 —— 人加不了行的表,改选后旧行跟手消失", async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(SEED, ctx);
const out = await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', prune: 'live', from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
{ ...ctx, quiet: true },
);
expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
});
}); });
/** /**
@@ -218,7 +218,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
* (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。 * (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。
* 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。 * 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。
*/ */
if (action.prune === true && !context.quiet) { /* true:非 quiet 拍才撤(F3,人正编辑不动他的行);'live':quiet 也撤 ——
只给人不会在件上加行的表用(契约注释与静态审都写着),跟手比守候更诚实 */
if (action.prune === 'live' || (action.prune === true && !context.quiet)) {
for (const stale of before) { for (const stale of before) {
const key: Record<string, string> = {}; const key: Record<string, string> = {};
for (const column of decl.keys) key[column] = String(stale[column] ?? ''); for (const column of decl.keys) key[column] = String(stale[column] ?? '');
+5
View File
@@ -363,6 +363,11 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
的规矩,这条路连说都没说)。基座给了两道护栏:有卡在等人(人正对着件填)那几拍**不 prune**; 的规矩,这条路连说都没说)。基座给了两道护栏:有卡在等人(人正对着件填)那几拍**不 prune**;
同一张表既被 `prune` 又列在这一步 `humanWrites` 里,装包时报 warn。护栏不是许可 —— 同一张表既被 `prune` 又列在这一步 `humanWrites` 里,装包时报 warn。护栏不是许可 ——
要人加行的表就别 `prune` 它。 要人加行的表就别 `prune` 它。
**`prune: 'live'`(2026-08-31)是这条纪律的反面特例**:行集**全由配方铺、人在件上加不了行**
的表(如 W05 的参数骨架 —— 行按「料 × 类型字段」派生,件上没有加行钮),quiet 拍也照撤 ——
F3 防的风险在这种表上不存在,而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎
(实测:用户改选类型,行「只会跟着变多,不会跟着变少」)。有加行钮的表**永远不许用 `'live'`**。
两档都跳过已落地的行并出声(`draftDrop` 的 `skipLanded`)。
6. **`card` 之后的那几笔,要等人交回之后的那一拍才跑。** 卡真发出去那一拍到此为止; 6. **`card` 之后的那几笔,要等人交回之后的那一拍才跑。** 卡真发出去那一拍到此为止;
人交回之后那一拍,`card` 这一笔被跳过、后面的照跑。所以「铺格 → 发卡 → 收口整理」读起来像一拍连做, 人交回之后那一拍,`card` 这一笔被跳过、后面的照跑。所以「铺格 → 发卡 → 收口整理」读起来像一拍连做,
其实是两拍 —— 收口那一笔看得见人填的值,这正是它该在的位置。装包时会为此报一条 warn 提醒。 其实是两拍 —— 收口那一笔看得见人填的值,这正是它该在的位置。装包时会为此报一条 warn 提醒。
@@ -28,6 +28,13 @@ describe('auto 配方契约', () => {
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false);
}); });
it("prune 收 'live' 档 —— quiet 拍也撤,只给人不会在件上加行的表用(其余字符串拒)", () => {
const live = autoRecipeSchema.safeParse({ do: [{ kind: 'project', into: 'a.b', prune: 'live', from: { once: true }, fields: { k: 'v' } }] });
expect(live.success).toBe(true);
const junk = autoRecipeSchema.safeParse({ do: [{ kind: 'project', into: 'a.b', prune: 'always', from: { once: true }, fields: { k: 'v' } }] });
expect(junk.success).toBe(false);
});
it('prune 只许挂在 project 上', () => { it('prune 只许挂在 project 上', () => {
const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] }); const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] });
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false);
+11 -3
View File
@@ -97,8 +97,16 @@ export type AutoAction =
readonly when?: string | undefined; readonly when?: string | undefined;
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */ /** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
readonly into: string; readonly into: string;
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */ /**
readonly prune?: boolean | undefined; * 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
* `true`:只在非 quiet 拍撤 —— 人正对着件编辑的那几拍不动他的行(2026-08-29 终审 F3:
* 人刚在件上加的行,配方的数据源里当然没有,quiet 撤行就是静默数据丢失)。
* `'live'`(2026-08-31):quiet 拍也撤 —— **只给人不会在件上加行的表用**(行集全由配方铺,
* 如 W05 的参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表没有这个风险,
* 而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎 —— 跟手比守候更诚实。
* 已落地的行两档都跳过并出声(draftDrop 的 skipLanded)。
*/
readonly prune?: boolean | 'live' | undefined;
readonly from: AutoSource; readonly from: AutoSource;
readonly fields: Readonly<Record<string, AutoExpr>>; readonly fields: Readonly<Record<string, AutoExpr>>;
} }
@@ -145,7 +153,7 @@ export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kin
kind: z.literal('project'), kind: z.literal('project'),
when: z.string().min(1).optional(), when: z.string().min(1).optional(),
into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'), into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'),
prune: z.boolean().optional(), prune: z.union([z.boolean(), z.literal('live')]).optional(),
from: autoSourceSchema, from: autoSourceSchema,
fields: z.record(z.string(), autoExprSchema), fields: z.record(z.string(), autoExprSchema),
}) })