@opum-ai/lore 0.1.0 → 0.2.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 +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/commands/query.ts
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* commands/query.ts — `lore query ["<text>"] [--type <T>] [--tag <t>]… [--status <S>] [--field k=v]… [--limit <n>]`.
|
|
3
|
-
*
|
|
4
|
-
* The thin, read-only layer behind the bundle's full-text search (cli-surface
|
|
5
|
-
* §query; LORE-33). Commander supplies a verified indexed {@link BundleGraph}
|
|
6
|
-
* with automatic reference fallback; direct core callers retain the reference
|
|
7
|
-
* loader. The optional search text and frontmatter filters then go to the pure
|
|
8
|
-
* {@link query} engine, which keeps concepts matching every filter, ranks them
|
|
9
|
-
* by BM25 relevance, and returns the top `--limit` hits with a bounded-output
|
|
10
|
-
* signal. **No vectors, RAG, or chunking** (ADR-0018): the lexical index remains
|
|
11
|
-
* deterministic and in memory.
|
|
12
|
-
*
|
|
13
|
-
* Output follows the uniform CLI modes: the `{schemaVersion, kind: "query.results",
|
|
14
|
-
* data}` envelope under the global `--json`, otherwise a ranked listing — one
|
|
15
|
-
* `<id> [<type>] (<score>) — <snippet>` line per hit followed by the §3 truncation
|
|
16
|
-
* footer when the `--limit` cap dropped matches. There is no command-specific JSON
|
|
17
|
-
* flag; machine consumers use the same `--json` they use everywhere. The pretty and
|
|
18
|
-
* plain renderers are identical (the listing is structural — no severities to color),
|
|
19
|
-
* so they share one renderer.
|
|
20
|
-
*
|
|
21
|
-
* Validation lives here (the ranking/filtering stays pure in `core/query.ts`): an
|
|
22
|
-
* unknown flag, a repeated `--type`/`--status`/`--limit`, a value-less value flag, a
|
|
23
|
-
* non-integer/too-large/non-positive `--limit`, a malformed `--field` (no `=` or an
|
|
24
|
-
* empty key), or a second positional is a `usage` error (exit 2). `query` never looks
|
|
25
|
-
* an id up, so there is no `not_found` path — zero hits is a normal exit 0.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { join } from "node:path";
|
|
29
|
-
import type { BacklogAdapter } from "../adapters/backlog";
|
|
30
|
-
import { loadBundle } from "../core/bundle";
|
|
31
|
-
import { loadProfile } from "../core/profile";
|
|
32
|
-
import { type FieldFilter, type QueryResult, query } from "../core/query";
|
|
33
|
-
import { loadRetrievalGraph, type RetrievalGraphLoader } from "../core/retrieval";
|
|
34
|
-
import { DOCS_DIR } from "../core/scaffold";
|
|
35
|
-
import type { WorkspaceRetrievalContext, WorkspaceRetrievalSelection } from "../core/workspace-retrieval";
|
|
36
|
-
import { EXIT_OK, LoreError, singleLine, stripAnsiAndControls, WarningCollector, type Writer } from "../errors";
|
|
37
|
-
import { emit, type OutputContext, type Renderable, renderTruncationLine, truncation } from "../output";
|
|
38
|
-
import { optionValues, parseCommandArgs, singleOptionValue, workspaceSelection } from "./args";
|
|
39
|
-
|
|
40
|
-
/** Options for {@link runQuery}; `root` and the streams are injectable for tests. */
|
|
41
|
-
export interface QueryCommandOptions {
|
|
42
|
-
/** The repo root the `docs/` bundle resolves against. */
|
|
43
|
-
root: string;
|
|
44
|
-
/** The resolved output mode/color (from `output.ts`). */
|
|
45
|
-
output: OutputContext;
|
|
46
|
-
/** The command's normalized positional + flag tokens from Commander. */
|
|
47
|
-
args: readonly string[];
|
|
48
|
-
/** stdout sink; defaults to `process.stdout`. */
|
|
49
|
-
stdout?: Writer;
|
|
50
|
-
/** stderr sink for advisory warnings; defaults to `process.stderr`. */
|
|
51
|
-
stderr?: Writer;
|
|
52
|
-
/** Backlog snapshot seam used only by indexed projection freshness/builds. */
|
|
53
|
-
adapter?: BacklogAdapter;
|
|
54
|
-
/** Indexed/reference selector injected by the Commander handler or conformance tests. */
|
|
55
|
-
retrieval?: RetrievalGraphLoader;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The parsed form of `lore query`'s arguments. */
|
|
59
|
-
interface QueryArgs {
|
|
60
|
-
/** The free-text search (the optional positional); `undefined` means filters-only. */
|
|
61
|
-
text?: string;
|
|
62
|
-
/** `--type` (at most once). */
|
|
63
|
-
type?: string;
|
|
64
|
-
/** `--status` (at most once). */
|
|
65
|
-
status?: string;
|
|
66
|
-
/** `--limit` (at most once); `undefined` falls back to the engine default. */
|
|
67
|
-
limit?: number;
|
|
68
|
-
/** `--tag` values, in order (repeatable). */
|
|
69
|
-
tags: string[];
|
|
70
|
-
/** `--field key=value` filters, in order (repeatable). */
|
|
71
|
-
fields: FieldFilter[];
|
|
72
|
-
readonly workspace?: WorkspaceRetrievalSelection;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The narrow-it hint on the §3 truncation line (AC#2) — the actionable ways to bound a broad result. */
|
|
76
|
-
const NARROW_HINT = "narrow with --type/--tag/--status/--field, or raise --limit";
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Run `lore query`: parse the arguments, load the bundle, search it, emit the
|
|
80
|
-
* `query.results`, and return `0`. A bad flag/positional throws a `usage`
|
|
81
|
-
* {@link LoreError} (exit `2`); there is no not-found path (zero hits is a normal `0`).
|
|
82
|
-
*/
|
|
83
|
-
export function runQuery(options: QueryCommandOptions): number | Promise<number> {
|
|
84
|
-
const parsed = parseQueryArgs(options.args);
|
|
85
|
-
const advisories = new WarningCollector();
|
|
86
|
-
const retrieval = options.retrieval ?? (parsed.workspace !== undefined ? loadRetrievalGraph : undefined);
|
|
87
|
-
if (retrieval !== undefined) {
|
|
88
|
-
return retrieval({
|
|
89
|
-
root: options.root,
|
|
90
|
-
warnings: advisories,
|
|
91
|
-
adapter: options.adapter,
|
|
92
|
-
...(parsed.workspace !== undefined ? { workspace: parsed.workspace } : {}),
|
|
93
|
-
}).then(async (loaded) => {
|
|
94
|
-
try {
|
|
95
|
-
const indexedResult = await loaded.indexed?.query({
|
|
96
|
-
text: parsed.text,
|
|
97
|
-
type: parsed.type,
|
|
98
|
-
tags: parsed.tags,
|
|
99
|
-
status: parsed.status,
|
|
100
|
-
fields: parsed.fields,
|
|
101
|
-
limit: parsed.limit,
|
|
102
|
-
});
|
|
103
|
-
return finishQuery(options, parsed, loaded.graph, advisories, indexedResult, loaded.workspace);
|
|
104
|
-
} finally {
|
|
105
|
-
await loaded.dispose?.();
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
const profile = loadProfile({ root: options.root });
|
|
110
|
-
const graph = loadBundle(join(options.root, DOCS_DIR), { warnings: advisories, profile });
|
|
111
|
-
return finishQuery(options, parsed, graph, advisories);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function finishQuery(
|
|
115
|
-
options: QueryCommandOptions,
|
|
116
|
-
parsed: QueryArgs,
|
|
117
|
-
graph: ReturnType<typeof loadBundle>,
|
|
118
|
-
advisories: WarningCollector,
|
|
119
|
-
indexedResult?: QueryResult,
|
|
120
|
-
workspace?: WorkspaceRetrievalContext,
|
|
121
|
-
): number {
|
|
122
|
-
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
123
|
-
|
|
124
|
-
const data =
|
|
125
|
-
indexedResult ??
|
|
126
|
-
query(graph, {
|
|
127
|
-
text: parsed.text,
|
|
128
|
-
type: parsed.type,
|
|
129
|
-
tags: parsed.tags,
|
|
130
|
-
status: parsed.status,
|
|
131
|
-
fields: parsed.fields,
|
|
132
|
-
limit: parsed.limit,
|
|
133
|
-
});
|
|
134
|
-
const scoped: QueryResult =
|
|
135
|
-
workspace === undefined
|
|
136
|
-
? data
|
|
137
|
-
: {
|
|
138
|
-
...data,
|
|
139
|
-
hits: data.hits.map((hit) => ({ ...hit, provenance: workspace.provenanceById.get(hit.id) })),
|
|
140
|
-
workspace: workspace.scope,
|
|
141
|
-
};
|
|
142
|
-
emit(queryRenderable(scoped), options.output, options.stdout);
|
|
143
|
-
return EXIT_OK;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ── Argument parsing ───────────────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Parse `query`'s tokens into the optional text positional and the value flags
|
|
150
|
-
* (`--type`/`--status`/`--limit` at most once each; `--tag`/`--field` repeatable),
|
|
151
|
-
* also accepting the `--flag=value` form. Commander has already resolved Lore's
|
|
152
|
-
* global flags, so a `--`-prefixed token here is a command flag: an unrecognized one
|
|
153
|
-
* is a `usage` error, as is a repeated single-value flag, a value-less value flag, a
|
|
154
|
-
* non-integer/out-of-range `--limit`, a malformed `--field`, or a second positional.
|
|
155
|
-
* A `--` ends option parsing.
|
|
156
|
-
*/
|
|
157
|
-
function parseQueryArgs(args: readonly string[]): QueryArgs {
|
|
158
|
-
const parsed = parseCommandArgs(args, "query");
|
|
159
|
-
const workspace = workspaceSelection(parsed);
|
|
160
|
-
const positionals = parsed.positionals;
|
|
161
|
-
const trimmed = (name: string): string | undefined => {
|
|
162
|
-
const value = singleOptionValue(parsed, name);
|
|
163
|
-
if (value === undefined) return undefined;
|
|
164
|
-
const result = value.trim();
|
|
165
|
-
if (result === "") throw usage(`--${name} needs a value`, `pass a value, e.g. \`--${name} orders\``);
|
|
166
|
-
return result;
|
|
167
|
-
};
|
|
168
|
-
const type = trimmed("type");
|
|
169
|
-
const status = trimmed("status");
|
|
170
|
-
const rawLimit = singleOptionValue(parsed, "limit");
|
|
171
|
-
if (rawLimit === "") throw usage("--limit needs a value", "pass a value, e.g. `--limit orders`");
|
|
172
|
-
const limit = rawLimit === undefined ? undefined : parseCount("--limit", rawLimit);
|
|
173
|
-
const tags = optionValues(parsed, "tag").map((value) => {
|
|
174
|
-
const tag = value.trim();
|
|
175
|
-
if (tag === "") throw usage("--tag needs a value", "pass a value, e.g. `--tag orders`");
|
|
176
|
-
return tag;
|
|
177
|
-
});
|
|
178
|
-
const fields = optionValues(parsed, "field").map(parseFieldFilter);
|
|
179
|
-
if (positionals.length > 1) {
|
|
180
|
-
throw usage(
|
|
181
|
-
`unexpected argument "${positionals[1]}"`,
|
|
182
|
-
'pass one quoted search string, e.g. `lore query "soft delete retention"`',
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
return { text: positionals[0], type, status, limit, tags, fields, workspace };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Parse a `--field` argument into a `key=value` {@link FieldFilter}. Splits on the
|
|
190
|
-
* **first** `=` (so a value may itself contain `=`); both sides are **trimmed** (the
|
|
191
|
-
* comparison folds case and ignores surrounding space, so a padded `status= Done` must
|
|
192
|
-
* not silently miss). A missing `=`, an empty key, or an **empty value** is a `usage`
|
|
193
|
-
* error — the latter matching the empty-value guard {@link readValue} enforces for every
|
|
194
|
-
* other flag, so `--field status=` is rejected rather than matching only a literally
|
|
195
|
-
* empty field.
|
|
196
|
-
*/
|
|
197
|
-
function parseFieldFilter(raw: string): FieldFilter {
|
|
198
|
-
const eq = raw.indexOf("=");
|
|
199
|
-
if (eq === -1) {
|
|
200
|
-
throw usage(`invalid --field "${raw}"`, "use key=value, e.g. `--field status=in-progress`");
|
|
201
|
-
}
|
|
202
|
-
const key = raw.slice(0, eq).trim();
|
|
203
|
-
if (key === "") {
|
|
204
|
-
throw usage(`invalid --field "${raw}"`, "the field name before = must not be empty");
|
|
205
|
-
}
|
|
206
|
-
const value = raw.slice(eq + 1).trim();
|
|
207
|
-
if (value === "") {
|
|
208
|
-
throw usage(`invalid --field "${raw}"`, "the value after = must not be empty");
|
|
209
|
-
}
|
|
210
|
-
return { key, value };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Parse `--limit`'s value as a positive integer. Rejects a non-digit run (`Number()`
|
|
215
|
-
* would coerce `"1.5"`/`"0x2"`/`" 2 "`/`"1e3"`), `0` (a zero cap returns nothing
|
|
216
|
-
* useful), and a precision-losing `> 2^53` run — mirroring the count guard `lore
|
|
217
|
-
* graph`/`context` use so the commands accept counts identically.
|
|
218
|
-
*/
|
|
219
|
-
function parseCount(flag: string, value: string): number {
|
|
220
|
-
if (!/^\d+$/.test(value)) {
|
|
221
|
-
throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
|
|
222
|
-
}
|
|
223
|
-
const count = Number.parseInt(value, 10);
|
|
224
|
-
if (!Number.isSafeInteger(count)) {
|
|
225
|
-
throw usage(`${flag} "${value}" is too large`, "pass a smaller integer");
|
|
226
|
-
}
|
|
227
|
-
if (count < 1) {
|
|
228
|
-
throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
|
|
229
|
-
}
|
|
230
|
-
return count;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Read a value flag's argument: its inline `--flag=value` form when present, else the
|
|
235
|
-
* **next** token. A missing/empty value — or a next token that is itself an option
|
|
236
|
-
* (`--type --tag`) — is a `usage` error rather than a silently swallowed flag
|
|
237
|
-
* (mirroring `lore graph`/`context`'s value-flag guard).
|
|
238
|
-
*/
|
|
239
|
-
// ── Output ─────────────────────────────────────────────────────────────────────
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* The per-result rendering bundle for `query` (output.ts dispatches on the mode).
|
|
243
|
-
* `--json` always carries the structured {@link QueryResult}; the pretty/plain text is
|
|
244
|
-
* the ranked listing. The two text modes render identically (the data is structural —
|
|
245
|
-
* no severities to color), so pretty and plain share one renderer.
|
|
246
|
-
*/
|
|
247
|
-
function queryRenderable(data: QueryResult): Renderable<QueryResult> {
|
|
248
|
-
return { kind: "query.results", data, pretty: renderText, plain: renderText };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* A human/pipe-stable ranked listing: a `query "<text>": <n> matches` header (or
|
|
253
|
-
* `query (filters): …` when filters drove the result), one `<id> [<type>]
|
|
254
|
-
* (<score>) — <snippet>` line per hit (the `(<score>)` shown only under a text query;
|
|
255
|
-
* the `— <snippet>` dropped when the concept has neither summary nor title), and the
|
|
256
|
-
* trailing §3 truncation footer when the `--limit` cap dropped matches. ANSI-free and
|
|
257
|
-
* deterministic: the query text and every hit's `id`/`type`/`snippet` are sanitized
|
|
258
|
-
* ({@link sanitizeField}) before interpolation, so a crafted concept file (an id, a
|
|
259
|
-
* `type`/`summary` frontmatter scalar) or the raw `--query` argument cannot smuggle an
|
|
260
|
-
* ANSI escape sequence or other control byte into the rendered line (LORE-118).
|
|
261
|
-
*/
|
|
262
|
-
function renderText(data: QueryResult): string {
|
|
263
|
-
const queryText = data.query !== undefined ? sanitizeField(data.query) : undefined;
|
|
264
|
-
const head = queryText !== undefined ? `query "${queryText}"` : "query (filters)";
|
|
265
|
-
const lines = [
|
|
266
|
-
...(data.workspace !== undefined
|
|
267
|
-
? [`workspace: ${sanitizeField(data.workspace.workspaceId)} (${data.workspace.repositories.length} repositories)`]
|
|
268
|
-
: []),
|
|
269
|
-
`${head}: ${data.total} ${data.total === 1 ? "match" : "matches"}`,
|
|
270
|
-
];
|
|
271
|
-
for (const hit of data.hits) {
|
|
272
|
-
const score = queryText !== undefined ? ` (${formatScore(hit.score)})` : "";
|
|
273
|
-
const id = sanitizeField(hit.id);
|
|
274
|
-
const type = sanitizeField(hit.type);
|
|
275
|
-
const snippet = hit.snippet !== undefined ? ` — ${sanitizeField(hit.snippet)}` : "";
|
|
276
|
-
lines.push(` ${id} [${type}]${score}${snippet}`);
|
|
277
|
-
}
|
|
278
|
-
const footer = renderTruncationLine(truncation(data.total, data.shown, NARROW_HINT));
|
|
279
|
-
if (footer !== "") {
|
|
280
|
-
lines.push(footer);
|
|
281
|
-
}
|
|
282
|
-
return lines.join("\n");
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Sanitize a field before it is interpolated into the plain/pretty listing: collapse
|
|
287
|
-
* it to one line ({@link singleLine}) and strip ANSI escape sequences plus residual C0/C1
|
|
288
|
-
* control bytes ({@link stripAnsiAndControls}) — every source here (a concept `id`, its
|
|
289
|
-
* `type`/`summary` frontmatter, or the raw `--query` text) can carry attacker-influenced
|
|
290
|
-
* bytes (a crafted bundle file, or the CLI argument itself), and without this a CSI
|
|
291
|
-
* sequence could rewrite terminal state or forge output (LORE-118).
|
|
292
|
-
*/
|
|
293
|
-
function sanitizeField(text: string): string {
|
|
294
|
-
return stripAnsiAndControls(singleLine(text));
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Format a BM25 score for the text listing: two decimals normally, but a **positive**
|
|
299
|
-
* score that would round to `0.00` (a real hit whose relevance is tiny — e.g. a
|
|
300
|
-
* near-ubiquitous term in a large bundle) falls back to two significant figures, so a
|
|
301
|
-
* returned hit is never displayed as the `0.00` that reads like "should have been
|
|
302
|
-
* dropped". Exported for direct unit coverage of both branches.
|
|
303
|
-
*/
|
|
304
|
-
export function formatScore(score: number): string {
|
|
305
|
-
const fixed = score.toFixed(2);
|
|
306
|
-
return score > 0 && Number(fixed) === 0 ? score.toPrecision(2) : fixed;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
|
|
310
|
-
function usage(message: string, hint: string): LoreError {
|
|
311
|
-
return new LoreError("usage", message, hint);
|
|
312
|
-
}
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* reconcile-shared.ts — resolve every `tasks:`-linked concept's live Backlog data and compute its
|
|
3
|
-
* reconciled status + managed-block rows, shared by `lore sync` (LORE-26, writes the result) and
|
|
4
|
-
* `lore check` (LORE-27, diffs it against disk and never writes — ADR-0007).
|
|
5
|
-
*
|
|
6
|
-
* Both commands need the *identical* gather: skip reserved-stem concepts (`index`/`log`, regenerated
|
|
7
|
-
* wholesale elsewhere), read and validate the project's status flow/overrides **before** spending any
|
|
8
|
-
* Backlog subprocess round-trip (so a broken `.lore/config.toml` is reported immediately rather than
|
|
9
|
-
* masked behind N task resolutions — sync's own fail-fast property, LORE-26 round 4), resolve every
|
|
10
|
-
* distinct linked task id once, and run `core/reconcile.ts`'s `reconcileStatus` per concept. Only what
|
|
11
|
-
* happens with the result differs, so that part stays with each command.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { posix } from "node:path";
|
|
15
|
-
import { type BacklogAdapter, type BacklogTaskDetail, readStatusFlow } from "../adapters/backlog";
|
|
16
|
-
import { loadConfig } from "../config";
|
|
17
|
-
import { toRefList } from "../core/bundle";
|
|
18
|
-
import type { Concept } from "../core/concept";
|
|
19
|
-
import type { ManagedTaskRow } from "../core/managed-block";
|
|
20
|
-
import { type ReconciledStatus, reconcileStatus, validateReconcileInputs } from "../core/reconcile";
|
|
21
|
-
import { RESERVED_STEMS } from "../core/scaffold";
|
|
22
|
-
import { LoreError } from "../errors";
|
|
23
|
-
// `mapWithConcurrency`/`TASK_DETAILS_CONCURRENCY` moved to the neutral `./concurrency` module
|
|
24
|
-
// (LORE-233) so `link.ts` can share them too without a `link -> reconcile-shared -> link` import
|
|
25
|
-
// cycle (this file already imports `dedupeTaskIds`/`defaultAdapter`/`verifiedViewTask` FROM
|
|
26
|
-
// `link.ts`). Re-exported here so `check.ts` and `reconcile-shared.test.ts`, which import both
|
|
27
|
-
// from this module, keep working unchanged.
|
|
28
|
-
import { mapWithConcurrency, TASK_DETAILS_CONCURRENCY } from "./concurrency";
|
|
29
|
-
import { dedupeTaskIds, defaultAdapter, verifiedViewTask } from "./link";
|
|
30
|
-
|
|
31
|
-
export { mapWithConcurrency, TASK_DETAILS_CONCURRENCY };
|
|
32
|
-
|
|
33
|
-
/** The outcome of resolving one task id: its live detail, or the error resolving it hit. */
|
|
34
|
-
export type TaskResolution =
|
|
35
|
-
| { readonly ok: true; readonly detail: BacklogTaskDetail }
|
|
36
|
-
| { readonly ok: false; readonly error: unknown };
|
|
37
|
-
|
|
38
|
-
/** One concept's resolved reconciliation: its recomputed status and live managed-block rows. */
|
|
39
|
-
export interface ReconcileTarget {
|
|
40
|
-
/** The concept as loaded (unmodified) — its `frontmatter.status` is the pre-reconciliation value. */
|
|
41
|
-
readonly concept: Concept;
|
|
42
|
-
/** The rolled-up status (`core/reconcile.ts`), or `null` when the concept links no tasks (never true here). */
|
|
43
|
-
readonly newStatus: ReconciledStatus | null;
|
|
44
|
-
/** The linked tasks' live data, in the concept's own `tasks:` order, ready for `regenerateTaskBlock`. */
|
|
45
|
-
readonly rows: ManagedTaskRow[];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** One concept eligible for reconciliation: not a reserved stem, and linking at least one task. */
|
|
49
|
-
export interface EligibleConcept {
|
|
50
|
-
readonly concept: Concept;
|
|
51
|
-
/** Its `tasks:` frontmatter, deduplicated (case-insensitively) but otherwise in authored order. */
|
|
52
|
-
readonly linked: string[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Filter `concepts` to those eligible for reconciliation: not a reserved stem (`index`/`log` —
|
|
57
|
-
* regenerated wholesale elsewhere, so a reconciled write here would only be silently discarded,
|
|
58
|
-
* mirroring `link`/`rename`/`supersede`'s shared `assertNotReservedStem` policy) and linking at
|
|
59
|
-
* least one task. Pure and synchronous — callers use it to decide *whether* reconciliation is
|
|
60
|
-
* needed at all (e.g. `lore check` deciding sync-vs-async) without paying for any Backlog IO.
|
|
61
|
-
*
|
|
62
|
-
* Deliberately type-agnostic: a `tasks:` list is reconciled on ANY concept type, not only
|
|
63
|
-
* `Story`/`Spec` — `lore link` (LORE-24) never restricts which type it targets, and this predates
|
|
64
|
-
* LORE-27 entirely (`tasks:` on a `Reference`/`ADR`/etc. is an OKF §9-tolerated unknown-key warning,
|
|
65
|
-
* never an error). Narrowing eligibility to specific types here would make `check` (which shares
|
|
66
|
-
* this exact function) more restrictive than `sync` — the disagreement-with-`sync` failure mode
|
|
67
|
-
* this module's callers otherwise take pains to avoid — not fix a LORE-27-introduced gap.
|
|
68
|
-
*/
|
|
69
|
-
export function linkedConcepts(concepts: Iterable<Concept>): EligibleConcept[] {
|
|
70
|
-
const eligible: EligibleConcept[] = [];
|
|
71
|
-
for (const concept of concepts) {
|
|
72
|
-
if (RESERVED_STEMS.has(posix.basename(concept.id))) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
const linked = dedupeTaskIds(toRefList(concept.frontmatter.tasks));
|
|
76
|
-
if (linked.length > 0) {
|
|
77
|
-
eligible.push({ concept, linked });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return eligible;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** The project's reconciliation config: its ordered status flow and `[reconcile.overrides]`. */
|
|
84
|
-
export interface ReconcileConfig {
|
|
85
|
-
readonly flow: readonly string[];
|
|
86
|
-
readonly overrides: Readonly<Record<string, string>>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Read (but do not validate) `backlog/config.yml`'s status flow and `.lore/config.toml`'s
|
|
91
|
-
* `[reconcile.overrides]` — pure IO, no semantic check, no Backlog subprocess round-trip. Split out
|
|
92
|
-
* from {@link resolveReconcileConfig} so a caller with its OWN precedence to preserve (`lore sync`,
|
|
93
|
-
* against `.lore/profile.toml`) can interleave the syntactic reads, the semantic validation, and
|
|
94
|
-
* whatever else it needs in the exact order its own contract requires, rather than this module
|
|
95
|
-
* silently deciding that order.
|
|
96
|
-
*/
|
|
97
|
-
export function readReconcileConfig(root: string): ReconcileConfig {
|
|
98
|
-
const flow = readStatusFlow(root);
|
|
99
|
-
const config = loadConfig({ root });
|
|
100
|
-
return { flow, overrides: config.reconcile.overrides };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* {@link readReconcileConfig} plus its semantic validation — the fast, local, IO-only half of
|
|
105
|
-
* reconciliation, with no Backlog subprocess round-trip. This is what {@link gatherReconciliation}
|
|
106
|
-
* uses by default; a caller that already resolved (and validated) its own config up front (`lore
|
|
107
|
-
* sync`, see {@link readReconcileConfig}'s doc comment) passes it straight to `gatherReconciliation`
|
|
108
|
-
* instead, so the files are never read/validated twice in the same command run.
|
|
109
|
-
*
|
|
110
|
-
* @throws LoreError `validation` if the status flow has fewer than two entries, a duplicate entry,
|
|
111
|
-
* or an override's target is not a valid rollup status.
|
|
112
|
-
*/
|
|
113
|
-
export function resolveReconcileConfig(root: string): ReconcileConfig {
|
|
114
|
-
const resolved = readReconcileConfig(root);
|
|
115
|
-
validateReconcileInputs(resolved.flow, resolved.overrides);
|
|
116
|
-
return resolved;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Resolve every `tasks:`-linked, non-reserved-stem concept in `concepts` to a {@link ReconcileTarget}.
|
|
121
|
-
* Returns `[]` (constructing no adapter at all, mirroring `rename.ts`'s precedent) when nothing in
|
|
122
|
-
* `concepts` links a task — so a bundle with no Story/Spec coupling never shells out to Backlog.
|
|
123
|
-
*
|
|
124
|
-
* @param root the repo root `backlog/config.yml` / `.lore/config.toml` / the Backlog adapter resolve
|
|
125
|
-
* against — independent of whichever docs bundle root `concepts` was loaded from.
|
|
126
|
-
* @param concepts the concepts to consider (already loaded/scoped by the caller).
|
|
127
|
-
* @param adapterOverride test seam; defaults to {@link defaultAdapter}.
|
|
128
|
-
* @param configOverride an already-resolved-and-validated {@link ReconcileConfig} (`lore sync` passes
|
|
129
|
-
* its own, having already called {@link resolveReconcileConfig} itself for ordering reasons); when
|
|
130
|
-
* omitted, resolved (and validated) here.
|
|
131
|
-
* @param detailsOverride an already-resolved {@link TaskResolution} map, keyed by lowercase task id,
|
|
132
|
-
* covering (at least) every id `concepts` links — `lore check`'s multi-root drift pass (LORE-50)
|
|
133
|
-
* resolves the union of every bundle root's linked ids exactly once and passes the shared result
|
|
134
|
-
* to each root's own `gatherReconciliation` call, rather than each root re-fetching the same id.
|
|
135
|
-
* When omitted, every id is resolved fresh via `adapterOverride`/`defaultAdapter`, as before.
|
|
136
|
-
* @param configErrorOverride a config-validation failure another caller already hit resolving the
|
|
137
|
-
* SAME config this call would otherwise read fresh (LORE-50: `lore check`'s multi-root pass
|
|
138
|
-
* resolves the config once, up front, for every bundle root that has any eligible concept). When
|
|
139
|
-
* set (and `configOverride` is not), thrown immediately in place of re-reading/re-validating —
|
|
140
|
-
* preserving the exact fail-fast point a fresh {@link resolveReconcileConfig} call would have hit
|
|
141
|
-
* for THIS call's own eligible concepts, without a second disk read. Ignored when `eligible` is
|
|
142
|
-
* empty (mirrors `resolveReconcileConfig` never running for a concept list with nothing to
|
|
143
|
-
* reconcile) or when `configOverride` is given (a caller with a genuinely resolved config, like
|
|
144
|
-
* `lore sync`, never also carries a cached failure).
|
|
145
|
-
* @throws LoreError `validation` if the status flow/overrides are malformed (before any task
|
|
146
|
-
* resolution); `not_found` (exit 3) naming the first linked task id that no longer exists, OR
|
|
147
|
-
* whose resolved detail's `id` does not case-insensitively match the requested id.
|
|
148
|
-
*/
|
|
149
|
-
export async function gatherReconciliation(
|
|
150
|
-
root: string,
|
|
151
|
-
concepts: Iterable<Concept>,
|
|
152
|
-
adapterOverride?: BacklogAdapter,
|
|
153
|
-
configOverride?: ReconcileConfig,
|
|
154
|
-
detailsOverride?: ReadonlyMap<string, TaskResolution>,
|
|
155
|
-
configErrorOverride?: unknown,
|
|
156
|
-
): Promise<ReconcileTarget[]> {
|
|
157
|
-
const eligible = linkedConcepts(concepts);
|
|
158
|
-
if (eligible.length === 0) {
|
|
159
|
-
return [];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (configOverride === undefined && configErrorOverride !== undefined) {
|
|
163
|
-
throw configErrorOverride;
|
|
164
|
-
}
|
|
165
|
-
const { flow, overrides } = configOverride ?? resolveReconcileConfig(root);
|
|
166
|
-
|
|
167
|
-
const allTaskIds = dedupeTaskIds(eligible.flatMap((e) => e.linked));
|
|
168
|
-
const details = detailsOverride
|
|
169
|
-
? pickResolved(detailsOverride, allTaskIds)
|
|
170
|
-
: await resolveAllTasks(adapterOverride ?? defaultAdapter(root), allTaskIds);
|
|
171
|
-
|
|
172
|
-
return eligible.map(({ concept, linked }) => {
|
|
173
|
-
const detailList = linked.map((id) => details.get(id.toLowerCase()) as BacklogTaskDetail);
|
|
174
|
-
const newStatus = reconcileStatus(
|
|
175
|
-
detailList.map((d) => d.status),
|
|
176
|
-
flow,
|
|
177
|
-
overrides,
|
|
178
|
-
);
|
|
179
|
-
const rows: ManagedTaskRow[] = detailList.map((d) => ({
|
|
180
|
-
id: d.id,
|
|
181
|
-
title: d.title,
|
|
182
|
-
status: d.status,
|
|
183
|
-
file: d.file,
|
|
184
|
-
}));
|
|
185
|
-
return { concept, newStatus, rows };
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Resolve every task id to a {@link TaskResolution}, keyed by lowercase id — a not-found, an
|
|
191
|
-
* identity-mismatched, or a rejected `viewTask` all become an `ok: false` entry rather than a
|
|
192
|
-
* thrown error, so a caller resolving ids shared across several independent groups (bundle roots,
|
|
193
|
-
* in `lore check`'s multi-root drift pass) can fetch each distinct id exactly once and let EACH
|
|
194
|
-
* group decide for itself whether ids IT needs failed, instead of one failing id aborting every
|
|
195
|
-
* group's resolution. Reads run concurrently, bounded to {@link TASK_DETAILS_CONCURRENCY} in
|
|
196
|
-
* flight at once (via {@link mapWithConcurrency}); this never throws.
|
|
197
|
-
*
|
|
198
|
-
* Every resolved detail's own `id` is checked (case-insensitively) against the requested `taskId`
|
|
199
|
-
* via `link.ts`'s shared {@link verifiedViewTask} — a mismatch is treated exactly like a not-found
|
|
200
|
-
* rather than stored as `ok: true` (it throws a `LoreError`, caught below and stored verbatim, same
|
|
201
|
-
* as any other `viewTask` rejection). Nothing downstream (`gatherReconciliation`'s
|
|
202
|
-
* `details.get(id.toLowerCase())` lookup keys purely on the REQUESTED id) would otherwise notice an
|
|
203
|
-
* adapter handing back the wrong task's detail, and a mismatch here would silently attribute
|
|
204
|
-
* another task's title/status to this concept's managed `tasks:` block. This is the ONE place the
|
|
205
|
-
* check lives — `verifiedViewTask` — shared with `link.ts`'s own `viewTask` consumers and
|
|
206
|
-
* `tasks.ts`'s `resolveRollup` (LORE-125), so the comparison/`LoreError` never drifts between sites.
|
|
207
|
-
*/
|
|
208
|
-
export async function resolveTaskDetails(
|
|
209
|
-
adapter: BacklogAdapter,
|
|
210
|
-
taskIds: readonly string[],
|
|
211
|
-
): Promise<Map<string, TaskResolution>> {
|
|
212
|
-
const resolved = new Map<string, TaskResolution>();
|
|
213
|
-
await mapWithConcurrency(taskIds, TASK_DETAILS_CONCURRENCY, async (taskId) => {
|
|
214
|
-
try {
|
|
215
|
-
const detail = await verifiedViewTask(adapter, taskId);
|
|
216
|
-
if (detail === null) {
|
|
217
|
-
resolved.set(taskId.toLowerCase(), {
|
|
218
|
-
ok: false,
|
|
219
|
-
error: new LoreError(
|
|
220
|
-
"not_found",
|
|
221
|
-
`task "${taskId}" does not exist`,
|
|
222
|
-
"a linked concept's tasks: list must reference only live Backlog tasks — check the id, or unlink it",
|
|
223
|
-
{ taskId },
|
|
224
|
-
),
|
|
225
|
-
});
|
|
226
|
-
} else {
|
|
227
|
-
resolved.set(taskId.toLowerCase(), { ok: true, detail });
|
|
228
|
-
}
|
|
229
|
-
} catch (cause) {
|
|
230
|
-
resolved.set(taskId.toLowerCase(), {
|
|
231
|
-
ok: false,
|
|
232
|
-
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
return resolved;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Resolve every task id to its live {@link BacklogTaskDetail}, keyed by lowercase id. Every id is
|
|
241
|
-
* validated to exist BEFORE any concept's status/managed-block is computed — mirrors
|
|
242
|
-
* `commands/link.ts`'s up-front validation exactly, including running the reads concurrently
|
|
243
|
-
* (`allSettled`) but reporting the first not-found/failure in argument order.
|
|
244
|
-
*
|
|
245
|
-
* @throws LoreError `not_found` (exit 3) naming the first missing task id, in `taskIds` order.
|
|
246
|
-
*/
|
|
247
|
-
async function resolveAllTasks(
|
|
248
|
-
adapter: BacklogAdapter,
|
|
249
|
-
taskIds: readonly string[],
|
|
250
|
-
): Promise<Map<string, BacklogTaskDetail>> {
|
|
251
|
-
const resolved = await resolveTaskDetails(adapter, taskIds);
|
|
252
|
-
return pickResolved(resolved, taskIds);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Extract `taskIds` from an already-resolved {@link TaskResolution} map, throwing the FIRST (in
|
|
257
|
-
* `taskIds` order) unresolved id's own error — the same contract {@link resolveAllTasks} always
|
|
258
|
-
* had, now shared by a caller (`lore check`, LORE-50) that resolved the map once, up front, for a
|
|
259
|
-
* UNION of ids spanning more than this one `taskIds` list. An id genuinely absent from `resolved`
|
|
260
|
-
* (the caller's union omitted one `gatherReconciliation` call actually needs — never expected in
|
|
261
|
-
* practice, since every caller derives its union from the same eligible concepts) fails loud rather
|
|
262
|
-
* than silently treating it as resolved.
|
|
263
|
-
*/
|
|
264
|
-
function pickResolved(
|
|
265
|
-
resolved: ReadonlyMap<string, TaskResolution>,
|
|
266
|
-
taskIds: readonly string[],
|
|
267
|
-
): Map<string, BacklogTaskDetail> {
|
|
268
|
-
const details = new Map<string, BacklogTaskDetail>();
|
|
269
|
-
for (const taskId of taskIds) {
|
|
270
|
-
const r = resolved.get(taskId.toLowerCase());
|
|
271
|
-
if (r === undefined) {
|
|
272
|
-
throw new Error(`internal: task "${taskId}" was never resolved`);
|
|
273
|
-
}
|
|
274
|
-
if (!r.ok) {
|
|
275
|
-
throw r.error;
|
|
276
|
-
}
|
|
277
|
-
details.set(taskId.toLowerCase(), r.detail);
|
|
278
|
-
}
|
|
279
|
-
return details;
|
|
280
|
-
}
|