blun-king-cli 9.1.562 → 9.1.564
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/LIESMICH.txt +2 -2
- package/README.md +1 -1
- package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
- package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-spine-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-spine-plugin/blun.plugin.json +3 -3
- package/agent-spine-plugin/hooks/codex.json +2 -2
- package/agent-spine-plugin/hooks/hooks.json +1 -1
- package/agent-spine-plugin/hooks/version.json +1 -1
- package/agent-spine-plugin/package.json +1 -1
- package/agent-spine-plugin/scripts/check-hosts.js +199 -196
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +1 -1
- package/agent-spine-plugin/src/cli.js +390 -7
- package/agent-spine-plugin/src/hook.js +130 -11
- package/agent-spine-plugin/src/index.js +4 -2
- package/agent-spine-plugin/src/lib/audit.js +20 -2
- package/agent-spine-plugin/src/lib/filesystem-retry.js +5 -0
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/learning.js +5575 -128
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -0
- package/agent-spine-plugin/src/lib/selfstarter.js +52 -5
- package/agent-spine-plugin/src/lib/source-roots.js +36 -20
- package/agent-spine-plugin/src/version.js +1 -1
- package/blun.mjs +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
|
|
3
|
+
import { isFileLockContention, isTransientLockMetadataError } from "./filesystem-retry.js";
|
|
4
|
+
|
|
5
|
+
const LOCK_SCHEMA = "agentspine.owned-file-lock/v1";
|
|
6
|
+
|
|
7
|
+
function delay(milliseconds) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function lockPayload(token, acquiredAt, leaseMs) {
|
|
12
|
+
return {
|
|
13
|
+
schema: LOCK_SCHEMA,
|
|
14
|
+
token,
|
|
15
|
+
acquiredAt,
|
|
16
|
+
leaseMs,
|
|
17
|
+
authority: "state-coordination-only"
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readOwner(path) {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
25
|
+
|| value.schema !== LOCK_SCHEMA || typeof value.token !== "string") return null;
|
|
26
|
+
return value;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError) return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sameFile(left, right) {
|
|
34
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
35
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function removeStaleLock(path, staleAfterMs) {
|
|
39
|
+
let before;
|
|
40
|
+
try {
|
|
41
|
+
before = await stat(path);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isTransientLockMetadataError(error)) return false;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (Date.now() - before.mtimeMs <= staleAfterMs) return false;
|
|
47
|
+
await readOwner(path);
|
|
48
|
+
let after;
|
|
49
|
+
try {
|
|
50
|
+
after = await stat(path);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (isTransientLockMetadataError(error)) return false;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
if (!sameFile(before, after) || Date.now() - after.mtimeMs <= staleAfterMs) return false;
|
|
56
|
+
try {
|
|
57
|
+
await unlink(path);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (isFileLockContention(error) || isTransientLockMetadataError(error)) return false;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function withOwnedFileLock(path, task, {
|
|
66
|
+
staleAfterMs = 15000,
|
|
67
|
+
heartbeatIntervalMs = 1000,
|
|
68
|
+
retryDelayMs = 25,
|
|
69
|
+
maxAttempts = 80
|
|
70
|
+
} = {}) {
|
|
71
|
+
if (typeof task !== "function") throw new Error("owned file lock requires a task");
|
|
72
|
+
if (!Number.isInteger(staleAfterMs) || staleAfterMs < 50
|
|
73
|
+
|| !Number.isInteger(heartbeatIntervalMs) || heartbeatIntervalMs < 10
|
|
74
|
+
|| heartbeatIntervalMs * 3 >= staleAfterMs
|
|
75
|
+
|| !Number.isInteger(retryDelayMs) || retryDelayMs < 1
|
|
76
|
+
|| !Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
77
|
+
throw new Error("owned file lock timing is invalid");
|
|
78
|
+
}
|
|
79
|
+
const token = randomUUID();
|
|
80
|
+
const acquiredAt = new Date().toISOString();
|
|
81
|
+
let acquired = false;
|
|
82
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
83
|
+
let handle;
|
|
84
|
+
try {
|
|
85
|
+
handle = await open(path, "wx", 0o600);
|
|
86
|
+
const payload = `${JSON.stringify(lockPayload(token, acquiredAt, staleAfterMs))}\n`;
|
|
87
|
+
await handle.writeFile(payload, "utf8");
|
|
88
|
+
acquired = true;
|
|
89
|
+
break;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!isFileLockContention(error)) {
|
|
92
|
+
if (handle) {
|
|
93
|
+
await handle.close();
|
|
94
|
+
handle = null;
|
|
95
|
+
await unlink(path).catch((cleanupError) => {
|
|
96
|
+
if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
await removeStaleLock(path, staleAfterMs);
|
|
102
|
+
if (attempt + 1 < maxAttempts) await delay(retryDelayMs);
|
|
103
|
+
} finally {
|
|
104
|
+
await handle?.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!acquired) throw new Error("state is busy; retry shortly");
|
|
108
|
+
|
|
109
|
+
let ownershipError = null;
|
|
110
|
+
let heartbeat = Promise.resolve();
|
|
111
|
+
const assertOwned = async () => {
|
|
112
|
+
if (ownershipError) throw ownershipError;
|
|
113
|
+
const owner = await readOwner(path);
|
|
114
|
+
if (!owner || owner.token !== token) {
|
|
115
|
+
ownershipError = new Error("state lock ownership was lost; mutation aborted");
|
|
116
|
+
throw ownershipError;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const renew = async () => {
|
|
120
|
+
await assertOwned();
|
|
121
|
+
const now = new Date();
|
|
122
|
+
await utimes(path, now, now);
|
|
123
|
+
};
|
|
124
|
+
const timer = setInterval(() => {
|
|
125
|
+
heartbeat = heartbeat.then(renew).catch((error) => { ownershipError ||= error; });
|
|
126
|
+
}, heartbeatIntervalMs);
|
|
127
|
+
timer.unref?.();
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const result = await task({ token, acquiredAt, assertOwned });
|
|
131
|
+
await assertOwned();
|
|
132
|
+
return result;
|
|
133
|
+
} finally {
|
|
134
|
+
clearInterval(timer);
|
|
135
|
+
await heartbeat;
|
|
136
|
+
const owner = await readOwner(path).catch(() => null);
|
|
137
|
+
if (owner?.token === token) {
|
|
138
|
+
await unlink(path).catch((error) => {
|
|
139
|
+
if (error.code !== "ENOENT") throw error;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -6,6 +6,7 @@ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.j
|
|
|
6
6
|
import { loadCoordination } from "./coordination.js";
|
|
7
7
|
import { loadGraph } from "./graph.js";
|
|
8
8
|
import { projectStateDir } from "./paths.js";
|
|
9
|
+
import { isInaccessibleScanError } from "./source-roots.js";
|
|
9
10
|
|
|
10
11
|
const POLICY_SCHEMA = "agentspine.execution-policy/v1";
|
|
11
12
|
const JOB_SCHEMA = "agentspine.selfstarter/v1";
|
|
@@ -149,16 +150,39 @@ async function pathsFor(root, providedCatalog = null) {
|
|
|
149
150
|
|
|
150
151
|
async function collectWorkspaceFiles(root) {
|
|
151
152
|
const files = [];
|
|
153
|
+
const skippedInaccessibleEntries = [];
|
|
152
154
|
let totalBytes = 0;
|
|
153
155
|
async function walk(directory) {
|
|
154
|
-
|
|
156
|
+
let stream;
|
|
157
|
+
try {
|
|
158
|
+
stream = await opendir(directory);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (!isInaccessibleScanError(error)) throw error;
|
|
161
|
+
skippedInaccessibleEntries.push({
|
|
162
|
+
relativePath: relative(root, directory).split(sep).join("/") || ".",
|
|
163
|
+
code: error.code,
|
|
164
|
+
kind: "directory"
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
155
168
|
const entries = [];
|
|
156
169
|
for await (const entry of stream) entries.push(entry);
|
|
157
170
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
158
171
|
for (const entry of entries) {
|
|
159
172
|
if (EXCLUDED_NAMES.has(entry.name)) continue;
|
|
160
173
|
const path = join(directory, entry.name);
|
|
161
|
-
|
|
174
|
+
let metadata;
|
|
175
|
+
try {
|
|
176
|
+
metadata = await lstat(path);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (!isInaccessibleScanError(error)) throw error;
|
|
179
|
+
skippedInaccessibleEntries.push({
|
|
180
|
+
relativePath: relative(root, path).split(sep).join("/"),
|
|
181
|
+
code: error.code,
|
|
182
|
+
kind: "entry"
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
162
186
|
if (metadata.isSymbolicLink()) throw new Error(`workspace fingerprint rejects symbolic link: ${relative(root, path)}`);
|
|
163
187
|
if (metadata.isDirectory()) await walk(path);
|
|
164
188
|
else if (metadata.isFile()) {
|
|
@@ -171,18 +195,38 @@ async function collectWorkspaceFiles(root) {
|
|
|
171
195
|
}
|
|
172
196
|
}
|
|
173
197
|
await walk(root);
|
|
174
|
-
return files;
|
|
198
|
+
return { files, skippedInaccessibleEntries };
|
|
175
199
|
}
|
|
176
200
|
|
|
177
201
|
export async function workspaceFingerprint(inputRoot = process.cwd()) {
|
|
178
202
|
const root = resolve(inputRoot);
|
|
179
|
-
const files = await collectWorkspaceFiles(root);
|
|
203
|
+
const { files, skippedInaccessibleEntries } = await collectWorkspaceFiles(root);
|
|
180
204
|
const hash = createHash("sha256");
|
|
205
|
+
for (const skipped of skippedInaccessibleEntries) {
|
|
206
|
+
hash.update("inaccessible\0").update(skipped.kind).update("\0")
|
|
207
|
+
.update(skipped.relativePath).update("\0").update(skipped.code).update("\0");
|
|
208
|
+
}
|
|
181
209
|
for (const file of files) {
|
|
182
210
|
hash.update(file.relativePath).update("\0").update(String(file.size)).update("\0");
|
|
183
211
|
hash.update(await readFile(file.path)).update("\0");
|
|
184
212
|
}
|
|
185
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
digest: hash.digest("hex"), files: files.length,
|
|
215
|
+
bytes: files.reduce((sum, file) => sum + file.size, 0),
|
|
216
|
+
skippedInaccessibleEntries: skippedInaccessibleEntries.length,
|
|
217
|
+
skippedInaccessibleDetails: skippedInaccessibleEntries
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function incompleteWorkspaceScan(skipped) {
|
|
222
|
+
const first = skipped[0];
|
|
223
|
+
const error = new Error(`workspace scan skipped ${skipped.length} inaccessible path${skipped.length === 1 ? "" : "s"}: ${first.relativePath}`);
|
|
224
|
+
error.code = "AGENTSPINE_SCAN_INCOMPLETE";
|
|
225
|
+
error.path = first.relativePath;
|
|
226
|
+
error.syscall = first.kind;
|
|
227
|
+
error.agentSpineScan = true;
|
|
228
|
+
error.skipped = skipped;
|
|
229
|
+
return error;
|
|
186
230
|
}
|
|
187
231
|
|
|
188
232
|
function knownActor(graph, id) {
|
|
@@ -649,6 +693,9 @@ export async function authorizeJobEffect({
|
|
|
649
693
|
const deliveryId = stableId(toolUseId, "toolUseId");
|
|
650
694
|
const at = timestamp(now, "now");
|
|
651
695
|
const fingerprint = await workspaceFingerprint(root);
|
|
696
|
+
if (fingerprint.skippedInaccessibleDetails.length) {
|
|
697
|
+
throw incompleteWorkspaceScan(fingerprint.skippedInaccessibleDetails);
|
|
698
|
+
}
|
|
652
699
|
return lockedStates(root, ({ policy, state, coordination, paths }) => {
|
|
653
700
|
const job = state.jobs.find((item) => item.id === scope.jobId);
|
|
654
701
|
if (!job) throw new Error("unknown self-starter job");
|
|
@@ -20,6 +20,10 @@ const SOURCE_RESOLUTION_MS = 2000;
|
|
|
20
20
|
const SAFE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
|
|
21
21
|
const SKIP_EXTRA_DIRS = new Set([".git", ".hg", ".svn", ".claude", ".codex", "node_modules", "vendor", "dist", "build", "coverage"]);
|
|
22
22
|
|
|
23
|
+
export function isInaccessibleScanError(error) {
|
|
24
|
+
return error?.code === "EACCES" || error?.code === "EPERM";
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
function digest(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
24
28
|
function registryPath(env = process.env) { return join(stateRoot(env), "source-roots.json"); }
|
|
25
29
|
function emptyRegistry() { return { schema: SOURCE_REGISTRY_SCHEMA, revision: 0, bindings: [], history: [] }; }
|
|
@@ -202,32 +206,41 @@ async function containsEmbeddedHostProfile(directory) {
|
|
|
202
206
|
async function boundedMarkdownTree(directory, prefix, host, scope, precedenceStart, deadline, {
|
|
203
207
|
projectBoundary = false,
|
|
204
208
|
maxFiles = MAX_RULE_FILES,
|
|
205
|
-
maxDirectoryEntries = MAX_DIRECTORY_ENTRIES
|
|
209
|
+
maxDirectoryEntries = MAX_DIRECTORY_ENTRIES,
|
|
210
|
+
skippedInaccessibleDirectories = []
|
|
206
211
|
} = {}) {
|
|
207
212
|
const root = await existingDirectory(directory);
|
|
208
213
|
if (!root) return [];
|
|
209
214
|
const output = [];
|
|
210
215
|
let visitedEntries = 0;
|
|
211
216
|
async function walk(current) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
222
|
-
for (const entry of entries) {
|
|
223
|
-
if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
|
|
224
|
-
if (entry.isSymbolicLink()) continue;
|
|
225
|
-
const path = join(current, entry.name);
|
|
226
|
-
if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
|
|
227
|
-
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
228
|
-
output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
|
|
229
|
-
binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
|
|
217
|
+
try {
|
|
218
|
+
if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
|
|
219
|
+
if (projectBoundary && current !== root
|
|
220
|
+
&& (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
|
|
221
|
+
const entries = [];
|
|
222
|
+
for await (const entry of await opendir(current)) {
|
|
223
|
+
visitedEntries += 1;
|
|
224
|
+
if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
|
|
225
|
+
entries.push(entry);
|
|
230
226
|
}
|
|
227
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
|
|
230
|
+
if (entry.isSymbolicLink()) continue;
|
|
231
|
+
const path = join(current, entry.name);
|
|
232
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
|
|
233
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
234
|
+
output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
|
|
235
|
+
binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (!isInaccessibleScanError(error)) throw error;
|
|
240
|
+
skippedInaccessibleDirectories.push({
|
|
241
|
+
relativePath: relative(root, current).replaceAll("\\", "/") || ".",
|
|
242
|
+
code: error.code
|
|
243
|
+
});
|
|
231
244
|
}
|
|
232
245
|
}
|
|
233
246
|
await walk(root);
|
|
@@ -434,9 +447,11 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
|
|
|
434
447
|
const knownHomeRoots = await homeRoots(env);
|
|
435
448
|
const skippedHomeTree = knownHomeRoots.some((root) => samePath(root, projectRoot));
|
|
436
449
|
const skippedFallbackHomeTree = skippedHomeTree && rootResolution === "cwd-fallback";
|
|
450
|
+
const skippedInaccessibleDirectories = [];
|
|
437
451
|
if (!skippedHomeTree) {
|
|
438
452
|
sources.push(...await boundedMarkdownTree(projectRoot, "agentspine:project", host, "project", 3000, deadline,
|
|
439
|
-
{ projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES
|
|
453
|
+
{ projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES,
|
|
454
|
+
skippedInaccessibleDirectories }));
|
|
440
455
|
}
|
|
441
456
|
const nativeNames = new Set(host === "codex"
|
|
442
457
|
? ["AGENTS.override.md", "AGENTS.md", ...(hostDetails.fallbackNames || [])]
|
|
@@ -459,6 +474,7 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
|
|
|
459
474
|
personalContinuityLoaded: documents.some((item) => item.sourceScope === "user") || Boolean(activeUserState),
|
|
460
475
|
broadHomeScan: false, projectTreeScan: skippedFallbackHomeTree ? "skipped-unmarked-home"
|
|
461
476
|
: skippedHomeTree ? "skipped-home-root" : "bounded",
|
|
477
|
+
skippedInaccessibleDirectories,
|
|
462
478
|
rootResolution, registryRevision: registry.revision,
|
|
463
479
|
...(host === "claude" ? {
|
|
464
480
|
memoryBound: Boolean(hostDetails.memoryRoot),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.49.0";
|
package/blun.mjs
CHANGED
|
@@ -342898,6 +342898,7 @@ function detectedUiLocale() {
|
|
|
342898
342898
|
const SOURCE_LOCALE = "en";
|
|
342899
342899
|
const PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9_]*)\}/g;
|
|
342900
342900
|
const catalogs = {};
|
|
342901
|
+
const missingLocalizedUiTextWarnings = /* @__PURE__ */ new Set();
|
|
342901
342902
|
let currentUiLocale = SOURCE_LOCALE;
|
|
342902
342903
|
function registerUiCatalogFragment(fragment) {
|
|
342903
342904
|
for (const [locale, copy] of Object.entries(fragment)) {
|
|
@@ -342928,7 +342929,13 @@ function uiTextFor(locale, key, params = {}) {
|
|
|
342928
342929
|
const sourceTemplate = catalogs[SOURCE_LOCALE]?.[key];
|
|
342929
342930
|
if (sourceTemplate === void 0) throw new Error(`Missing English UI source text for key "${key}".`);
|
|
342930
342931
|
const localizedTemplate = catalogs[locale]?.[key];
|
|
342931
|
-
if (locale !== SOURCE_LOCALE && isUiLocaleAvailable(locale) && localizedTemplate === void 0)
|
|
342932
|
+
if (locale !== SOURCE_LOCALE && isUiLocaleAvailable(locale) && localizedTemplate === void 0) {
|
|
342933
|
+
const warningKey = `${locale}:${key}`;
|
|
342934
|
+
if (!missingLocalizedUiTextWarnings.has(warningKey)) {
|
|
342935
|
+
missingLocalizedUiTextWarnings.add(warningKey);
|
|
342936
|
+
log?.warn("Missing localized UI text; using English fallback", { locale, key });
|
|
342937
|
+
}
|
|
342938
|
+
}
|
|
342932
342939
|
const template = localizedTemplate ?? sourceTemplate;
|
|
342933
342940
|
validateCatalogPlaceholders(key, sourceTemplate, template);
|
|
342934
342941
|
return interpolate(key, template, params);
|