@sema-agent/settings-schema 14.7.0 → 14.8.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +58 -2
  3. package/conformance/VERSION +1 -1
  4. package/dist/atomic-write.d.ts +15 -0
  5. package/dist/atomic-write.js +109 -5
  6. package/dist/catalog-manages.d.ts +15 -0
  7. package/dist/catalog-manages.js +60 -0
  8. package/dist/center-refs.d.ts +68 -0
  9. package/dist/center-refs.js +70 -0
  10. package/dist/config-dir-migration.js +1 -0
  11. package/dist/config-fns.js +5 -3
  12. package/dist/config-root-documents.d.ts +106 -0
  13. package/dist/config-root-documents.js +93 -0
  14. package/dist/config-root-node.d.ts +117 -0
  15. package/dist/config-root-node.js +260 -0
  16. package/dist/conformance/run.d.ts +3 -0
  17. package/dist/conformance/run.js +10 -6
  18. package/dist/conformance/temp-roots.d.ts +31 -0
  19. package/dist/conformance/temp-roots.js +184 -0
  20. package/dist/default-model.d.ts +1 -1
  21. package/dist/default-model.js +5 -4
  22. package/dist/env-model-table.d.ts +163 -0
  23. package/dist/env-model-table.js +257 -0
  24. package/dist/fleet.d.ts +25 -7
  25. package/dist/fleet.js +24 -6
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.js +5 -0
  28. package/dist/mcp-request-entry.d.ts +58 -6
  29. package/dist/mcp-request-entry.js +45 -13
  30. package/dist/mcp-server-name.d.ts +17 -0
  31. package/dist/mcp-server-name.js +19 -0
  32. package/dist/migration-receipt.d.ts +10 -6
  33. package/dist/migration-receipt.js +5 -3
  34. package/dist/node.d.ts +1 -0
  35. package/dist/node.js +2 -0
  36. package/dist/one-time-migrations.d.ts +4 -2
  37. package/dist/one-time-migrations.js +29 -1
  38. package/dist/own-key-read.d.ts +1 -1
  39. package/dist/retired-form.d.ts +19 -0
  40. package/dist/retired-form.js +24 -1
  41. package/dist/safety-merge-spec.d.ts +6 -6
  42. package/dist/tier-words.d.ts +33 -0
  43. package/dist/tier-words.js +43 -0
  44. package/dist/types.d.ts +6 -0
  45. package/dist/types.js +24 -0
  46. package/dist/worker-row-verdict.js +7 -1
  47. package/docs/API-SURFACE.md +68 -4
  48. package/docs/CONSUMER-INTEGRATION.md +72 -2
  49. package/llms-full.txt +4 -1
  50. package/llms.txt +9 -3
  51. package/package.json +2 -2
@@ -0,0 +1,260 @@
1
+ /**
2
+ * 配置根两份根级文档的**读写函数**与一次性迁移 SS-M11 的迁移体(Node 专用,经 `./node` 出口;名字、形与闭集答案表在 `./config-root-documents`)。
3
+ *
4
+ * 读写都经一只注入的 I/O 座 {@link ConfigRootIo}(真绑定 {@link nodeConfigRootIo}:读走 `node:fs/promises`,写走本包唯一的原子写器与它的模式表),
5
+ * 所以单元层能把任意读写失败造出来;调用方不传就是真文件系统。
6
+ *
7
+ * · {@link readEnvModelsDecision} / {@link readMigrationMarker} —— 唯一的读者:不存在(`ENOENT`)⇒ `absent`;别的读错(权限 —— 可信属主的 0000 文件过得了引擎的目录信任判官、
8
+ * 读恒 `EACCES`,是**持久**的 —— 不是常规文件、悬空符号链接、瞬时错)⇒ `unreadable io`;字节在 ⇒ 文本读法(`./config-root-documents`;决定的答案带同一次读的 `digest`)。
9
+ * 读数从不抛(`readMigrationMarker` 只对不写运行记录的 id 抛 `TypeError` —— 调用方的错,不拿它拼路径)。**引擎在读之前先过它自己的目录信任判官**(属主 / 写位 / 常规文件);
10
+ * 本函数不判属主。确认文件的字节只在这里被打开。
11
+ * · {@link writeEnvModelsDecision} —— 决定文件的唯一写函数(迁移铸确认也经它):写者(用户确认之后)用 `overwrite`(原子改名覆盖,读不懂的旧文件整份换掉);
12
+ * `createIfAbsent` 不存在才建(已在 ⇒ `present`;迁移铸确认用的就是这一种)。运行记录不经它 —— 由迁移独占创建。
13
+ * 模式与 `config.d` 域文档同一条规则(原子写器的 `config-document` 类:新建 0o644 经进程 umask、更严的 umask 只会更严;已有的文件沿用它原来的模式)—— 写者不要自己写这份文件:
14
+ * 自写在 umask 002 下落 0664,引擎的目录信任判官会拒。
15
+ * · {@link migrateEnvModelsDecision} —— SS-M11(窗与删法见 `ONE_TIME_MIGRATIONS` 登记表那一行)。**先读后选举**:先读决定与 models.json,再以「不存在才建」写
16
+ * 运行记录 `config.migration.SS-M11.json` —— 建成的那个进程是本根唯一的铸造者,且只按它选举之前读到的决定铸;别的进程见记录已在即停、永不铸。
17
+ * 撤销的终局按创建者算(见 {@link migrateEnvModelsDecision}):运行记录在场、创建者的结局落定之后,删掉决定文件与写 `kept` 同义、是终局(删掉运行记录再起服会重跑迁移);其它副本的返回不构成终局。
18
+ */
19
+ import { readFile, stat } from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { createFileExclusive, ensureOwnerOnlyDirectory, resolveSymlinkTarget, writeFileAtomic } from "./atomic-write.js";
22
+ import { catalogManagesModelPlane } from "./catalog-manages.js";
23
+ import { configDDocumentText, configDPath, judgeConfigDText } from "./config-d-file.js";
24
+ import { ENV_MODELS_DECISIONS, ENV_MODELS_DOCUMENT, decodeRootBytes, ioDecision, isDecisionWord, isMarkedMigrationId, migrationMarkerDocument, migrationMarkerValue, readEnvModelsDecisionBytes, readMigrationMarkerBytes, } from "./config-root-documents.js";
25
+ import { ENV_MODEL_TABLE_KEYS, buildEnvModelBaseline, snapshotEnvModelTable } from "./env-model-table.js";
26
+ import { FsFailure, fsFailureMessage } from "./fs-failure.js";
27
+ import { readKey } from "./own-key-read.js";
28
+ import { jsonShapeOf } from "./plain-json-object.js";
29
+ /** 根级文档 → 原子写器的模式类(穷举:加一份根级文档不补这一行 ⇒ 编译红,逼着为它选一类)。两份现有的根级文档都是契约文档(只带固定词,不带秘密值),与 `config.d` 域文档同属 `config-document`。 */
30
+ const ROOT_DOCUMENT_CLASS = {
31
+ [ENV_MODELS_DOCUMENT]: "config-document",
32
+ [migrationMarkerDocument("SS-M11")]: "config-document",
33
+ };
34
+ /** 真文件系统的绑定:读先过本包的符号链接判法(链接 ⇒ 它指向的真文件;悬空 ⇒ 抛,不是缺席),只读常规文件;写走本包唯一的原子写器。 */
35
+ export const nodeConfigRootIo = {
36
+ readFile: async (file) => {
37
+ const real = resolveSymlinkTarget(file);
38
+ const st = await stat(real);
39
+ if (!st.isFile())
40
+ throw new Error(`${file} is not a regular file`);
41
+ return readFile(real);
42
+ },
43
+ writeDocument: async (document, file, text) => writeFileAtomic(file, text, ROOT_DOCUMENT_CLASS[document]),
44
+ createDocument: async (document, file, text) => createFileExclusive(file, text, ROOT_DOCUMENT_CLASS[document]),
45
+ ensureDirectory: async (dir) => ensureOwnerOnlyDirectory(dir),
46
+ };
47
+ const decisionFile = (root) => path.join(root, `${ENV_MODELS_DOCUMENT}.json`);
48
+ const markerFile = (root, id) => path.join(root, `${migrationMarkerDocument(id)}.json`);
49
+ /** 一个被抛出的东西的 errno:**只认自有的**字符串 `code`(经内核的键读法);读不出(会抛的 `code` getter、已撤销的 Proxy)、只靠继承、不是串 ⇒ `undefined`。从不抛(读者「从不抛」靠它)。 */
50
+ const errnoOf = (e) => {
51
+ const shape = jsonShapeOf(e); // 形状只问座位(总、不抛);穷举 switch:座位加一个词这里编译红
52
+ switch (shape) {
53
+ case "plain":
54
+ case "non_plain_object": {
55
+ const code = readKey(e, "code"); // Error 实例是 non_plain_object;读不出 ⇒ unreadable(不抛)
56
+ return code.presence === "own" && typeof code.value === "string" ? code.value : undefined;
57
+ }
58
+ case "array":
59
+ case "null":
60
+ case "primitive":
61
+ case "uninspectable": // 检视不了(撤销的 Proxy / 陷阱抛)的东西没有 errno,不是读者的错
62
+ return undefined;
63
+ default: {
64
+ const unhandled = shape;
65
+ throw new Error(`errnoOf: unhandled shape ${String(unhandled)}`);
66
+ }
67
+ }
68
+ };
69
+ /** 配置根下一份文件(两份根级文档与 `config.d/models.json`)的字节 —— 本文件唯一的缺席律:不存在(只有 `ENOENT`)⇒ `absent`;
70
+ * 别的读不出 ⇒ `io`(带 errno 与捕获的错,调用方要句子时据它铸)。 */
71
+ async function readRootBytes(io, file) {
72
+ try {
73
+ return { kind: "bytes", bytes: await io.readFile(file) };
74
+ }
75
+ catch (e) {
76
+ const errno = errnoOf(e);
77
+ return errno === "ENOENT" ? { kind: "absent" } : { kind: "io", errno, cause: e };
78
+ }
79
+ }
80
+ /** 配置根上 env 模型表决定的**唯一读者**(闭集答案见 `EnvModelsDecision`;从字节读出的答案带同一次读的 `digest`,io 带 `errno`)。从不抛。 */
81
+ export async function readEnvModelsDecision(root, io = nodeConfigRootIo) {
82
+ const bytes = await readRootBytes(io, decisionFile(root));
83
+ switch (bytes.kind) {
84
+ case "absent":
85
+ return { kind: "absent" };
86
+ case "io":
87
+ return ioDecision(bytes.errno);
88
+ case "bytes":
89
+ return readEnvModelsDecisionBytes(bytes.bytes);
90
+ default: {
91
+ const unhandled = bytes;
92
+ throw new Error(`readEnvModelsDecision: unhandled reading ${JSON.stringify(unhandled)}`);
93
+ }
94
+ }
95
+ }
96
+ /**
97
+ * 一条一次性迁移的运行记录的**唯一读者**(文件 `<根>/config.migration.<id>.json`):不存在 ⇒ `absent`;`{"migration":<id>}` ⇒ `present`;别的 ⇒ `unreadable`
98
+ * (坏 JSON / 坏形 —— id 对不上也算 / 读不出)。「跑过」只问这一份文件在不在、读不读得懂,不读任何聚合文件。读数从不抛;`id` 不是写运行记录的迁移(根级文档名单里没有它的记录)⇒
99
+ * `TypeError`,不拿它拼路径。
100
+ */
101
+ export async function readMigrationMarker(root, id, io = nodeConfigRootIo) {
102
+ if (!isMarkedMigrationId(id))
103
+ throw new TypeError(`readMigrationMarker: ${JSON.stringify(id)} is not a migration that writes a run marker (the root documents list it) — no path is built from it`);
104
+ const bytes = await readRootBytes(io, markerFile(root, id));
105
+ switch (bytes.kind) {
106
+ case "absent":
107
+ return { kind: "absent" };
108
+ case "io":
109
+ return { kind: "unreadable", reason: "io" };
110
+ case "bytes":
111
+ return readMigrationMarkerBytes(id, bytes.bytes);
112
+ default: {
113
+ const unhandled = bytes;
114
+ throw new Error(`readMigrationMarker: unhandled reading ${JSON.stringify(unhandled)}`);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * 写配置根上的 env 模型表决定 —— 决定文件的**唯一写函数**(迁移铸确认也经它的 `createIfAbsent`;运行记录不归它,见文件头)。`overwrite` 恒答 `written`;`createIfAbsent` 在文档已在(`EEXIST`)时答 `present`(一字不动)。
120
+ * 决定词 / 写法不在闭集 ⇒ `TypeError`(一字不写)。写失败 ⇒ 抛本包的文件系统失败句(点名路径)。根须已存在。
121
+ */
122
+ export async function writeEnvModelsDecision(root, decision, opts, io = nodeConfigRootIo) {
123
+ if (!isDecisionWord(decision))
124
+ throw new TypeError(`writeEnvModelsDecision: decision must be one of ${ENV_MODELS_DECISIONS.join(" / ")} (got ${JSON.stringify(decision)})`);
125
+ const file = decisionFile(root);
126
+ const text = configDDocumentText({ envModels: decision });
127
+ const loud = (e) => {
128
+ throw e instanceof FsFailure ? e : new FsFailure({ landing: "write", path: file }, e);
129
+ };
130
+ const write = opts.write;
131
+ switch (write) {
132
+ case "overwrite":
133
+ await io.writeDocument(ENV_MODELS_DOCUMENT, file, text).catch(loud);
134
+ return "written";
135
+ case "createIfAbsent":
136
+ return (await io.createDocument(ENV_MODELS_DOCUMENT, file, text).catch(loud)) === "created" ? "written" : "present";
137
+ default: {
138
+ const unhandled = write;
139
+ throw new TypeError(`writeEnvModelsDecision: write must be "overwrite" or "createIfAbsent" (got ${JSON.stringify(unhandled)})`);
140
+ }
141
+ }
142
+ }
143
+ /** 手工恢复句(迁移建不成收据 / 建不成决定时随结局交出;调用方原样放进它那一行告警):点名决定文件与两种写法。 */
144
+ const wayOutFor = (root) => `write ${decisionFile(root)} by hand — {"envModels":"replaced"} lets the catalog replace the env models, {"envModels":"kept"} keeps them`;
145
+ /** 读 `config.d/models.json` 一次(字节经 {@link readRootBytes} —— 与两份根级文档同一条缺席律;文本过 `config.d` 文档的那一只文本判 —— 与文件仓库同一读法):
146
+ * 缺席 / 坏文件 ⇒ 不管;别的读错 ⇒ 读不出(不折成不管)。 */
147
+ async function readModelsDocument(io, root) {
148
+ const file = configDPath(root, "models");
149
+ const read = await readRootBytes(io, file);
150
+ let bytes;
151
+ switch (read.kind) {
152
+ case "absent":
153
+ return { kind: "not_managing" };
154
+ case "io":
155
+ return { kind: "unreadable", errno: read.errno, error: fsFailureMessage({ landing: "read", path: file, of: "file" }, read.cause) };
156
+ case "bytes":
157
+ bytes = read.bytes;
158
+ break;
159
+ default: {
160
+ const unhandled = read;
161
+ throw new Error(`readModelsDocument: unhandled reading ${JSON.stringify(unhandled)}`);
162
+ }
163
+ }
164
+ let doc;
165
+ try {
166
+ doc = judgeConfigDText("models", decodeRootBytes(bytes), file);
167
+ }
168
+ catch {
169
+ return { kind: "not_managing" }; // a bad file manages nothing; the file store refuses it loudly on its own read
170
+ }
171
+ return catalogManagesModelPlane(doc) ? { kind: "manages" } : { kind: "not_managing" }; // JSON 解析出来的值恒是纯数据:判官不会抛
172
+ }
173
+ /**
174
+ * **一次性迁移 SS-M11**(登记见 `ONE_TIME_MIGRATIONS`;起服、第一次读本地配置之前调,只在配置源是本地时):
175
+ * 1. 读运行记录:`present` ⇒ `already_ran`;读不懂 ⇒ `marker_unreadable`(按已跑处理)。
176
+ * 2. **先读**:决定 D0 = `readEnvModelsDecision`,M0 = `config.d/models.json` 管不管模型面(管 / 不管 / 读不出)。此后的铸只按 D0 / M0 决定,不再读决定 ——
177
+ * D0 是 replaced(预置)而它在选举之后被删 ⇒ 不重铸;D0 是 absent 而选举之后有人写了决定 ⇒ 第 5 步的独占创建守住它。
178
+ * 3. 根缺席 ⇒ 建(仅属主可进,连同缺的上级,模式恰是 0o700);**不建 `config.d`**。
179
+ * 4. **独占创建运行记录** `{"migration":"SS-M11"}`:建成 ⇒ 本进程是本根**唯一的铸造者**;已在(`EEXIST`)⇒ `not_minter`,永不铸;别的失败 ⇒ `marker_write_failed`,不铸。
180
+ * 5. 仅铸造者:D0 不是 `absent` ⇒ 不铸(读不懂的也不覆盖);M0 不管 ⇒ 不铸;M0 读不出 ⇒ 不铸(`models_unreadable`,带 errno(若有)与手工恢复句);否则**独占创建** `{"envModels":"replaced"}` —— 结局三词:`minted` / `exists`(只有 `EEXIST`)/
181
+ * `failed`(带 errno 与手工恢复句)。铸不铸不看 env;env 只在铸成之后读一次、只读两槽,用来点名被顶替的 env 模型。
182
+ * 运行记录在场之后,确认缺席 ≡ kept(读这份决定的一律这么读:引擎强制这道闸之后、迁移、写者)。
183
+ * 撤销的终局按运行记录的创建者算:只有创建者能写确认;以运行记录在场为前提,创建者写完 / 判定不写 / 失败(结局 `minted` / `exists` / `skipped` / `models_unreadable` / `failed`,调用方各记一行)之后,删掉决定文件就是撤销且是终局(删掉运行记录再起服 ⇒ 迁移重跑,决定缺席即重铸);其它副本读到记录在场 = 已跑、跳过(或得 `EEXIST` 答 `not_minter`),它们的返回不构成终局;创建者当选到结局落定之间的窗口在挂起 / I/O 卡顿下无上界,窗口内唯一安全的撤销是写 `kept`(独占创建护住它;删掉的会被创建者照它先读的 D0 铸回);创建者在写确认之前崩溃 ⇒ 永久 `kept`(fail-closed,披露)。
184
+ */
185
+ export async function migrateEnvModelsDecision(input) {
186
+ const { root, env } = input;
187
+ const io = input.io ?? nodeConfigRootIo;
188
+ const marker = await readMigrationMarker(root, "SS-M11", io);
189
+ switch (marker.kind) {
190
+ case "present":
191
+ return { kind: "already_ran" };
192
+ case "unreadable":
193
+ return { kind: "marker_unreadable", reason: marker.reason };
194
+ case "absent":
195
+ break;
196
+ default: {
197
+ const unhandled = marker;
198
+ throw new Error(`migrateEnvModelsDecision: unhandled marker ${JSON.stringify(unhandled)}`);
199
+ }
200
+ }
201
+ const decision = await readEnvModelsDecision(root, io);
202
+ const models = await readModelsDocument(io, root);
203
+ const file = markerFile(root, "SS-M11");
204
+ let createdRoot;
205
+ let created;
206
+ try {
207
+ createdRoot = (await io.ensureDirectory(root)) === "created";
208
+ created = await io.createDocument(migrationMarkerDocument("SS-M11"), file, configDDocumentText(migrationMarkerValue("SS-M11")));
209
+ }
210
+ catch (e) {
211
+ return { kind: "marker_write_failed", errno: errnoOf(e), error: fsFailureMessage({ landing: "write", path: file }, e), wayOut: wayOutFor(root) };
212
+ }
213
+ if (created === "present")
214
+ return { kind: "not_minter" };
215
+ return { kind: "recorded", createdRoot, decision, mint: await mintDecision(io, root, env, decision, models) };
216
+ }
217
+ async function mintDecision(io, root, env, decision, models) {
218
+ switch (decision.kind) {
219
+ case "absent":
220
+ break;
221
+ case "replaced":
222
+ case "kept":
223
+ case "unreadable":
224
+ return { kind: "skipped", why: "decision_present" };
225
+ default: {
226
+ const unhandled = decision;
227
+ throw new Error(`migrateEnvModelsDecision: unhandled decision ${JSON.stringify(unhandled)}`);
228
+ }
229
+ }
230
+ switch (models.kind) {
231
+ case "manages":
232
+ break;
233
+ case "not_managing":
234
+ return { kind: "skipped", why: "not_managing" };
235
+ case "unreadable":
236
+ return { kind: "models_unreadable", errno: models.errno, error: models.error, wayOut: wayOutFor(root) };
237
+ default: {
238
+ const unhandled = models;
239
+ throw new Error(`migrateEnvModelsDecision: unhandled models reading ${JSON.stringify(unhandled)}`);
240
+ }
241
+ }
242
+ let wrote;
243
+ try {
244
+ wrote = await writeEnvModelsDecision(root, "replaced", { write: "createIfAbsent" }, io);
245
+ }
246
+ catch (e) {
247
+ // the write function throws only its file-system failure (it names the decision file and carries the errno)
248
+ return { kind: "failed", errno: errnoOf(e), error: fsFailureMessage({ landing: "write", path: decisionFile(root) }, e), wayOut: wayOutFor(root) };
249
+ }
250
+ if (wrote === "present")
251
+ return { kind: "exists" };
252
+ // env is read only now, once, and only the two slot keys: it names the env models the minted decision replaces (none when no table is declared);
253
+ // a run that stops earlier reads no env at all
254
+ const slots = snapshotEnvModelTable(env, [ENV_MODEL_TABLE_KEYS.mainId, ENV_MODEL_TABLE_KEYS.cheapId]);
255
+ return {
256
+ kind: "minted",
257
+ receipt: { migration: "SS-M11", document: ENV_MODELS_DOCUMENT, path: ["envModels"], appliedAs: "minted" },
258
+ replacedEnvModels: buildEnvModelBaseline(slots).entries,
259
+ };
260
+ }
@@ -6,6 +6,9 @@ import { type ConformanceAdapter, type ConformanceOptions, type ConformanceRepor
6
6
  * mtime 怎么设、目录怎么建就会各不相同,而 `updatedAt` 正是按 mtime 算的)。
7
7
  *
8
8
  * 返回 `root` 与 `cleanup`;所有文件的 mtime 统一设成 `mtime`,于是 `updatedAt` 对同一份快照恒定。
9
+ *
10
+ * 临时根只在 `./temp-roots` 里铸、只在那里删:进程在 `cleanup()` 之前死掉(`process.exit`、未捕获异常、信号、SIGKILL)时根留在 tmp 下,
11
+ * 根里的 `.alive` 超过 60 分钟没更新之后,由下一轮第一次铸根的清扫收走 —— 这里不碰临时目录的路径、前缀和清理,也不在进程上装任何监听。
9
12
  */
10
13
  export declare function materializeLocalRoot(files: Readonly<Record<string, string>>, mtime: string): Promise<{
11
14
  root: string;
@@ -11,8 +11,7 @@
11
11
  * 比对分三面,刻意不混:**effective**(剥掉元数据后结构化深相等)、**告警多重集**、**元数据**
12
12
  * (`version`/`updatedAt` 由 harness 注入或由腿自己算,独立断言 —— 不让 canonical 把它盖过去)。
13
13
  */
14
- import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile, utimes } from "node:fs/promises";
15
- import { tmpdir } from "node:os";
14
+ import { mkdir, readFile, realpath, stat, writeFile, utimes } from "node:fs/promises";
16
15
  import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
17
16
  import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "../plain-json-object.js";
18
17
  import { canonicalize, deepEqual, diffJson, stableKey } from "./canonical.js";
@@ -20,6 +19,7 @@ import { diffWarningMultiset, findSecretLeaks, projectWarning } from "./warnings
20
19
  import { deriveLocalExpectation, deriveLocalInput, deriveWireExpectation, deriveWireInput, toManifestDoc } from "./derive.js";
21
20
  import { kitIdentity, loadCorpus } from "./corpus.js";
22
21
  import { contractOwnedEntry, contractOwnedEntryWhy } from "./entry-origin.js";
22
+ import { mintLocalRoot, removeLocalRoot } from "./temp-roots.js";
23
23
  import { LEDGER_REF_RULE, LEG_DECLINE_REASONS, LEG_NAMES, } from "./types.js";
24
24
  /** 一个**模块**文件的样子 —— 来源律用它挡住「随便找个存在的文件当入口」(`package.json` 也是文件)。 */
25
25
  const MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".tsx", ".js", ".mjs", ".cjs", ".jsx"];
@@ -28,16 +28,20 @@ const MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".tsx", ".js", ".mjs", ".cjs",
28
28
  * mtime 怎么设、目录怎么建就会各不相同,而 `updatedAt` 正是按 mtime 算的)。
29
29
  *
30
30
  * 返回 `root` 与 `cleanup`;所有文件的 mtime 统一设成 `mtime`,于是 `updatedAt` 对同一份快照恒定。
31
+ *
32
+ * 临时根只在 `./temp-roots` 里铸、只在那里删:进程在 `cleanup()` 之前死掉(`process.exit`、未捕获异常、信号、SIGKILL)时根留在 tmp 下,
33
+ * 根里的 `.alive` 超过 60 分钟没更新之后,由下一轮第一次铸根的清扫收走 —— 这里不碰临时目录的路径、前缀和清理,也不在进程上装任何监听。
31
34
  */
32
35
  export async function materializeLocalRoot(files, mtime) {
33
36
  // 闸**先于**任何 fs 动作:一个坏名字不许留下半个临时根(「建了再清」比这多一条「清到一半又抛」的路)。
34
37
  for (const name of Object.keys(files))
35
38
  assertBareFileName(name);
36
- const root = await mkdtemp(join(tmpdir(), "settings-conformance-"));
39
+ const root = await mintLocalRoot();
37
40
  const dir = join(root, "config.d");
38
- await mkdir(dir, { recursive: true });
39
41
  const when = new Date(mtime);
40
42
  try {
43
+ // 不递归:根是刚铸出来的,只差这一层;根若已不在,这里响着失败(失败即清根),不顺手把根重建成一个不是 `mintLocalRoot` 铸出来的目录
44
+ await mkdir(dir);
41
45
  for (const [name, text] of Object.entries(files)) {
42
46
  const p = join(dir, name);
43
47
  // 上面那条单段律要保证的**就是**这条包含律。它是承重面上的断言,不是可跳过的装饰:真要有一天
@@ -50,10 +54,10 @@ export async function materializeLocalRoot(files, mtime) {
50
54
  }
51
55
  }
52
56
  catch (e) {
53
- await rm(root, { recursive: true, force: true });
57
+ await removeLocalRoot(root);
54
58
  throw e;
55
59
  }
56
- return { root, cleanup: () => rm(root, { recursive: true, force: true }) };
60
+ return { root, cleanup: () => removeLocalRoot(root) };
57
61
  }
58
62
  /**
59
63
  * 语料落盘的**文件名闸**。语料是仓外贡献面(每条 case 的 `input.json` 里的 `localFiles` 键,以及
@@ -0,0 +1,31 @@
1
+ /** 临时根的目录名前缀;名字 = 前缀 + `mkdtemp` 的六位随机(清扫只认这一种形)。 */
2
+ export declare const LOCAL_ROOT_PREFIX = "settings-conformance-";
3
+ /** 根内的心跳文件名:静态为空的文件,活进程定时(同一个 inode 才写)写一个字节再截回空、mtime 由文件系统自己打;清扫只看它的 mtime 年龄。 */
4
+ export declare const LOCAL_ROOT_ALIVE_FILE = ".alive";
5
+ /**
6
+ * 旧件的年龄线:60 分钟 —— 根里 `.alive` 的 mtime 早于它,清扫才删这个根。一个 case 的一条本地腿只要几毫秒、整套语料也只要几秒,60 分钟远长于一次正常的跑,
7
+ * 所以心跳停了这么久的根绝大多数是上一轮被收走的残件;线又短到清扫能及时收走它们,不让它们一天天攒。它必须远大于 {@link ALIVE_TOUCH_INTERVAL_MS},事件循环照常转的活进程的 `.alive` 才不会老到这条线。
8
+ * 具名常量,不是旋钮。
9
+ */
10
+ export declare const STALE_LOCAL_ROOT_AGE_MS: number;
11
+ /** 心跳间隔:5 分钟 —— 活进程每隔这么久在根里的 `.alive` 上写一个字节再截回空(文件系统给它打新的 mtime)。它必须远小于 {@link STALE_LOCAL_ROOT_AGE_MS}(现为十二分之一),偶尔一拍慢了也不会让活进程的 `.alive` 老过年龄线。具名常量,不是旋钮。 */
12
+ export declare const ALIVE_TOUCH_INTERVAL_MS: number;
13
+ /**
14
+ * 清扫 `dir` 下的旧临时根:名字恰是「前缀 + 六位字母数字」、`lstat` 是目录(符号链接与文件一概不动)、属主是当前用户(有 `getuid` 的平台)、
15
+ * 根内有 `.alive` 且 `lstat` 是常规文件(目录、符号链接都不算)、`nowMs - <.alive 的 mtime>` 超过 `maxAgeMs` ⇒ 整根删。没有 `.alive` 的一概不动。
16
+ * 不问进程号、不问主机名 —— 活性证据只有 `.alive` 的 mtime 年龄。返回被删的目录名。读不了 `dir` / 删不掉某一个 ⇒ 跳过,不抛。
17
+ */
18
+ export declare function sweepStaleLocalRoots(opts: {
19
+ readonly dir: string;
20
+ readonly nowMs: number;
21
+ readonly maxAgeMs?: number;
22
+ }): Promise<string[]>;
23
+ /**
24
+ * 铸一个临时根:根内建空文件 `.alive`、记下它的身份就关(本模块按路径建它只此一处)、起它的心跳定时器;本进程第一次铸根时先清扫旧件(见文件头 ①)。
25
+ * 铸出这个根之前任何一步失败(建 `.alive`、起定时器),都回滚到什么也没留下 —— 停定时器、删根,再把原错抛出去。返回根的绝对路径。
26
+ */
27
+ export declare function mintLocalRoot(): Promise<string>;
28
+ /**
29
+ * 删一个临时根:先停它的心跳定时器,再只删这一个(删不掉就抛,留下的根等 `.alive` 超过 60 分钟没更新之后的旧件清扫收)。不开不关任何文件;还在飞的一拍不等它。
30
+ */
31
+ export declare function removeLocalRoot(root: string): Promise<void>;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 符合性 kit 的临时根:唯一铸点 + 两道收口 —— 包内私有(不进 `./conformance` 的导出面)。
3
+ *
4
+ * `materializeLocalRoot` 每个 case 建一个 `<tmp>/settings-conformance-<随机>/`(`mkdtemp` 直铸的形);正常路径上由适配器 `finally` 里的 `cleanup()` 删。
5
+ * 进程在两者之间死掉(`process.exit`、未捕获异常、信号、SIGKILL)时 `finally` 不跑,根就留在宿主 tmp 下 —— 语料一大就是成千上万个残件。两道收口:
6
+ * ① **旧件清扫**:活性证据是**根里的心跳文件**。铸根时在根内以 `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` 建一个空文件 {@link LOCAL_ROOT_ALIVE_FILE}(`.alive`,静态为空),`fstat` 记下它的 `(dev, ino)` 就**关掉**;再起一只 `unref()` 的定时器,
7
+ * 每 {@link ALIVE_TOUCH_INTERVAL_MS}(5 分钟)一拍:按路径 `O_WRONLY | O_NOFOLLOW | O_NONBLOCK` 打开 `.alive`(不带 `O_CREAT`;`O_NONBLOCK` 让被换成没有读端的命名管道的 `.alive` 当场打不开〔`ENXIO`〕作罢,不在文件系统线程池里一直阻塞)、`fstat` 比 `(dev, ino)`,**相等才**写一个字节(换行,空白)再截回空,mtime 由文件系统自己打(两步都刷新它),写完关掉;不相等就关掉、这一拍不写;
8
+ * **心跳按 inode 身份写、不持句柄**:规则只有一条「同一 inode 才写」—— `.alive` 被换成符号链接(有 `O_NOFOLLOW` 的平台上打开就被拒、这一拍作罢;没有它的平台上打开会跟过去,身份比对仍拦住指向别的文件的链接)、被硬链接到别的文件、被换成另一个文件或目录、被删,或根被删后同名重建,心跳都不写别的文件,也不把任何路径建出来(打开不带 `O_CREAT`);
9
+ * 身份是文件系统的 `(dev, ino)`:要让它错认,得先在这个根(`mkdtemp` 铸出的、只有属主进得去的目录)里删掉 `.alive`、再让另一个文件拿到同一个 inode 号并放在这个名字下 —— 能做到这一步的,本来就能直接写这个根里的任何文件;
10
+ * **模块里没有常驻的文件描述符**:铸根时那一只用完即关,每一拍那一只也是一拍一开一关,所以一次铸许多个根、`cleanup()` 留到最后统一调的调用方,铸多少个根都不因此多占描述符;
11
+ * 进程以任何方式死掉,心跳随它停,`.alive` 就一分钟一分钟变老。本进程第一次铸根时清扫:名字恰是 `<前缀><六位随机>`、`lstat` 是目录、属主是当前用户、根内有 `.alive` 常规文件、
12
+ * 且 `.alive` 的 mtime 早于 {@link STALE_LOCAL_ROOT_AGE_MS}(60 分钟)的旧件,整根删 —— 规则只有这一条。
13
+ * 这管所有进程自己没 cleanup 的死法:根都留着,`.alive` 超过 60 分钟没更新之后,由下一轮第一次铸根时的清扫收。
14
+ * **不问进程号、不问主机名**:证据与文件系统同域,共享同一 tmp 的几个进程、几个容器(跨 pid 命名空间也一样)读到的是同一个答案 —— 谁的 `.alive` 在更新,谁的根就不被清。
15
+ * 根里没有 `.alive` 的(更早的版本铸的、运维手造的同形目录)一概不动,拿不准就不删。
16
+ * **限制**:心跳停了 60 分钟的活进程,它的根会被别的进程当作死进程的根清掉 —— 心跳停有两种:事件循环停转超过 60 分钟(被 `SIGSTOP` 暂停、容器被冻结、停在调试器断点上);
17
+ * 心跳连续 60 分钟写不进去(文件系统写满、被重挂成只读 —— 这一拍作罢、不抛、不报)。
18
+ * {@link ALIVE_TOUCH_INTERVAL_MS} 与 {@link STALE_LOCAL_ROOT_AGE_MS} 是源里的具名常量、读得到,**不是旋钮**(没有 env、没有参数改它们)。
19
+ * **`.alive` 静态为空,只给清扫看**:`loadLocalConfig` 只按名读 `config.d/<域>.json`、`config.d/remote-exec.json` 与 `.env`,列文件只列 `config.d/`,所以它不被自动读到;谁在 `.env` 里用 `file:` 显式引用它,读到的是空文件、被拒(`file:` 引用不接受去掉首尾空白后为空的文件,与引用一个不存在的文件同拒),
20
+ * 不会解析成一个值。心跳写进去的那一个字节是换行(空白),只存在于心跳那两步之间;截回失败或进程恰在两步之间死掉时它会留在文件里,但 `file:` 引用读到后会去掉首尾空白,留着一个换行的文件去掉首尾空白后仍为空,照样被拒。
21
+ * 心跳一拍可能卡在文件系统上(挂住的网络盘):上一拍还没做完时,到期的这一拍直接跳过(每个根一面在飞旗)—— 不叠写、不排队;挂住的那一拍做完(成功或失败)后旗复位,心跳照常。
22
+ * 还有四条边界:宿主进程活着时,它铸出而没 cleanup 的根一直带着新鲜的 `.alive`,清扫不碰(进程死后才开始变老);
23
+ * 宿主活着却没 `cleanup()` 的根,它的心跳定时器(`unref`,不拖住进程退出)每 {@link ALIVE_TOUCH_INTERVAL_MS} 开写一次、直到进程结束 —— 代价是一次一字节的小文件写(一拍一开一关,不占常驻的文件描述符),补偿是调 `cleanup()`(契约本就要求);
24
+ * 定时器取自 `node:timers`:只接管全局 `setInterval` 的伪计时器(vitest 的 `vi.useFakeTimers()` 默认如此)接管不了它;连 `timers` 模块一起接管、且先于本包首次 import 装上的伪计时器库会把这只心跳接过去,跑尽定时器的调用(如 `runAll`)会撞上它 ——
25
+ * 补偿是跑尽定时器之前先调 `cleanup()`(契约本就要求;它清掉的正是这只心跳);
26
+ * `mkdtemp` 与写 `.alive` 之间被杀的进程留下一个没有 `.alive` 的根,清扫不认它;
27
+ * ② **正常路径**:{@link removeLocalRoot}(`cleanup()` 调它)停这个根自己的心跳定时器、再删根(只删自己的根;不开不关任何文件;还在飞的一拍随后在已摘出目录的文件上做完、或打不开而落空,被吞掉)。
28
+ *
29
+ * 本模块不在进程上装任何监听:不碰 `exit`,也不碰 `SIGINT` / `SIGTERM` —— 宿主自己的 `exit` / 信号处理器(含排空、含装在铸根之后的)一概不受影响,
30
+ * 宿主的 `exit` 处理器里照样读得到根。死法的收口只有旧件清扫这一种,没有第二套机制;心跳定时器不是进程监听,且 `unref`,不拖住进程退出。
31
+ *
32
+ * 清扫是**尽力而为**:删不掉的(别的用户的、没权限的)留着,之后的清扫再试,绝不让一次清扫失败改变一次符合性结论。
33
+ * 本模块不读进程的钟:清扫的「现在」是刚铸出的根目录的 mtime(建 `.alive` 那一刻由文件系统打),心跳是在 `.alive` 上写一个字节再截回空、mtime 同样由文件系统打 ——
34
+ * 与它比的 `.alive` 的 mtime 和它的「现在」是同一只钟(文件系统的),所以进程自己的钟拨偏多少都不改变清扫的答案。
35
+ */
36
+ import { constants, lstat, mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
37
+ import { tmpdir } from "node:os";
38
+ import { join } from "node:path";
39
+ import { clearInterval, setInterval } from "node:timers"; // 心跳定时器取自模块,不取全局:只接管全局的伪计时器(vitest 的 `vi.useFakeTimers()` 默认如此)接管不了这只永不停的间隔(连模块一起接管的,见头注)
40
+ /** 临时根的目录名前缀;名字 = 前缀 + `mkdtemp` 的六位随机(清扫只认这一种形)。 */
41
+ export const LOCAL_ROOT_PREFIX = "settings-conformance-";
42
+ /** 根内的心跳文件名:静态为空的文件,活进程定时(同一个 inode 才写)写一个字节再截回空、mtime 由文件系统自己打;清扫只看它的 mtime 年龄。 */
43
+ export const LOCAL_ROOT_ALIVE_FILE = ".alive";
44
+ /** 心跳每一拍写进 `.alive` 的那一个字节:换行(空白)—— `file:` 引用读到后会去掉首尾空白再判空,所以它留在文件里多久,引用 `.alive` 都读到空 ⇒ 被拒,不会解析成值;写完立即截回空(文件静态为空,铸根时不写它)。 */
45
+ const ALIVE_BYTE = "\n";
46
+ /**
47
+ * 旧件的年龄线:60 分钟 —— 根里 `.alive` 的 mtime 早于它,清扫才删这个根。一个 case 的一条本地腿只要几毫秒、整套语料也只要几秒,60 分钟远长于一次正常的跑,
48
+ * 所以心跳停了这么久的根绝大多数是上一轮被收走的残件;线又短到清扫能及时收走它们,不让它们一天天攒。它必须远大于 {@link ALIVE_TOUCH_INTERVAL_MS},事件循环照常转的活进程的 `.alive` 才不会老到这条线。
49
+ * 具名常量,不是旋钮。
50
+ */
51
+ export const STALE_LOCAL_ROOT_AGE_MS = 60 * 60 * 1000;
52
+ /** 心跳间隔:5 分钟 —— 活进程每隔这么久在根里的 `.alive` 上写一个字节再截回空(文件系统给它打新的 mtime)。它必须远小于 {@link STALE_LOCAL_ROOT_AGE_MS}(现为十二分之一),偶尔一拍慢了也不会让活进程的 `.alive` 老过年龄线。具名常量,不是旋钮。 */
53
+ export const ALIVE_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
54
+ const OWN_NAME = new RegExp(`^${LOCAL_ROOT_PREFIX}[A-Za-z0-9]{6}$`);
55
+ /**
56
+ * 清扫 `dir` 下的旧临时根:名字恰是「前缀 + 六位字母数字」、`lstat` 是目录(符号链接与文件一概不动)、属主是当前用户(有 `getuid` 的平台)、
57
+ * 根内有 `.alive` 且 `lstat` 是常规文件(目录、符号链接都不算)、`nowMs - <.alive 的 mtime>` 超过 `maxAgeMs` ⇒ 整根删。没有 `.alive` 的一概不动。
58
+ * 不问进程号、不问主机名 —— 活性证据只有 `.alive` 的 mtime 年龄。返回被删的目录名。读不了 `dir` / 删不掉某一个 ⇒ 跳过,不抛。
59
+ */
60
+ export async function sweepStaleLocalRoots(opts) {
61
+ const maxAgeMs = opts.maxAgeMs ?? STALE_LOCAL_ROOT_AGE_MS;
62
+ const uid = process.getuid?.();
63
+ const removed = [];
64
+ let names;
65
+ try {
66
+ names = await readdir(opts.dir);
67
+ }
68
+ catch {
69
+ return removed;
70
+ }
71
+ for (const name of names) {
72
+ if (!OWN_NAME.test(name))
73
+ continue;
74
+ const path = join(opts.dir, name);
75
+ try {
76
+ const st = await lstat(path);
77
+ if (!st.isDirectory() || (uid !== undefined && st.uid !== uid))
78
+ continue;
79
+ const alive = await lstat(join(path, LOCAL_ROOT_ALIVE_FILE)); // 没有 `.alive` ⇒ 抛 ENOENT ⇒ 跳过(没有活性证据,不删)
80
+ if (!alive.isFile() || opts.nowMs - alive.mtimeMs <= maxAgeMs)
81
+ continue;
82
+ await rm(path, { recursive: true, force: true });
83
+ removed.push(name);
84
+ }
85
+ catch {
86
+ // 尽力而为(见文件头):留着,之后的清扫再试
87
+ }
88
+ }
89
+ return removed;
90
+ }
91
+ /** 本进程铸出、还没删的根 → 它的心跳。 */
92
+ const heartbeats = new Map();
93
+ /**
94
+ * 建 `.alive`:`O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`(路径上已有任何东西 —— 符号链接也算 —— 就抛),`fstat` 记下 `(dev, ino)`,**关掉**再返回;
95
+ * `fstat` 失败时也关掉,抛的是 `fstat` 的错(关闭的错不盖它)。
96
+ */
97
+ async function createAliveFile(path) {
98
+ const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
99
+ let identity;
100
+ try {
101
+ const { dev, ino } = await file.stat({ bigint: true });
102
+ identity = { dev, ino };
103
+ }
104
+ catch (e) {
105
+ await file.close().catch(() => { });
106
+ throw e;
107
+ }
108
+ await file.close();
109
+ return identity;
110
+ }
111
+ /**
112
+ * 心跳的一拍:按路径 `O_WRONLY | O_NOFOLLOW | O_NONBLOCK` 打开 `.alive`(不带 `O_CREAT`:文件或根已被删,这一拍 `ENOENT` 作罢,不把它们重建出来;符号链接 `ELOOP`、目录 `EISDIR`、没有读端的命名管道 `ENXIO`〔`O_NONBLOCK` 的用处:不然这个打开会一直等读端〕同样作罢),
113
+ * `fstat` 比 `(dev, ino)`:不等(换成了别的文件,含硬链接到别处的文件)⇒ 本拍不写;相等才写一个字节(换行)、再截回空 —— 两步都改变文件大小,所以都由文件系统给 mtime 打新戳;
114
+ * 文件静态为空,那一个字节只存在于这两步之间(截回失败或进程恰在两步之间死掉时会留着,留着也是空白,见头注)。无论哪条路都在 `finally` 里关掉这一拍自己打开的文件。
115
+ */
116
+ async function beat(path, identity) {
117
+ const file = await open(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
118
+ try {
119
+ const { dev, ino } = await file.stat({ bigint: true });
120
+ if (dev !== identity.dev || ino !== identity.ino)
121
+ return;
122
+ await file.write(ALIVE_BYTE, 0);
123
+ await file.truncate(0);
124
+ }
125
+ finally {
126
+ await file.close();
127
+ }
128
+ }
129
+ /** 定时器到期:上一拍还没做完就跳过;否则起一拍,失败吞掉(尽力而为:文件系统暂时写不了 / 根已被删 / `.alive` 已不是铸根时那个文件 ⇒ 这一拍作罢,下一拍再看),做完(成功或失败)旗复位。 */
130
+ function tick(path, heartbeat) {
131
+ if (heartbeat.beating)
132
+ return;
133
+ heartbeat.beating = true;
134
+ beat(path, heartbeat.identity)
135
+ .catch(() => { })
136
+ .finally(() => {
137
+ heartbeat.beating = false;
138
+ });
139
+ }
140
+ let sweptThisProcess = false;
141
+ /**
142
+ * 铸一个临时根:根内建空文件 `.alive`、记下它的身份就关(本模块按路径建它只此一处)、起它的心跳定时器;本进程第一次铸根时先清扫旧件(见文件头 ①)。
143
+ * 铸出这个根之前任何一步失败(建 `.alive`、起定时器),都回滚到什么也没留下 —— 停定时器、删根,再把原错抛出去。返回根的绝对路径。
144
+ */
145
+ export async function mintLocalRoot() {
146
+ const dir = tmpdir();
147
+ const root = await mkdtemp(join(dir, LOCAL_ROOT_PREFIX));
148
+ const alive = join(root, LOCAL_ROOT_ALIVE_FILE);
149
+ let started;
150
+ try {
151
+ const identity = await createAliveFile(alive); // 静态为空,心跳的那一个字节只在每一拍的两步之间存在
152
+ const heartbeat = { identity, beating: false, timer: setInterval(() => tick(alive, heartbeat), ALIVE_TOUCH_INTERVAL_MS) };
153
+ started = heartbeat;
154
+ heartbeat.timer.unref();
155
+ heartbeats.set(root, heartbeat);
156
+ }
157
+ catch (e) {
158
+ if (started !== undefined)
159
+ clearInterval(started.timer);
160
+ await rm(root, { recursive: true, force: true }); // 没有 `.alive` 的根没人清得掉:建不成就不留这个根
161
+ throw e;
162
+ }
163
+ if (!sweptThisProcess) {
164
+ sweptThisProcess = true;
165
+ try {
166
+ await sweepStaleLocalRoots({ dir, nowMs: (await stat(root)).mtimeMs });
167
+ }
168
+ catch {
169
+ // 读不到刚铸出的根的 mtime ⇒ 这一轮不清扫(尽力而为)
170
+ }
171
+ }
172
+ return root;
173
+ }
174
+ /**
175
+ * 删一个临时根:先停它的心跳定时器,再只删这一个(删不掉就抛,留下的根等 `.alive` 超过 60 分钟没更新之后的旧件清扫收)。不开不关任何文件;还在飞的一拍不等它。
176
+ */
177
+ export async function removeLocalRoot(root) {
178
+ const heartbeat = heartbeats.get(root);
179
+ if (heartbeat !== undefined) {
180
+ clearInterval(heartbeat.timer);
181
+ heartbeats.delete(root);
182
+ }
183
+ await rm(root, { recursive: true, force: true });
184
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { ModelsConfig, RoleTarget } from "./types.js";
9
9
  /** core 对 `default` 角色的档语义:从它的缺省档 `pro` 起**只向低档**扫 —— pro → flash → lite,绝不向上抓 max / ultra
10
- * (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER`;钉对 devDependency 里精确钉住的那一版 core)。 */
10
+ * (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER`;档序只在 `./tier-words` 一处,这里按起点派生;对拍钉对 devDependency 里精确钉住的那一版 core)。 */
11
11
  export declare const DEFAULT_ROLE_TIER_LADDER: readonly ["pro", "flash", "lite"];
12
12
  /** 命名链每一级的来源词(闭集)。 */
13
13
  export type CatalogDefaultSource = "roles.default" | `tierGroup.${(typeof DEFAULT_ROLE_TIER_LADDER)[number]}` | "fallback";
@@ -1,7 +1,8 @@
1
1
  import { resolveActiveTiers } from "./config-fns.js";
2
+ import { tierLadderFrom } from "./tier-words.js";
2
3
  /** core 对 `default` 角色的档语义:从它的缺省档 `pro` 起**只向低档**扫 —— pro → flash → lite,绝不向上抓 max / ultra
3
- * (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER`;钉对 devDependency 里精确钉住的那一版 core)。 */
4
- export const DEFAULT_ROLE_TIER_LADDER = ["pro", "flash", "lite"];
4
+ * (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER`;档序只在 `./tier-words` 一处,这里按起点派生;对拍钉对 devDependency 里精确钉住的那一版 core)。 */
5
+ export const DEFAULT_ROLE_TIER_LADDER = tierLadderFrom("pro");
5
6
  /**
6
7
  * 缺省模型由 `roles.default` 的形分两路(一条规则:点名的走命名链,不点名的交回引擎):
7
8
  * · `{select}` ⇒ `{ kind: "select", select }`(见 {@link CatalogDefaultModel});
@@ -13,8 +14,8 @@ export function resolveCatalogDefaultModel(models, has, fallback) {
13
14
  const target = models.roles?.default;
14
15
  const named = target !== undefined && "model" in target ? target.model : undefined;
15
16
  const select = target !== undefined && "select" in target ? target.select : undefined;
16
- // 判序与引擎同一只真值判(core `partsModel`:`model` 为真 ⇒ 按名;否则 `select` 为真 ⇒ 按属性挑):schema 读过的值只有一形,这一判序只对
17
- // 绕过 schema 递进来的两形并在(含 `model: ""`)起作用 —— 那时也与引擎同答。
17
+ // 判序按真值判:`model` 为非空串 ⇒ 按名;否则 `select` 为真 ⇒ 按属性挑。schema 读过的值只有一形(`model` 是非空串),这一判序只对绕过 schema
18
+ // 递进来的空串 `model` 起作用 —— 本包把它读作没写;引擎(devDependency 钉的那一版)按在场判、空串按名拒(`unknown_model_ref`),这一形两边不同答。
18
19
  if (!named && select)
19
20
  return { kind: "select", select };
20
21
  const tiers = resolveActiveTiers(models) ?? {};