@lorekit/cli 1.39.1 → 1.40.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.
- package/README.md +2 -1
- package/bin/lorekit.mjs +17 -8
- package/package.json +1 -1
- package/src/config.mjs +6 -1
- package/src/dedupe.mjs +82 -22
- package/src/install.mjs +18 -1
- package/src/lessons-view.mjs +51 -0
- package/src/telemetry.mjs +80 -7
package/README.md
CHANGED
|
@@ -103,7 +103,8 @@ from `.gitignore`, negate it with `!.mcp.json`, or `git add -f .mcp.json` once);
|
|
|
103
103
|
`install --mcp-json` **warns when the file it wrote is still git-ignored**, so a
|
|
104
104
|
fresh web clone silently missing the config is not a mystery. Once `.mcp.json` is
|
|
105
105
|
tracked, only run `install --mcp-json` in that repo — a plain `install --project`
|
|
106
|
-
would embed a live token in the now-committed file
|
|
106
|
+
would embed a live token in the now-committed file (and the command warns before
|
|
107
|
+
it does). See the
|
|
107
108
|
[Claude Code on the web guide](https://lorekit.io/docs/claude-code-web).
|
|
108
109
|
|
|
109
110
|
In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
|
package/bin/lorekit.mjs
CHANGED
|
@@ -542,18 +542,27 @@ HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embeddin
|
|
|
542
542
|
measure — so it surfaces candidates for a human to review, and can both miss
|
|
543
543
|
paraphrases and group coincidental overlaps. Tune the cutoff with --threshold.
|
|
544
544
|
|
|
545
|
+
Pass --cluster-by-key <regex> to cluster by KEY shape instead of value overlap:
|
|
546
|
+
entries whose keys share the same first capture group (or full match) form one
|
|
547
|
+
family. This catches coordinate-key debt — e.g. many pr{N}-{commentId} rows for
|
|
548
|
+
one review comment — that the value heuristic misses when the values differ.
|
|
549
|
+
Key-shape mode has no similarity cutoff, so --threshold and --cluster-by-key are
|
|
550
|
+
mutually exclusive: passing both is a usage error rather than a silent ignore.
|
|
551
|
+
|
|
545
552
|
${c.bold('Options')}
|
|
546
|
-
-d, --dir <path>
|
|
547
|
-
--scope <scope>
|
|
548
|
-
--threshold <0..1>
|
|
549
|
-
--
|
|
550
|
-
|
|
551
|
-
-
|
|
552
|
-
|
|
553
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
554
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
555
|
+
--threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
|
|
556
|
+
--cluster-by-key <re> Cluster by shared key capture instead of value overlap
|
|
557
|
+
--json Machine-readable output (clusters + signal)
|
|
558
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
559
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
560
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
553
561
|
|
|
554
562
|
${c.bold('Examples')}
|
|
555
563
|
npx @lorekit/cli dedupe
|
|
556
564
|
npx @lorekit/cli dedupe --threshold 0.6 --json
|
|
565
|
+
npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
|
|
557
566
|
`,
|
|
558
567
|
link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
|
|
559
568
|
|
|
@@ -652,7 +661,7 @@ const KNOWN_FLAGS = [
|
|
|
652
661
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
653
662
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
654
663
|
// Scale-aware survey flags
|
|
655
|
-
'all', 'max', 'since', 'until', 'key-prefix',
|
|
664
|
+
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
|
656
665
|
];
|
|
657
666
|
|
|
658
667
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -591,7 +591,12 @@ export function tokenKind(token) {
|
|
|
591
591
|
// avoid a circular import with mcp.mjs.
|
|
592
592
|
//
|
|
593
593
|
// A source that STORES a token wins outright (project beats global — closest
|
|
594
|
-
// scope),
|
|
594
|
+
// scope), and it brings its OWN endpoint: a token authenticates one endpoint,
|
|
595
|
+
// so the two must travel together. A TOKENLESS source therefore never shadows a
|
|
596
|
+
// later source that has a token — not its token AND not its endpoint; its
|
|
597
|
+
// endpoint is only remembered as a fallback, used solely when NO source stores a
|
|
598
|
+
// token. (So "closest-scope-first" governs which token wins; the endpoint simply
|
|
599
|
+
// follows that token.)
|
|
595
600
|
// That shadowing is exactly what `install --global --mcp-json` created: it
|
|
596
601
|
// writes a committable, token-free project .mcp.json (auth via ${LOREKIT_TOKEN})
|
|
597
602
|
// AND the real token into ~/.claude.json. Returning early on the project entry
|
package/src/dedupe.mjs
CHANGED
|
@@ -15,13 +15,18 @@ import { resolveProjectRoot, readLorekitJson } from './config.mjs';
|
|
|
15
15
|
import { deriveScope } from './scope.mjs';
|
|
16
16
|
import { resolveDenies } from './control.mjs';
|
|
17
17
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
-
import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, DEFAULT_MAX } from './lessons-view.mjs';
|
|
19
|
-
import { log, heading, status, c } from './util.mjs';
|
|
18
|
+
import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, clusterByKeyPattern, compileKeyPattern, DEFAULT_MAX } from './lessons-view.mjs';
|
|
19
|
+
import { log, heading, status, err, c } from './util.mjs';
|
|
20
20
|
|
|
21
21
|
const DEFAULT_THRESHOLD = 0.8;
|
|
22
|
-
// Maximum entries to accumulate before the
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// Maximum entries to accumulate before the survey becomes memory-prohibitive.
|
|
23
|
+
// It bounds the accumulated entry list itself, so it applies in BOTH modes —
|
|
24
|
+
// key-shape clustering is O(n) and needs no blocking index, but it still holds
|
|
25
|
+
// the whole population in memory, and an unbounded remote drain is the risk the
|
|
26
|
+
// cap exists for. In value mode it additionally bounds the token-blocking index,
|
|
27
|
+
// which is the super-linear part. Beyond the cap the results are genuinely
|
|
28
|
+
// partial in either mode, and the user must narrow via --key-prefix / --since /
|
|
29
|
+
// --max.
|
|
25
30
|
const DEDUPE_POP_CAP = 2000;
|
|
26
31
|
|
|
27
32
|
// Smallest threshold the blocked clusterer accepts. `clusterDuplicatesBlocked`
|
|
@@ -77,6 +82,34 @@ export async function dedupe(args) {
|
|
|
77
82
|
args.threshold !== undefined
|
|
78
83
|
? parseThreshold(args.threshold)
|
|
79
84
|
: (repoThreshold(root) ?? DEFAULT_THRESHOLD);
|
|
85
|
+
|
|
86
|
+
// `--cluster-by-key <regex>` switches from value-overlap clustering to KEY-shape
|
|
87
|
+
// clustering: entries whose keys share the same first capture group (or full
|
|
88
|
+
// match) of the regex are grouped as a duplicate family — catches coordinate-key
|
|
89
|
+
// debt (e.g. `bucket::pr{N}-{commentId}::slug`) that the Jaccard heuristic misses
|
|
90
|
+
// when the values differ. A bare flag (no value) or an unparseable regex is a
|
|
91
|
+
// usage error: report it and exit non-zero rather than silently surveying by value.
|
|
92
|
+
const clusterByKeyRaw = args['cluster-by-key'];
|
|
93
|
+
const byKeyMode = clusterByKeyRaw !== undefined;
|
|
94
|
+
const keyPattern = byKeyMode ? compileKeyPattern(clusterByKeyRaw) : null;
|
|
95
|
+
if (byKeyMode && !keyPattern) {
|
|
96
|
+
err(
|
|
97
|
+
clusterByKeyRaw === true
|
|
98
|
+
? '--cluster-by-key needs a regex value, e.g. --cluster-by-key "(pr\\d+-\\d+)"'
|
|
99
|
+
: `--cluster-by-key: invalid regex ${JSON.stringify(String(clusterByKeyRaw))}`,
|
|
100
|
+
);
|
|
101
|
+
return { exitCode: 1 };
|
|
102
|
+
}
|
|
103
|
+
// Key-shape mode has no similarity cutoff, so an explicit `--threshold` would
|
|
104
|
+
// be silently ignored — the same silent fallback the bad-regex branch above
|
|
105
|
+
// refuses. Refuse it too. Only the EXPLICIT flag errors: a repo-level
|
|
106
|
+
// `dedupe.threshold` in .lorekit.json is a value-mode default, not a request,
|
|
107
|
+
// so it must not break a key-mode run.
|
|
108
|
+
if (byKeyMode && args.threshold !== undefined) {
|
|
109
|
+
err('--threshold is not used with --cluster-by-key (key-shape clustering has no similarity cutoff); drop one of them');
|
|
110
|
+
return { exitCode: 1 };
|
|
111
|
+
}
|
|
112
|
+
|
|
80
113
|
const scopeInfo = deriveScope(root);
|
|
81
114
|
// Default to every applicable scope; `--scope <s>` narrows to one.
|
|
82
115
|
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
@@ -156,7 +189,9 @@ export async function dedupe(args) {
|
|
|
156
189
|
}
|
|
157
190
|
return {
|
|
158
191
|
available: true,
|
|
159
|
-
clusters:
|
|
192
|
+
clusters: byKeyMode
|
|
193
|
+
? clusterByKeyPattern(entries, keyPattern)
|
|
194
|
+
: clusterDuplicatesBlocked(entries, threshold),
|
|
160
195
|
errored: flat.errored,
|
|
161
196
|
popCapped,
|
|
162
197
|
};
|
|
@@ -164,7 +199,9 @@ export async function dedupe(args) {
|
|
|
164
199
|
const { entries, errored, popCapped } = await streamAccumulate(store);
|
|
165
200
|
return {
|
|
166
201
|
available: true,
|
|
167
|
-
clusters:
|
|
202
|
+
clusters: byKeyMode
|
|
203
|
+
? clusterByKeyPattern(entries, keyPattern)
|
|
204
|
+
: clusterDuplicatesBlocked(entries, threshold),
|
|
168
205
|
errored,
|
|
169
206
|
popCapped,
|
|
170
207
|
};
|
|
@@ -188,12 +225,16 @@ export async function dedupe(args) {
|
|
|
188
225
|
const remoteClusters = remoteSection.available ? remoteSection.clusters.length : 0;
|
|
189
226
|
|
|
190
227
|
if (args.json) {
|
|
191
|
-
log(JSON.stringify(buildJson({ root, scopes, threshold, offlineSection, remoteSection }), null, 2));
|
|
228
|
+
log(JSON.stringify(buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }), null, 2));
|
|
192
229
|
} else {
|
|
193
230
|
heading('LoreKit dedupe');
|
|
194
231
|
log(` project: ${c.dim(root)}`);
|
|
195
232
|
log(` scopes: ${scopes.join(' → ')}`);
|
|
196
|
-
log(
|
|
233
|
+
log(
|
|
234
|
+
byKeyMode
|
|
235
|
+
? ` ${c.dim(`key-shape: clustering by shared key capture of /${keyPattern.source}/`)}`
|
|
236
|
+
: ` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`,
|
|
237
|
+
);
|
|
197
238
|
|
|
198
239
|
if (offlineSection.available && offlineSection.popCapped) {
|
|
199
240
|
log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
|
|
@@ -211,7 +252,13 @@ export async function dedupe(args) {
|
|
|
211
252
|
log('');
|
|
212
253
|
const total = offlineClusters + remoteClusters;
|
|
213
254
|
if (total === 0) {
|
|
214
|
-
|
|
255
|
+
// "at this threshold" is only true in value mode — key-shape mode has no
|
|
256
|
+
// cutoff, so name the pattern that found nothing instead.
|
|
257
|
+
log(
|
|
258
|
+
byKeyMode
|
|
259
|
+
? ` ${c.green('✓')} no key-shape clusters for /${keyPattern.source}/`
|
|
260
|
+
: ` ${c.green('✓')} no likely-duplicate clusters at this threshold`,
|
|
261
|
+
);
|
|
215
262
|
} else {
|
|
216
263
|
const plural = total === 1 ? '' : 's';
|
|
217
264
|
log(` ${c.yellow('!')} ${total} duplicate cluster${plural} found`);
|
|
@@ -220,11 +267,14 @@ export async function dedupe(args) {
|
|
|
220
267
|
}
|
|
221
268
|
|
|
222
269
|
// Bounded, non-PII telemetry extras — counts + a boolean, never a scope
|
|
223
|
-
// string, key, path, or token.
|
|
270
|
+
// string, key, path, or token. `threshold` is emitted only in value mode,
|
|
271
|
+
// where it is the cutoff actually applied; the key-mode pattern is user text
|
|
272
|
+
// and is never emitted, only the bounded mode name.
|
|
224
273
|
return {
|
|
225
274
|
exitCode: 0,
|
|
226
275
|
'lorekit.cli.dedupe.scope_count': scopes.length,
|
|
227
|
-
'lorekit.cli.dedupe.
|
|
276
|
+
'lorekit.cli.dedupe.mode': byKeyMode ? 'key' : 'value',
|
|
277
|
+
...(byKeyMode ? {} : { 'lorekit.cli.dedupe.threshold': threshold }),
|
|
228
278
|
'lorekit.cli.dedupe.offline_clusters': offlineClusters,
|
|
229
279
|
'lorekit.cli.dedupe.remote_clusters': remoteClusters,
|
|
230
280
|
'lorekit.cli.dedupe.remote_available': remoteAvailable,
|
|
@@ -255,25 +305,35 @@ function renderDedupeSection(header, section) {
|
|
|
255
305
|
let n = 0;
|
|
256
306
|
for (const cluster of section.clusters) {
|
|
257
307
|
n += 1;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
// Key-shape clusters carry a `keyGroup`; value-overlap clusters carry a
|
|
309
|
+
// similarity range. Render whichever signal the cluster has.
|
|
310
|
+
let signal;
|
|
311
|
+
if (cluster.keyGroup !== undefined) {
|
|
312
|
+
signal = `${cluster.size} memories, key-group "${cluster.keyGroup}"`;
|
|
313
|
+
} else {
|
|
314
|
+
const range =
|
|
315
|
+
cluster.minSimilarity === cluster.maxSimilarity
|
|
316
|
+
? cluster.minSimilarity.toFixed(2)
|
|
317
|
+
: `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
|
|
318
|
+
signal = `${cluster.size} memories, similarity ${range}`;
|
|
319
|
+
}
|
|
320
|
+
log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${signal})`)}`);
|
|
263
321
|
for (const m of cluster.members) {
|
|
264
322
|
log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
|
|
265
323
|
}
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
326
|
|
|
269
|
-
// The `--json` payload: `{ root, scopes, threshold, offline,
|
|
270
|
-
//
|
|
271
|
-
// maxSimilarity
|
|
272
|
-
|
|
327
|
+
// The `--json` payload: `{ root, scopes, mode, threshold|keyPattern, offline,
|
|
328
|
+
// remote }`. In value mode (`mode: "value"`) each cluster carries `minSimilarity`
|
|
329
|
+
// / `maxSimilarity`; in key-shape mode (`mode: "key"`) each carries `keyGroup`.
|
|
330
|
+
// Each store is a `{ available, clusters, errored }` record (or an unavailable note).
|
|
331
|
+
function buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }) {
|
|
273
332
|
return {
|
|
274
333
|
root,
|
|
275
334
|
scopes,
|
|
276
|
-
|
|
335
|
+
mode: byKeyMode ? 'key' : 'value',
|
|
336
|
+
...(byKeyMode ? { keyPattern: keyPattern.source } : { threshold }),
|
|
277
337
|
offline: sectionJson(offlineSection),
|
|
278
338
|
remote: sectionJson(remoteSection),
|
|
279
339
|
};
|
package/src/install.mjs
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
homeDir,
|
|
24
24
|
mcpConfigPath,
|
|
25
25
|
readJsonIfExists,
|
|
26
|
+
readLorekitServer,
|
|
27
|
+
isWebMcpServerEntry,
|
|
26
28
|
} from './config.mjs';
|
|
27
29
|
import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
|
|
28
30
|
import { deriveScope } from './scope.mjs';
|
|
@@ -364,6 +366,21 @@ export async function install(args) {
|
|
|
364
366
|
let file = null;
|
|
365
367
|
let existed = false;
|
|
366
368
|
if (!scopeWriteOwnedByWeb) {
|
|
369
|
+
// A plain project install embeds the token in .mcp.json. If that file
|
|
370
|
+
// currently holds the committable web form (written by an earlier
|
|
371
|
+
// `--mcp-json`), this write replaces it with an embedded secret — in the
|
|
372
|
+
// very file the docs tell you to commit. Warn before clobbering it;
|
|
373
|
+
// `isWebMcpServerEntry` recognises the shape we are about to overwrite.
|
|
374
|
+
if (scope === 'project' && token) {
|
|
375
|
+
const prior = readLorekitServer(root);
|
|
376
|
+
if (prior && isWebMcpServerEntry(prior.server)) {
|
|
377
|
+
status(
|
|
378
|
+
'warn',
|
|
379
|
+
'.mcp.json',
|
|
380
|
+
`replacing the committable web entry with an embedded token — do not commit this file, or re-run with --mcp-json to keep the \${${WEB_TOKEN_ENV_VAR}} form`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
367
384
|
({ file, existed } = upsertMcpServer(root, remoteUrl, scope));
|
|
368
385
|
}
|
|
369
386
|
|
|
@@ -527,7 +544,7 @@ export async function install(args) {
|
|
|
527
544
|
'warn',
|
|
528
545
|
'token',
|
|
529
546
|
`not stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret${
|
|
530
|
-
|
|
547
|
+
plan.action === 'flag' ? ' (the token you supplied is not persisted)' : ''
|
|
531
548
|
}`,
|
|
532
549
|
);
|
|
533
550
|
} else if (kind === 'none') {
|
package/src/lessons-view.mjs
CHANGED
|
@@ -430,6 +430,57 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
|
|
|
430
430
|
return clusters;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// Compile a `--cluster-by-key` pattern into a stateless RegExp, or null on bad
|
|
434
|
+
// input (empty / non-string / unparseable) — never throws, mirroring
|
|
435
|
+
// `parseThreshold`'s "never crash on bad input" contract. The source is always a
|
|
436
|
+
// STRING, so `new RegExp(raw)` carries no flags and is stateless by construction
|
|
437
|
+
// — flag stripping is only needed on the RegExp branch of `clusterByKeyPattern`,
|
|
438
|
+
// which can be handed a caller-built `/…/g`. Pure.
|
|
439
|
+
export function compileKeyPattern(raw) {
|
|
440
|
+
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
441
|
+
try {
|
|
442
|
+
return new RegExp(raw);
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Cluster entries by a shared KEY capture rather than value overlap. Two entries
|
|
449
|
+
// cluster iff their keys yield the SAME first capture group of `pattern` (or the
|
|
450
|
+
// same full match when the pattern has no capture group). This catches
|
|
451
|
+
// coordinate-key duplicate FAMILIES — many `bucket::pr{N}-{commentId}::slug` rows
|
|
452
|
+
// recorded for one review comment, whose slugs (and thus values) differ enough
|
|
453
|
+
// that `clusterDuplicates`'s Jaccard sweep never links them. Returns only 2+
|
|
454
|
+
// clusters, each `{ members: [{ scope, key }], size, keyGroup }`, largest first
|
|
455
|
+
// (ties broken by keyGroup for stable output). A key that doesn't match the
|
|
456
|
+
// pattern is left unclustered. Pure — O(n), no threshold.
|
|
457
|
+
export function clusterByKeyPattern(entries = [], pattern) {
|
|
458
|
+
const re =
|
|
459
|
+
pattern instanceof RegExp
|
|
460
|
+
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
|
|
461
|
+
: compileKeyPattern(pattern);
|
|
462
|
+
if (!re) return [];
|
|
463
|
+
const byGroup = new Map();
|
|
464
|
+
for (const e of entries) {
|
|
465
|
+
const key = e.key == null ? '' : String(e.key);
|
|
466
|
+
re.lastIndex = 0;
|
|
467
|
+
const m = re.exec(key);
|
|
468
|
+
if (!m) continue;
|
|
469
|
+
const group = m[1] ?? m[0];
|
|
470
|
+
if (!byGroup.has(group)) byGroup.set(group, []);
|
|
471
|
+
byGroup.get(group).push({ scope: e.scope ?? null, key });
|
|
472
|
+
}
|
|
473
|
+
const clusters = [];
|
|
474
|
+
for (const [keyGroup, members] of byGroup) {
|
|
475
|
+
if (members.length < 2) continue;
|
|
476
|
+
clusters.push({ members, size: members.length, keyGroup });
|
|
477
|
+
}
|
|
478
|
+
clusters.sort(
|
|
479
|
+
(x, y) => y.size - x.size || String(x.keyGroup).localeCompare(String(y.keyGroup)),
|
|
480
|
+
);
|
|
481
|
+
return clusters;
|
|
482
|
+
}
|
|
483
|
+
|
|
433
484
|
// Collect a store's non-archived entries for each scope, via the common
|
|
434
485
|
// `store.list({scope})` contract. Returns ordered per-scope groups plus a total
|
|
435
486
|
// — a per-scope read failure is captured on the group, never thrown, so one bad
|
package/src/telemetry.mjs
CHANGED
|
@@ -39,6 +39,9 @@ const DEFAULT_DATASET = 'default';
|
|
|
39
39
|
// Flags worth counting (e.g. how many installs are --global). Bounded on
|
|
40
40
|
// purpose: only these booleans are ever attached, never free-form values.
|
|
41
41
|
const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json', 'link'];
|
|
42
|
+
// One definition, used both to WRITE a flag attribute and to recognise one as
|
|
43
|
+
// reserved in `commandAttributes` — the two must not be able to drift.
|
|
44
|
+
const FLAG_ATTR_PREFIX = 'lorekit.cli.flag.';
|
|
42
45
|
|
|
43
46
|
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
|
44
47
|
|
|
@@ -245,17 +248,87 @@ function resourceAttributes(version, env = process.env) {
|
|
|
245
248
|
|
|
246
249
|
// ── Payload builders (pure — unit-tested) ─────────────────────────────────────
|
|
247
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The CLOSED vocabulary of `lorekit.cli.outcome`, and the one place it is
|
|
253
|
+
* written down in code.
|
|
254
|
+
*
|
|
255
|
+
* The three values are not synonyms and the distinction is load-bearing:
|
|
256
|
+
*
|
|
257
|
+
* - `ok` — ran, exit 0.
|
|
258
|
+
* - `failure` — RAN TO COMPLETION and reported a negative VERDICT (a failing
|
|
259
|
+
* `doctor` check, a `lint` finding). The command did its job.
|
|
260
|
+
* - `error` — CRASHED. This is the only one that also sets the span status to
|
|
261
|
+
* `STATUS_CODE_ERROR`.
|
|
262
|
+
*
|
|
263
|
+
* That is what keeps the `cli` service's error rate a measure of the CLI being
|
|
264
|
+
* broken rather than of the user's environment being unhealthy (see the note
|
|
265
|
+
* above the non-zero-exit branch in {@link traceCommand}).
|
|
266
|
+
*
|
|
267
|
+
* WHY A FROZEN CONSTANT RATHER THAN THREE STRING LITERALS. The values were only
|
|
268
|
+
* ever written inline, so the vocabulary was discoverable from the emitted
|
|
269
|
+
* telemetry and nowhere else — and read from telemetry alone the distinction is
|
|
270
|
+
* genuinely easy to misread. A `doctor` that CRASHED in one release and FAILED
|
|
271
|
+
* GRACEFULLY in the next shows up as `error` then `failure` for the same
|
|
272
|
+
* user-visible symptom, which reads like the attribute drifting when it is
|
|
273
|
+
* actually the CLI getting better. Naming the set makes the difference legible
|
|
274
|
+
* at the call site and gives `telemetry.test.mjs` something to pin the docs to.
|
|
275
|
+
*/
|
|
276
|
+
export const CLI_OUTCOMES = Object.freeze({
|
|
277
|
+
OK: 'ok',
|
|
278
|
+
FAILURE: 'failure',
|
|
279
|
+
ERROR: 'error',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/** The same vocabulary as a value list, for guards and exhaustiveness checks. */
|
|
283
|
+
export const CLI_OUTCOME_VALUES = Object.freeze(Object.values(CLI_OUTCOMES));
|
|
284
|
+
|
|
285
|
+
/** The attribute keys `commandAttributes` owns — see its docblock below. */
|
|
286
|
+
const isReservedAttr = (key) =>
|
|
287
|
+
key === 'lorekit.cli.command' ||
|
|
288
|
+
key === 'lorekit.cli.outcome' ||
|
|
289
|
+
key === 'lorekit.cli.exit_code' ||
|
|
290
|
+
key.startsWith(FLAG_ATTR_PREFIX);
|
|
291
|
+
|
|
248
292
|
/**
|
|
249
293
|
* Collect the bounded, non-PII attributes for a command invocation. Only the
|
|
250
294
|
* command name, allow-listed boolean flags, the outcome and the exit code.
|
|
295
|
+
*
|
|
296
|
+
* Deliberately does NOT validate `outcome`: this runs inside the `finally` of
|
|
297
|
+
* every traced command, where throwing would turn a telemetry problem into a
|
|
298
|
+
* command failure. The vocabulary is enforced at the call sites (all of which
|
|
299
|
+
* are in this file) and pinned by `telemetry.test.mjs`.
|
|
300
|
+
*
|
|
301
|
+
* The keys this function owns — command, outcome, exit code, flags — are a
|
|
302
|
+
* RESERVED NAMESPACE: an `extraAttrs` entry under one of them is dropped, and
|
|
303
|
+
* the owned value (if any) is written afterwards. `extraAttrs` used to be
|
|
304
|
+
* merged over last, which meant a command returning
|
|
305
|
+
* `{ exitCode, 'lorekit.cli.outcome': … }` silently replaced the frozen value on
|
|
306
|
+
* its way out — a runtime path the source scan cannot see, because it proves
|
|
307
|
+
* the literal at the call site and not that the value reaches the wire.
|
|
308
|
+
*
|
|
309
|
+
* Reserving the NAMESPACE rather than just overwriting key by key matters
|
|
310
|
+
* because two of the owned keys are written conditionally: `exit_code` only
|
|
311
|
+
* when `exitCode` is a number, and each flag only when it is truthy. Overwriting
|
|
312
|
+
* alone therefore left the gap open in exactly the cases where the CLI emits
|
|
313
|
+
* nothing — an extras value would have been the only `lorekit.cli.exit_code` on
|
|
314
|
+
* the span, sourced from the command rather than from here.
|
|
315
|
+
*
|
|
316
|
+
* A collision is dropped, not rejected: this runs inside the `finally` of every
|
|
317
|
+
* traced command, where throwing would turn a telemetry problem into a command
|
|
318
|
+
* failure. Losing a datum a command should not have put there is the smaller
|
|
319
|
+
* harm than emitting an unowned value under an owned key.
|
|
251
320
|
*/
|
|
252
321
|
export function commandAttributes({ command, args = {}, outcome, exitCode, extraAttrs = {} }) {
|
|
253
|
-
const attrs = {
|
|
322
|
+
const attrs = {};
|
|
323
|
+
for (const [key, value] of Object.entries(extraAttrs)) {
|
|
324
|
+
if (!isReservedAttr(key)) attrs[key] = value;
|
|
325
|
+
}
|
|
326
|
+
attrs['lorekit.cli.command'] = command;
|
|
327
|
+
attrs['lorekit.cli.outcome'] = outcome;
|
|
254
328
|
if (typeof exitCode === 'number') attrs['lorekit.cli.exit_code'] = exitCode;
|
|
255
329
|
for (const flag of FLAG_ATTRS) {
|
|
256
|
-
if (args[flag]) attrs[
|
|
330
|
+
if (args[flag]) attrs[`${FLAG_ATTR_PREFIX}${flag}`] = true;
|
|
257
331
|
}
|
|
258
|
-
Object.assign(attrs, extraAttrs);
|
|
259
332
|
return attrs;
|
|
260
333
|
}
|
|
261
334
|
|
|
@@ -389,7 +462,7 @@ export async function probeTelemetryExport(config, { version = '0.0.0', timeoutM
|
|
|
389
462
|
name: 'lorekit.cli.doctor.telemetry_probe',
|
|
390
463
|
attributes: {
|
|
391
464
|
'lorekit.cli.command': 'doctor',
|
|
392
|
-
'lorekit.cli.outcome':
|
|
465
|
+
'lorekit.cli.outcome': CLI_OUTCOMES.OK,
|
|
393
466
|
'lorekit.telemetry.probe': true,
|
|
394
467
|
},
|
|
395
468
|
startMs: now,
|
|
@@ -533,7 +606,7 @@ export async function traceCommand(command, args, version, run) {
|
|
|
533
606
|
// `outcome` is the command's VERDICT (ok | failure | error); `status` is the
|
|
534
607
|
// SPAN status, and only a crash sets it to error. See the note above the
|
|
535
608
|
// non-zero-exit branch below.
|
|
536
|
-
let outcome =
|
|
609
|
+
let outcome = CLI_OUTCOMES.OK;
|
|
537
610
|
let status = 'ok';
|
|
538
611
|
let statusMessage;
|
|
539
612
|
let extraAttrs = {};
|
|
@@ -563,11 +636,11 @@ export async function traceCommand(command, args, version, run) {
|
|
|
563
636
|
// the CLI being broken rather than of the user's environment being
|
|
564
637
|
// unhealthy. Query the failure verdicts on those attributes, never on the
|
|
565
638
|
// span status.
|
|
566
|
-
outcome =
|
|
639
|
+
outcome = CLI_OUTCOMES.FAILURE;
|
|
567
640
|
}
|
|
568
641
|
return exitCode;
|
|
569
642
|
} catch (e) {
|
|
570
|
-
outcome =
|
|
643
|
+
outcome = CLI_OUTCOMES.ERROR;
|
|
571
644
|
status = 'error';
|
|
572
645
|
// Record only a bounded, non-PII identifier — NEVER e.message. Node fs /
|
|
573
646
|
// network error messages embed absolute paths (e.g. "ENOENT: ... open
|