blun-king-cli 9.1.563 → 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/agent-spine-plugin/src/hook.js +50 -1
- package/agent-spine-plugin/src/lib/audit.js +1 -1
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/selfstarter.js +52 -5
- package/agent-spine-plugin/src/lib/source-roots.js +36 -20
- package/blun.mjs +8 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import { recordAttentionEvent } from "./lib/attention.js";
|
|
|
5
5
|
import { catalogForStateRoot, saveCatalog } from "./lib/catalog.js";
|
|
6
6
|
import { loadGraph } from "./lib/graph.js";
|
|
7
7
|
import { canonicalPath } from "./lib/paths.js";
|
|
8
|
-
import { resolveHostSourceCatalog } from "./lib/source-roots.js";
|
|
8
|
+
import { isInaccessibleScanError, resolveHostSourceCatalog } from "./lib/source-roots.js";
|
|
9
9
|
import { sessionBriefing } from "./lib/briefing.js";
|
|
10
10
|
import { captureContinuityPrompt, loadContinuity } from "./lib/continuity.js";
|
|
11
11
|
import { recordLearningApplications, recordLearningDeliveries } from "./lib/learning.js";
|
|
@@ -16,6 +16,7 @@ import { claimChannelEvent } from "./lib/channel-runtime.js";
|
|
|
16
16
|
import { syncPersonaRosterFromEnvironment } from "./lib/persona-runtime.js";
|
|
17
17
|
import { captureMustRememberPrompt, recordPreflightFailure, runPreflight, verifyPreflightReceipt } from "./lib/preflight.js";
|
|
18
18
|
import { isMainModule } from "./lib/runtime.js";
|
|
19
|
+
import { recordHookScanAudit } from "./lib/hook-audit.js";
|
|
19
20
|
|
|
20
21
|
const MAX_STDIN_BYTES = 64 * 1024;
|
|
21
22
|
const STANDARD_HOST_CONTEXT_BYTES = 9500;
|
|
@@ -471,6 +472,30 @@ function isMutationTool(name = "") {
|
|
|
471
472
|
return /(^|__)(apply_patch|edit|write|delete|move|rename|bash|exec_command|shell)(_|$)/i.test(name);
|
|
472
473
|
}
|
|
473
474
|
|
|
475
|
+
function filesystemScanError(error) {
|
|
476
|
+
return Boolean(error && (isInaccessibleScanError(error)
|
|
477
|
+
|| error.code === "AGENTSPINE_SCAN_INCOMPLETE" || error.agentSpineScan === true));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function auditSkippedScans(input, phase, skipped = []) {
|
|
481
|
+
for (const item of skipped) {
|
|
482
|
+
await recordHookScanAudit({
|
|
483
|
+
event: "PreToolUse", toolName: input.tool_name || null, phase,
|
|
484
|
+
error: { code: item.code, message: `${item.code}: ${item.kind || item.operation || "scan"} skipped ${item.relativePath || item.path}` },
|
|
485
|
+
path: item.path || item.relativePath, operation: item.operation || item.kind,
|
|
486
|
+
now: input.timestamp || new Date()
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function allowScanFailure(input, phase, error) {
|
|
492
|
+
await recordHookScanAudit({
|
|
493
|
+
event: "PreToolUse", toolName: input.tool_name || null, phase, error,
|
|
494
|
+
path: error?.path || input.cwd || process.cwd(), now: input.timestamp || new Date()
|
|
495
|
+
});
|
|
496
|
+
return { blocked: false, degraded: true, scanFailedOpen: true, phase, error: error.message };
|
|
497
|
+
}
|
|
498
|
+
|
|
474
499
|
function stringValues(value, output = []) {
|
|
475
500
|
if (typeof value === "string") output.push(value);
|
|
476
501
|
else if (Array.isArray(value)) value.forEach((item) => stringValues(item, output));
|
|
@@ -546,6 +571,19 @@ export async function runHook(payload = null, options = {}) {
|
|
|
546
571
|
resolvedSources = await resolveHostSourceCatalog({ host: instructionHost, cwd, input });
|
|
547
572
|
} catch (error) {
|
|
548
573
|
const reason = `AgentSpine source resolution failed closed: ${error.message}`;
|
|
574
|
+
if (event === "PreToolUse" && isMutationTool(input.tool_name) && filesystemScanError(error)) {
|
|
575
|
+
const allowed = await allowScanFailure(input, "source-resolution", error);
|
|
576
|
+
const context = JSON.stringify({
|
|
577
|
+
schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: false,
|
|
578
|
+
indexedSources: 0,
|
|
579
|
+
sourceResolution: { status: "degraded", reason: error.message, auditFinding: "inaccessible-source-scan" },
|
|
580
|
+
instruction: "The filesystem scan was incomplete. The requested tool remains governed by native host permissions.",
|
|
581
|
+
authority: "context-only"
|
|
582
|
+
});
|
|
583
|
+
if (payload) return { ...allowed, context };
|
|
584
|
+
process.stdout.write("{}\n");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
549
587
|
if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
|
|
550
588
|
if (payload) return { blocked: true, failedClosed: true, reason };
|
|
551
589
|
deny(reason);
|
|
@@ -575,6 +613,11 @@ export async function runHook(payload = null, options = {}) {
|
|
|
575
613
|
let channelEvent = null;
|
|
576
614
|
let learningDelivery = null;
|
|
577
615
|
|
|
616
|
+
if (event === "PreToolUse" && isMutationTool(input.tool_name)
|
|
617
|
+
&& resolvedSources.diagnostics.skippedInaccessibleDirectories?.length) {
|
|
618
|
+
await auditSkippedScans(input, "source-resolution", resolvedSources.diagnostics.skippedInaccessibleDirectories);
|
|
619
|
+
}
|
|
620
|
+
|
|
578
621
|
if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
|
|
579
622
|
const { graph } = await loadGraph(root, catalog);
|
|
580
623
|
const inferredProtected = new Set(graph.annotations
|
|
@@ -624,6 +667,12 @@ export async function runHook(payload = null, options = {}) {
|
|
|
624
667
|
});
|
|
625
668
|
}
|
|
626
669
|
} catch (error) {
|
|
670
|
+
if (isMutationTool(input.tool_name) && filesystemScanError(error)) {
|
|
671
|
+
const allowed = await allowScanFailure(input, "self-starter", error);
|
|
672
|
+
if (payload) return allowed;
|
|
673
|
+
process.stdout.write("{}\n");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
627
676
|
const reason = `AgentSpine self-starter denied this effect: ${error.message}`;
|
|
628
677
|
if (payload) return { blocked: true, reason, selfstarter: { allowed: false, reason: error.message } };
|
|
629
678
|
deny(reason);
|
|
@@ -288,7 +288,7 @@ export async function runAudit(root = process.cwd(), { host = null } = {}) {
|
|
|
288
288
|
gate(2, "Discovery", catalog.schema === "agentspine.catalog/v1"
|
|
289
289
|
&& (!host || (sourceResolution?.status === "loaded" && !sourceResolutionError)), sourceResolutionError
|
|
290
290
|
? `${catalog.documents.length} project documents; host-native source resolution failed closed: ${sourceResolutionError}`
|
|
291
|
-
: sourceResolution ? `${sourceResolution.scopes.user} user, ${sourceResolution.scopes.project} project, and ${sourceResolution.scopes["project-memory"]} memory sources; broad home scan disabled`
|
|
291
|
+
: sourceResolution ? `${sourceResolution.scopes.user} user, ${sourceResolution.scopes.project} project, and ${sourceResolution.scopes["project-memory"]} memory sources; broad home scan disabled; ${(sourceResolution.skippedInaccessibleDirectories || []).length} inaccessible directories skipped`
|
|
292
292
|
: `${catalog.documents.length} Markdown documents indexed`),
|
|
293
293
|
gate(3, "State isolation", [catalogPath, graphPath, attentionPath, learningPath, continuityPath,
|
|
294
294
|
policyPath, coordinationPath, executionPolicyPath, selfstarterPath, channelPolicyPath,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { stateRoot } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
export function hookScanAuditPath(env = process.env) {
|
|
6
|
+
return join(stateRoot(env), "hook-scan-audit.jsonl");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function recordHookScanAudit({ event = "PreToolUse", toolName = null, phase, error = null,
|
|
10
|
+
path = null, operation = null, now = new Date(), env = process.env }) {
|
|
11
|
+
try {
|
|
12
|
+
const target = hookScanAuditPath(env);
|
|
13
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
14
|
+
const record = {
|
|
15
|
+
schema: "agentspine.hook-scan-audit/v1",
|
|
16
|
+
at: new Date(now).toISOString(), event, toolName, phase,
|
|
17
|
+
code: String(error?.code || "SCAN_ERROR").slice(0, 64),
|
|
18
|
+
error: String(error?.message || error || "filesystem scan skipped").slice(0, 2048),
|
|
19
|
+
path: String(path || error?.path || "unknown").slice(0, 4096),
|
|
20
|
+
operation: operation || error?.syscall || null,
|
|
21
|
+
decision: "allow",
|
|
22
|
+
authority: "diagnostic-only"
|
|
23
|
+
};
|
|
24
|
+
await appendFile(target, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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),
|
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);
|