dsh-m 0.9.84 → 0.9.87

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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * packument 访问器(ADR-0019):npm 全量 packument 取数的唯一属主模块(GLOSSARY 在案)。
3
+ * 一次取回按需派生视图(times/versions/distTags/readme/repo),按(pkg, 生效 registry)
4
+ * 记忆化共享给登记、预检与 B3 预热;latest 小端点探测与其缓存制度(ADR-0006)不在辖区。
5
+ *
6
+ * 取数双面:fetchDocThrowing(strict——两腿阶梯,npmjs 首腿 `min(timeoutMs,5s)` 收紧 →
7
+ * 生效源备腿,终末失败抛原始错误,供 readme 阶梯错误透传);fetchPackumentDoc(lenient——
8
+ * 同阶梯 catch → null,times 的 fail-open 口径)。发布时戳是不可变历史事实,TTL 5min 只防
9
+ * 内存滞留过期内容,不作废旧时戳判定;失败与 regex-invalid 的确定性 null 同按 TTL 负缓存
10
+ * (故障窗口内预检/登记不二次打网)。
11
+ *
12
+ * 腿策略(ADR-0012 消费端):times 走两腿(完整性敏感读,npmjs 权威优先);warm 与 readme
13
+ * 兜底腿走「生效源单腿」(warm 暖的正是 B3 重试要打的 CDN,两腿会让镜像机先付 npmjs 5s
14
+ * 且暖错对象——评审 G1-3/G2-2 裁决)。onRouteSwitch 作废 + 缓存键含生效源双保险;本模块是
15
+ * 唯一注册点(release-age 旧注册已随其本地缓存退役)。
16
+ */
17
+ import { fetchJsonLimited } from './httpx.js';
18
+ import { DEFAULT_NPM_REGISTRY, NPM_MIRROR, activeNpmRegistry, onRouteSwitch, syncNpmmirrorPackage } from './npm-route.js';
19
+ /** npm 包名形状(与既有四函数同一字符集,scoped 允许)。 */
20
+ const PKG_RE = /^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/;
21
+ /** registry base 归一(去尾斜杠;空/缺省 → 官方 npmjs)。 */
22
+ function registryBase(registry) {
23
+ const base = typeof registry === 'string' && registry.trim() !== '' ? registry.trim() : DEFAULT_NPM_REGISTRY;
24
+ return base.replace(/\/+$/, '');
25
+ }
26
+ /** 精确 semver:接受 prerelease/build metadata,拒绝 v 前缀、range、tag 与脏尾缀。 */
27
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
28
+ export function isExactVersion(version) {
29
+ return EXACT_VERSION_RE.test(version);
30
+ }
31
+ // ---------- 取数层(strict / lenient 双面) ----------
32
+ /**
33
+ * strict 面:两腿阶梯取全量 packument,终末失败抛原始错误。
34
+ * L3 权威链(ADR-0012):npmjs 首腿超时收紧 `Math.min(timeoutMs, 5_000)`;生效源即 npmjs
35
+ * 时失败直接抛(权威源无备腿可降);两腿皆败抛终末腿错误(readme 阶梯的错误透传依赖此约定)。
36
+ */
37
+ export async function fetchDocThrowing(pkg, timeoutMs = 20_000, signal, deps) {
38
+ if (!PKG_RE.test(pkg))
39
+ throw new Error(`无效 npm 包名: ${pkg}`);
40
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
41
+ const firstLegMs = Math.min(timeoutMs, 5_000);
42
+ try {
43
+ return (await fetcher(`${registryBase(DEFAULT_NPM_REGISTRY)}/${encodeURIComponent(pkg)}`, {
44
+ timeoutMs: firstLegMs,
45
+ signal,
46
+ maxBytes: 8 * 1024 * 1024,
47
+ }));
48
+ }
49
+ catch (firstErr) {
50
+ const alt = await activeNpmRegistry();
51
+ if (alt === DEFAULT_NPM_REGISTRY)
52
+ throw firstErr;
53
+ return (await fetcher(`${registryBase(alt)}/${encodeURIComponent(pkg)}`, {
54
+ timeoutMs,
55
+ signal,
56
+ maxBytes: 8 * 1024 * 1024,
57
+ }));
58
+ }
59
+ }
60
+ /** lenient 面:同阶梯 catch → null(times 的 fail-open 口径;regex-invalid 亦 null)。 */
61
+ export async function fetchPackumentDoc(pkg, timeoutMs = 20_000, signal, deps) {
62
+ try {
63
+ return await fetchDocThrowing(pkg, timeoutMs, signal, deps);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** 生效源单腿 strict 取数(warm 与 readme 兜底腿的取数原语;失败抛原始错误)。返回 doc 与取数所用 base——调用方回填键必须用同一 base(评审 R1-1:一次生效源判定贯穿取数与回填,杜绝路由切换窗口的键错配)。 */
70
+ async function fetchDocViaActiveThrowing(pkg, timeoutMs, signal, deps) {
71
+ if (!PKG_RE.test(pkg))
72
+ throw new Error(`无效 npm 包名: ${pkg}`);
73
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
74
+ const base = await activeNpmRegistry();
75
+ const doc = (await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}`, {
76
+ timeoutMs,
77
+ signal,
78
+ maxBytes: 8 * 1024 * 1024,
79
+ }));
80
+ return { doc, base };
81
+ }
82
+ // ---------- 视图层 ----------
83
+ /** packument repository → "owner/repo"(仅 github.com;git+https / git@ / .git / #frag 归一;非 GitHub 返回空串)。 */
84
+ export function extractGithubRepo(v) {
85
+ const url = v && typeof v === 'object' && !Array.isArray(v) && typeof v.url === 'string'
86
+ ? v.url
87
+ : typeof v === 'string'
88
+ ? v
89
+ : '';
90
+ if (!url)
91
+ return '';
92
+ const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:[/?#]|$)/i.exec(url);
93
+ return m ? `${m[1]}/${m[2]}` : '';
94
+ }
95
+ /** 全量 packument → 派生视图包(纯函数;畸形字段一律回落默认值,不抛)。 */
96
+ export function packumentViews(doc) {
97
+ const d = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
98
+ const times = {};
99
+ const t = d.time;
100
+ if (t && typeof t === 'object' && !Array.isArray(t)) {
101
+ for (const [k, v] of Object.entries(t)) {
102
+ if (typeof v === 'string' && v !== '')
103
+ times[k] = v;
104
+ }
105
+ }
106
+ const versions = d.versions && typeof d.versions === 'object' && !Array.isArray(d.versions) ? Object.keys(d.versions) : [];
107
+ const distTags = d['dist-tags'] && typeof d['dist-tags'] === 'object' && !Array.isArray(d['dist-tags'])
108
+ ? d['dist-tags']
109
+ : {};
110
+ const readme = typeof d.readme === 'string' && d.readme.trim() !== '' ? d.readme : '';
111
+ return { times, versions, distTags, readme, repo: extractGithubRepo(d.repository) };
112
+ }
113
+ const packumentCache = new Map();
114
+ const packumentInflight = new Map();
115
+ const PACKUMENT_TTL_MS = 5 * 60_000;
116
+ const PACKUMENT_CAP = 4;
117
+ function packumentKey(pkg, registry) {
118
+ return `${pkg}\n${registry}`;
119
+ }
120
+ function backfill(key, views) {
121
+ if (packumentCache.has(key))
122
+ packumentCache.delete(key); // 重写移尾:淘汰语义 = 最旧写入
123
+ packumentCache.set(key, { at: Date.now(), views });
124
+ if (packumentCache.size > PACKUMENT_CAP) {
125
+ const oldest = packumentCache.keys().next().value;
126
+ if (oldest !== undefined)
127
+ packumentCache.delete(oldest);
128
+ }
129
+ }
130
+ /** 路由作废(ADR-0012 L1:生效源切换 → 缓存整体作废);亦是测试隔离钩子。 */
131
+ export function clearPackumentAccessorCache() {
132
+ packumentCache.clear();
133
+ packumentInflight.clear();
134
+ }
135
+ onRouteSwitch(() => clearPackumentAccessorCache());
136
+ /** lenient 带缓存取数(times/全视图共用;single-flight;失败/非法 → null 负缓存)。 */
137
+ async function entryLenient(pkg, timeoutMs, signal, deps) {
138
+ const key = packumentKey(pkg, await activeNpmRegistry());
139
+ const hit = packumentCache.get(key);
140
+ if (hit && Date.now() - hit.at < PACKUMENT_TTL_MS)
141
+ return hit.views;
142
+ const existing = packumentInflight.get(key);
143
+ if (existing)
144
+ return existing;
145
+ const p = (async () => {
146
+ let views = null;
147
+ try {
148
+ views = packumentViews(await fetchDocThrowing(pkg, timeoutMs, signal, deps));
149
+ }
150
+ catch {
151
+ views = null;
152
+ }
153
+ backfill(key, views);
154
+ return views;
155
+ })().finally(() => {
156
+ packumentInflight.delete(key);
157
+ });
158
+ packumentInflight.set(key, p);
159
+ return p;
160
+ }
161
+ /** 带缓存发布时戳表(预检/登记消费;语义 = 旧 cachedPackumentTimes:失败 null 不抛)。 */
162
+ export async function packumentTimes(pkg, timeoutMs = 12_000, signal, deps) {
163
+ const views = await entryLenient(pkg, timeoutMs, signal, deps);
164
+ return views ? views.times : null;
165
+ }
166
+ /** 带缓存全视图(npmPackument 隐式 registry 路径消费)。 */
167
+ export async function packumentDocViews(pkg, timeoutMs = 20_000, signal, deps) {
168
+ return entryLenient(pkg, timeoutMs, signal, deps);
169
+ }
170
+ /**
171
+ * 生效源单腿带缓存全视图(readme 隐式 registry 兜底腿专用,G2-2 方案 a):命中(且非
172
+ * 负缓存)直回;miss/负缓存 → 单腿 strict 重取(失败抛原始错误)+ 回填。负缓存条目仅
173
+ * lenient 面消费——strict 面视 null 为 miss,保终末错误透传(≈现状无缓存每次尝试)。
174
+ */
175
+ export async function packumentDocViewsActive(pkg, timeoutMs = 20_000, signal, deps) {
176
+ const key = packumentKey(pkg, await activeNpmRegistry());
177
+ const hit = packumentCache.get(key);
178
+ if (hit && Date.now() - hit.at < PACKUMENT_TTL_MS && hit.views !== null)
179
+ return hit.views;
180
+ const { doc, base } = await fetchDocViaActiveThrowing(pkg, timeoutMs, signal, deps);
181
+ const views = packumentViews(doc);
182
+ backfill(packumentKey(pkg, base), views);
183
+ return views;
184
+ }
185
+ /**
186
+ * B3 预热(G1-3 裁决):bypass 缓存读 + 生效源单腿直取 + 成功回填——预热的目的就是真发
187
+ * 请求暖 CDN(B3 重试要打的正是生效源),缓存命中≠预热;失败/abort 异常向上传播(调用方
188
+ * makeNpmWarmPackument 按 R4c 消化:abort rethrow、其余吞错)。
189
+ */
190
+ export async function warmPackumentFor(pkg, timeoutMs = 20_000, signal, deps) {
191
+ const { doc, base } = await fetchDocViaActiveThrowing(pkg, timeoutMs, signal, deps);
192
+ backfill(packumentKey(pkg, base), packumentViews(doc));
193
+ }
194
+ function sleepAbortable(ms, signal) {
195
+ return new Promise((resolve) => {
196
+ const done = () => {
197
+ clearTimeout(t);
198
+ signal?.removeEventListener('abort', done);
199
+ resolve();
200
+ };
201
+ const t = setTimeout(done, ms);
202
+ signal?.addEventListener('abort', done, { once: true });
203
+ });
204
+ }
205
+ /**
206
+ * 精确版本元数据(兼容预检/integrity 锚定)。L2① 履约阶梯(ADR-0012):
207
+ * 显式 registry 参数 > 生效源 → 失败且生效源为镜像 → sync → 有界等待 10s → 同源重试一次
208
+ * → 仍败 → npmjs 兜底(sync+重试为尽力而为,npmjs 兜底是主安全网);
209
+ * 主源即 npmjs 时失败直接抛(权威源无镜像滞后可言)。
210
+ */
211
+ export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, registry, deps) {
212
+ if (!PKG_RE.test(pkg))
213
+ throw new Error(`无效 npm 包名: ${pkg}`);
214
+ if (!isExactVersion(version))
215
+ throw new Error(`不是精确版本(拒绝 range/tag/前缀): ${version}`);
216
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
217
+ const sync = deps?.syncNpmmirror ?? syncNpmmirrorPackage;
218
+ const wait = deps?.wait ?? sleepAbortable;
219
+ const leg = async (base) => {
220
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/${version}`, { timeoutMs, signal });
221
+ const resolved = typeof data.version === 'string' ? data.version : '';
222
+ if (!resolved)
223
+ throw new Error(`npm 未返回版本: ${pkg}@${version}`);
224
+ const peers = {};
225
+ if (data.peerDependencies !== null && typeof data.peerDependencies === 'object') {
226
+ for (const [name, range] of Object.entries(data.peerDependencies)) {
227
+ if (typeof range === 'string')
228
+ peers[name] = range;
229
+ }
230
+ }
231
+ return {
232
+ version: resolved,
233
+ integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
234
+ tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
235
+ peers,
236
+ };
237
+ };
238
+ const primary = registry?.trim() ? registry.trim() : await activeNpmRegistry();
239
+ try {
240
+ return await leg(primary);
241
+ }
242
+ catch (firstErr) {
243
+ // L2①(ADR-0012):仅 sync 真正受理才重试镜像(kill switch / 同步失败不付 10s 等待)。
244
+ if (primary === NPM_MIRROR && (await sync(pkg))) {
245
+ await wait(10_000, signal);
246
+ try {
247
+ return await leg(primary);
248
+ }
249
+ catch {
250
+ // 同源重试仍败 → npmjs 兜底
251
+ }
252
+ }
253
+ if (primary !== DEFAULT_NPM_REGISTRY)
254
+ return leg(DEFAULT_NPM_REGISTRY);
255
+ throw firstErr;
256
+ }
257
+ }
258
+ /**
259
+ * 升级探测(检测读)。L3 权威链(ADR-0012):新鲜度敏感——npmjs 权威源优先
260
+ * (首腿超时收紧 `Math.min(timeoutMs, 5_000)`),失败降级生效源(≠npmjs 时);
261
+ * 不设 sync 腿。显式 registry 参数优先。
262
+ */
263
+ export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry, deps) {
264
+ if (!PKG_RE.test(pkg))
265
+ throw new Error(`无效 npm 包名: ${pkg}`);
266
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
267
+ const leg = async (base, ms) => {
268
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: ms, signal });
269
+ const version = typeof data.version === 'string' ? data.version : '';
270
+ if (!version)
271
+ throw new Error(`npm 未返回版本: ${pkg}`);
272
+ return {
273
+ version,
274
+ integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
275
+ tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
276
+ };
277
+ };
278
+ const explicit = registry?.trim();
279
+ if (explicit)
280
+ return leg(explicit, timeoutMs);
281
+ const firstLegMs = Math.min(timeoutMs, 5_000);
282
+ try {
283
+ return await leg(DEFAULT_NPM_REGISTRY, firstLegMs);
284
+ }
285
+ catch (firstErr) {
286
+ const alt = await activeNpmRegistry();
287
+ if (alt === DEFAULT_NPM_REGISTRY)
288
+ throw firstErr;
289
+ return leg(alt, timeoutMs);
290
+ }
291
+ }
292
+ /**
293
+ * 拉取完整 packument(NO_MATCHING_VERSION 退避重试前的预热/校验原语)。
294
+ * 返回该包已知的全部版本号;解析不出 versions 时返回空列表(不抛)。
295
+ * 隐式 registry → 访问器缓存(lenient 两腿面;该路径在 B3 改用 warmPackumentFor 后已无
296
+ * 生产消费方,仅测试触达——G3-2 备案);显式 registry 直取不走缓存。
297
+ */
298
+ export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry, deps) {
299
+ if (!PKG_RE.test(pkg))
300
+ throw new Error(`无效 npm 包名: ${pkg}`);
301
+ const explicit = registry?.trim();
302
+ if (!explicit) {
303
+ const views = await packumentDocViews(pkg, timeoutMs, signal, deps?.fetchJsonLimited ? { fetchJsonLimited: deps.fetchJsonLimited } : undefined);
304
+ if (!views)
305
+ throw new Error(`packument 不可用: ${pkg}`);
306
+ return { versions: views.versions };
307
+ }
308
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
309
+ const data = await fetcher(`${registryBase(explicit)}/${encodeURIComponent(pkg)}`, {
310
+ timeoutMs,
311
+ signal,
312
+ maxBytes: 8 * 1024 * 1024,
313
+ });
314
+ const versions = data?.versions !== null && typeof data?.versions === 'object' ? Object.keys(data.versions) : [];
315
+ return { versions };
316
+ }
317
+ /**
318
+ * npm packument readme 兜底(0.9.45 U10b / 0.9.50 两腿阶梯):腿1 `/latest` 小端点优先
319
+ * (KB 级载荷,超时收紧 `min(timeoutMs,5s)`),readme 命中即短路;空或腿失败落腿2
320
+ * packument。任一腿成功即成功——readme 空渲染「没有 README」(含 latest 空白 readme +
321
+ * packument 失败的降级,不抛);两腿全败抛终末腿(packument)错误。两腿均在生效源。
322
+ * 0.9.86:腿2 隐式 registry 改走访问器「生效源单腿带缓存」面(G2-2 方案 a——腿策略与
323
+ * 现状单腿一致,不换两腿;共享缓存);显式 registry 直取不走缓存。
324
+ */
325
+ export async function npmPackumentReadme(pkg, timeoutMs = 20_000, signal, registry, deps) {
326
+ if (!PKG_RE.test(pkg))
327
+ throw new Error(`无效 npm 包名: ${pkg}`);
328
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
329
+ const explicit = registry?.trim();
330
+ const base = explicit ? registryBase(explicit) : registryBase(await activeNpmRegistry());
331
+ const extractReadme = (data) => typeof data?.readme === 'string' && data.readme.trim() !== '' ? data.readme : '';
332
+ // 腿1:/latest 版本文档(小载荷;超时收紧同 npmLatest 首腿先例)
333
+ let latestOk = false;
334
+ let latestRepo = '';
335
+ try {
336
+ const latestDoc = await fetcher(`${base}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: Math.min(timeoutMs, 5_000), signal, maxBytes: 8 * 1024 * 1024 });
337
+ latestOk = true;
338
+ latestRepo = extractGithubRepo(latestDoc?.repository);
339
+ const latestReadme = extractReadme(latestDoc);
340
+ if (latestReadme)
341
+ return { readme: latestReadme, repo: latestRepo };
342
+ }
343
+ catch {
344
+ // latest 腿失败 → 落 packument 腿(终末腿语义见下)
345
+ }
346
+ // 腿2:packument(终末腿)——隐式 registry 走访问器 Active 面(单腿带缓存 + 错误透传)
347
+ try {
348
+ if (explicit) {
349
+ const data = await fetcher(`${base}/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
350
+ return { readme: extractReadme(data), repo: extractGithubRepo(data?.repository) };
351
+ }
352
+ const views = await packumentDocViewsActive(pkg, timeoutMs, signal, deps?.fetchJsonLimited ? { fetchJsonLimited: deps.fetchJsonLimited } : undefined);
353
+ return { readme: views.readme, repo: views.repo };
354
+ }
355
+ catch (packumentErr) {
356
+ if (latestOk) {
357
+ // latest 到达但 readme 空 + packument 失败 → 降级空 readme(0.9.50 语义,不抛)
358
+ return { readme: '', repo: latestRepo };
359
+ }
360
+ throw packumentErr;
361
+ }
362
+ }
@@ -37,7 +37,7 @@ import { join } from 'node:path';
37
37
  import { webProfileDir } from './env.js';
38
38
  import { isSafePkgName, resolvePluginDir } from './installed.js';
39
39
  import { setLivePluginDisabled } from './live-plugin.js';
40
- import { npmPackument } from './versions.js';
40
+ import { warmPackumentFor } from './packument.js';
41
41
  import { syncNpmmirrorPackage } from './npm-route.js';
42
42
  import { clearAllLatestCache } from './latest-cache.js';
43
43
  import { assertNpmIntegrity, atomicWriteFile, readPnpmLockIntegrity, readPnpmLockOverrides, restoreSnapshots, snapshotFiles, verifySnapshots, } from './npm-integrity.js';
@@ -65,7 +65,11 @@ export const TX_FAILURE_CODES = [
65
65
  * fetcher 参数仅测试注入;失败吞错。deps 未提供且调用方未绑定时,
66
66
  * 事务内 B3 跳过预热仅重试——绝不调用 undefined。
67
67
  */
68
- export function makeNpmWarmPackument(timeoutMs, fetcher = npmPackument) {
68
+ export function makeNpmWarmPackument(timeoutMs,
69
+ // 0.9.86(ADR-0019):缺省 fetcher 改 warmPackumentFor——bypass 缓存 + 生效源单腿真发请求
70
+ // (预热的目的就是暖 CDN)+ 成功回填访问器缓存;abort 由本包装按 R4c 消化(rethrow)、
71
+ // 其余失败吞错(预热尽力而为)不变。
72
+ fetcher = warmPackumentFor) {
69
73
  return async (pkg, signal) => {
70
74
  try {
71
75
  await fetcher(pkg, timeoutMs, signal);
@@ -26,8 +26,7 @@
26
26
  import { readFile } from 'node:fs/promises';
27
27
  import { join } from 'node:path';
28
28
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
29
- import { fetchJsonLimited } from './httpx.js';
30
- import { DEFAULT_NPM_REGISTRY, activeNpmRegistry, onRouteSwitch } from './npm-route.js';
29
+ import { clearPackumentAccessorCache, fetchPackumentDoc, packumentTimes, packumentViews } from './packument.js';
31
30
  /** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
32
31
  export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
33
32
  function scalarNumber(value) {
@@ -148,46 +147,13 @@ export function describeReleaseAgeFailure(input) {
148
147
  lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员把 pnpm-workspace.yaml 中对应的独立精确排除条目改为包名级或复合条目以解除拦截`);
149
148
  return { message: lines.join('\n'), violations, targetViolating: targetViol !== undefined };
150
149
  }
151
- async function fetchPackumentTimes(base, pkg, timeoutMs, signal, fetcher) {
152
- const data = await fetcher(`${base}/${encodeURIComponent(pkg)}`, {
153
- timeoutMs,
154
- signal,
155
- maxBytes: 8 * 1024 * 1024,
156
- });
157
- if (!data || typeof data !== 'object' || data.time === null || typeof data.time !== 'object')
158
- return null;
159
- const out = {};
160
- for (const [k, v] of Object.entries(data.time)) {
161
- if (typeof v === 'string')
162
- out[k] = v;
163
- }
164
- return out;
165
- }
166
150
  /**
167
- * registry packument 的 time 映射(与 npmPackument 同源;失败返回 null,不抛)。
168
- * L3 权威链(ADR-0012):发布时刻是完整性敏感读——npmjs 权威源优先(首腿超时收紧
169
- * `Math.min(timeoutMs, 5_000)`),失败降级生效源;两腿皆败 → null(fail-open 口径不变)。
151
+ * registry packument 的 time 映射(0.9.86 起薄委托 packument 访问器,ADR-0019:两腿阶梯
152
+ * 单份活代码于 fetchDocThrowing)。失败返回 null,不抛;L3 权威链(ADR-0012)语义不变。
170
153
  */
171
154
  export async function npmPackumentTimes(pkg, timeoutMs = 20_000, signal, deps) {
172
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
173
- return null;
174
- const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
175
- const firstLegMs = Math.min(timeoutMs, 5_000);
176
- try {
177
- return await fetchPackumentTimes(DEFAULT_NPM_REGISTRY, pkg, firstLegMs, signal, fetcher);
178
- }
179
- catch {
180
- // 权威源不可达 → 生效源降级
181
- }
182
- const alt = await activeNpmRegistry();
183
- if (alt === DEFAULT_NPM_REGISTRY)
184
- return null;
185
- try {
186
- return await fetchPackumentTimes(alt, pkg, timeoutMs, signal, fetcher);
187
- }
188
- catch {
189
- return null;
190
- }
155
+ const doc = await fetchPackumentDoc(pkg, timeoutMs, signal, deps);
156
+ return doc ? packumentViews(doc).times : null;
191
157
  }
192
158
  /** 读 profile 的 pnpm-workspace.yaml 策略;缺席/不可读 → null(调用方 fail-open)。 */
193
159
  export async function readWorkspacePolicy(profileDir) {
@@ -200,28 +166,20 @@ export async function readWorkspacePolicy(profileDir) {
200
166
  return null;
201
167
  }
202
168
  }
203
- const packumentCache = new Map();
204
- const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
205
- /** L1(ADR-0012):npm 生效源切换 → 作废 packument 缓存(镜像的答案不是官方源的答案)。 */
206
- export function clearPackumentCache() {
207
- packumentCache.clear();
208
- }
209
- onRouteSwitch(() => clearPackumentCache());
210
169
  /**
211
- * 带进程内缓存的 packument time 探测(TTL 5min):预检与登记(exclude-governance)
212
- * 共用——同一操作里预检刚拉过的 packument 不二次拉取(8MiB 级全量)。
170
+ * 0.9.86 薄委托(ADR-0019):本地 Map/TTL/路由注册整体退役——缓存与作废语义由 packument
171
+ * 访问器属主承担(single-flight/TTL 5min/负缓存/cap=4/onRouteSwitch 唯一注册均在彼处)。
213
172
  */
173
+ export function clearPackumentCache() {
174
+ clearPackumentAccessorCache();
175
+ }
176
+ /** 带缓存 packument time 探测(预检与登记共用):薄委托访问器 times 视图,签名与语义不变。 */
214
177
  export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal, deps) {
215
- const hit = packumentCache.get(pkg);
216
- if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
217
- return hit.times;
218
- const times = await npmPackumentTimes(pkg, timeoutMs, signal, deps);
219
- packumentCache.set(pkg, { at: Date.now(), times });
220
- return times;
178
+ return packumentTimes(pkg, timeoutMs, signal, deps);
221
179
  }
222
- /** 测试钩子:清空 packument 进程内缓存。 */
180
+ /** 测试钩子:清空 packument 进程内缓存(现属访问器)。 */
223
181
  export function _resetReleaseAgeCachesForTests() {
224
- packumentCache.clear();
182
+ clearPackumentAccessorCache();
225
183
  }
226
184
  /**
227
185
  * 委派前预检(ADR-0009 收敛语义):仅当策略显式设置 age 或开启 strict 时,对窗口内