@opum-ai/lore 0.1.0 → 0.1.1

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.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
package/src/core/graph.ts DELETED
@@ -1,201 +0,0 @@
1
- /**
2
- * graph.ts — shape the bundle graph into an exportable model (and DOT text).
3
- *
4
- * `lore graph` surfaces the cross-link graph for orientation: concepts as nodes,
5
- * OKF cross-links and frontmatter refs as edges, each carrying a chars/4 token
6
- * estimate (cli-surface §graph). This module is the pure shaping layer between
7
- * the in-memory {@link BundleGraph} and what the command emits — it turns the
8
- * graph (optionally narrowed to a {@link subgraph} id-set) into the serializable
9
- * {@link GraphExport} the `--json` envelope carries, and renders that same model
10
- * as Graphviz {@link toDot} text. It reads the graph and returns data; the
11
- * command owns I/O and flag parsing (design §2.1).
12
- *
13
- * The token estimates are the bundle's own {@link BundleGraph.tokenEstimate}
14
- * (memoized, chars/4 over canonical serialized bytes — *not* a real tokenizer);
15
- * this layer only sums and presents them. The total is summed over the
16
- * **included** nodes, so a subgraph reports the budget of exactly what it shows.
17
- */
18
-
19
- import { type BundleGraph, type EdgeKind, frontmatterScalar } from "./bundle";
20
- import { compareCodeUnits } from "./order";
21
- import type { WorkspaceRecordProvenance, WorkspaceResultScope } from "./workspace-contract";
22
- import type { WorkspaceProjectedLink } from "./workspace-projection";
23
-
24
- /** One concept in the exported graph. */
25
- export interface GraphNode {
26
- /** The concept id (bundle-root-relative, e.g. `adr/0010-x`). */
27
- readonly id: string;
28
- /** The concept's resolved `type` (mirrors `frontmatter.type`). */
29
- readonly type: string;
30
- /**
31
- * The concept's optional `title` frontmatter, via {@link frontmatterScalar}: a
32
- * finite number or boolean value is coerced to its string form, and the field
33
- * is omitted only for a missing/empty/whitespace/non-scalar value.
34
- */
35
- readonly title?: string;
36
- /** This concept's chars/4 token estimate over its canonical serialized bytes. */
37
- readonly tokenEstimate: number;
38
- /** Complete locator-free provenance in explicit workspace mode. */
39
- readonly provenance?: WorkspaceRecordProvenance;
40
- }
41
-
42
- /** One directed reference in the exported graph (mirrors a {@link Edge}, with `dangling` made explicit). */
43
- export interface GraphEdge {
44
- /** The source concept id. */
45
- readonly from: string;
46
- /** The resolved target concept id, or `null` when the reference dangles. */
47
- readonly to: string | null;
48
- /** Which kind of reference produced this edge (`link`/`specs`/`supersedes`/`superseded_by`). */
49
- readonly kind: EdgeKind;
50
- /** The reference as parsed (link destination / frontmatter value), for diagnostics. */
51
- readonly target: string;
52
- /** `true` when the reference resolves to no concept in the bundle (`to === null`). */
53
- readonly dangling: boolean;
54
- }
55
-
56
- /** The `graph.export` payload: the nodes, the edges among them, and the token budget. */
57
- export interface GraphExport {
58
- /** The root concept id when the export is a subgraph; omitted for a whole-bundle export. */
59
- readonly root?: string;
60
- /** The hop radius when the subgraph was bounded by `--depth`; omitted when unbounded or whole-bundle. */
61
- readonly depth?: number;
62
- /** Every included concept, in ascending id order. */
63
- readonly nodes: readonly GraphNode[];
64
- /** Every reference whose source is included and whose target is included or dangling, in graph order. */
65
- readonly edges: readonly GraphEdge[];
66
- /** The summed chars/4 token estimate over the included nodes (labeled a heuristic, not a tokenizer). */
67
- readonly tokenEstimate: number;
68
- /** Explicit selected workspace scope; absent for repository-local output. */
69
- readonly workspace?: WorkspaceResultScope;
70
- /** Exact manifest-authored cross-repository links in workspace mode. */
71
- readonly workspaceLinks?: readonly WorkspaceProjectedLink[];
72
- }
73
-
74
- /** Options for {@link buildGraphExport}. */
75
- export interface GraphExportOptions {
76
- /**
77
- * The concept ids to include — typically a {@link subgraph} result. When omitted,
78
- * the whole bundle is exported. Ids not present in the bundle are ignored.
79
- */
80
- readonly include?: ReadonlySet<string>;
81
- /** The root concept id, recorded on the result when this is a subgraph export. */
82
- readonly root?: string;
83
- /** The hop radius, recorded on the result when the subgraph was bounded. */
84
- readonly depth?: number;
85
- readonly workspace?: WorkspaceResultScope;
86
- readonly provenanceById?: ReadonlyMap<string, WorkspaceRecordProvenance>;
87
- readonly workspaceLinks?: readonly WorkspaceProjectedLink[];
88
- }
89
-
90
- /**
91
- * Shape a {@link BundleGraph} into a {@link GraphExport}, optionally narrowed to
92
- * an `include` id-set (a {@link subgraph}).
93
- *
94
- * Nodes are the bundle's concepts (in their already-sorted id order) filtered to
95
- * the included set, each annotated with its `type`, optional `title`, and token
96
- * estimate. Edges are the **closure** of the included set: an edge is kept when
97
- * its `from` is included **and** its `to` is included *or* dangling — so a
98
- * subgraph keeps its internal links and its members' broken links, but drops
99
- * edges that lead to concepts outside the requested radius (a bounded `--depth`
100
- * is an explicit cut, not a leak). For a whole-bundle export every edge
101
- * qualifies. The token total is summed over exactly the included nodes, so it
102
- * equals the whole-bundle estimate for a full export and the subgraph's budget
103
- * otherwise.
104
- */
105
- export function buildGraphExport(graph: BundleGraph, options: GraphExportOptions = {}): GraphExport {
106
- const { include, root, depth } = options;
107
- // Drive node iteration by the *smaller* of the two: the whole concept map for a
108
- // full export (already ascending-id order), or just the included ids — sorted to
109
- // preserve that order — for a subgraph, so a bounded `--depth` query costs
110
- // O(N_sub log N_sub), not O(N_total).
111
- const ids = include === undefined ? graph.concepts.keys() : [...include].sort(compareCodeUnits);
112
- const nodes: GraphNode[] = [];
113
- let tokenEstimate = 0;
114
- for (const id of ids) {
115
- const concept = graph.concepts.get(id);
116
- if (concept === undefined) {
117
- continue; // an include id that names no concept — ignored, never an error
118
- }
119
- const tokens = graph.tokenEstimate(id);
120
- tokenEstimate += tokens;
121
- const title = frontmatterScalar(concept.frontmatter.title);
122
- nodes.push({
123
- id,
124
- type: concept.type,
125
- ...(title !== undefined ? { title } : {}),
126
- tokenEstimate: tokens,
127
- ...(options.provenanceById?.get(id) !== undefined ? { provenance: options.provenanceById.get(id) } : {}),
128
- });
129
- }
130
-
131
- // Edge closure: for a full export every edge qualifies (per the Edge contract
132
- // `from` is always a concept and `to` is null-or-concept), so no membership
133
- // filtering is needed; for a subgraph, keep an edge whose `from` is included and
134
- // whose `to` is included or dangling — the `include` set already equals the node
135
- // id-set, so no second set is built.
136
- const edges: GraphEdge[] = [];
137
- for (const edge of graph.edges) {
138
- if (include !== undefined && (!include.has(edge.from) || (edge.to !== null && !include.has(edge.to)))) {
139
- continue; // leads outside the requested subgraph — an explicit radius cut
140
- }
141
- edges.push({ from: edge.from, to: edge.to, kind: edge.kind, target: edge.target, dangling: edge.to === null });
142
- }
143
-
144
- return {
145
- ...(root !== undefined ? { root } : {}),
146
- ...(depth !== undefined ? { depth } : {}),
147
- nodes,
148
- edges,
149
- tokenEstimate,
150
- ...(options.workspace !== undefined ? { workspace: options.workspace } : {}),
151
- ...(options.workspaceLinks !== undefined ? { workspaceLinks: options.workspaceLinks } : {}),
152
- };
153
- }
154
-
155
- /**
156
- * Render a {@link GraphExport} as Graphviz DOT — a `digraph` with one statement
157
- * per node (labeled with its title or id) and one per **resolved** edge (labeled
158
- * with its kind). Dangling edges are omitted: they have no target node to draw
159
- * to, and the `--json` model already carries them for tooling that needs the
160
- * broken-link signal (`lore check` is the dedicated reporter). Output is
161
- * deterministic — nodes in the export's id order, edges in graph order — so
162
- * `lore graph --dot | dot -Tpng` is stable across runs.
163
- */
164
- export function toDot(data: GraphExport): string {
165
- const lines = ["digraph lore {"];
166
- for (const node of data.nodes) {
167
- lines.push(` ${quote(node.id)} [label=${quote(node.title ?? node.id)}];`);
168
- }
169
- for (const edge of data.edges) {
170
- if (edge.to === null) {
171
- continue; // no target node to draw to
172
- }
173
- lines.push(` ${quote(edge.from)} -> ${quote(edge.to)} [label=${quote(edge.kind)}];`);
174
- }
175
- lines.push("}");
176
- return lines.join("\n");
177
- }
178
-
179
- /**
180
- * Quote a string as a DOT double-quoted ID (an escString, per the Graphviz
181
- * language spec). Graphviz's lexer (`lib/cgraph/scan.l`) recognizes exactly
182
- * two escapes inside a quoted string — `\"` for a literal quote and `\\` for
183
- * a literal backslash — and otherwise **drops** a backslash that precedes
184
- * any other character (so an *unescaped* `\` is not safe passthrough; it is
185
- * silently eaten, or worse, combines with the following character). A literal
186
- * backslash therefore must be doubled to `\\` so it survives the round trip,
187
- * and that doubling must happen **first**, before any escape sequence this
188
- * function injects (`\"`, `\n`) — otherwise the backslash the doubling itself
189
- * introduces would be doubled again. A raw line break cannot survive inside a
190
- * quoted ID without splitting `toDot()`'s one-statement-per-line output
191
- * across two physical lines, so an embedded newline (`value` is
192
- * bundle-controlled — a concept id or frontmatter scalar, either of which can
193
- * carry one) is rewritten to the two-character escape `\n` — the same escape
194
- * Graphviz itself uses to force a line break inside a label.
195
- */
196
- function quote(value: string): string {
197
- return `"${value
198
- .replace(/\\/g, "\\\\")
199
- .replace(/\r\n|\r|\n/g, "\\n")
200
- .replace(/"/g, '\\"')}"`;
201
- }
@@ -1,436 +0,0 @@
1
- /**
2
- * indexes.ts — the bundle's `index.md` files as **graph-derived**, byte-stable navigable hubs (LORE-29).
3
- *
4
- * Where [`log.ts`](./log.ts) derives `log.md` from git history, this module derives every
5
- * `index.md` from the bundle graph: the reserved root entry point plus one local hub per
6
- * sub-directory, each listing its immediate children (concepts + child indexes) as portable
7
- * markdown links (okf-conformance: "index bodies link children as a navigable hub").
8
- *
9
- * ### What it owns, and what it deliberately does not
10
- *
11
- * Index files are **hand-authored documents with one machine-owned region** — exactly the
12
- * managed-block discipline of lore-design §6.2, where everything outside the region is the
13
- * author's and is never touched. {@link generateIndexes} regenerates only the
14
- * `<!-- lore:index:begin -->…<!-- lore:index:end -->` listing block, by **string-splicing it
15
- * into the existing file's raw bytes**. It never round-trips the file through
16
- * {@link serializeConcept}: doing so would canonicalize frontmatter and, worse, drop the root
17
- * index's in-fence editor modeline (an accepted js-yaml round-trip limitation, ADR-0011 §2). So
18
- * the root index's `okf_version` frontmatter, its modeline, and all curated prose survive
19
- * byte-for-byte; only the listing block changes.
20
- *
21
- * Two responsibilities stay with their owners, not here:
22
- *
23
- * - **Root index *creation* and `okf_version`** belong to `scaffold.ts` / `lore init`
24
- * (okf-conformance: the bundle-root `index.md` is the only file carrying `okf_version`; a
25
- * sub-index carries none, AC#2). `generateIndexes` only *maintains the region* of a root index
26
- * that already exists; it never invents the frontmatter. A **sub**-index that does not yet
27
- * exist is synthesized frontmatter-free.
28
- * - **Writing files and reading their current bytes** is the command layer's (`lore sync`,
29
- * LORE-26). The current on-disk index bytes enter through the injected {@link GenerateIndexesOptions.existing}
30
- * seam — the same determinism boundary `log.ts` draws with its `GitAdapter`: core stays pure
31
- * (no filesystem, no clock), and tests inject a fixed map rather than touching disk.
32
- *
33
- * ### Byte-stability (AC#1)
34
- *
35
- * The output is a pure function of `(g.concepts, existing)`: directory sets, child lists, and the
36
- * listing are all sorted with {@link compareCodeUnits}, so the same bundle always yields the same
37
- * bytes. Splicing is a **fixpoint** — re-running over already-generated bytes replaces the region
38
- * with an identical region, so a no-change `lore sync` is a byte-level no-op and `lore check`'s
39
- * regenerate-and-compare drift gate stays trustworthy (`index.md` is gated, unlike the
40
- * git-derived `log.md`; see [log.ts](./log.ts)).
41
- *
42
- * ### A note for the link gate (LORE-27)
43
- *
44
- * A generated hub links to child-directory indexes (`[adr](adr/index.md)`), but those sub-indexes
45
- * are frontmatter-free (AC#2) and so are **not** concepts in the {@link BundleGraph} — the root
46
- * index *is* a concept (it carries `okf_version`), so `bundle.ts` extracts those links as edges that
47
- * resolve to `null` (dangling). That is expected: when `lore check`'s broken-link reporting lands
48
- * (LORE-27), it must treat a reserved `index.md`/`log.md` link target as resolving to the file, not
49
- * flag a freshly-generated, correct hub as broken.
50
- *
51
- * Per the core contract (lore-design §2.1) this module is pure: no filesystem, no spawn, no clock.
52
- */
53
-
54
- import { posix } from "node:path";
55
- import { LoreError, singleLine } from "../errors";
56
- import { type BundleGraph, frontmatterScalar } from "./bundle";
57
- import type { Concept } from "./concept";
58
- import { encodePathSegments } from "./links";
59
- import { compareCodeUnits } from "./order";
60
-
61
- /** The reserved index file name — the per-directory navigable hub (one per dir, plus the bundle root). */
62
- const INDEX_FILE = "index.md";
63
-
64
- /** Reserved file names excluded from a directory's child listing (they are structure, not content). */
65
- const RESERVED_FILES: ReadonlySet<string> = new Set([INDEX_FILE, "log.md"]);
66
-
67
- /**
68
- * The HTML-comment markers bounding the machine-owned listing block. HTML comments are invisible
69
- * in every target renderer (GitHub, Obsidian, MkDocs, Docusaurus), and the exact `lore:index:*`
70
- * sentinels never occur in authored prose, so a literal find is safe (no AST needed here; the
71
- * remark/mdast unification of all managed regions is LORE-22's `managed-block.ts`). The `:begin`/
72
- * `:end` shape mirrors the `<!-- lore:tasks:begin -->…<!-- lore:tasks:end -->` task block
73
- * (lore-design §6.2). Exported so `lore sync`/`lore check` (LORE-26/27) match the same block.
74
- */
75
- export const INDEX_BLOCK_BEGIN = "<!-- lore:index:begin -->";
76
- export const INDEX_BLOCK_END = "<!-- lore:index:end -->";
77
-
78
- /** Options for {@link generateIndexes}. */
79
- export interface GenerateIndexesOptions {
80
- /**
81
- * The current raw bytes of each index file, keyed by bundle-relative path (e.g. `"index.md"`,
82
- * `"adr/index.md"`) — the injected determinism seam (lore-design §8). The command layer reads
83
- * these from disk; tests inject a fixed map. A present entry has its listing region spliced in
84
- * place (or appended if it carries no markers yet), preserving every other byte — frontmatter,
85
- * modeline, and prose. An absent entry means the index file does not exist, so a minimal
86
- * frontmatter-free hub is synthesized. Default: empty (every index synthesized fresh).
87
- */
88
- readonly existing?: ReadonlyMap<string, string>;
89
- }
90
-
91
- /**
92
- * Regenerate every bundle `index.md` from the graph, returning a map of bundle-relative path →
93
- * new bytes (the same path space as {@link BundleGraph} concept ids/paths, e.g. `"adr/index.md"`).
94
- *
95
- * An index is produced for the bundle root and for every directory that (transitively) contains a
96
- * concept, so the hub is navigable from the root down. Each index lists, sorted by link target:
97
- * its immediate child concepts (excluding the reserved `index.md`/`log.md`) and a link into each
98
- * immediate child directory's index.
99
- *
100
- * Pure and total: identical `(g, options)` always yield identical output, and splicing is a
101
- * fixpoint (AC#1). Existing files keep all bytes outside the listing block (AC#2: a sub-index is
102
- * never given frontmatter); see the module header for the seam and ownership boundaries.
103
- */
104
- export function generateIndexes(g: BundleGraph, options: GenerateIndexesOptions = {}): Map<string, string> {
105
- const existing = options.existing ?? new Map<string, string>();
106
-
107
- // Group concepts by their directory, excluding reserved files (an index/log is bundle structure,
108
- // not a listable child — and the root index is itself a concept we must not list under itself).
109
- const conceptsByDir = new Map<string, Concept[]>();
110
- for (const concept of g.concepts.values()) {
111
- if (RESERVED_FILES.has(posix.basename(concept.path))) {
112
- continue;
113
- }
114
- const dir = dirOf(concept.path);
115
- const bucket = conceptsByDir.get(dir);
116
- if (bucket === undefined) {
117
- conceptsByDir.set(dir, [concept]);
118
- } else {
119
- bucket.push(concept);
120
- }
121
- }
122
-
123
- // Every directory that needs an index: each dir holding a concept, plus all of its ancestors up
124
- // to the root (so an intermediate dir with concepts only deeper still gets a hub that links down).
125
- const indexDirs = liveIndexDirs(g);
126
-
127
- // Immediate child directories of each index dir (only dirs that themselves get an index).
128
- const childDirs = new Map<string, string[]>();
129
- for (const dir of indexDirs) {
130
- if (dir === "") {
131
- continue;
132
- }
133
- const parent = parentDir(dir);
134
- const bucket = childDirs.get(parent);
135
- if (bucket === undefined) {
136
- childDirs.set(parent, [dir]);
137
- } else {
138
- bucket.push(dir);
139
- }
140
- }
141
-
142
- const result = new Map<string, string>();
143
- for (const dir of indexDirs) {
144
- const indexPath = dir === "" ? INDEX_FILE : `${dir}/${INDEX_FILE}`;
145
- const listing = buildListing(conceptsByDir.get(dir) ?? [], childDirs.get(dir) ?? []);
146
- const block = `${INDEX_BLOCK_BEGIN}\n${listing}\n${INDEX_BLOCK_END}`;
147
- result.set(indexPath, render(existing.get(indexPath), block, dir));
148
- }
149
- return result;
150
- }
151
-
152
- /**
153
- * Every directory that needs a **live** index for `g`: the bundle root, every directory holding a
154
- * concept directly (excluding the reserved `index.md`/`log.md`), plus each such directory's
155
- * ancestors up to the root — so an intermediate directory with concepts only deeper still counts as
156
- * live (it gets a hub that links down to them). This is exactly {@link generateIndexes}'s own
157
- * `indexDirs` derivation, factored out so {@link orphanedIndexPaths} can compare against the same
158
- * live set without duplicating (and risking drift from) the ancestor-walk logic.
159
- */
160
- function liveIndexDirs(g: BundleGraph): Set<string> {
161
- const dirs = new Set<string>([""]);
162
- for (const concept of g.concepts.values()) {
163
- if (RESERVED_FILES.has(posix.basename(concept.path))) {
164
- continue;
165
- }
166
- for (const ancestor of ancestorsInclusive(dirOf(concept.path))) {
167
- dirs.add(ancestor);
168
- }
169
- }
170
- return dirs;
171
- }
172
-
173
- /**
174
- * The bundle-relative paths of every `index.md` present in `existing` whose directory {@link
175
- * generateIndexes} did **not** regenerate for `g` — a directory holding no concept, directly or via
176
- * any descendant (LORE-150). This is the stale-index detection `generateIndexes` itself never
177
- * performed: it derives `indexDirs` solely from the live graph and simply never emits an entry for a
178
- * directory outside that set, so a caller iterating only its *returned* map has no way to tell "this
179
- * on-disk index was never regenerated because its directory is orphaned" apart from "this on-disk
180
- * index was never regenerated because nothing has changed" — both look identical from that map
181
- * alone. Diffing `existing`'s keys against the live set here makes that distinction explicit and
182
- * queryable, so `lore sync` (the one caller with a `existing` map sourced from disk) can report an
183
- * orphan instead of silently leaving it untouched and unmentioned.
184
- *
185
- * `generateIndexes`'s own signature and behavior are deliberately unchanged (its returned map is
186
- * still keyed only by live directories, exactly as before) — this is a separate, additive query so
187
- * every existing caller (`lore rename`'s `mergeIndexWrites`, which has its own established
188
- * empty-listing strategy for a directory a rename itself empties) keeps compiling and behaving
189
- * identically; only `lore sync` needs to ask this new question.
190
- *
191
- * Pure and total, sorted with {@link compareCodeUnits} (matching {@link generateIndexes}'s own
192
- * ordering) so a caller's report is byte-stable regardless of `existing`'s iteration order.
193
- */
194
- export function orphanedIndexPaths(g: BundleGraph, existing: ReadonlyMap<string, string>): string[] {
195
- const live = liveIndexDirs(g);
196
- const livePaths = new Set<string>();
197
- for (const dir of live) {
198
- livePaths.add(dir === "" ? INDEX_FILE : `${dir}/${INDEX_FILE}`);
199
- }
200
- return [...existing.keys()].filter((path) => !livePaths.has(path)).sort(compareCodeUnits);
201
- }
202
-
203
- /**
204
- * The sorted listing lines for one directory: its immediate child concepts and child-directory
205
- * indexes, each `- [title](link)`, ordered by link target with {@link compareCodeUnits} so the
206
- * block is byte-stable regardless of map iteration order. An empty directory (no listable children)
207
- * yields an empty string, so the block is still well-formed (just the two markers around nothing).
208
- */
209
- function buildListing(concepts: readonly Concept[], childDirSet: readonly string[]): string {
210
- const entries: Array<{ link: string; title: string }> = [];
211
-
212
- for (const concept of concepts) {
213
- const target = posix.basename(concept.path);
214
- entries.push({ link: encodePathSegments(target), title: conceptTitle(concept) });
215
- }
216
- for (const childDir of childDirSet) {
217
- const name = posix.basename(childDir);
218
- entries.push({ link: `${encodePathSegments(name)}/${INDEX_FILE}`, title: name });
219
- }
220
-
221
- return entries
222
- .sort((a, b) => compareCodeUnits(a.link, b.link))
223
- .map((e) => `- [${linkText(e.title)}](${e.link})`)
224
- .join("\n");
225
- }
226
-
227
- /**
228
- * Sanitize an untrusted title (a concept's frontmatter `title` or a file/dir base name) into safe
229
- * markdown **link text**. Three defenses, all of which keep the rendered text readable:
230
- *
231
- * - **Single-line** ({@link singleLine}, as `log.ts` does for commit subjects) — a title carrying a
232
- * newline (a YAML block scalar) would otherwise split the entry out of the list.
233
- * - **Escape `\`** — done *first*, before the `[`/`]` escape below. A pre-existing literal backslash
234
- * immediately before a bracket (e.g. a title `Plan \]B\[`) would otherwise combine with the
235
- * bracket-escape's inserted backslash into CommonMark's `\\` (an escaped backslash) followed by a
236
- * live, link-syntax bracket — shifting text into a real `[text](link)` boundary (same class of bug
237
- * as {@link cell}, LORE-154). Doubling backslashes first means the later step's own backslashes are
238
- * never mistaken for source content and never re-escaped.
239
- * - **Escape `[` / `]`** — an unbalanced bracket would truncate or break the `[text](link)` syntax,
240
- * leaving the entry as literal text that links nowhere.
241
- * - **Neutralize HTML-comment sentinels** (`<!--` / `-->` → entities) — a title literally containing
242
- * the managed-block end marker would otherwise be written *inside* the generated block and, on the
243
- * next run, be mistaken for the region boundary — corrupting the file and breaking the AC#1
244
- * fixpoint. The entities render identically (and, as a bonus, a stray `<!--` no longer starts a
245
- * real HTML comment that hides text).
246
- */
247
- function linkText(title: string): string {
248
- return singleLine(title)
249
- .replace(/\\/g, "\\\\")
250
- .replace(/[[\]]/g, (c) => `\\${c}`)
251
- .replace(/<!--/g, "&lt;!--")
252
- .replace(/-->/g, "--&gt;");
253
- }
254
-
255
- /**
256
- * A concept's display title: its frontmatter `title` via the shared {@link frontmatterScalar}
257
- * (a non-empty string verbatim, or a finite number/boolean coerced to its string form) when that
258
- * yields a value, else the file's base name (the id's last segment). Falling back to the file
259
- * name keeps the listing total for a title-less or unknown-type concept (OKF tolerance) without
260
- * inventing prose. Sharing `frontmatterScalar` keeps this in lockstep with `graph.ts`/`query.ts`/
261
- * `context.ts`, so the same concept never shows one title in the generated index and another
262
- * everywhere else (LORE-244).
263
- */
264
- function conceptTitle(concept: Concept): string {
265
- return frontmatterScalar(concept.frontmatter.title) ?? posix.basename(concept.path, ".md");
266
- }
267
-
268
- /**
269
- * Produce the final index bytes for one directory, given the directory's current raw bytes (or
270
- * `undefined` if absent) and the freshly built listing `block`.
271
- *
272
- * - **Existing file with the markers** → the block (begin…end inclusive) is replaced in place; all
273
- * other bytes are preserved verbatim (frontmatter, modeline, prose), so only the listing changes.
274
- * - **Existing file without markers** → the block is appended after the body, separated by one
275
- * blank line, with a single trailing newline; nothing the author wrote is altered.
276
- * - **Absent or blank file** → a minimal, frontmatter-free hub is synthesized (`# <title>` + the
277
- * block). An existing-but-empty/whitespace-only file is treated like an absent one so a
278
- * `touch`-ed or blanked index still gets a heading rather than a heading-less, blank-line-led stub.
279
- * The root index is never synthesized with `okf_version` here (that is `lore init`'s job); a
280
- * synthesized root simply carries the block under a heading.
281
- *
282
- * Every branch ends in exactly one trailing newline, and re-rendering the output is a **fixpoint**
283
- * (AC#1): the replace branch absorbs a block left at end-of-file by re-adding the newline only when
284
- * nothing follows the block.
285
- */
286
- function render(current: string | undefined, block: string, dir: string): string {
287
- if (current === undefined || current.trim() === "") {
288
- return `# ${headingFor(dir)}\n\n${block}\n`;
289
- }
290
- const bounds = blockBounds(current);
291
- if (bounds !== null) {
292
- const tail = current.slice(bounds.end);
293
- const spliced = current.slice(0, bounds.start) + block + tail;
294
- // A block sitting at end-of-file (a normal append, or a truncated region replaced to EOF) leaves
295
- // no trailing newline; add exactly one. When prose follows the block, `tail` already carries it
296
- // (newline included), so this is a no-op and the result stays a fixpoint.
297
- return tail === "" ? `${spliced}\n` : spliced;
298
- }
299
- // No managed region yet: append it, normalizing to exactly one blank-line separator and one
300
- // trailing newline so a second run (which now finds the markers) is a byte-level fixpoint.
301
- return `${current.replace(/\n+$/, "")}\n\n${block}\n`;
302
- }
303
-
304
- /**
305
- * Locate the managed block in `content` as a `[start, end)` byte range, or `null` when no begin
306
- * marker is present (an unmanaged file the caller appends to). See {@link locateManagedBlock} for
307
- * the full contract, including the malformed-layout cases that are a fail-loud `validation` error
308
- * (LORE-86).
309
- */
310
- function blockBounds(content: string): { start: number; end: number } | null {
311
- return locateManagedBlock(content, INDEX_BLOCK_BEGIN, INDEX_BLOCK_END);
312
- }
313
-
314
- /**
315
- * The generic managed-block locator behind {@link blockBounds}, exported as the **one** rule for
316
- * where a lore-managed region lives in raw text — `[start, end)` spanning the `begin` marker to the
317
- * `end` marker (markers included), or `null` when no `begin` is present (an unmanaged file the
318
- * caller appends to).
319
- *
320
- * A well-formed file carries exactly one `begin` and one `end`, with the `end` at or after the
321
- * `begin`. Anything else is a **malformed layout** — a duplicated pair (both markers appear more
322
- * than once, e.g. a 3-way-merge artifact) or an unmatched `begin` (no `end` after it, e.g. an
323
- * interrupted write, or an `end` marker that precedes it) — and is a fail-loud `validation`
324
- * {@link LoreError} (exit 6) rather than a guessed range. This module previously collapsed a
325
- * duplicated pair to its first-begin→last-end span and extended an unmatched begin to end-of-file,
326
- * both to keep regeneration converging to a byte-level fixpoint even from a corrupted file — but a
327
- * duplicated pair's collapse silently deleted any hand-authored prose sitting between the two
328
- * blocks (LORE-86), and a guessed range is exactly the ambiguity {@link findMarkers} in
329
- * `managed-block.ts` already refuses for the sibling `lore:tasks` block. This function now matches
330
- * that same fail-loud contract instead of guessing.
331
- *
332
- * Exported so every consumer that must agree on a managed region's extent (and its validation) uses
333
- * the *same* rule: index regeneration ({@link generateIndexes}) splices it, and `lore replace`
334
- * (LORE-35) protects it. Sharing this is what keeps `replace` from editing a span that `lore sync`
335
- * would later regenerate.
336
- *
337
- * The unavoidable cost of literal markers (no AST): a begin/end the *author* wrote in prose — e.g. a
338
- * doc demonstrating this very format — is treated as a real marker occurrence, contributing to the
339
- * duplicate count above. That ambiguity is what LORE-22's mdast-based `managed-block.ts` resolves
340
- * structurally; here it remains a documented limitation of the string splice.
341
- */
342
- export function locateManagedBlock(content: string, begin: string, end: string): { start: number; end: number } | null {
343
- const begins = allIndicesOf(content, begin);
344
- if (begins.length === 0) {
345
- return null; // no managed block yet — the caller appends one
346
- }
347
- const ends = allIndicesOf(content, end);
348
- if (begins.length > 1 || ends.length > 1) {
349
- throw managedBlockError(
350
- begin,
351
- end,
352
- `the managed block is duplicated (found ${begins.length} begin and ${ends.length} end markers; expected exactly one of each)`,
353
- { begins: begins.length, ends: ends.length },
354
- );
355
- }
356
- const start = begins[0] as number;
357
- if (ends.length === 0) {
358
- throw managedBlockError(begin, end, "the begin marker has no matching end marker after it", {
359
- begins: begins.length,
360
- ends: ends.length,
361
- });
362
- }
363
- const endStart = ends[0] as number;
364
- if (endStart < start + begin.length) {
365
- throw managedBlockError(begin, end, "the markers are crossed (the end marker precedes the begin marker)", {
366
- begin: start,
367
- end: endStart,
368
- });
369
- }
370
- return { start, end: endStart + end.length };
371
- }
372
-
373
- /** Every (non-overlapping) occurrence of `marker` in `content`, in ascending order. */
374
- function allIndicesOf(content: string, marker: string): number[] {
375
- const indices: number[] = [];
376
- let from = 0;
377
- for (;;) {
378
- const at = content.indexOf(marker, from);
379
- if (at === -1) {
380
- return indices;
381
- }
382
- indices.push(at);
383
- from = at + marker.length;
384
- }
385
- }
386
-
387
- /**
388
- * Build the fail-loud "malformed managed-block markers" error {@link locateManagedBlock} throws
389
- * (`validation`, exit 6) — the same category and shape `managed-block.ts`'s marker validation uses
390
- * for the sibling `lore:tasks` block, so both engines refuse to guess in the same voice.
391
- */
392
- function managedBlockError(begin: string, end: string, reason: string, input: Record<string, unknown>): LoreError {
393
- return new LoreError(
394
- "validation",
395
- `cannot locate the managed block bounded by \`${begin}\` / \`${end}\`: ${reason}`,
396
- `place exactly one \`${begin}\` and one \`${end}\` on their own lines, in that order`,
397
- input,
398
- );
399
- }
400
-
401
- /** The synthesized heading for a brand-new index: the directory's base name, or `index` for the root. */
402
- function headingFor(dir: string): string {
403
- return dir === "" ? "index" : posix.basename(dir);
404
- }
405
-
406
- /**
407
- * The directory of a bundle-relative concept path, normalized to `""` for the bundle root.
408
- * `posix.dirname("x.md")` is `"."`; we use `""` for the root so the root index path is `index.md`
409
- * (not `./index.md`) and ancestor walking terminates cleanly.
410
- */
411
- function dirOf(path: string): string {
412
- const dir = posix.dirname(path);
413
- return dir === "." ? "" : dir;
414
- }
415
-
416
- /** The parent of a non-root bundle directory (`"a/b/c"` → `"a/b"`, `"a"` → `""`). */
417
- function parentDir(dir: string): string {
418
- const slash = dir.lastIndexOf("/");
419
- return slash === -1 ? "" : dir.slice(0, slash);
420
- }
421
-
422
- /**
423
- * Every directory from `dir` up to (and including) the root `""`, e.g. `"a/b"` → `["a/b", "a", ""]`.
424
- * Used to ensure an intermediate directory that holds no concept directly — only in a descendant —
425
- * still gets an index that links down toward it.
426
- */
427
- function ancestorsInclusive(dir: string): string[] {
428
- const chain: string[] = [];
429
- let current = dir;
430
- while (current !== "") {
431
- chain.push(current);
432
- current = parentDir(current);
433
- }
434
- chain.push("");
435
- return chain;
436
- }