@opum-ai/lore 0.1.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/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/orphans.ts — `lore orphans [--tasks-only | --docs-only] [--limit <n>]` (cli-surface §orphans).
|
|
3
|
+
*
|
|
4
|
+
* The read-only, **bidirectional** doc↔task coupling report: the CI/agent signal that the
|
|
5
|
+
* coupling `lore link`/`sync` maintain has gaps. Two directions, one envelope:
|
|
6
|
+
*
|
|
7
|
+
* - **orphanTasks** — tasks with **no owning doc**: no concept lists the task in its `tasks:`
|
|
8
|
+
* frontmatter AND the task carries no `doc:<conceptId>` back-reference label AND (LORE-261) no
|
|
9
|
+
* ancestor in its Backlog `parentTaskId` chain is owned either — a subtask of an already-linked
|
|
10
|
+
* parent task is not reported, since linking the parent does not stamp each subtask with its own
|
|
11
|
+
* back-reference (see {@link hasOwnedAncestor}). Work that exists in Backlog but is documented
|
|
12
|
+
* nowhere, at any level of its parent/subtask hierarchy.
|
|
13
|
+
* - **danglingLinks** — docs whose linked task **vanished**: a `tasks:` id the current-branch
|
|
14
|
+
* Backlog snapshot no longer knows. A doc pointing at a task that has been deleted or renamed.
|
|
15
|
+
*
|
|
16
|
+
* ## One snapshot, pure set arithmetic — not a per-task probe
|
|
17
|
+
*
|
|
18
|
+
* Unlike `lore tasks` (which `viewTask`s each of *one* concept's linked ids), `orphans` spans the
|
|
19
|
+
* whole bundle, so it reads Backlog **once**: `adapter.listTasks()` returns the current-branch
|
|
20
|
+
* on-disk truth across every status (backlog-cli-contract.md §: `task list --json` — "Never
|
|
21
|
+
* `backlog board`"), and both directions fall out of set arithmetic against the loaded graph:
|
|
22
|
+
* `orphanTasks` from the snapshot minus the forward `tasks:` set (and the `doc:` labels the snapshot
|
|
23
|
+
* itself carries), `danglingLinks` from each concept's `tasks:` minus the snapshot's known ids. No
|
|
24
|
+
* N+1 per-id reads.
|
|
25
|
+
*
|
|
26
|
+
* ## A report, never a gate
|
|
27
|
+
*
|
|
28
|
+
* The Backlog capability probe runs UP FRONT (fail-fast: a missing binary is `not_found`/exit 3, a
|
|
29
|
+
* stock non-`--json` binary is `validation`/exit 6), and the single `listTasks()` either returns the
|
|
30
|
+
* snapshot or throws hard drift (exit 6) — there is no soft-null path here, because a dangling link
|
|
31
|
+
* is the *content* of the report, not an advisory swallowed on the way to a rollup. Everything after
|
|
32
|
+
* the snapshot is pure, so `orphans` always returns exit `0` (ADR-0007: `orphans` is a detection
|
|
33
|
+
* report, not a coherence gate — even a non-empty report is exit 0). Only a *usage* error (a bad
|
|
34
|
+
* flag / a stray positional), a *bundle* failure (unreadable/malformed `docs/`), or the *probe/read*
|
|
35
|
+
* failure above throws, funneling through the router's one error seam like every command.
|
|
36
|
+
*
|
|
37
|
+
* ## Scope boundary
|
|
38
|
+
*
|
|
39
|
+
* "No owning doc" is the *literal* surface definition: **any** `doc:` label exempts a task, even one
|
|
40
|
+
* pointing at a since-removed concept (a `doc:`→dead-concept is a third asymmetry, out of this
|
|
41
|
+
* command's two-direction scope). Broken doc→doc cross-links are `lore check`'s job (its link/anchor
|
|
42
|
+
* pass), not repeated here. An **archived** Backlog task reads identically to a deleted one through the
|
|
43
|
+
* JSON adapter — archiving moves it to `backlog/archive/tasks/`, dropping it from BOTH `task list` and
|
|
44
|
+
* `task view` (ADR-0002 keeps lore to that JSON-only surface, so the archive directory is deliberately
|
|
45
|
+
* never consulted) — so a doc still linking an archived task surfaces here as a dangling link, exactly
|
|
46
|
+
* as `lore tasks` drops that same id from its rollup. That is intentional and consistent, not a
|
|
47
|
+
* distinction lore could draw. Output follows the uniform CLI modes: the `{schemaVersion, kind:
|
|
48
|
+
* "orphans.report", data}` envelope under `--json` — `data` an object `{ orphanTasks?, danglingLinks? }`
|
|
49
|
+
* (object-wrapped so the contract can grow additively; the section a flag excludes is **omitted**, not
|
|
50
|
+
* emitted empty, so `--docs-only --json` never shows a misleading `orphanTasks: []`) — and otherwise an
|
|
51
|
+
* aligned text report.
|
|
52
|
+
*
|
|
53
|
+
* ## Bounded output (cli-contract §3)
|
|
54
|
+
*
|
|
55
|
+
* `orphans` is named alongside `query`/`graph`/`context` as a read-heavy command that must cap its
|
|
56
|
+
* output rather than dump an unbounded snapshot into a CI log or an agent's context window. Its two
|
|
57
|
+
* sections are independent (an unrelated task backlog and an unrelated doc set), so each is capped —
|
|
58
|
+
* and reports its own `total`/`shown`/`truncated` — separately against the same `--limit` (default
|
|
59
|
+
* {@link DEFAULT_ORPHANS_LIMIT}), mirroring `query`'s single-section shape once per section.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import type { BacklogAdapter, BacklogTask } from "../adapters/backlog";
|
|
64
|
+
import { loadBundle, toRefList } from "../core/bundle";
|
|
65
|
+
import type { Concept } from "../core/concept";
|
|
66
|
+
import { loadProfile } from "../core/profile";
|
|
67
|
+
import { DOCS_DIR } from "../core/scaffold";
|
|
68
|
+
import { ANSI, EXIT_OK, paint, WarningCollector, type Writer } from "../errors";
|
|
69
|
+
import {
|
|
70
|
+
emit,
|
|
71
|
+
maxLen,
|
|
72
|
+
type OutputContext,
|
|
73
|
+
type Renderable,
|
|
74
|
+
renderTaskSummaryRows,
|
|
75
|
+
renderTruncationLine,
|
|
76
|
+
type TaskSummaryRow,
|
|
77
|
+
truncation,
|
|
78
|
+
} from "../output";
|
|
79
|
+
import { assertFlagAtMostOnce, parseCommandArgs, singleOptionValue, usage } from "./args";
|
|
80
|
+
import { dedupeTaskIds, defaultAdapter } from "./link";
|
|
81
|
+
|
|
82
|
+
/** Options for {@link runOrphans}; `root`, the streams, and the adapter are injectable for tests. */
|
|
83
|
+
export interface OrphansOptions {
|
|
84
|
+
/** The repo root the `docs/` bundle and the Backlog adapter resolve against. */
|
|
85
|
+
root: string;
|
|
86
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
87
|
+
output: OutputContext;
|
|
88
|
+
/** The command's normalized flag tokens from Commander. */
|
|
89
|
+
args: readonly string[];
|
|
90
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
91
|
+
stdout?: Writer;
|
|
92
|
+
/** stderr sink for advisory warnings; defaults to `process.stderr`. */
|
|
93
|
+
stderr?: Writer;
|
|
94
|
+
/** The Backlog adapter; defaults to the real `backlog` binary resolved against `root`. Injected for tests. */
|
|
95
|
+
adapter?: BacklogAdapter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The parsed form of `lore orphans`'s arguments — the two mutually-exclusive section filters, plus the cap. */
|
|
99
|
+
interface OrphansArgs {
|
|
100
|
+
/** `--tasks-only`: report only the orphan-task side (omit `danglingLinks`). */
|
|
101
|
+
readonly tasksOnly: boolean;
|
|
102
|
+
/** `--docs-only`: report only the dangling-link side (omit `orphanTasks`). */
|
|
103
|
+
readonly docsOnly: boolean;
|
|
104
|
+
/** `--limit` (at most once); `undefined` falls back to {@link DEFAULT_ORPHANS_LIMIT}. */
|
|
105
|
+
readonly limit?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One task with no owning doc: its live identity + current Backlog status, from the Backlog snapshot. */
|
|
109
|
+
export type OrphanTask = TaskSummaryRow;
|
|
110
|
+
|
|
111
|
+
/** One dangling doc→task link: a concept and the `tasks:` id Backlog no longer knows. */
|
|
112
|
+
export interface DanglingLink {
|
|
113
|
+
/** The owning concept's id (`"stories/bulk-archive-orders"`). */
|
|
114
|
+
readonly concept: string;
|
|
115
|
+
/** The vanished task id, echoed **verbatim** as the concept's `tasks:` frontmatter wrote it (Backlog has no record to re-case it). */
|
|
116
|
+
readonly task: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `orphans.report` payload. Both keys are optional: a run with no flag carries both, `--tasks-only`
|
|
121
|
+
* carries only the orphan-task fields, `--docs-only` only the dangling-link fields. An excluded section
|
|
122
|
+
* is entirely absent (not `[]`) so a consumer can tell "not requested" from "requested, found none".
|
|
123
|
+
*
|
|
124
|
+
* Each section carries its own `total`/`shown`/`truncated` (cli-contract §3) rather than one combined
|
|
125
|
+
* triple — `orphanTasks` and `danglingLinks` count unrelated things (a task backlog vs. a doc set), so a
|
|
126
|
+
* single combined cap would make one section's truncation state say nothing about the other.
|
|
127
|
+
*/
|
|
128
|
+
export interface OrphansReport {
|
|
129
|
+
/** Tasks with no owning doc, sorted by id (case-insensitive), capped to `--limit`. Omitted under `--docs-only`. */
|
|
130
|
+
readonly orphanTasks?: readonly OrphanTask[];
|
|
131
|
+
/** The full count of orphan tasks before the `--limit` cap. Present iff {@link orphanTasks} is. */
|
|
132
|
+
readonly orphanTasksTotal?: number;
|
|
133
|
+
/** The number of orphan tasks actually returned (`orphanTasksShown <= orphanTasksTotal`). */
|
|
134
|
+
readonly orphanTasksShown?: number;
|
|
135
|
+
/** `true` when the `--limit` cap dropped orphan tasks. */
|
|
136
|
+
readonly orphanTasksTruncated?: boolean;
|
|
137
|
+
/** Docs whose linked task vanished, sorted by (concept, task), capped to `--limit`. Omitted under `--tasks-only`. */
|
|
138
|
+
readonly danglingLinks?: readonly DanglingLink[];
|
|
139
|
+
/** The full count of dangling links before the `--limit` cap. Present iff {@link danglingLinks} is. */
|
|
140
|
+
readonly danglingLinksTotal?: number;
|
|
141
|
+
/** The number of dangling links actually returned (`danglingLinksShown <= danglingLinksTotal`). */
|
|
142
|
+
readonly danglingLinksShown?: number;
|
|
143
|
+
/** `true` when the `--limit` cap dropped dangling links. */
|
|
144
|
+
readonly danglingLinksTruncated?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run `lore orphans`: parse the flags, load the bundle, take the one Backlog snapshot, compute both
|
|
149
|
+
* coupling-gap directions by set arithmetic, emit the `orphans.report`, and return `0`. Async because
|
|
150
|
+
* it drives the Backlog subprocess. See the module docstring for the failure modes.
|
|
151
|
+
*/
|
|
152
|
+
export async function runOrphans(options: OrphansOptions): Promise<number> {
|
|
153
|
+
const parsed = parseOrphansArgs(options.args);
|
|
154
|
+
const docsRoot = join(options.root, DOCS_DIR);
|
|
155
|
+
const advisories = new WarningCollector();
|
|
156
|
+
const profile = loadProfile({ root: options.root });
|
|
157
|
+
const graph = loadBundle(docsRoot, { warnings: advisories, profile });
|
|
158
|
+
// Flush load advisories (e.g. a file skipped for a malformed header) before any Backlog I/O, so a
|
|
159
|
+
// "why isn't this a concept" note survives even if the snapshot read below throws (mirrors `lore tasks`).
|
|
160
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
161
|
+
|
|
162
|
+
const adapter = options.adapter ?? defaultAdapter(options.root);
|
|
163
|
+
// Probe UP FRONT (fail-fast 3/6) before the snapshot, so an incapable binary is reported as such and
|
|
164
|
+
// never mistaken for "the project has zero tasks" — the disambiguation the report's meaning relies on.
|
|
165
|
+
await adapter.probe();
|
|
166
|
+
const snapshot = await adapter.listTasks();
|
|
167
|
+
|
|
168
|
+
emit(orphansRenderable(computeOrphans(graph.concepts.values(), snapshot, parsed)), options.output, options.stdout);
|
|
169
|
+
return EXIT_OK;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The pure heart of the command: from the bundle's concepts and one Backlog snapshot, derive both
|
|
174
|
+
* coupling-gap directions and apply the section filter. Kept side-effect-free (a plain iterable of
|
|
175
|
+
* concepts + the snapshot array in, a report out) so it is exercised directly in tests without a bundle
|
|
176
|
+
* or a subprocess.
|
|
177
|
+
*
|
|
178
|
+
* A single pass over the concepts builds the forward `tasks:` set (for the orphan-task test) and the
|
|
179
|
+
* flat list of every `(concept, taskId)` reference (for the dangling test) at once; `orphanTasks` then
|
|
180
|
+
* filters the snapshot and `danglingLinks` filters the references, both against case-insensitive id
|
|
181
|
+
* sets. Both outputs are sorted for a deterministic, diff-stable report.
|
|
182
|
+
*/
|
|
183
|
+
export function computeOrphans(
|
|
184
|
+
concepts: Iterable<Concept>,
|
|
185
|
+
snapshot: readonly BacklogTask[],
|
|
186
|
+
parsed: OrphansArgs,
|
|
187
|
+
): OrphansReport {
|
|
188
|
+
const referenced = new Set<string>(); // lower-cased task ids any concept forward-links
|
|
189
|
+
const references: DanglingLink[] = []; // every (concept, taskId) pair, for the dangling test
|
|
190
|
+
for (const concept of concepts) {
|
|
191
|
+
for (const task of dedupeTaskIds(toRefList(concept.frontmatter.tasks))) {
|
|
192
|
+
referenced.add(task.toLowerCase());
|
|
193
|
+
references.push({ concept: concept.id, task });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const known = new Set(snapshot.map((task) => task.id.toLowerCase())); // lower-cased ids Backlog knows
|
|
198
|
+
const byId = new Map(snapshot.map((task) => [task.id.toLowerCase(), task])); // for the parent-chain walk below
|
|
199
|
+
const orphanTasks = snapshot
|
|
200
|
+
.filter(
|
|
201
|
+
(task) =>
|
|
202
|
+
!referenced.has(task.id.toLowerCase()) && !hasDocLabel(task) && !hasOwnedAncestor(task, referenced, byId),
|
|
203
|
+
)
|
|
204
|
+
.map((task): OrphanTask => ({ id: task.id, title: task.title, status: task.status }))
|
|
205
|
+
.sort((a, b) => compareLower(a.id, b.id));
|
|
206
|
+
const danglingLinks = references
|
|
207
|
+
.filter((ref) => !known.has(ref.task.toLowerCase()))
|
|
208
|
+
.sort((a, b) => compareLower(a.concept, b.concept) || compareLower(a.task, b.task));
|
|
209
|
+
|
|
210
|
+
const limit = parsed.limit ?? DEFAULT_ORPHANS_LIMIT;
|
|
211
|
+
|
|
212
|
+
// Compute both directions unconditionally (both are cheap and share the same inputs), then omit the
|
|
213
|
+
// section a flag excluded — omission, not an empty array, is how the envelope says "not requested".
|
|
214
|
+
// Each section is capped to `limit` independently and carries its own total/shown/truncated (§3):
|
|
215
|
+
// the two counts are unrelated, so one combined cap would make one section's truncation state say
|
|
216
|
+
// nothing about the other.
|
|
217
|
+
const report: {
|
|
218
|
+
orphanTasks?: OrphanTask[];
|
|
219
|
+
orphanTasksTotal?: number;
|
|
220
|
+
orphanTasksShown?: number;
|
|
221
|
+
orphanTasksTruncated?: boolean;
|
|
222
|
+
danglingLinks?: DanglingLink[];
|
|
223
|
+
danglingLinksTotal?: number;
|
|
224
|
+
danglingLinksShown?: number;
|
|
225
|
+
danglingLinksTruncated?: boolean;
|
|
226
|
+
} = {};
|
|
227
|
+
if (!parsed.docsOnly) {
|
|
228
|
+
const shown = orphanTasks.slice(0, limit);
|
|
229
|
+
report.orphanTasks = shown;
|
|
230
|
+
report.orphanTasksTotal = orphanTasks.length;
|
|
231
|
+
report.orphanTasksShown = shown.length;
|
|
232
|
+
report.orphanTasksTruncated = shown.length < orphanTasks.length;
|
|
233
|
+
}
|
|
234
|
+
if (!parsed.tasksOnly) {
|
|
235
|
+
const shown = danglingLinks.slice(0, limit);
|
|
236
|
+
report.danglingLinks = shown;
|
|
237
|
+
report.danglingLinksTotal = danglingLinks.length;
|
|
238
|
+
report.danglingLinksShown = shown.length;
|
|
239
|
+
report.danglingLinksTruncated = shown.length < danglingLinks.length;
|
|
240
|
+
}
|
|
241
|
+
return report;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The default `--limit` when none is given, matching `query`'s cap (cli-surface §orphans). */
|
|
245
|
+
export const DEFAULT_ORPHANS_LIMIT = 20;
|
|
246
|
+
|
|
247
|
+
/** Whether a task claims an owning doc via a `doc:<conceptId>` back-reference label (case-insensitive). */
|
|
248
|
+
function hasDocLabel(task: BacklogTask): boolean {
|
|
249
|
+
return task.labels.some((label) => label.toLowerCase().startsWith("doc:"));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* LORE-261: exempt a Backlog **subtask** from the orphan report when an ancestor in its
|
|
254
|
+
* `parentTaskId` chain is already owned — forward-referenced by some concept's `tasks:` list, or
|
|
255
|
+
* itself carrying a `doc:` label. Linking a parent task to a Story does not stamp every subtask
|
|
256
|
+
* with its own back-reference (one `backlog task edit` per task — ADR-0009 §2), so without this
|
|
257
|
+
* walk a correctly-coupled Story's subtasks would all read as false-positive orphans (the Meridian
|
|
258
|
+
* stress test: 8 reported instead of the intended 2).
|
|
259
|
+
*
|
|
260
|
+
* This is **orphans-side hierarchy awareness**, chosen over a link-side cascade (`lore link` writing
|
|
261
|
+
* a `doc:` label onto every subtask): the `--json` adapter already carries `parentTaskId` on every
|
|
262
|
+
* task in the SAME `listTasks()` snapshot this command already reads (no extra per-task `view`
|
|
263
|
+
* call, preserving the "one snapshot" design above), and a cascade would go stale the instant a new
|
|
264
|
+
* subtask is added after the parent was linked — this walk instead recomputes fresh on every run.
|
|
265
|
+
*
|
|
266
|
+
* Only the task's **ancestors** are walked (never siblings or descendants) — starting at
|
|
267
|
+
* `task.parentTaskId`, not `task` itself, since the caller already tested the task's own
|
|
268
|
+
* reference/label directly. A parent absent from the current snapshot (deleted, archived, or
|
|
269
|
+
* renamed out from under a stale `parentTaskId`) grants no exemption — a subtask under a vanished
|
|
270
|
+
* ancestor is reported, matching how `orphans` already treats an archived task as gone everywhere
|
|
271
|
+
* else (module docstring, "Scope boundary"). A visited-id set guards a corrupt or cyclic parent
|
|
272
|
+
* chain: a cycle yields `false` (not owned, so still reported) rather than looping forever — this
|
|
273
|
+
* is the fail-toward-reporting side of AC#3's no-false-negatives requirement.
|
|
274
|
+
*/
|
|
275
|
+
function hasOwnedAncestor(
|
|
276
|
+
task: BacklogTask,
|
|
277
|
+
referenced: ReadonlySet<string>,
|
|
278
|
+
byId: ReadonlyMap<string, BacklogTask>,
|
|
279
|
+
): boolean {
|
|
280
|
+
const visited = new Set<string>([task.id.toLowerCase()]);
|
|
281
|
+
let parentId = task.parentTaskId;
|
|
282
|
+
while (parentId !== null) {
|
|
283
|
+
const key = parentId.toLowerCase();
|
|
284
|
+
if (visited.has(key)) {
|
|
285
|
+
return false; // a cycle/self-reference in the parent chain — fail toward "still reported"
|
|
286
|
+
}
|
|
287
|
+
visited.add(key);
|
|
288
|
+
const parent = byId.get(key);
|
|
289
|
+
if (parent === undefined) {
|
|
290
|
+
return false; // the ancestor isn't in the current-branch snapshot — no exemption to grant
|
|
291
|
+
}
|
|
292
|
+
if (referenced.has(key) || hasDocLabel(parent)) {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
parentId = parent.parentTaskId;
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Case-insensitive string order, locale-independent, for the report's deterministic sort. */
|
|
301
|
+
function compareLower(a: string, b: string): number {
|
|
302
|
+
const x = a.toLowerCase();
|
|
303
|
+
const y = b.toLowerCase();
|
|
304
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Argument parsing ───────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Parse `orphans`'s tokens: the two boolean switches `--tasks-only` / `--docs-only`, the value flag
|
|
311
|
+
* `--limit <n>` (also accepting `--limit=<n>`), and nothing else. Commander has already resolved
|
|
312
|
+
* lore's global flags, so a `--`-prefixed token here is a command flag; an unrecognized one, a
|
|
313
|
+
* positional (orphans takes none), a repeated or value-bearing `--tasks-only`/`--docs-only`, passing
|
|
314
|
+
* **both** section filters, or a repeated/value-less/non-integer/too-large/non-positive `--limit` is a
|
|
315
|
+
* `usage` error (exit 2). A `--` ends option parsing (after which any token is a stray positional).
|
|
316
|
+
*/
|
|
317
|
+
function parseOrphansArgs(args: readonly string[]): OrphansArgs {
|
|
318
|
+
const parsed = parseCommandArgs(args, "orphans");
|
|
319
|
+
assertFlagAtMostOnce(parsed, "tasks-only");
|
|
320
|
+
assertFlagAtMostOnce(parsed, "docs-only");
|
|
321
|
+
if (parsed.positionals.length > 0) {
|
|
322
|
+
throw usage(`unexpected argument "${parsed.positionals[0]}"`, "orphans takes no positional arguments");
|
|
323
|
+
}
|
|
324
|
+
const tasksOnly = parsed.flags.has("tasks-only");
|
|
325
|
+
const docsOnly = parsed.flags.has("docs-only");
|
|
326
|
+
const rawLimit = singleOptionValue(parsed, "limit");
|
|
327
|
+
if (rawLimit === "") {
|
|
328
|
+
throw usage("--limit needs a value", "pass a value, e.g. `--limit 20`");
|
|
329
|
+
}
|
|
330
|
+
const limit = rawLimit === undefined ? undefined : parseCount("--limit", rawLimit);
|
|
331
|
+
if (tasksOnly && docsOnly) {
|
|
332
|
+
throw usage(
|
|
333
|
+
"--tasks-only and --docs-only are mutually exclusive",
|
|
334
|
+
"pass at most one of them, or neither for the full report",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return { tasksOnly, docsOnly, limit };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parse `--limit`'s value as a positive integer. Rejects a non-digit run (`Number()` would coerce
|
|
342
|
+
* `"1.5"`/`"0x2"`/`" 2 "`/`"1e3"`), `0` (a zero cap returns nothing useful), and a precision-losing
|
|
343
|
+
* `> 2^53` run — mirroring `query`'s `--limit` guard so every capped command accepts counts identically.
|
|
344
|
+
*/
|
|
345
|
+
function parseCount(flag: string, value: string): number {
|
|
346
|
+
if (!/^\d+$/.test(value)) {
|
|
347
|
+
throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
|
|
348
|
+
}
|
|
349
|
+
const count = Number.parseInt(value, 10);
|
|
350
|
+
if (!Number.isSafeInteger(count)) {
|
|
351
|
+
throw usage(`${flag} "${value}" is too large`, "pass a smaller integer");
|
|
352
|
+
}
|
|
353
|
+
if (count < 1) {
|
|
354
|
+
throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
|
|
355
|
+
}
|
|
356
|
+
return count;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Output ─────────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The rendering bundle for `orphans` (output.ts dispatches on the mode). `--json` carries the structured
|
|
363
|
+
* {@link OrphansReport}; the pretty/plain text is an aligned report. The two text modes differ only in the
|
|
364
|
+
* painted header, so they share one renderer.
|
|
365
|
+
*/
|
|
366
|
+
function orphansRenderable(data: OrphansReport): Renderable<OrphansReport> {
|
|
367
|
+
return {
|
|
368
|
+
kind: "orphans.report",
|
|
369
|
+
data,
|
|
370
|
+
pretty: (d, opts) => renderReport(d, opts.color),
|
|
371
|
+
plain: (d) => renderReport(d, false),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The narrow-it hint on each section's §3 truncation line — the one actionable way to see more of it. */
|
|
376
|
+
const LIMIT_HINT = "raise --limit to see more";
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A human/pipe-stable report: a header summarizing the requested sections' **total** counts, then one
|
|
380
|
+
* aligned block per non-empty section (` <id> <status> <title>` for orphan tasks; ` <concept> ->
|
|
381
|
+
* <task>` for dangling links), each followed by its own §3 truncation footer when `--limit` dropped
|
|
382
|
+
* rows. When every requested section is empty, a single all-clear line stands in for the blocks. ANSI
|
|
383
|
+
* only on the header, and only when `color`.
|
|
384
|
+
*/
|
|
385
|
+
function renderReport(data: OrphansReport, color: boolean): string {
|
|
386
|
+
const { orphanTasks, danglingLinks } = data;
|
|
387
|
+
const counts: string[] = [];
|
|
388
|
+
if (orphanTasks !== undefined) {
|
|
389
|
+
const total = data.orphanTasksTotal as number;
|
|
390
|
+
counts.push(`${total} orphan ${total === 1 ? "task" : "tasks"}`);
|
|
391
|
+
}
|
|
392
|
+
if (danglingLinks !== undefined) {
|
|
393
|
+
const total = data.danglingLinksTotal as number;
|
|
394
|
+
counts.push(`${total} dangling ${total === 1 ? "link" : "links"}`);
|
|
395
|
+
}
|
|
396
|
+
const lines = [paint(`orphans: ${counts.join(", ")}`, ANSI.green, color)];
|
|
397
|
+
|
|
398
|
+
if (orphanTasks !== undefined && orphanTasks.length > 0) {
|
|
399
|
+
lines.push("", "tasks with no owning doc:");
|
|
400
|
+
// A per-item loop, not `lines.push(...renderTaskSummaryRows(orphanTasks))` — spreading a large
|
|
401
|
+
// array into a function-call argument list has its own engine argument-count ceiling, the same
|
|
402
|
+
// class of RangeError the spread-free `maxLen` (below and in output.ts) was written to avoid.
|
|
403
|
+
for (const row of renderTaskSummaryRows(orphanTasks)) {
|
|
404
|
+
lines.push(row);
|
|
405
|
+
}
|
|
406
|
+
const footer = renderTruncationLine(
|
|
407
|
+
truncation(data.orphanTasksTotal as number, data.orphanTasksShown as number, LIMIT_HINT),
|
|
408
|
+
);
|
|
409
|
+
if (footer !== "") {
|
|
410
|
+
lines.push(footer);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (danglingLinks !== undefined && danglingLinks.length > 0) {
|
|
414
|
+
const conceptWidth = maxLen(danglingLinks, (link) => link.concept.length);
|
|
415
|
+
lines.push("", "docs with a vanished linked task:");
|
|
416
|
+
for (const link of danglingLinks) {
|
|
417
|
+
lines.push(` ${link.concept.padEnd(conceptWidth)} -> ${link.task}`);
|
|
418
|
+
}
|
|
419
|
+
const footer = renderTruncationLine(
|
|
420
|
+
truncation(data.danglingLinksTotal as number, data.danglingLinksShown as number, LIMIT_HINT),
|
|
421
|
+
);
|
|
422
|
+
if (footer !== "") {
|
|
423
|
+
lines.push(footer);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const allClear = allClearLine(orphanTasks, danglingLinks);
|
|
428
|
+
if (allClear !== undefined) {
|
|
429
|
+
lines.push(allClear);
|
|
430
|
+
}
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The all-clear line when every **requested** section is empty, else `undefined` (a section had entries,
|
|
436
|
+
* so its block already stands on its own). Crucially, the phrasing asserts cleanliness ONLY for the
|
|
437
|
+
* sections that were actually computed: under `--tasks-only`/`--docs-only` the excluded side (an
|
|
438
|
+
* `undefined` argument) was never checked, so it is left out of the sentence rather than falsely
|
|
439
|
+
* declared clean. At least one side is always requested (the parser rejects both flags), so the line is
|
|
440
|
+
* never empty.
|
|
441
|
+
*/
|
|
442
|
+
function allClearLine(
|
|
443
|
+
orphanTasks: readonly OrphanTask[] | undefined,
|
|
444
|
+
danglingLinks: readonly DanglingLink[] | undefined,
|
|
445
|
+
): string | undefined {
|
|
446
|
+
if ((orphanTasks?.length ?? 0) > 0 || (danglingLinks?.length ?? 0) > 0) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const clauses: string[] = [];
|
|
450
|
+
if (orphanTasks !== undefined) {
|
|
451
|
+
clauses.push("every task has an owning doc");
|
|
452
|
+
}
|
|
453
|
+
if (danglingLinks !== undefined) {
|
|
454
|
+
clauses.push("every linked task is live");
|
|
455
|
+
}
|
|
456
|
+
return `(none — ${clauses.join(", ")})`;
|
|
457
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** `lore path`: bounded deterministic paths across exact authored typed edges. */
|
|
2
|
+
|
|
3
|
+
import type { BacklogAdapter } from "../adapters/backlog";
|
|
4
|
+
import { loadRetrievalGraph, type RetrievalGraphLoader } from "../core/retrieval";
|
|
5
|
+
import { findPaths } from "../core/traversal";
|
|
6
|
+
import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
|
|
7
|
+
import { emit, type OutputContext } from "../output";
|
|
8
|
+
import { parseCommandArgs, usage, workspaceSelection } from "./args";
|
|
9
|
+
import {
|
|
10
|
+
assertKnownEdgeKinds,
|
|
11
|
+
normalizeEndpointId,
|
|
12
|
+
parseEndpointKind,
|
|
13
|
+
parseTraversalFlags,
|
|
14
|
+
pathRenderable,
|
|
15
|
+
} from "./traversal";
|
|
16
|
+
|
|
17
|
+
export interface PathCommandOptions {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
readonly output: OutputContext;
|
|
20
|
+
readonly args: readonly string[];
|
|
21
|
+
readonly stdout?: Writer;
|
|
22
|
+
readonly stderr?: Writer;
|
|
23
|
+
readonly adapter?: BacklogAdapter;
|
|
24
|
+
readonly retrieval?: RetrievalGraphLoader;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runPath(options: PathCommandOptions): Promise<number> {
|
|
28
|
+
const parsed = parseCommandArgs(options.args, "path");
|
|
29
|
+
const workspace = workspaceSelection(parsed);
|
|
30
|
+
if (parsed.positionals.length !== 2) {
|
|
31
|
+
throw usage(
|
|
32
|
+
"path needs exactly <from> and <to>",
|
|
33
|
+
"run `lore path <from> <to> --from-kind <kind> --to-kind <kind> --direction <direction>`",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const fromKind = parseEndpointKind(parsed, "from-kind");
|
|
37
|
+
const toKind = parseEndpointKind(parsed, "to-kind");
|
|
38
|
+
const flags = parseTraversalFlags(parsed);
|
|
39
|
+
const advisories = new WarningCollector();
|
|
40
|
+
const loaded = await (options.retrieval ?? loadRetrievalGraph)({
|
|
41
|
+
root: options.root,
|
|
42
|
+
warnings: advisories,
|
|
43
|
+
adapter: options.adapter,
|
|
44
|
+
includeTraversal: true,
|
|
45
|
+
...(workspace !== undefined ? { workspace } : {}),
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
49
|
+
if (loaded.traversal === undefined) throw new LoreError("validation", "traversal snapshot was not loaded");
|
|
50
|
+
assertKnownEdgeKinds(loaded.traversal, flags.edgeKinds);
|
|
51
|
+
const data = findPaths(loaded.traversal, {
|
|
52
|
+
from: {
|
|
53
|
+
kind: fromKind,
|
|
54
|
+
id: normalizeEndpointId(parsed.positionals[0] as string, fromKind, workspace !== undefined),
|
|
55
|
+
},
|
|
56
|
+
to: {
|
|
57
|
+
kind: toKind,
|
|
58
|
+
id: normalizeEndpointId(parsed.positionals[1] as string, toKind, workspace !== undefined),
|
|
59
|
+
},
|
|
60
|
+
...flags,
|
|
61
|
+
});
|
|
62
|
+
emit(pathRenderable(data), options.output, options.stdout);
|
|
63
|
+
return EXIT_OK;
|
|
64
|
+
} finally {
|
|
65
|
+
await loaded.dispose?.();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** `lore provenance`: exact retained-snapshot record evidence. */
|
|
2
|
+
|
|
3
|
+
import type { BacklogAdapter } from "../adapters/backlog";
|
|
4
|
+
import { findRetainedProvenance, type ProvenanceResult, type RetainedFactKind } from "../core/snapshot";
|
|
5
|
+
import { resolveSnapshotScope } from "../core/snapshot-runtime";
|
|
6
|
+
import type { SnapshotScopeSelection } from "../core/snapshot-store";
|
|
7
|
+
import { loadSnapshot } from "../core/snapshot-store";
|
|
8
|
+
import { WarningCollector, type Writer } from "../errors";
|
|
9
|
+
import { emit, type OutputContext, type Renderable } from "../output";
|
|
10
|
+
import { parseCommandArgs, singleOptionValue, usage, workspaceSelection } from "./args";
|
|
11
|
+
|
|
12
|
+
export interface ProvenanceCommandOptions {
|
|
13
|
+
readonly root: string;
|
|
14
|
+
readonly output: OutputContext;
|
|
15
|
+
readonly args: readonly string[];
|
|
16
|
+
readonly stdout?: Writer;
|
|
17
|
+
readonly stderr?: Writer;
|
|
18
|
+
readonly adapter?: BacklogAdapter;
|
|
19
|
+
readonly resolveScope?: () => SnapshotScopeSelection | Promise<SnapshotScopeSelection>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runProvenance(options: ProvenanceCommandOptions): Promise<number> {
|
|
23
|
+
const parsed = parseCommandArgs(options.args, "provenance");
|
|
24
|
+
const workspace = workspaceSelection(parsed);
|
|
25
|
+
if (parsed.positionals.length !== 1)
|
|
26
|
+
throw usage("provenance needs exactly one <id>", "run `lore provenance <id> --kind <kind> --snapshot <selector>`");
|
|
27
|
+
const kind = parseKind(singleOptionValue(parsed, "kind"));
|
|
28
|
+
const selector = singleOptionValue(parsed, "snapshot");
|
|
29
|
+
if (selector === undefined || selector.trim() === "")
|
|
30
|
+
throw usage("--snapshot needs a value", "pass an exact retained snapshot key or unambiguous commit");
|
|
31
|
+
const advisories = new WarningCollector();
|
|
32
|
+
const selection = workspace === undefined ? {} : { workspace: workspace.manifestPath };
|
|
33
|
+
const scope =
|
|
34
|
+
options.resolveScope === undefined
|
|
35
|
+
? await resolveSnapshotScope({ root: options.root, selection, warnings: advisories, adapter: options.adapter })
|
|
36
|
+
: await options.resolveScope();
|
|
37
|
+
const snapshot = loadSnapshot(options.root, scope, selector.trim());
|
|
38
|
+
const data = findRetainedProvenance(snapshot, {
|
|
39
|
+
id: parsed.positionals[0] as string,
|
|
40
|
+
kind,
|
|
41
|
+
repositories: workspace?.memberIds ?? [],
|
|
42
|
+
});
|
|
43
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
44
|
+
emit(provenanceRenderable(data), options.output, options.stdout);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseKind(value: string | undefined): RetainedFactKind {
|
|
49
|
+
if (value !== "concept" && value !== "task" && value !== "edge")
|
|
50
|
+
throw usage("--kind must be concept, task, or edge", "pass the retained fact kind explicitly");
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function provenanceRenderable(data: ProvenanceResult): Renderable<ProvenanceResult> {
|
|
55
|
+
return { kind: "provenance.result", data, pretty: renderProvenance, plain: renderProvenance };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderProvenance(data: ProvenanceResult): string {
|
|
59
|
+
const source = data.fact.provenance;
|
|
60
|
+
return [
|
|
61
|
+
`${data.fact.kind} ${data.fact.id} @ ${data.snapshot.snapshotKey}`,
|
|
62
|
+
`repository ${source.memberId ?? source.repositoryScopeKey}`,
|
|
63
|
+
`commit ${source.gitCommit ?? "uncommitted"}`,
|
|
64
|
+
`export ${source.exportDigest}`,
|
|
65
|
+
`record ${source.recordKey}`,
|
|
66
|
+
`source ${source.sourcePath ?? "none"}`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|