@lorekit/cli 1.55.3 → 1.57.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 +54 -1
- package/bin/lorekit.mjs +84 -3
- package/package.json +1 -1
- package/src/commands/completion.mjs +68 -0
- package/src/commands/hook.mjs +47 -5
- package/src/commands/install.mjs +104 -2
- package/src/commands/obligations.mjs +135 -0
- package/src/commands/uninstall.mjs +21 -2
- package/src/commands.mjs +8 -0
- package/src/shared/completions.mjs +471 -0
- package/src/shared/mirror-pairs.mjs +86 -0
- package/src/shared/obligations-map.mjs +154 -0
- package/src/shared/obligations-pure.mjs +246 -0
- package/src/telemetry/telemetry.mjs +36 -22
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The Surface-Partner Map — a declarative registry of known, path-keyed file
|
|
2
|
+
// partnerships in this repo. This is slice 1 of the fix for a structural
|
|
3
|
+
// retrieval leak the `dash0-dev` review bot repeatedly flags: a fix to one
|
|
4
|
+
// surface (a mirrored module, a doc that copies a claim, a generated artifact,
|
|
5
|
+
// a required test assertion) leaves its PARTNER surface stale, because the
|
|
6
|
+
// lessons documenting each partnership are retrieved lexically (FTS +
|
|
7
|
+
// recency) and rarely surface at edit time for the exact file just touched.
|
|
8
|
+
//
|
|
9
|
+
// Every entry here is SEEDED from an existing, real CI guard — it duplicates
|
|
10
|
+
// no enforcement, it just makes the partnership visible to `lorekit
|
|
11
|
+
// obligations` (see `../commands/obligations.mjs`) BEFORE the guard runs, so
|
|
12
|
+
// a human or agent can sweep the partner in the same commit instead of
|
|
13
|
+
// discovering the gap in review. `guard` names that real spec/script per
|
|
14
|
+
// entry; entries with no automated guard (`perf-index`, `error-code-doc`) say
|
|
15
|
+
// so explicitly rather than implying one exists.
|
|
16
|
+
//
|
|
17
|
+
// Entry schema:
|
|
18
|
+
// {
|
|
19
|
+
// id: string, // stable identifier
|
|
20
|
+
// match: string|string[], // repo-relative glob(s); a `re:` prefix is
|
|
21
|
+
// // used as a verbatim RegExp; `**/{name}` or
|
|
22
|
+
// // `{name}` binds the matched module's
|
|
23
|
+
// // relative path (dirs + stem, extension
|
|
24
|
+
// // stripped) for reuse in `obliges`
|
|
25
|
+
// obliges: Array<string | string[]>,
|
|
26
|
+
// // each element is EITHER a required partner
|
|
27
|
+
// // path/glob (may reuse `{name}`), a
|
|
28
|
+
// // `run:<action>` advisory (never gates
|
|
29
|
+
// // --strict), OR an array of alternative
|
|
30
|
+
// // paths satisfied by ANY ONE of them (used
|
|
31
|
+
// // when a name-derived module's real partner
|
|
32
|
+
// // could live under either of two sibling
|
|
33
|
+
// // directories)
|
|
34
|
+
// lessonKey: string, // canonical memory key to cite
|
|
35
|
+
// guard?: string, // the existing CI spec/script that enforces
|
|
36
|
+
// // this partnership, if any
|
|
37
|
+
// note?: string, // why this entry exists / its limitations
|
|
38
|
+
// }
|
|
39
|
+
//
|
|
40
|
+
// See `obligations-pure.mjs` for the matcher and the exact `{name}` grammar.
|
|
41
|
+
//
|
|
42
|
+
// The `edge-mirror` / `edge-mirror-core` rows below are a special case: they
|
|
43
|
+
// are GENERATED, one pair of rows per entry in `mirrorPairs`
|
|
44
|
+
// (`./mirror-pairs.mjs`, the single-source inventory shared with
|
|
45
|
+
// `packages/mcp-core/src/edge/edge-parity.spec.ts`), rather than hand-authored
|
|
46
|
+
// with a `{name}`-substituting glob. A glob assuming the edge copy mirrors
|
|
47
|
+
// mcp-core's directory structure false-positives whenever a real mirror
|
|
48
|
+
// flattens or renames it (e.g. `packages/mcp-core/src/auth/auth-token.ts` ↔
|
|
49
|
+
// the FLAT `supabase/functions/mcp/auth-token.ts`) — the exact-path
|
|
50
|
+
// reconstruction it would need to get right is simply not derivable from the
|
|
51
|
+
// two paths alone. Enumerating each KNOWN pair's real partner has no such
|
|
52
|
+
// assumption to violate. `checkObligations` already merges every map entry
|
|
53
|
+
// that shares an `id` into one reported bucket (see `obligations-pure.mjs`'s
|
|
54
|
+
// `byId` merge in the matcher), so the many generated rows below still
|
|
55
|
+
// surface as the two logical `edge-mirror` / `edge-mirror-core` entries.
|
|
56
|
+
|
|
57
|
+
import { mirrorPairs } from './mirror-pairs.mjs';
|
|
58
|
+
|
|
59
|
+
// The flagship recurrence class: a partner copies a CLAIM (a mirrored
|
|
60
|
+
// module's behavior, a generated artifact's content, a documented mechanism)
|
|
61
|
+
// and goes stale when the source changes. Cited by every entry below whose
|
|
62
|
+
// obligation is "this partner copies what you just changed."
|
|
63
|
+
const COPIES_A_CLAIM_LESSON =
|
|
64
|
+
'implement-suggestion-lessons::a-mechanism-clause-you-correct-in-the-pr-body-must-be-corrected-in-every-doc-that-copies-it';
|
|
65
|
+
|
|
66
|
+
// The registered-everywhere / sibling-set recurrence class: adding or moving
|
|
67
|
+
// something that a SET of surfaces enumerates (a doc listing every command,
|
|
68
|
+
// a generated mirror listing every file) re-flags every surface that lists
|
|
69
|
+
// the set and now has a hole.
|
|
70
|
+
const SIBLING_SET_LESSON = 'aw-lessons::docs-drift-grep-must-search-names-not-invocation';
|
|
71
|
+
|
|
72
|
+
const EDGE_MIRROR_GUARD = 'packages/mcp-core/src/edge/edge-parity.spec.ts';
|
|
73
|
+
|
|
74
|
+
// One `{ match, obliges }` row PER KNOWN PAIR, in both directions — see the
|
|
75
|
+
// module-level note above for why this replaces a `{name}`-templated glob.
|
|
76
|
+
const EDGE_MIRROR_ENTRIES = mirrorPairs.flatMap(({ core, edge }) => [
|
|
77
|
+
{
|
|
78
|
+
id: 'edge-mirror',
|
|
79
|
+
match: edge,
|
|
80
|
+
obliges: [core],
|
|
81
|
+
lessonKey: COPIES_A_CLAIM_LESSON,
|
|
82
|
+
guard: EDGE_MIRROR_GUARD,
|
|
83
|
+
note: 'An edge (Deno) module mirrored self-contained from mcp-core — edit one, mirror the other. Partner looked up from the shared mirror-pairs inventory, never reconstructed from an assumed-symmetric path.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'edge-mirror-core',
|
|
87
|
+
match: core,
|
|
88
|
+
obliges: [edge],
|
|
89
|
+
lessonKey: COPIES_A_CLAIM_LESSON,
|
|
90
|
+
guard: EDGE_MIRROR_GUARD,
|
|
91
|
+
note: 'The reverse direction of edge-mirror — a mcp-core source file changed, its known edge mirror (from the same mirror-pairs inventory) is the partner.',
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
export const SURFACE_PARTNER_MAP = [
|
|
96
|
+
...EDGE_MIRROR_ENTRIES,
|
|
97
|
+
{
|
|
98
|
+
id: 'tool-catalog',
|
|
99
|
+
match: 'packages/schemas/src/shared/tool-catalog.ts',
|
|
100
|
+
obliges: [
|
|
101
|
+
'supabase/functions/mcp/tool-dispatch.generated.ts',
|
|
102
|
+
'packages/cli/src/surfaces.generated.mjs',
|
|
103
|
+
'packages/web/public/llms.txt',
|
|
104
|
+
'run:pnpm nx generate:llms schemas',
|
|
105
|
+
],
|
|
106
|
+
lessonKey: COPIES_A_CLAIM_LESSON,
|
|
107
|
+
guard: 'packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts, scripts/codegen/gen-surfaces.mjs --check',
|
|
108
|
+
note: 'The catalog is the single origin of the operation surface — every generated projection of it must be regenerated in the same commit.',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'llms-generated',
|
|
112
|
+
match: ['packages/schemas/src/llms/template.md', 'packages/schemas/src/shared/tool-catalog.ts'],
|
|
113
|
+
obliges: ['packages/web/public/llms.txt', 'run:pnpm nx generate:llms schemas'],
|
|
114
|
+
lessonKey: COPIES_A_CLAIM_LESSON,
|
|
115
|
+
guard: 'packages/schemas/src/llms/render.spec.ts',
|
|
116
|
+
note: 'llms.txt is GENERATED — never hand-edited; the committed file must be what the generator produces from these two sources.',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'docs-section',
|
|
120
|
+
match: 're:^packages/web/src/content/docs/[^/]+\\.mdx$',
|
|
121
|
+
obliges: ['packages/web/src/lib/docs/sections.ts'],
|
|
122
|
+
lessonKey: SIBLING_SET_LESSON,
|
|
123
|
+
guard: 'packages/web/src/lib/docs/sections.spec.ts',
|
|
124
|
+
note: 'A new/removed docs page needs its DOCS_SECTIONS entry, or the site index and the page itself drift apart.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'plugin-skill',
|
|
128
|
+
match: 'packages/cli/skill/**',
|
|
129
|
+
obliges: ['run:node scripts/codegen/sync-plugin-skill.mjs', 'plugins/lorekit-claude/skills/**'],
|
|
130
|
+
lessonKey: SIBLING_SET_LESSON,
|
|
131
|
+
guard: 'scripts/codegen/sync-plugin-skill.mjs --check',
|
|
132
|
+
note: 'The Claude plugin vendors a copy of every skill/* source — regenerate the mirror, never hand-edit it.',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'perf-index',
|
|
136
|
+
match: 're:^supabase/migrations/.*index.*\\.sql$',
|
|
137
|
+
obliges: ['supabase/tests/migrations.test.sql'],
|
|
138
|
+
lessonKey: SIBLING_SET_LESSON,
|
|
139
|
+
guard: null,
|
|
140
|
+
note: 'Convention only — a real gap. A new index migration has no automated nudge to add its coverage to the migrations test.',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'error-code-doc',
|
|
144
|
+
match: ['supabase/functions/mcp/mcp-handler.ts', 'packages/mcp-core/src/auth/account-wide-tools.ts'],
|
|
145
|
+
obliges: [
|
|
146
|
+
'docs/mcp-tools.md',
|
|
147
|
+
'packages/schemas/src/llms/template.md',
|
|
148
|
+
'packages/web/public/llms.txt',
|
|
149
|
+
],
|
|
150
|
+
lessonKey: COPIES_A_CLAIM_LESSON,
|
|
151
|
+
guard: null,
|
|
152
|
+
note: 'A documented path-proxy, not a content predicate — obligations sees file paths, not diffs, so it cannot tell an error-const edit from an unrelated one in the same file and may over-flag. Advisory only (never gates unless --strict, and even then it is one signal among several).',
|
|
153
|
+
},
|
|
154
|
+
];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Pure, DEPENDENCY-FREE matcher for the Surface-Partner Map (`obligations-map.mjs`).
|
|
2
|
+
//
|
|
3
|
+
// The problem this solves: a fix to one surface (a mirrored module, a doc that
|
|
4
|
+
// copies a claim, a generated artifact) routinely leaves its PARTNER surface
|
|
5
|
+
// stale, because the ~45 memory lessons documenting each known partnership are
|
|
6
|
+
// retrieved lexically (FTS + recency) and never surface at edit time for a
|
|
7
|
+
// specific file. Since the recurrences are known, PATH-KEYED file
|
|
8
|
+
// partnerships, a deterministic path-keyed check beats search — this module is
|
|
9
|
+
// that check.
|
|
10
|
+
//
|
|
11
|
+
// Zero imports on purpose (mirrors `lessons-pure.mjs`) and NEVER touches the
|
|
12
|
+
// filesystem or `process.cwd()` — see the module-level note in
|
|
13
|
+
// `obligations-map.mjs`. It matches the path STRINGS it is given against the
|
|
14
|
+
// map; the IO shell (`commands/obligations.mjs`) resolves those strings from
|
|
15
|
+
// argv/stdin.
|
|
16
|
+
//
|
|
17
|
+
// ── the `{name}` placeholder ────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// A `match`/`obliges` glob may contain the literal token `{name}` to bind the
|
|
20
|
+
// module's relative path (directories + file stem, extension stripped) so a
|
|
21
|
+
// generic partnership (`edge-mirror`) doesn't need one map entry per module.
|
|
22
|
+
// The token is recognised in two shapes:
|
|
23
|
+
//
|
|
24
|
+
// `**/{name}` — ONE capturing group spanning zero or more path segments
|
|
25
|
+
// AND the final stem, e.g. `src/**/{name}.ts` captures
|
|
26
|
+
// `audit/audit` out of `src/audit/audit.ts`, for reuse in a
|
|
27
|
+
// partner pattern's own `**/{name}` slot. This ONLY works
|
|
28
|
+
// when the partner's directory structure is a predictable
|
|
29
|
+
// function of the source's — e.g. `docs-section`'s
|
|
30
|
+
// `sections.ts` entry needs no name at all, so it's not used
|
|
31
|
+
// there either. It is NOT used by the seed map's
|
|
32
|
+
// `edge-mirror`/`edge-mirror-core` entries (see
|
|
33
|
+
// `obligations-map.mjs`): a real edge (Deno) mirror does not
|
|
34
|
+
// reliably preserve mcp-core's directory structure — it may
|
|
35
|
+
// flatten or rename it — so those two entries instead
|
|
36
|
+
// enumerate every KNOWN pair's exact partner from a
|
|
37
|
+
// single-source inventory (`mirror-pairs.mjs`) rather than
|
|
38
|
+
// reconstruct a partner path via substitution. The token
|
|
39
|
+
// remains available here as a general primitive for any
|
|
40
|
+
// future map entry whose partner path genuinely IS a
|
|
41
|
+
// predictable function of the source's.
|
|
42
|
+
// `{name}` — a single-segment capturing group (no preceding `**/`) for
|
|
43
|
+
// a flat `{name}.ext` match with no directory component.
|
|
44
|
+
//
|
|
45
|
+
// `stemOf` (below) is the simpler, general-purpose primitive — a plain
|
|
46
|
+
// basename-without-extension — exported for standalone use and unit testing.
|
|
47
|
+
|
|
48
|
+
export const RUN_PREFIX = 'run:';
|
|
49
|
+
export const REGEX_PREFIX = 're:';
|
|
50
|
+
|
|
51
|
+
const NAME_TOKEN = '{name}';
|
|
52
|
+
const DIR_NAME_TOKEN = `**/${NAME_TOKEN}`;
|
|
53
|
+
|
|
54
|
+
// Basename of `path`, extension stripped. A leading-dot file (`.env`) is its
|
|
55
|
+
// own stem (no extension to strip) rather than an empty string.
|
|
56
|
+
export function stemOf(path) {
|
|
57
|
+
const base = String(path ?? '').split('/').pop() ?? '';
|
|
58
|
+
const dot = base.lastIndexOf('.');
|
|
59
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Escape one character for literal inclusion in a RegExp source string.
|
|
63
|
+
function escapeChar(ch) {
|
|
64
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Compile a single glob/regex pattern string into `{ regex, capturesName }`.
|
|
68
|
+
// `re:`-prefixed patterns are used verbatim (already anchored by the author,
|
|
69
|
+
// per the seed map's `docs-section`/`perf-index` entries) and never capture a
|
|
70
|
+
// name. Anything else is compiled token-by-token: `**/{name}` and bare
|
|
71
|
+
// `{name}` become the ONE capturing group a pattern may have, `**` becomes
|
|
72
|
+
// `.*`, `*` becomes `[^/]*`, and every other character is escaped literally.
|
|
73
|
+
// Returns null for a non-string / empty pattern, never throws.
|
|
74
|
+
export function compilePattern(pattern) {
|
|
75
|
+
if (typeof pattern !== 'string' || !pattern) return null;
|
|
76
|
+
if (pattern.startsWith(REGEX_PREFIX)) {
|
|
77
|
+
try {
|
|
78
|
+
return { regex: new RegExp(pattern.slice(REGEX_PREFIX.length)), capturesName: false };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let src = '';
|
|
84
|
+
let capturesName = false;
|
|
85
|
+
let i = 0;
|
|
86
|
+
while (i < pattern.length) {
|
|
87
|
+
if (pattern.startsWith(DIR_NAME_TOKEN, i)) {
|
|
88
|
+
src += '(.+)';
|
|
89
|
+
capturesName = true;
|
|
90
|
+
i += DIR_NAME_TOKEN.length;
|
|
91
|
+
} else if (pattern.startsWith(NAME_TOKEN, i)) {
|
|
92
|
+
src += '([^/]+)';
|
|
93
|
+
capturesName = true;
|
|
94
|
+
i += NAME_TOKEN.length;
|
|
95
|
+
} else if (pattern.startsWith('**', i)) {
|
|
96
|
+
src += '.*';
|
|
97
|
+
i += 2;
|
|
98
|
+
} else if (pattern[i] === '*') {
|
|
99
|
+
src += '[^/]*';
|
|
100
|
+
i += 1;
|
|
101
|
+
} else {
|
|
102
|
+
src += escapeChar(pattern[i]);
|
|
103
|
+
i += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { regex: new RegExp(`^${src}$`), capturesName };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Compile `glob` into a plain, non-capturing-aware `RegExp` — the general
|
|
110
|
+
// membership test used both to check whether a changed file satisfies a
|
|
111
|
+
// (post-substitution) oblige target, and by tests exercising the glob syntax
|
|
112
|
+
// in isolation. Same token grammar as `compilePattern`; a `{name}` left
|
|
113
|
+
// unsubstituted (never expected once `checkObligations` has run) simply
|
|
114
|
+
// compiles to its capturing form, still usable as a plain matcher.
|
|
115
|
+
export function globToRegExp(glob) {
|
|
116
|
+
return compilePattern(glob)?.regex ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Replace the pattern's name placeholder with a captured value. `**/{name}`
|
|
120
|
+
// is replaced as ONE unit (so the substitution reconstructs a full relative
|
|
121
|
+
// path, matching how `compilePattern` captured it); a bare `{name}` is
|
|
122
|
+
// replaced on its own. `name == null` (no capture in the matching `match`
|
|
123
|
+
// pattern) returns `pattern` unchanged — nothing to substitute.
|
|
124
|
+
export function substituteName(pattern, name) {
|
|
125
|
+
if (name == null || typeof pattern !== 'string') return pattern;
|
|
126
|
+
if (pattern.includes(DIR_NAME_TOKEN)) return pattern.split(DIR_NAME_TOKEN).join(name);
|
|
127
|
+
if (pattern.includes(NAME_TOKEN)) return pattern.split(NAME_TOKEN).join(name);
|
|
128
|
+
return pattern;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Does `file` satisfy `globPattern` (post-substitution, so no `{name}` token
|
|
132
|
+
// remains, but `**`/`*` wildcards may)? Compiles and tests in one step; an
|
|
133
|
+
// uncompilable pattern never matches.
|
|
134
|
+
function fileSatisfies(file, globPattern) {
|
|
135
|
+
const regex = globToRegExp(globPattern);
|
|
136
|
+
return regex ? regex.test(file) : false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fold one `obliges` element into `bucket` (a `Map<target, row>` keyed by the
|
|
140
|
+
// rendered target string, so repeated matches — several changed files hitting
|
|
141
|
+
// the same entry — dedupe by the concrete, substituted target rather than by
|
|
142
|
+
// raw template).
|
|
143
|
+
//
|
|
144
|
+
// An element is one of:
|
|
145
|
+
// `run:<action>` — advisory, `kind:'action'`, `met: null`, never
|
|
146
|
+
// gates `--strict`.
|
|
147
|
+
// a string — a single required path/glob, `kind:'path'`.
|
|
148
|
+
// an array of strings — an "any of" group: the obligation is met if ANY
|
|
149
|
+
// candidate is present in `changedFiles` (for a
|
|
150
|
+
// partner that could legitimately live in more
|
|
151
|
+
// than one place). Rendered as one row whose
|
|
152
|
+
// `target` joins every candidate with ` OR `,
|
|
153
|
+
// `met` true iff any candidate matches. The seed
|
|
154
|
+
// map's `edge-mirror`/`edge-mirror-core` entries do
|
|
155
|
+
// NOT use this — see `obligations-map.mjs` — since
|
|
156
|
+
// enumerating each known pair's exact partner from
|
|
157
|
+
// `mirror-pairs.mjs` needs no "either of" guess.
|
|
158
|
+
// A row already present for the same rendered target has its `met` OR'd in
|
|
159
|
+
// (never downgraded from true to false by a later, differently-named match).
|
|
160
|
+
function addOblige(bucket, rawOblige, name, changedFiles) {
|
|
161
|
+
if (typeof rawOblige === 'string' && rawOblige.startsWith(RUN_PREFIX)) {
|
|
162
|
+
if (!bucket.has(rawOblige)) bucket.set(rawOblige, { target: rawOblige, kind: 'action', met: null });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const candidates = (Array.isArray(rawOblige) ? rawOblige : [rawOblige])
|
|
166
|
+
.filter((c) => typeof c === 'string' && c);
|
|
167
|
+
if (candidates.length === 0) return;
|
|
168
|
+
const substituted = candidates.map((c) => substituteName(c, name));
|
|
169
|
+
const met = substituted.some((c) => changedFiles.some((f) => fileSatisfies(f, c)));
|
|
170
|
+
const target = substituted.join(' OR ');
|
|
171
|
+
const existing = bucket.get(target);
|
|
172
|
+
if (existing) existing.met = existing.met || met;
|
|
173
|
+
else bucket.set(target, { target, kind: 'path', met });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check a changed-file set against the Surface-Partner Map. For every
|
|
178
|
+
* `(file, entry)` pair where `file` satisfies one of `entry.match`'s
|
|
179
|
+
* patterns, the entry is recorded as matched and its `obliges` are resolved
|
|
180
|
+
* (with `{name}` substituted from whatever the matching pattern captured) and
|
|
181
|
+
* checked for membership in `changedFiles`. Entries are deduped by `id` — a
|
|
182
|
+
* file set can hit the same entry via several files, and each contributes its
|
|
183
|
+
* own name-substituted targets into that one entry's `obliges` list.
|
|
184
|
+
*
|
|
185
|
+
* Pure and total: a malformed `map`/`changedFiles` degrades to no matches
|
|
186
|
+
* rather than throwing.
|
|
187
|
+
*
|
|
188
|
+
* Returns `{ files, matched, unmet, ok }` — see `obligations-map.mjs`'s
|
|
189
|
+
* schema docblock for the shape of each `matched` entry.
|
|
190
|
+
*/
|
|
191
|
+
export function checkObligations({ changedFiles = [], map = [] } = {}) {
|
|
192
|
+
const files = Array.isArray(changedFiles)
|
|
193
|
+
? changedFiles.filter((f) => typeof f === 'string' && f.length > 0)
|
|
194
|
+
: [];
|
|
195
|
+
const entries = Array.isArray(map) ? map : [];
|
|
196
|
+
|
|
197
|
+
const byId = new Map(); // id -> { id, lessonKey, guard, note, obliges: Map<target, row> }
|
|
198
|
+
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (!entry || typeof entry !== 'object' || !entry.id) continue;
|
|
201
|
+
const patterns = Array.isArray(entry.match) ? entry.match : [entry.match];
|
|
202
|
+
|
|
203
|
+
for (const file of files) {
|
|
204
|
+
let matched = false;
|
|
205
|
+
let name = null;
|
|
206
|
+
for (const pattern of patterns) {
|
|
207
|
+
const compiled = compilePattern(pattern);
|
|
208
|
+
if (!compiled) continue;
|
|
209
|
+
const m = compiled.regex.exec(file);
|
|
210
|
+
if (m) {
|
|
211
|
+
matched = true;
|
|
212
|
+
if (compiled.capturesName) name = m[1];
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!matched) continue;
|
|
217
|
+
|
|
218
|
+
let bucket = byId.get(entry.id);
|
|
219
|
+
if (!bucket) {
|
|
220
|
+
bucket = {
|
|
221
|
+
id: entry.id,
|
|
222
|
+
lessonKey: entry.lessonKey ?? null,
|
|
223
|
+
guard: entry.guard ?? null,
|
|
224
|
+
note: entry.note ?? null,
|
|
225
|
+
obliges: new Map(),
|
|
226
|
+
};
|
|
227
|
+
byId.set(entry.id, bucket);
|
|
228
|
+
}
|
|
229
|
+
for (const rawOblige of Array.isArray(entry.obliges) ? entry.obliges : []) {
|
|
230
|
+
addOblige(bucket.obliges, rawOblige, name, files);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const matched = [...byId.values()].map((b) => ({
|
|
236
|
+
id: b.id,
|
|
237
|
+
lessonKey: b.lessonKey,
|
|
238
|
+
guard: b.guard,
|
|
239
|
+
note: b.note,
|
|
240
|
+
obliges: [...b.obliges.values()],
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
const unmet = matched.reduce((n, e) => n + e.obliges.filter((o) => o.met === false).length, 0);
|
|
244
|
+
|
|
245
|
+
return { files, matched, unmet, ok: unmet === 0 };
|
|
246
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
|
|
6
6
|
// packages. One span + one counter data point per human-facing command
|
|
7
7
|
// (install / uninstall / doctor / list / search / show / stats / scopes / diff /
|
|
8
|
-
// tree / lint / dedupe / link / migrate), fired to Dash0 so the maintainers can
|
|
8
|
+
// tree / lint / dedupe / obligations / link / migrate), fired to Dash0 so the maintainers can
|
|
9
9
|
// see which commands people actually run.
|
|
10
10
|
//
|
|
11
11
|
// Privacy — this runs on end-users' machines, so it is deliberately narrow:
|
|
@@ -655,25 +655,28 @@ function normalizeExitCode(result) {
|
|
|
655
655
|
* Run a MACHINE-facing command (`hook`, `mcp`) and count the invocation —
|
|
656
656
|
* counter only, no span. Returns the command's exit code unchanged.
|
|
657
657
|
*
|
|
658
|
-
* The
|
|
659
|
-
*
|
|
660
|
-
* ordering is what makes this affordable on `hook`, which fires several times
|
|
661
|
-
* per agent turn: by the time there is anything to await, the POST has usually
|
|
662
|
-
* already completed, and {@link METERED_TIMEOUT_MS} caps the worst case.
|
|
658
|
+
* The two commands are counted at DIFFERENT times, for a reason specific to
|
|
659
|
+
* each:
|
|
663
660
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
* verdict belongs to its stdout contract
|
|
669
|
-
*
|
|
661
|
+
* • `mcp` is a LONG-LIVED stdio server — `run()` does not return until the
|
|
662
|
+
* server exits, and a killed server would never report at all if the export
|
|
663
|
+
* waited for it. So its count fires BEFORE run and is awaited after, which
|
|
664
|
+
* also overlaps the export with the server's own startup. It carries no
|
|
665
|
+
* outcome: a server's verdict belongs to its stdout contract.
|
|
666
|
+
* • `hook` is short-lived and fires several times per agent turn. It is
|
|
667
|
+
* counted AFTER run so the counter can carry the health dimensions run
|
|
668
|
+
* reports (`lorekit.hook.event` + `lorekit.hook.outcome`) — the difference
|
|
669
|
+
* between a healthy hook and one silently degrading (an unusable store, a
|
|
670
|
+
* swallowed lookup error) that a bare invocation ping cannot show. The extra
|
|
671
|
+
* latency is run's own duration (a hook is tens of ms); the
|
|
672
|
+
* {@link METERED_TIMEOUT_MS} export cap dominates either way.
|
|
670
673
|
*
|
|
671
674
|
* Nothing here can affect the command: the exit code is passed through
|
|
672
675
|
* untouched, and every telemetry failure is swallowed.
|
|
673
676
|
*
|
|
674
677
|
* @param {string} command `hook` | `mcp`
|
|
675
678
|
* @param {string} version CLI version
|
|
676
|
-
* @param {() => Promise<number>} run the command handler
|
|
679
|
+
* @param {() => Promise<number | { exitCode?: number, meter?: object }>} run the command handler
|
|
677
680
|
*/
|
|
678
681
|
export async function meterCommand(command, version, run) {
|
|
679
682
|
let config;
|
|
@@ -688,15 +691,26 @@ export async function meterCommand(command, version, run) {
|
|
|
688
691
|
// cost nothing at all: no identity read, no timer, no promise.
|
|
689
692
|
if (!config.enabled) return normalizeExitCode(await run());
|
|
690
693
|
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
694
|
+
// Long-lived server: count before run (see docblock). `.catch` attached
|
|
695
|
+
// IMMEDIATELY, before any await: an unawaited rejecting promise is an
|
|
696
|
+
// unhandled rejection, which on a machine-facing command would print to
|
|
697
|
+
// stderr and pollute a host's log.
|
|
698
|
+
if (command === 'mcp') {
|
|
699
|
+
const pending = countInvocation(config, command, version).catch(() => {});
|
|
700
|
+
try {
|
|
701
|
+
return normalizeExitCode(await run());
|
|
702
|
+
} finally {
|
|
703
|
+
await pending;
|
|
704
|
+
}
|
|
699
705
|
}
|
|
706
|
+
|
|
707
|
+
// Short-lived hook: count after run so the counter carries what run reported.
|
|
708
|
+
// A `meter` object on the result is the health payload; anything else (a bare
|
|
709
|
+
// exit code) counts with no extra dimensions.
|
|
710
|
+
const result = await run();
|
|
711
|
+
const meter = (result && typeof result === 'object' && result.meter) ? result.meter : {};
|
|
712
|
+
await countInvocation(config, command, version, meter).catch(() => {});
|
|
713
|
+
return normalizeExitCode(result);
|
|
700
714
|
}
|
|
701
715
|
|
|
702
716
|
/**
|
|
@@ -709,7 +723,7 @@ export async function meterCommand(command, version, run) {
|
|
|
709
723
|
* `lint` finding) reports `lorekit.cli.outcome=failure` on a span the exporter
|
|
710
724
|
* emits as STATUS_CODE_OK — never ERROR.
|
|
711
725
|
*
|
|
712
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
|
|
726
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | obligations | link | migrate
|
|
713
727
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
714
728
|
* @param {string} version CLI version (from package.json)
|
|
715
729
|
* @param {() => Promise<number>} run the command handler
|