dsh-m 0.9.23 → 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.24 变更:排除条目代管——供应链等待期从「提前拒绝」到「治理 + 登记」(ADR-0009)
12
+
13
+ - **问题(实机 2026-10-03 实证)**:0.9.19 的委派前预检把「目标版本未满 24h 等待期」预测成"官方管理器必拦"并提前拒绝——同目标 dsh-market 却能装上(copilot-auth@1.2.4 发布 8 分钟、quota-watch@0.1.21 两例)。pnpm 11.7 默认非严格策略对显式点名的新版本本来就放行并自动登记排除条目,预检的墙并不存在。
14
+ - **三挂点代管**:① 委派前治理——desktop profile 排除块的坏形态(pnpm 自追加的死规则)合并为"每包一条、版本并集复合";② 装机成功后登记——等待期内目标并入排除块(scoped 精确单条 / 非 scoped 目标+上一版双选择器);③ 双码失败(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_NO_MATURE_MATCHING_VERSION`)→ 治理 → 至多重试一次,仍败走既有失败翻译与账实分裂复读。web ladder 同套三挂点。
15
+ - **边界与安全**:红线收窄出唯一例外(desktop 仅此块、仅委派前后挂点、原子写、解析失败即弃、留痕不落全文);治理/登记全程持官方同款锁并 fail-open——任何失败不阻塞委派,pnpm 仍是最终执行者;显式设置 age 或 strict 的 profile 仍前置拒绝并给可重试时刻;机理按"首条规则生效"口径改判(know-how 020 §2.4 形态论退役)。
16
+ - **文档**:决策全文 `docs/adr/0009-release-age-exclude-governance.md`(含 9 项评估过不做的栅栏附录与观察预案);GLOSSARY 新词「首条规则/排除条目/治理/登记/等待期」。
17
+
11
18
  ### 0.9.23 变更:已装页两段加载——列表秒开 + 更新提示就地补 ⬆(ADR-0008)
12
19
 
13
20
  - **问题(实机 2026-10-03 实证)**:更新探测焊在已装列表接口里且带 60min 内存 TTL——发版后重开面板吃到陈旧缓存,升级提示迟迟不出现(quota-watch 新版发布 17 分钟后面板仍无提示,dshmarket 同刻已见)。
@@ -249,6 +256,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
249
256
 
250
257
  ## English
251
258
 
259
+ ### Changed in 0.9.24 — release-age exclusion governance: from pre-flight refusal to govern + register (ADR-0009)
260
+
261
+ - **Problem (observed live 2026-10-03)**: the 0.9.19 pre-flight precheck predicted "the official manager will block a target inside the 24h release-age window" and refused up front — while dsh-market installed the very same targets (copilot-auth@1.2.4 published 8 minutes prior; quota-watch@0.1.21). pnpm 11.7's default non-strict policy admits explicitly named fresh releases and auto-registers their exclusion entries; the wall the precheck predicted did not exist.
262
+ - **Three governance hooks**: (1) pre-delegation govern — merge malformed exclusion shapes (pnpm's auto-appended dead rules) into "one rule per package, version union"; (2) post-success register — fold in-window targets into the exclusion block (scoped exact / non-scoped target+previous composite); (3) on dual-code failure (`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_NO_MATURE_MATCHING_VERSION`) → govern → retry the command at most once, then fall through to the existing failure translation. The web ladder gets the same three hooks.
263
+ - **Boundary & safety**: the red line narrows by exactly one exception (desktop: this block only, delegation hooks only, atomic write, parse-failure bail-out, tracing without file dumps); govern/register hold the official same lock and fail open — any failure never blocks delegation, pnpm stays the final enforcer; profiles that explicitly set `minimumReleaseAge` or `minimumReleaseAgeStrict` still get refused up front with a retry time; the mechanism verdict follows "first rule wins" (know-how 020 §2.4 form-theory retired).
264
+ - **Docs**: full decision record in `docs/adr/0009-release-age-exclude-governance.md` (with a fence appendix of 9 evaluated-and-skipped items and the observation contingency); GLOSSARY terms "First Rule / Release-Age Exclusion / Govern / Register / Release Age".
265
+
252
266
  ### Changed in 0.9.23 — two-phase Installed tab: instant list + update hints filled in place (ADR-0008)
253
267
 
254
268
  - **Problem (observed live 2026-10-03)**: update probes were welded into the installed-list endpoint behind a 60-min in-memory TTL — after publishing a release, reopening the panel served the stale cache and update hints lagged (quota-watch's new version stayed invisible 17 minutes after publish while dsh-market already showed it).
package/docs/DESIGN.md CHANGED
@@ -152,6 +152,8 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
152
152
 
153
153
  与 `application`(Profile 变更事务词汇)的边界:`activation` 只是「新版本如何生效」的操作建议,不是事务状态——事务四态(applied/restart-required/overridden/failed)描述**变更本身**,`activation` 描述**生效方式**;两者不互相推导(GLOSSARY Avoid 行同源)。
154
154
 
155
+ 排除条目治理与登记(desktop 红线例外的唯一记载处为 ADR):见 [ADR-0009](./adr/0009-release-age-exclude-governance.md)。
156
+
155
157
  ## 4. GUI(旗舰,v1 必须做好)
156
158
 
157
159
  0.4.0 增补:已装卡右上 `dshm-switch` 开关(受 `toggleable` 控制,锁因 title 提示);sub 行相位点 `● active · v1.0.8 · npm`(相位点只映射 phase 五值,「已停用」归 Switch,两输入源各管各的);开关结果通知按 `applied` 分流(live → 绿 toast「即时生效」;restart-required → 沿用重启横幅 + 一键重启);安装遇兼容拦截(409 + issue)弹「仍要安装」确认(红字风险 + peers 清单 → `forceIncompatible` 重发)。
@@ -11,6 +11,9 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
11
11
  import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
12
12
  import { createProgressTracker } from './progress.js';
13
13
  import { applyPreciseBuilds, readPendingBuilds } from './build-approval.js';
14
+ import { governExcludeBlock, parseNpmSpec, registerExclusion } from './exclude-governance.js';
15
+ import { listInstalledPlugins } from './installed.js';
16
+ const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
14
17
  /** pnpm patchedDependencies 条目键与目标包匹配:`pkg` 或 `pkg@任意版本/区间`。 */
15
18
  function matchesPatchedKey(key, pkg) {
16
19
  return key === pkg || key.startsWith(`${pkg}@`);
@@ -579,54 +582,118 @@ export function makeAddViaLadder(deps) {
579
582
  const resolveBuilds = deps.resolveBuilds ?? writePreciseBuilds;
580
583
  const opts = (signal) => (signal !== undefined ? { signal } : undefined);
581
584
  return async (source, profileDir, signal) => {
582
- const retryAfterPrepare = async () => {
583
- // Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
584
- let decision;
585
+ // ADR-0009 挂点1:委派前治理(fail-open)——已落盘的编辑以短语进 output(成功/失败都带)
586
+ let governNote = '';
587
+ try {
588
+ const gov = await (deps.govern ?? governExcludeBlock)(profileDir);
589
+ if (gov.changed && gov.mergedNames?.length)
590
+ governNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
591
+ }
592
+ catch {
593
+ // fail-open:治理失败不阻塞
594
+ }
595
+ // 登记原料:npm 精确 spec + 上一版本(本地读,失败 → undefined)
596
+ const spec = parseNpmSpec(source);
597
+ let previousVersion;
598
+ if (spec) {
585
599
  try {
586
- decision = resolveBuilds(profileDir);
600
+ previousVersion = deps.installedVersion
601
+ ? await deps.installedVersion(profileDir, spec.pkg)
602
+ : (await listInstalledPlugins(profileDir)).items.find((it) => it.pkg === spec.pkg)?.version;
587
603
  }
588
- catch (err) {
589
- const text = errText(err);
590
- return { class: 'hard-fail', output: truncateOutput(text) };
604
+ catch {
605
+ previousVersion = undefined;
591
606
  }
607
+ }
608
+ let registerNote = '';
609
+ const safeRegister = async () => {
610
+ if (!spec)
611
+ return;
592
612
  try {
593
- const output = await run(WEB_PROFILE, ['add', source], opts(signal));
594
- return { class: 'ok', output: truncateOutput(output), buildApprovals: decision.approvals, fallbackAllBuilds: decision.fallbackAll };
613
+ const reg = await (deps.register ?? registerExclusion)(profileDir, { ...spec, previousVersion });
614
+ if (reg.applied && reg.form)
615
+ registerNote = `;排除条目已登记 ${spec.pkg}@${spec.version}(${reg.form})`;
595
616
  }
596
- catch (retryErr) {
597
- return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
617
+ catch {
618
+ // fail-open:登记失败不影响成功态
598
619
  }
599
620
  };
600
- try {
601
- const output = await run(WEB_PROFILE, ['add', source], opts(signal));
602
- return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
603
- }
604
- catch (err) {
605
- const text = errText(err);
606
- if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
621
+ const okOutcome = async (output, buildApprovals = [], fallbackAllBuilds = false) => {
622
+ await safeRegister();
623
+ return { class: 'ok', output: truncateOutput(output + governNote + governRetryNote + registerNote), buildApprovals, fallbackAllBuilds };
624
+ };
625
+ const failOutcome = (text) => ({ ...classifyPnpmError(text), output: truncateOutput(text + governNote + governRetryNote) });
626
+ // ADR-0009 挂点3:阶梯内每次真实 pnpm 命令统一包装——双码失败 → 治理 → changed 则
627
+ // 重试该命令一次(布尔全局保证每次 ladder 调用至多一次;四个失败出口共用)。
628
+ // 挂点3 已落盘的合并以短语并入 output(留痕契约含挂点3,与 hook1 短语去重)。
629
+ let governedRetryUsed = false;
630
+ let governRetryNote = '';
631
+ const attemptAdd = async (args) => {
632
+ try {
633
+ return { ok: true, output: await run(WEB_PROFILE, args, opts(signal)) };
634
+ }
635
+ catch (err) {
636
+ const text = errText(err);
637
+ if (governedRetryUsed || !DUAL_CODE_RE.test(text))
638
+ return { ok: false, text };
639
+ governedRetryUsed = true;
607
640
  try {
608
- await run(WEB_PROFILE, ['install', '--no-frozen-lockfile'], opts(signal));
641
+ const gov = await (deps.govern ?? governExcludeBlock)(profileDir);
642
+ if (!gov.changed)
643
+ return { ok: false, text };
644
+ if (gov.mergedNames?.length) {
645
+ const note = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
646
+ if (!governNote.includes(note))
647
+ governRetryNote = note;
648
+ }
609
649
  }
610
- catch (rebuildErr) {
611
- const rebuildText = errText(rebuildErr);
612
- return { ...classifyPnpmError(rebuildText), output: truncateOutput(rebuildText) };
650
+ catch {
651
+ return { ok: false, text };
613
652
  }
614
653
  try {
615
- const output = await run(WEB_PROFILE, ['add', source], opts(signal));
616
- return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
654
+ return { ok: true, output: await run(WEB_PROFILE, args, opts(signal)) };
617
655
  }
618
656
  catch (retryErr) {
619
- const retryText = errText(retryErr);
620
- if (isPrepareBlocked(retryText))
621
- return retryAfterPrepare();
622
- return { ...classifyPnpmError(retryText), output: truncateOutput(retryText) };
657
+ return { ok: false, text: errText(retryErr) };
623
658
  }
624
659
  }
625
- if (!isPrepareBlocked(text)) {
626
- return { ...classifyPnpmError(text), output: truncateOutput(text) };
660
+ };
661
+ const retryAfterPrepare = async () => {
662
+ // Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
663
+ let decision;
664
+ try {
665
+ decision = resolveBuilds(profileDir);
666
+ }
667
+ catch (err) {
668
+ const text = errText(err);
669
+ return { class: 'hard-fail', output: truncateOutput(text + governNote + governRetryNote) };
627
670
  }
628
- return retryAfterPrepare();
671
+ const retried = await attemptAdd(['add', source]);
672
+ if (retried.ok)
673
+ return okOutcome(retried.output, decision.approvals, decision.fallbackAll);
674
+ return { ...classifyPnpmError(retried.text), output: truncateOutput(retried.text + governNote + governRetryNote) };
675
+ };
676
+ const first = await attemptAdd(['add', source]);
677
+ if (first.ok)
678
+ return okOutcome(first.output);
679
+ const text = first.text;
680
+ if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
681
+ // 重建 install 自身也过锁文件校验:双码治理+重试同样适用(attemptAdd 包装)
682
+ const rebuild = await attemptAdd(['install', '--no-frozen-lockfile']);
683
+ if (!rebuild.ok)
684
+ return failOutcome(rebuild.text);
685
+ const second = await attemptAdd(['add', source]);
686
+ if (second.ok)
687
+ return okOutcome(second.output);
688
+ const retryText = second.text;
689
+ if (isPrepareBlocked(retryText))
690
+ return retryAfterPrepare();
691
+ return failOutcome(retryText);
692
+ }
693
+ if (!isPrepareBlocked(text)) {
694
+ return failOutcome(text);
629
695
  }
696
+ return retryAfterPrepare();
630
697
  };
631
698
  }
632
699
  function errText(err) {
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 排除条目代管(ADR-0009):pnpm-workspace.yaml `minimumReleaseAgeExclude` 块的
3
+ * 纯文本变换(解析 / 治理合并 / 登记)与落盘封装(原子写 + 官方同款锁 + fail-open)。
4
+ *
5
+ * 规范形态:每包一条、版本并集复合(`name@a || b`);包名级保持裸名;scoped 名在
6
+ * YAML 中必须单引号包裹(`@` 不能作 plain scalar 开头)。治理只修不建;登记可为
7
+ * 无块的文件创建块;解析失败(含 `#` 注释行)一律弃写——pnpm 仍是最终执行者。
8
+ *
9
+ * 机理(首条规则生效):pnpm evaluateVersionPolicy 每包名只认第一条排除规则,
10
+ * 同名后续规则死亡(dshmarket #732 实证)——同名合并即消除死规则。
11
+ * 语义对照(只读参考):.dsh-research/dsh-market-clone/src/profile.ts:1310-1385(merge)。
12
+ */
13
+ import { readFile as readFileFs } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { writeFileAtomic, withFileLock } from '@deepseek-ai/dsh-atomic-write';
16
+ import { isExactVersion } from './versions.js';
17
+ import { cachedPackumentTimes, DEFAULT_MINIMUM_RELEASE_AGE_MIN, readWorkspacePolicy } from './release-age.js';
18
+ /** 块形态(`minimumReleaseAgeExclude:` + 块内 `- <条目>` 行);行内数组等其它形态不在此列。 */
19
+ const BLOCK_RE = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
20
+ /** 任意形态的键存在性(含行内);登记据此避免产生重复键。 */
21
+ const KEY_ANY_FORM_RE = /^[ \t]*minimumReleaseAgeExclude[ \t]*:/m;
22
+ const WS_FILE = 'pnpm-workspace.yaml';
23
+ function splitEntry(raw) {
24
+ let text = raw.trim();
25
+ if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
26
+ text = text.slice(1, -1);
27
+ }
28
+ const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
29
+ if (at === -1)
30
+ return text === '' ? null : { name: text, selectors: null };
31
+ const name = text.slice(0, at);
32
+ const selector = text.slice(at + 1).trim();
33
+ if (name === '' || selector === '')
34
+ return null;
35
+ return { name, selectors: selector.split('||').map((s) => s.trim()) };
36
+ }
37
+ /** 解析排除块:absent = 键缺失;unparseable = 块内含 `#` 或无法精确读取的条目(调用方弃写)。 */
38
+ export function parseExcludeBlock(yaml) {
39
+ const block = BLOCK_RE.exec(yaml);
40
+ if (block === null)
41
+ return { kind: 'absent' };
42
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
43
+ const indentMatch = /^([ \t]+)-/.exec(block[1]);
44
+ const indent = indentMatch === null ? ' ' : indentMatch[1];
45
+ const entries = [];
46
+ const rawLines = [];
47
+ for (const line of block[1].split(/\r?\n/)) {
48
+ if (line.trim() === '')
49
+ continue;
50
+ const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
51
+ if (m === null || m[1].includes('#'))
52
+ return { kind: 'unparseable' };
53
+ const split = splitEntry(m[1]);
54
+ if (split === null)
55
+ return { kind: 'unparseable' };
56
+ entries.push({ name: split.name, selectors: split.selectors, quoted: /^['"]/.test(m[1]), raw: line });
57
+ rawLines.push(line);
58
+ }
59
+ return { kind: 'ok', entries, rawLines, eol, indent };
60
+ }
61
+ /** 规范行渲染:单选择器 `name@v`、多选择器 `name@a || b`、包名级裸名;scoped 名加单引号。 */
62
+ function renderEntryLine(name, selectors, indent) {
63
+ const text = selectors === null || selectors.length === 0
64
+ ? name
65
+ : selectors.length === 1
66
+ ? `${name}@${selectors[0]}`
67
+ : `${name}@${selectors.join(' || ')}`;
68
+ return `${indent}- ${text.startsWith('@') ? `'${text.replaceAll("'", "''")}'` : text}`;
69
+ }
70
+ /**
71
+ * 治理:同名规则合并为规范形态(每包一条、版本并集、原首现顺序);只修不建;
72
+ * 块缺失 → changed=false;解析失败 → null(弃写)。changed 的判定逐名比较
73
+ * 「合并后渲染行 vs 原始行」,只有实际被改写的名字进入 mergedNames。
74
+ */
75
+ export function mergeExcludeRules(yaml) {
76
+ const parse = parseExcludeBlock(yaml);
77
+ if (parse.kind === 'absent')
78
+ return { yaml, changed: false, mergedNames: [], blockAbsent: true };
79
+ if (parse.kind === 'unparseable')
80
+ return null;
81
+ const { entries, rawLines, eol, indent } = parse;
82
+ const groups = new Map();
83
+ const order = [];
84
+ entries.forEach((e, i) => {
85
+ let g = groups.get(e.name);
86
+ if (g === undefined) {
87
+ g = { selectors: [], seen: new Set(), firstRaw: rawLines[i], count: 0 };
88
+ groups.set(e.name, g);
89
+ order.push(e.name);
90
+ }
91
+ g.count += 1;
92
+ if (e.selectors === null) {
93
+ // 包名级吞掉同名一切版本段
94
+ g.selectors = null;
95
+ g.seen.clear();
96
+ }
97
+ else if (g.selectors !== null) {
98
+ for (const s of e.selectors) {
99
+ if (!g.seen.has(s)) {
100
+ g.seen.add(s);
101
+ g.selectors.push(s);
102
+ }
103
+ }
104
+ }
105
+ });
106
+ const mergedNames = order.filter((name) => {
107
+ const g = groups.get(name);
108
+ if (g === undefined)
109
+ return false;
110
+ return g.count > 1 || renderEntryLine(name, g.selectors, indent) !== g.firstRaw;
111
+ });
112
+ if (mergedNames.length === 0)
113
+ return { yaml, changed: false, mergedNames: [], blockAbsent: false };
114
+ const lines = order.map((name) => {
115
+ const g = groups.get(name);
116
+ return renderEntryLine(name, g?.selectors ?? null, indent);
117
+ });
118
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
119
+ return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, mergedNames, blockAbsent: false };
120
+ }
121
+ function newRuleLine(target, indent) {
122
+ if (target.pkg.startsWith('@'))
123
+ return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-scoped-exact' };
124
+ if (target.previousVersion !== undefined && target.previousVersion !== target.version) {
125
+ return { line: renderEntryLine(target.pkg, [target.version, target.previousVersion], indent), form: 'created-composite' };
126
+ }
127
+ return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-exact' };
128
+ }
129
+ /**
130
+ * 登记:把目标精确版本并入该包**首条**规则(首条规则生效口径);无既有规则时按
131
+ * scoped → 精确单条、非 scoped + 异版 previousVersion → 双选择器复合、其余 → 精确单条
132
+ * 新建;块缺失的文件在尾部创建块(键以行内等其它形式存在 → null,避免重复键)。
133
+ * 目标已被覆盖(包名级 / 选择器含该版本)→ noop。
134
+ */
135
+ export function registerExclusionInYaml(yaml, target) {
136
+ const parse = parseExcludeBlock(yaml);
137
+ if (parse.kind === 'unparseable')
138
+ return null;
139
+ if (parse.kind === 'absent') {
140
+ if (KEY_ANY_FORM_RE.test(yaml))
141
+ return null;
142
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
143
+ const { line, form } = newRuleLine(target, ' ');
144
+ const base = yaml === '' || yaml.endsWith(eol) ? yaml : yaml + eol;
145
+ return { yaml: `${base}minimumReleaseAgeExclude:${eol}${line}${eol}`, changed: true, form };
146
+ }
147
+ const { entries, rawLines, eol, indent } = parse;
148
+ const lines = [...rawLines];
149
+ const idx = entries.findIndex((e) => e.name === target.pkg);
150
+ if (idx >= 0) {
151
+ const e = entries[idx];
152
+ if (e.selectors === null)
153
+ return { yaml, changed: false, form: 'noop' };
154
+ if (e.selectors.includes(target.version))
155
+ return { yaml, changed: false, form: 'noop' };
156
+ lines[idx] = renderEntryLine(target.pkg, [...e.selectors, target.version], indent);
157
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
158
+ return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form: 'merged' };
159
+ }
160
+ const { line, form } = newRuleLine(target, indent);
161
+ lines.push(line);
162
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
163
+ return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form };
164
+ }
165
+ export function parseNpmSpec(spec) {
166
+ let text = spec.trim();
167
+ if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
168
+ text = text.slice(1, -1);
169
+ }
170
+ if (text === '' || text.includes(':'))
171
+ return null;
172
+ const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
173
+ if (at <= 0)
174
+ return null;
175
+ const pkg = text.slice(0, at);
176
+ const version = text.slice(at + 1);
177
+ if (pkg === '' || version === '' || !isExactVersion(version))
178
+ return null;
179
+ return { pkg, version };
180
+ }
181
+ /**
182
+ * 委派前治理:读-改-写全程持官方同款锁(`withFileLock(<profile>/package.json)`,
183
+ * toggle 同先例),changed 才原子写。fail-open:任何失败返回结构化 reason,不抛——
184
+ * pnpm 是最终执行者。锁只串行持锁写者,不防不持锁的 pnpm 追加(残余风险见 ADR-0009)。
185
+ */
186
+ export async function governExcludeBlock(profileDir, deps = {}) {
187
+ if (!profileDir)
188
+ return { ok: true, changed: false, reason: 'no-dir' };
189
+ const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
190
+ const doWrite = deps.writeFile ??
191
+ (async (path, text) => {
192
+ await writeFileAtomic(path, text, { mode: 0o600 });
193
+ });
194
+ const file = join(profileDir, WS_FILE);
195
+ try {
196
+ return await withFileLock(join(profileDir, 'package.json'), async () => {
197
+ let yaml;
198
+ try {
199
+ yaml = await doRead(file);
200
+ }
201
+ catch {
202
+ return { ok: false, changed: false, reason: 'no-file' };
203
+ }
204
+ if (!KEY_ANY_FORM_RE.test(yaml))
205
+ return { ok: true, changed: false, reason: 'no-block' };
206
+ const merged = mergeExcludeRules(yaml);
207
+ if (merged === null)
208
+ return { ok: false, changed: false, reason: 'unparseable' };
209
+ if (!merged.changed)
210
+ return { ok: true, changed: false, reason: 'clean' };
211
+ await doWrite(file, merged.yaml);
212
+ return { ok: true, changed: true, mergedNames: merged.mergedNames };
213
+ });
214
+ }
215
+ catch {
216
+ return { ok: false, changed: false, reason: 'write-failed' };
217
+ }
218
+ }
219
+ /**
220
+ * 成功后登记:仅对发布时刻可判「窗口内」(显式 age 值优先,缺省 1440 分钟)的目标
221
+ * 触发;时刻不可得一律不登记(不发明)。本地检查(文件缺席 → no-file)先于 registry
222
+ * 探测——缺省绑定下无块文件零网络。与治理同锁同 fail-open 纪律。
223
+ */
224
+ export async function registerExclusion(profileDir, target, deps = {}) {
225
+ if (!profileDir)
226
+ return { applied: false, reason: 'no-dir' };
227
+ const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
228
+ const doWrite = deps.writeFile ??
229
+ (async (path, text) => {
230
+ await writeFileAtomic(path, text, { mode: 0o600 });
231
+ });
232
+ const file = join(profileDir, WS_FILE);
233
+ try {
234
+ return await withFileLock(join(profileDir, 'package.json'), async () => {
235
+ // 本地检查先行:文件缺席直接 no-file(不发 registry 探测——缺省绑定下测试离线可跑)
236
+ let yaml;
237
+ try {
238
+ yaml = await doRead(file);
239
+ }
240
+ catch {
241
+ return { applied: false, reason: 'no-file' };
242
+ }
243
+ const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
244
+ const now = deps.nowMs ?? Date.now();
245
+ const publishedAt = times ? Date.parse(times[target.version] ?? '') : NaN;
246
+ if (!Number.isFinite(publishedAt))
247
+ return { applied: false, reason: 'not-young' };
248
+ const policy = await readWorkspacePolicy(profileDir);
249
+ const windowMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
250
+ if (now >= publishedAt + windowMin * 60_000)
251
+ return { applied: false, reason: 'not-young' };
252
+ const next = registerExclusionInYaml(yaml, target);
253
+ if (next === null)
254
+ return { applied: false, reason: 'unparseable' };
255
+ if (!next.changed)
256
+ return { applied: false, reason: 'up-to-date', form: next.form };
257
+ await doWrite(file, next.yaml);
258
+ return { applied: true, form: next.form };
259
+ });
260
+ }
261
+ catch {
262
+ return { applied: false, reason: 'write-failed' };
263
+ }
264
+ }
@@ -31,6 +31,7 @@ import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTa
31
31
  import { classifyUpgradeActivation } from './activation.js';
32
32
  import { togglePlugin, ToggleError } from './toggle.js';
33
33
  import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
34
+ import { governExcludeBlock, registerExclusion } from './exclude-governance.js';
34
35
  // 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
35
36
  // 目标/旁包分述。0.9.10 版只取第一条违规并把旁包名字当成本次目标、给「改用 DSH Web」的
36
37
  // 失配指引(本机 quota-watch 0.1.15 事件实证:被拦的是旁包 dsh-m@0.9.18,文案却让用户等
@@ -121,9 +122,23 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
121
122
  else {
122
123
  throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
123
124
  }
124
- // 0.9.19:供应链等待期预检——pnpm 11 锁文件校验会拦死整次安装,且失败前目标已写入
125
- // node_modules(账实分裂);目标太新或锁内「独立精确排除条目」未满期 → 委派前结构化拒绝。
126
- if (entry.source === 'npm' && version !== undefined) {
125
+ // ADR-0009:委派前治理(排除块规范形态,fail-open)+ 供应链等待期预检收敛——默认策略
126
+ // 放行并附陈述(官方管理器对显式点名的新版本直接安装并自动登记);显式设置 age 或
127
+ // strict 开启才拒绝。治理已落盘的编辑在成功 output 与失败 message 双路留痕。
128
+ let governanceNote = '';
129
+ let releaseAgeNotice = '';
130
+ let governedMergedNames;
131
+ if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
132
+ try {
133
+ const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
134
+ if (gov.changed && gov.mergedNames?.length) {
135
+ governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
136
+ governedMergedNames = gov.mergedNames;
137
+ }
138
+ }
139
+ catch {
140
+ // fail-open:治理失败不阻塞委派(pnpm 仍是最终执行者)
141
+ }
127
142
  const gate = await releaseAgePrecheck({
128
143
  pkg,
129
144
  version,
@@ -132,20 +147,35 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
132
147
  signal: opts.signal,
133
148
  deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
134
149
  });
135
- if (gate.blocked)
136
- throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
150
+ if (gate.blocked) {
151
+ throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
152
+ }
153
+ if (gate.notice)
154
+ releaseAgeNotice = `;${gate.notice}`;
137
155
  }
138
156
  let change;
139
157
  let bundleName;
158
+ let registerNote;
159
+ let managed;
140
160
  try {
141
- ({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
161
+ managed = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
162
+ change = managed.change;
163
+ bundleName = managed.bundleName;
164
+ registerNote = managed.registerNote;
142
165
  }
143
166
  catch (err) {
144
167
  if (err instanceof DesktopBuildApprovalNeeded) {
145
- return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
168
+ return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
169
+ }
170
+ if (err instanceof Error) {
171
+ if (governanceNote !== '' && !err.message.includes(governanceNote))
172
+ err.message += governanceNote;
173
+ if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
174
+ err.message += managed.governRetryNote;
146
175
  }
147
176
  throw err;
148
177
  }
178
+ const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
149
179
  return {
150
180
  id: entry.id,
151
181
  pkg,
@@ -158,7 +188,7 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
158
188
  ...(compat !== null ? { compat } : {}),
159
189
  ...(compatSkipped ? { compatSkipped } : {}),
160
190
  needsRestart: true,
161
- output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
191
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
162
192
  via: 'desktop-manager',
163
193
  ...(change.application === 'overridden' ? { overridden: true } : {}),
164
194
  };
@@ -208,7 +238,17 @@ function enableStageFailureMessage(pkg, change) {
208
238
  }
209
239
  return `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):${writtenText},请在官方 Desktop 插件页查看状态或重试`;
210
240
  }
211
- async function runManagedInstall(service, ctx) {
241
+ const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
242
+ /** 可治理的失败形态:cancelled / build-blocked 不治理不重试(与 mapFailure 分类一致)。 */
243
+ function isGovableFailure(change) {
244
+ if (change.application === 'cancelled')
245
+ return false;
246
+ const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
247
+ if (change.packageResult?.kind === 'build-blocked' && pending.length > 0)
248
+ return false;
249
+ return change.application === 'failed' || change.error !== undefined;
250
+ }
251
+ async function runManagedInstall(service, ctx, deps = {}) {
212
252
  const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
213
253
  enabled: true,
214
254
  ...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
@@ -276,9 +316,46 @@ async function runManagedInstall(service, ctx) {
276
316
  await sleepMs(enableRetryDelayMs);
277
317
  change = await installOnce();
278
318
  }
279
- if (change.application === 'failed' || change.application === 'cancelled' || change.error)
280
- await mapFailure(change);
281
- return verifyAndBundle(change);
319
+ // ADR-0009 挂点3:双码失败 → 治理 → changed 则重试该命令一次(每次调用至多一次;
320
+ // cancelled / build-blocked 不治理不重试)。仍败走既有 mapFailure(含账实分裂复读);
321
+ // 挂点3 已落盘的合并以短语并入失败 message 与成功 output(留痕契约含挂点3)。
322
+ let governRetryNote;
323
+ if (isGovableFailure(change) && DUAL_CODE_RE.test(`${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`)) {
324
+ const gov = await (deps.governExclude ?? governExcludeBlock)(ctx.profileDir ?? '');
325
+ if (gov.changed) {
326
+ if (gov.mergedNames?.length)
327
+ governRetryNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
328
+ change = await installOnce();
329
+ }
330
+ }
331
+ if (change.application === 'failed' || change.application === 'cancelled' || change.error) {
332
+ try {
333
+ await mapFailure(change);
334
+ }
335
+ catch (err) {
336
+ if (err instanceof Error && governRetryNote !== undefined && !err.message.includes(governRetryNote))
337
+ err.message += governRetryNote;
338
+ throw err;
339
+ }
340
+ }
341
+ const verified = await verifyAndBundle(change);
342
+ // ADR-0009 挂点2:成功后登记(仅 npm 源窗口内目标;fail-open,applied 短语由调用方并入 output)
343
+ let registerNote;
344
+ if (ctx.profileDir && ctx.version) {
345
+ try {
346
+ const reg = await (deps.registerExclude ?? registerExclusion)(ctx.profileDir, {
347
+ pkg: ctx.pkg,
348
+ version: ctx.version,
349
+ ...(ctx.previousVersion !== undefined ? { previousVersion: ctx.previousVersion } : {}),
350
+ }, deps.packumentTimes ? { packumentTimes: deps.packumentTimes } : {});
351
+ if (reg.applied && reg.form)
352
+ registerNote = `;排除条目已登记 ${ctx.pkg}@${ctx.version}(${reg.form})`;
353
+ }
354
+ catch {
355
+ // fail-open:登记失败不影响安装/升级成功态
356
+ }
357
+ }
358
+ return { ...verified, ...(registerNote !== undefined || governRetryNote !== undefined ? { registerNote, governRetryNote } : {}) };
282
359
  }
283
360
  /**
284
361
  * Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
@@ -383,8 +460,21 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
383
460
  else {
384
461
  throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
385
462
  }
386
- // 0.9.19:供应链等待期预检(同 desktopInstallLocked;0:15 quota-watch 事件即此形态实证)
387
- if (entry.source === 'npm' && version !== undefined) {
463
+ // ADR-0009:委派前治理 + 预检收敛(同 desktopInstallLocked;0:15 quota-watch 事件为治理前的旧形态实证)
464
+ let governanceNote = '';
465
+ let releaseAgeNotice = '';
466
+ let governedMergedNames;
467
+ if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
468
+ try {
469
+ const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
470
+ if (gov.changed && gov.mergedNames?.length) {
471
+ governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
472
+ governedMergedNames = gov.mergedNames;
473
+ }
474
+ }
475
+ catch {
476
+ // fail-open:治理失败不阻塞委派
477
+ }
388
478
  const gate = await releaseAgePrecheck({
389
479
  pkg,
390
480
  version,
@@ -393,20 +483,35 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
393
483
  signal: opts.signal,
394
484
  deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
395
485
  });
396
- if (gate.blocked)
397
- throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
486
+ if (gate.blocked) {
487
+ throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
488
+ }
489
+ if (gate.notice)
490
+ releaseAgeNotice = `;${gate.notice}`;
398
491
  }
399
492
  let change;
400
493
  let bundleName;
494
+ let registerNote;
495
+ let managed;
401
496
  try {
402
- ({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
497
+ managed = await runManagedInstall(service, { pkg, spec, version, previousVersion: target.version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
498
+ change = managed.change;
499
+ bundleName = managed.bundleName;
500
+ registerNote = managed.registerNote;
403
501
  }
404
502
  catch (err) {
405
503
  if (err instanceof DesktopBuildApprovalNeeded) {
406
- return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
504
+ return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
505
+ }
506
+ if (err instanceof Error) {
507
+ if (governanceNote !== '' && !err.message.includes(governanceNote))
508
+ err.message += governanceNote;
509
+ if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
510
+ err.message += managed.governRetryNote;
407
511
  }
408
512
  throw err;
409
513
  }
514
+ const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
410
515
  // 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
411
516
  let activation;
412
517
  let needsRestart = true;
@@ -432,7 +537,7 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
432
537
  ...(compat !== null ? { compat } : {}),
433
538
  ...(compatSkipped ? { compatSkipped } : {}),
434
539
  needsRestart,
435
- output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
540
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
436
541
  via: 'desktop-manager',
437
542
  ...(change.application === 'overridden' ? { overridden: true } : {}),
438
543
  fromVersion: target.version,
@@ -825,3 +825,14 @@ export async function loadDefaultRegistry(cfg = {}, opts = {}) {
825
825
  }
826
826
  return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
827
827
  }
828
+ /**
829
+ * verified 污染检测(know-how 022):`verified` 只记 **DSH 运行时**装机实测版本;
830
+ * 历史上反复出现把**插件自身版本号**误填进 verified 的事故(实证:dsh-quota-watch
831
+ * 条目曾出现 "0.1.0"——那是 quota-watch 的插件版本,不是任何 DSH 运行时版本)。
832
+ * 把条目 verified 与该插件自身 npm 已发布版本求交集,非空即高度可疑(warn 不 fail:
833
+ * 极小概率某代 DSH 与插件版本字面重合,人工复核后放行)。
834
+ */
835
+ export function verifiedPollution(verified, publishedVersions) {
836
+ const own = new Set(publishedVersions);
837
+ return (verified ?? []).filter((v) => own.has(v));
838
+ }
@@ -1,35 +1,34 @@
1
1
  /**
2
- * Desktop 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19)。
2
+ * 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19 立基,0.9.x 按治理语义收敛,ADR-0009)。
3
3
  *
4
- * pnpm 11.7 实机实证(2026-10-03,本机 desktop profile,官方管理器操作日志
5
- * operation-hNjgaN / operation-l5fXEd):
6
- * 1. 显式安装「太新」目标:非严格模式放行,pnpm 自动向 pnpm-workspace.yaml 的
7
- * `minimumReleaseAgeExclude` 追加一条「独立精确条目」(`pkg@ver` 形态);
8
- * 2. 但后续任何包操作的锁文件级供应链校验(`Verifying lockfile against
9
- * supply-chain policies (N entries)`)不认可 pnpm 自己追加的这种条目——
10
- * 任何一条未满期的 `pkg@ver` 都会拦死整个安装,且与本次目标无关(旁包能拦住
11
- * 所有升级,0.9.18 拦住 quota-watch 0.1.15 升级即实证);
4
+ * 实证基准(2026-10-03,本机 desktop profile,pnpm 11.7.0,官方管理器操作日志
5
+ * operation-zsc4ei / operation-L8oq1z / operation-l5fXEd):
6
+ * 1. 默认策略(未显式设置 age、未开 strict = 非严格)下,显式点名的「太新」目标
7
+ * 直接放行安装,pnpm 自动向 pnpm-workspace.yaml 的 `minimumReleaseAgeExclude`
8
+ * 追加 `pkg@ver` 条目("Added 1 entry …");
9
+ * 2. pnpm 的 evaluateVersionPolicy **每包名只认第一条排除规则**,同名后续规则死亡
10
+ * (dshmarket #732 实证,首条规则生效机理)——死规则会让等待期内全部包操作被
11
+ * 锁文件级校验拦死(0:15 quota-watch 连坐实录);
12
12
  * 3. 校验失败前目标包可能已写入 node_modules,官方管理器只回滚 manifest/lockfile
13
- * →「账实分裂」:界面实装新版 active,账本仍旧版,下次包操作静默回退。
13
+ * →「账实分裂」,下次包操作静默回退。
14
14
  *
15
- * 对策(零文件级红线不破:只读 profile 文件,绝不写):
16
- * ① 委派前预检(releaseAgePrecheck):目标版本或锁内「不被校验认可的独立精确
17
- * 排除条目」任一未满等待期 → 结构化拒绝,不委派官方管理器;
15
+ * 职责(ADR-0009):
16
+ * ① 委派前预检(releaseAgePrecheck):仅「显式设置 age 或 strict 开启」时对窗口内
17
+ * 目标拒绝(附可重试时刻);默认策略放行并返回陈述性 notice。锁内坏形态由
18
+ * exclude-governance 的治理挂点处理,本模块不再预拒绝(0.9.19 ②腿退役)。
18
19
  * ② 失败翻译(describeReleaseAgeFailure):解析全部违规条目,按「本次目标 vs
19
- * 锁内旁包」分别给出发布时刻与可重试时刻——不再把旁包名字冒充成本次目标被拦。
20
+ * 旁包」分述发布时刻与可重试时刻。
21
+ * ③ cachedPackumentTimes:预检与登记共用的带缓存 packument 探测。
20
22
  *
21
- * fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中有效排除
22
- * 条目(包名级、`||` 复合、scoped 独立精确)→ 一律放行;pnpm 仍是最终执行者。
23
+ * fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中任一
24
+ * 排除规则(首条规则口径)→ 一律放行;pnpm 仍是最终执行者。
23
25
  */
24
26
  import { readFile } from 'node:fs/promises';
25
27
  import { join } from 'node:path';
26
28
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
27
29
  import { fetchJsonLimited } from './httpx.js';
28
- import { isExactVersion } from './versions.js';
29
30
  /** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
30
31
  export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
31
- /** 锁内「独立精确排除条目」旁包探测上限(防清单失控;超出部分放弃预判,交 pnpm 兜底)。 */
32
- export const MAX_EXCLUDE_PROBES = 8;
33
32
  function scalarNumber(value) {
34
33
  if (typeof value === 'number' && Number.isFinite(value))
35
34
  return value;
@@ -43,7 +42,7 @@ function scalarNumber(value) {
43
42
  * 调用方按默认窗口 + 无排除处理,绝不抛。
44
43
  */
45
44
  export function parseWorkspacePolicy(text) {
46
- const empty = { minimumReleaseAgeMin: null, excludes: [] };
45
+ const empty = { explicitAge: false, minimumReleaseAgeMin: null, strict: false, excludes: [] };
47
46
  let document;
48
47
  try {
49
48
  document = parseDocument(text);
@@ -62,10 +61,17 @@ export function parseWorkspacePolicy(text) {
62
61
  }
63
62
  }
64
63
  }
65
- // yaml 库的 Map.get() 对标量返回已解包的原始值(number/string),对集合返回节点——两种形态都兜住
64
+ // yaml 库的 Map.get() 对标量返回已解包的原始值(number/string/boolean),对集合返回节点——两种形态都兜住
66
65
  const rawAge = document.contents.get('minimumReleaseAge');
67
66
  const age = isScalar(rawAge) ? scalarNumber(rawAge.value) : scalarNumber(rawAge);
68
- return { minimumReleaseAgeMin: age !== null && age > 0 ? age : null, excludes };
67
+ const rawStrict = document.contents.get('minimumReleaseAgeStrict');
68
+ const strict = rawStrict === true || (isScalar(rawStrict) && rawStrict.value === true);
69
+ return {
70
+ explicitAge: rawAge !== undefined && rawAge !== null,
71
+ minimumReleaseAgeMin: age !== null && age > 0 ? age : null,
72
+ strict,
73
+ excludes,
74
+ };
69
75
  }
70
76
  export function excludeMatch(selector, pkg, version) {
71
77
  const s = selector.trim();
@@ -73,7 +79,7 @@ export function excludeMatch(selector, pkg, version) {
73
79
  return 'no';
74
80
  if (s.includes('||')) {
75
81
  for (const part of s.split('||')) {
76
- if (excludeMatch(part.trim(), pkg, version) !== 'no')
82
+ if (excludeMatch(part.trim(), pkg, version) === 'effective')
77
83
  return 'effective';
78
84
  }
79
85
  return 'no';
@@ -83,30 +89,7 @@ export function excludeMatch(selector, pkg, version) {
83
89
  // 包名级(dshmarket)或裸版本号段(只应出现在复合条目内,顶层出现视为包名比较)
84
90
  return s === pkg || s === version ? 'effective' : 'no';
85
91
  }
86
- const namePart = s.slice(0, at);
87
- const verPart = s.slice(at + 1);
88
- if (namePart !== pkg)
89
- return 'no';
90
- if (verPart === version) {
91
- return pkg.startsWith('@') ? 'effective' : 'unreliable-unscoped-exact';
92
- }
93
- return 'no';
94
- }
95
- /** 从独立精确条目提取 (pkg, version);复合/包名级/版本段非精确 semver → null。 */
96
- export function splitExactSelector(selector) {
97
- const s = selector.trim();
98
- if (s === '' || s.includes('||'))
99
- return null;
100
- const at = s.lastIndexOf('@');
101
- if (at <= 0)
102
- return null;
103
- const pkg = s.slice(0, at);
104
- const version = s.slice(at + 1);
105
- if (!isExactVersion(version))
106
- return null;
107
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
108
- return null;
109
- return { pkg, version, reliable: pkg.startsWith('@') };
92
+ return s.slice(0, at) === pkg && s.slice(at + 1) === version ? 'effective' : 'no';
110
93
  }
111
94
  const VIOLATION_RE = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/g;
112
95
  /** 解析 pnpm 诊断里的全部等待期违规条目(0.9.10 只取第一条是「报错报错包」的根因之一)。 */
@@ -196,72 +179,65 @@ export async function readWorkspacePolicy(profileDir) {
196
179
  }
197
180
  const packumentCache = new Map();
198
181
  const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
182
+ /**
183
+ * 带进程内缓存的 packument time 探测(TTL 5min):预检与登记(exclude-governance)
184
+ * 共用——同一操作里预检刚拉过的 packument 不二次拉取(8MiB 级全量)。
185
+ */
186
+ export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal) {
187
+ const hit = packumentCache.get(pkg);
188
+ if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
189
+ return hit.times;
190
+ const times = await npmPackumentTimes(pkg, timeoutMs, signal);
191
+ packumentCache.set(pkg, { at: Date.now(), times });
192
+ return times;
193
+ }
199
194
  /** 测试钩子:清空 packument 进程内缓存。 */
200
195
  export function _resetReleaseAgeCachesForTests() {
201
196
  packumentCache.clear();
202
197
  }
203
198
  /**
204
- * 委派前预检:目标版本与锁内「不被校验认可的独立精确排除条目」逐个对发布时刻,
205
- * 任一未满等待期 → 结构化拒绝(blocked + 多行理由)。零写操作;不确定一律放行。
199
+ * 委派前预检(ADR-0009 收敛语义):仅当策略显式设置 age 或开启 strict 时,对窗口内
200
+ * 且未被任一排除规则覆盖(首条规则口径)的目标结构化拒绝(附可重试时刻);默认
201
+ * 策略(非严格)放行并返回陈述性 notice——官方管理器对显式点名的新版本会直接
202
+ * 安装并自动登记,锁内坏形态由 exclude-governance 的治理挂点处理。零写操作;
203
+ * fail-open:发布时刻不可得 / profileDir 缺席 / 命中排除规则 → 放行。
206
204
  */
207
205
  export async function releaseAgePrecheck(input) {
208
- // profileDir 缺席 = 读不到操作员排除条目 → 不预判(避免误拦操作员显式 pin 的安装)
206
+ // profileDir 缺席 = 读不到操作员排除条目与策略 → 不预判
209
207
  if (!input.profileDir)
210
208
  return { blocked: false };
211
209
  const now = input.nowMs ?? Date.now();
212
210
  const policy = input.deps?.workspacePolicy
213
211
  ? await input.deps.workspacePolicy(input.profileDir)
214
212
  : await readWorkspacePolicy(input.profileDir);
215
- const policyMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
213
+ const explicit = policy?.explicitAge ?? false;
214
+ const strict = policy?.strict ?? false;
215
+ const policyMin = explicit && policy?.minimumReleaseAgeMin != null ? policy.minimumReleaseAgeMin : DEFAULT_MINIMUM_RELEASE_AGE_MIN;
216
216
  const probe = async (pkg) => {
217
- const hit = packumentCache.get(pkg);
218
- if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
219
- return hit.times;
220
- const times = await (input.deps?.packumentTimes ?? npmPackumentTimes)(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
221
- packumentCache.set(pkg, { at: Date.now(), times });
222
- return times;
217
+ if (input.deps?.packumentTimes)
218
+ return input.deps.packumentTimes(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
219
+ return cachedPackumentTimes(pkg, 12_000, input.signal);
223
220
  };
224
- const blockers = [];
225
- // ① 本次目标版本的发布时刻(有效排除条目覆盖 → 放行)
226
221
  const times = await probe(input.pkg);
227
222
  const publishedAt = times ? Date.parse(times[input.version] ?? '') : NaN;
228
- if (Number.isFinite(publishedAt)) {
229
- const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
230
- if (!exempt) {
231
- const deadline = publishedAt + policyMin * 60_000;
232
- if (now < deadline) {
233
- blockers.push({ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline });
234
- }
235
- }
236
- }
237
- // ② 锁内「不被校验认可的独立精确排除条目」(非 scoped)——任何一条未满期都会拦死整次安装
238
- const seen = new Set([`${input.pkg}@${input.version}`]);
239
- const candidates = (policy?.excludes ?? [])
240
- .map((sel) => splitExactSelector(sel))
241
- .filter((x) => x !== null && !x.reliable);
242
- for (const ex of candidates.slice(0, MAX_EXCLUDE_PROBES)) {
243
- const entry = `${ex.pkg}@${ex.version}`;
244
- if (seen.has(entry))
245
- continue;
246
- seen.add(entry);
247
- const t2 = await probe(ex.pkg);
248
- const pub = t2 ? Date.parse(t2[ex.version] ?? '') : NaN;
249
- if (!Number.isFinite(pub))
250
- continue;
251
- const deadline = pub + policyMin * 60_000;
252
- if (now < deadline) {
253
- blockers.push({ entry, role: 'lockfile-exclude', publishedAt: pub, deadline });
254
- }
255
- }
256
- if (blockers.length === 0)
223
+ if (!Number.isFinite(publishedAt))
257
224
  return { blocked: false };
258
- const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
259
- for (const b of blockers) {
260
- lines.push(b.role === 'target'
261
- ? `· 目标 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期`
262
- : `· 锁内排除条目 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期(pnpm 自动追加的独立精确条目不被锁文件校验认可,等待期内任何官方包操作都会被拦)`);
225
+ const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
226
+ const deadline = publishedAt + policyMin * 60_000;
227
+ if (exempt || now >= deadline)
228
+ return { blocked: false };
229
+ if (!explicit && !strict) {
230
+ return {
231
+ blocked: false,
232
+ notice: `目标 ${input.pkg}@${input.version} 发布于 ${fmtLocal(publishedAt)}(等待期内):官方管理器将按显式点名安装,装好后 dsh-m 会登记豁免条目`,
233
+ };
263
234
  }
264
- const maxDeadline = Math.max(...blockers.map((b) => b.deadline));
265
- lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
266
- return { blocked: true, message: lines.join('\n'), blockers };
235
+ const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
236
+ lines.push(`· 目标 ${input.pkg}@${input.version}:发布于 ${fmtLocal(publishedAt)},预计 ${fmtLocal(deadline)} 后满等待期`);
237
+ lines.push(`预计 ${fmtLocal(deadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
238
+ return {
239
+ blocked: true,
240
+ message: lines.join('\n'),
241
+ blockers: [{ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline }],
242
+ };
267
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.23",
3
+ "version": "0.9.24",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.json CHANGED
@@ -99,10 +99,10 @@
99
99
  {
100
100
  "id": "dsh-copilot-auth",
101
101
  "name": "DSH Copilot Auth",
102
- "description": "为内置 Copilot 提供方补登录/注销设置页与开箱路由;模型目录兜底不覆盖定制。适配 0.1.7-rc.2 与 0.2.0-rc.1,旧版用 1.1.x。",
102
+ "description": "内置 Copilot 登录注销页与开箱路由;目录兜底不覆盖定制,桌面版不支持目录刷新。已适配 0.1.7-rc.2 与 0.2.0-rc.1/rc.2。",
103
103
  "category": "self-dev",
104
104
  "tags": ["Copilot", "认证", "模型接入", "工具"],
105
- "verified": ["0.1.7-rc.2", "0.2.0-rc.1"],
105
+ "verified": ["0.1.7-rc.2", "0.2.0-rc.1", "0.2.0-rc.2"],
106
106
  "source": "npm",
107
107
  "npm": "@inventec/dsh-copilot-auth",
108
108
  "github": "iasiv5/dsh-copilot-auth",
@@ -114,7 +114,7 @@
114
114
  "description": "GLM 与 Copilot 额度悬浮球:胶囊、详情面板与侧栏卡片,宿主解析凭据,适配 web 与 desktop。",
115
115
  "category": "self-dev",
116
116
  "tags": ["配额", "悬浮球", "GLM", "Copilot"],
117
- "verified": ["0.1.7-rc.2", "0.1.0"],
117
+ "verified": ["0.1.7-rc.2", "0.2.0-rc.2"],
118
118
  "source": "npm",
119
119
  "npm": "@iasiv5/dsh-quota-watch",
120
120
  "github": "iasiv5/dsh-quota-watch",