@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
@@ -1,320 +0,0 @@
1
- /**
2
- * commands/replace.ts — `lore replace <find> <replace> [--regex] [--in <glob>…] [--dry-run]`.
3
- *
4
- * The thin, side-effecting layer over the pure replace engine (cli-surface §replace): it parses the
5
- * command's own arguments, compiles the pattern **once** (so a bad pattern fails even when discovery
6
- * finds nothing), **discovers** the target markdown files (a default whole-bundle walk of `docs/`, or
7
- * the explicit `--in <glob>` set), reads their bytes, asks core to rewrite each — skipping every
8
- * lore-managed region (AC#1) — writes back the genuinely-changed files (unless `--dry-run`), renders a
9
- * per-file report, and returns the exit code. All file discovery and I/O live here; the
10
- * never-touch-managed-regions judgement lives in `core/replace.ts`.
11
- *
12
- * Discovery is hardened so a refactor can neither escape the bundle nor corrupt machine-owned files:
13
- * targets are `.md` only (a glob match on `mkdocs.yml` is left alone); a **symlink** is skipped (it
14
- * could resolve outside the repo, and `walkMarkdown` skips them too); files are de-duplicated by
15
- * **canonical identity** so one physical file reached two ways is rewritten once (never double-applied);
16
- * the fully-generated `log.md` is excluded (it is regenerated wholesale, so editing it is futile); and
17
- * an absolute `--in` glob resolves correctly. Reads and replacements for **all** files complete before
18
- * **any** write, so a bad pattern or an unreadable file aborts the run before it has changed a single
19
- * file on disk. Each individual write in that commit phase is itself atomic (LORE-116,
20
- * `writeFileAtomic`'s temp-file+rename discipline), so a crash or I/O error partway through a single
21
- * file's write can never leave that file truncated or half-written.
22
- *
23
- * A bad flag, an invalid/empty/zero-width pattern is a `usage` error (exit 2); an unreadable path an
24
- * I/O failure — both funnel through the router's one error seam like every command.
25
- */
26
-
27
- import { lstatSync } from "node:fs";
28
- import { basename, join, resolve } from "node:path";
29
- import { walkMarkdown } from "../core/bundle";
30
- import { compileReplacer, type Replacer } from "../core/replace";
31
- import { DOCS_DIR } from "../core/scaffold";
32
- import { EXIT_OK, LoreError, singleLine, stripAnsiAndControls, WarningCollector, type Writer } from "../errors";
33
- import { emit, type OutputContext, type Renderable } from "../output";
34
- import { optionValues, parseCommandArgs } from "./args";
35
- import { canonicalIdentity, readSource, toRepoRelative, withinRepo } from "./discover";
36
- import { writeFileAtomic } from "./fswrite";
37
-
38
- /** The reserved, fully git-derived file `lore` regenerates wholesale; editing it via `replace` is futile. */
39
- const GENERATED_FILE = "log.md";
40
-
41
- /** Options for {@link runReplace}; `root` and the streams are injectable for tests. */
42
- export interface ReplaceOptions {
43
- /** The repo root the bundle (and any `--in` globs) resolve against. */
44
- root: string;
45
- /** The resolved output mode/color (from `output.ts`). */
46
- output: OutputContext;
47
- /** The command's normalized positional + flag tokens from Commander. */
48
- args: readonly string[];
49
- /** stdout sink; defaults to `process.stdout`. */
50
- stdout?: Writer;
51
- /** stderr sink for discovery advisories; defaults to `process.stderr`. */
52
- stderr?: Writer;
53
- }
54
-
55
- /** The parsed form of `lore replace`'s arguments. */
56
- interface ReplaceArgs {
57
- /** The find pattern (literal text, or a regex source under `--regex`). */
58
- find: string;
59
- /** The replacement (literal, or with `$1`/`$&` substitutions under `--regex`). */
60
- replace: string;
61
- /** `--regex`: treat `find` as a JavaScript regular expression. */
62
- regex: boolean;
63
- /** `--in <glob>` scopes (repeatable); empty means the whole `docs/` bundle. */
64
- in: string[];
65
- /** `--dry-run`: report what would change, write nothing. */
66
- dryRun: boolean;
67
- }
68
-
69
- /** One discovered file: its repo-relative display path and the absolute path to read/write. */
70
- interface Target {
71
- /** Repo-relative POSIX path, for the report. */
72
- readonly display: string;
73
- /** Absolute filesystem path (canonical), for reading and writing. */
74
- readonly abs: string;
75
- }
76
-
77
- /** A planned write: a file whose bytes genuinely changed. */
78
- interface PlannedChange {
79
- readonly display: string;
80
- readonly abs: string;
81
- readonly text: string;
82
- readonly count: number;
83
- }
84
-
85
- /** One file's replacement tally, for the report. */
86
- interface FileChange {
87
- /** Repo-relative POSIX path of the changed file. */
88
- readonly path: string;
89
- /** Matches replaced in that file (outside managed regions). */
90
- readonly count: number;
91
- }
92
-
93
- /** The `replace.result` payload: the changed files and the run totals. */
94
- export interface ReplaceReport {
95
- /** Per changed file, in ascending path order; files with no real change are omitted. */
96
- readonly files: readonly FileChange[];
97
- /** Total matches replaced across the bundle. */
98
- readonly totalMatches: number;
99
- /** How many files changed. */
100
- readonly filesChanged: number;
101
- /** How many `.md` files were examined. */
102
- readonly filesScanned: number;
103
- /** Whether this was a `--dry-run` (nothing was written). */
104
- readonly dryRun: boolean;
105
- }
106
-
107
- /**
108
- * Run `lore replace`: parse the arguments, compile the pattern, discover and read the target markdown,
109
- * rewrite each outside its managed regions, write the genuinely-changed files (unless `--dry-run`),
110
- * emit the `replace.result`, and return `0`. A bad flag/positional or an invalid/empty/zero-width
111
- * pattern throws a `usage` {@link LoreError} (exit `2`); an unreadable bundle/path a
112
- * `not_found`/`denied`.
113
- */
114
- export function runReplace(options: ReplaceOptions): number {
115
- const parsed = parseReplaceArgs(options.args);
116
- // Compile (and validate) the pattern ONCE, before discovery — so an invalid/empty/zero-width pattern
117
- // fails fast even when no file matches, instead of slipping through a per-file loop that never runs.
118
- const replacer: Replacer = compileReplacer(parsed.find, parsed.replace, { regex: parsed.regex });
119
-
120
- const advisories = new WarningCollector();
121
- const targets = discoverFiles(options.root, parsed.in, advisories);
122
-
123
- // Phase 1 — read + replace every target into a plan. No write happens until every file has been read
124
- // and rewritten, so a read error or a no-match file can't leave the bundle half-rewritten.
125
- const planned: PlannedChange[] = [];
126
- for (const target of targets) {
127
- const original = readSource(target.abs, target.display);
128
- const { text, count } = replacer(original);
129
- if (count === 0 || text === original) {
130
- continue; // gate on a REAL byte change, so a no-op (e.g. find === replace) never churns a file
131
- }
132
- planned.push({ display: target.display, abs: target.abs, text, count });
133
- }
134
-
135
- // Phase 2 — commit the writes (unless dry-run). Each file goes through writeFileAtomic (LORE-116)
136
- // rather than the plain writeFileOverwriting: a crash, kill, or I/O error (e.g. disk full) partway
137
- // through a single file's write must never leave that target truncated or half-written — the same
138
- // temp-file+rename discipline `lore sync` already relies on for the identical reason. This is
139
- // per-file atomicity only, not a whole-run transaction: a failure partway through this loop still
140
- // leaves earlier files in this run already committed (by design — there is no cross-file rollback
141
- // here, matching `writeAllOrRollback`'s docstring, which notes that broader transactional rollback
142
- // for `lore replace` is a separate, deferred concern) and the loop's own error propagates uncaught
143
- // so the failure is never silently swallowed.
144
- if (!parsed.dryRun) {
145
- for (const change of planned) {
146
- writeFileAtomic(change.abs, change.text, change.display);
147
- }
148
- }
149
-
150
- const report: ReplaceReport = {
151
- files: planned.map((c) => ({ path: c.display, count: c.count })),
152
- totalMatches: planned.reduce((sum, c) => sum + c.count, 0),
153
- filesChanged: planned.length,
154
- filesScanned: targets.length,
155
- dryRun: parsed.dryRun,
156
- };
157
- emit(reportRenderable(report), options.output, options.stdout);
158
- advisories.flush({ color: options.output.color, stderr: options.stderr });
159
- return EXIT_OK;
160
- }
161
-
162
- // ── Argument parsing ───────────────────────────────────────────────────────────
163
-
164
- /**
165
- * Parse `replace`'s tokens into its two positionals (`<find> <replace>`) and flags. The router has
166
- * already stripped lore's global flags, so a `--`-prefixed token here is a command flag: an
167
- * unrecognized one is a `usage` error. `--in` takes a value (inline `=` or the next token, which may
168
- * not itself be a flag), and a `--` ends option parsing so a `find`/`replace` may begin with `-`.
169
- */
170
- function parseReplaceArgs(args: readonly string[]): ReplaceArgs {
171
- const parsed = parseCommandArgs(args, "replace");
172
- const positionals = parsed.positionals;
173
- const find = positionals[0];
174
- if (find === undefined) {
175
- throw usage("`lore replace` needs a find and a replace argument", 'run `lore replace "<find>" "<replace>"`');
176
- }
177
- const replace = positionals[1];
178
- if (replace === undefined) {
179
- throw usage(
180
- "`lore replace` needs a replacement argument",
181
- 'pass the replacement text, e.g. `lore replace "old" "new"` (use "" to delete matches)',
182
- );
183
- }
184
- if (positionals.length > 2) {
185
- throw usage(
186
- `unexpected argument "${positionals[2]}"`,
187
- "pass exactly a find and a replace; quote values containing spaces, and scope with --in <glob>",
188
- );
189
- }
190
- return {
191
- find,
192
- replace,
193
- regex: parsed.flags.has("regex"),
194
- in: [...optionValues(parsed, "in")],
195
- dryRun: parsed.flags.has("dry-run"),
196
- };
197
- }
198
-
199
- // ── File discovery ─────────────────────────────────────────────────────────────
200
-
201
- /**
202
- * Discover the `.md` files to rewrite as canonical {@link Target}s in ascending display order. With no
203
- * `--in` the target is the whole `docs/` bundle, walked the same way the loader does
204
- * ({@link walkMarkdown} — sorted, symlink-safe, `.md`-only). With `--in`, each pattern is expanded by
205
- * `Bun.Glob` relative to the repo root and filtered: `.md` only, no symlink (it could resolve outside
206
- * the repo, and `walkMarkdown` skips them too), inside the repo, never the reserved `log.md`. Both
207
- * paths de-duplicate by {@link canonicalIdentity}, so one physical file reached two ways (a symlink, a
208
- * case variant, or once directly and once via a walked directory) is rewritten exactly once.
209
- */
210
- function discoverFiles(root: string, patterns: readonly string[], warnings: WarningCollector): Target[] {
211
- const seen = new Set<string>();
212
- const targets: Target[] = [];
213
- const add = (abs: string, display: string): void => {
214
- const id = canonicalIdentity(abs);
215
- if (seen.has(id)) {
216
- return; // same physical file already queued
217
- }
218
- seen.add(id);
219
- targets.push({ display, abs });
220
- };
221
-
222
- if (patterns.length === 0) {
223
- const docsRoot = join(root, DOCS_DIR);
224
- for (const rel of walkMarkdown(docsRoot, warnings)) {
225
- if (basename(rel) === GENERATED_FILE) {
226
- continue; // log.md is regenerated wholesale — never an author-editable target
227
- }
228
- add(join(docsRoot, rel), `${DOCS_DIR}/${rel}`);
229
- }
230
- } else {
231
- for (const pattern of patterns) {
232
- for (const match of new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: true })) {
233
- // `resolve` (not `join`): Bun.Glob returns a path relative to cwd for a relative pattern but an
234
- // ABSOLUTE path for an absolute pattern — `resolve(root, abs)` returns it as-is rather than
235
- // doubling `root` onto it (a plain `join` would mangle an absolute --in glob).
236
- const abs = resolve(root, match);
237
- if (!match.endsWith(".md")) {
238
- continue; // replace edits the bundle's markdown only
239
- }
240
- if (basename(match) === GENERATED_FILE) {
241
- warnings.add(`skipping "${match}": log.md is regenerated wholesale and is not editable via replace`);
242
- continue;
243
- }
244
- if (isSymlink(abs)) {
245
- warnings.add(`skipping symlink "${match}": symlinks are not followed (it may resolve outside the bundle)`);
246
- continue;
247
- }
248
- if (!withinRepo(root, abs)) {
249
- warnings.add(`skipping "${match}": outside the repo root`);
250
- continue;
251
- }
252
- add(abs, toRepoRelative(root, abs));
253
- }
254
- }
255
- }
256
- return targets.sort((a, b) => (a.display < b.display ? -1 : a.display > b.display ? 1 : 0));
257
- }
258
-
259
- /** Whether the entry at `abs` is a symlink (a vanished/unstattable entry is treated as not-a-symlink; the read raises the real error). */
260
- function isSymlink(abs: string): boolean {
261
- try {
262
- return lstatSync(abs).isSymbolicLink();
263
- } catch {
264
- return false;
265
- }
266
- }
267
-
268
- // ── Output ─────────────────────────────────────────────────────────────────────
269
-
270
- /** The per-result-type rendering bundle for `replace` (output.ts dispatches on the mode). */
271
- function reportRenderable(data: ReplaceReport): Renderable<ReplaceReport> {
272
- return {
273
- kind: "replace.result",
274
- data,
275
- pretty: (report) => render(report),
276
- plain: (report) => render(report),
277
- };
278
- }
279
-
280
- /**
281
- * One line per changed file, then a summary line. (No color: the report carries no severities.)
282
- * Each file's `path` is sanitized ({@link sanitizeField}) before interpolation — it is a discovered
283
- * display path derived from real filesystem entries, so a crafted/unusual filename could otherwise
284
- * smuggle an ANSI escape sequence or an embedded newline into the rendered report (LORE-229).
285
- */
286
- function render(data: ReplaceReport): string {
287
- const verb = data.dryRun ? "would replace" : "replaced";
288
- const lines = data.files.map((f) => `${verb} ${f.count} in ${sanitizeField(f.path)}`);
289
- lines.push(summaryLine(data));
290
- return lines.join("\n");
291
- }
292
-
293
- /**
294
- * Sanitize a discovered file path before it is interpolated into the plain/pretty report: collapse
295
- * it to one line ({@link singleLine}) and strip ANSI escape sequences plus residual C0/C1 control
296
- * bytes ({@link stripAnsiAndControls}) — mirrors `query.ts`'s `sanitizeField` (LORE-118), for the
297
- * same reason: an untrusted, filesystem-derived path can carry attacker-influenced bytes.
298
- */
299
- function sanitizeField(text: string): string {
300
- return stripAnsiAndControls(singleLine(text));
301
- }
302
-
303
- /** The trailing summary: total matches, files changed of scanned, and a `(dry-run)` marker. */
304
- function summaryLine(data: ReplaceReport): string {
305
- const head = `${data.totalMatches} ${plural(data.totalMatches, "match", "matches")} in ${data.filesChanged} of ${data.filesScanned} ${plural(data.filesScanned, "file")}`;
306
- return data.dryRun ? `${head} (dry-run)` : head;
307
- }
308
-
309
- /** Pluralize a noun by count, with an optional explicit plural for irregulars. */
310
- function plural(count: number, noun: string, plural?: string): string {
311
- if (count === 1) {
312
- return noun;
313
- }
314
- return plural ?? `${noun}s`;
315
- }
316
-
317
- /** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
318
- function usage(message: string, hint: string): LoreError {
319
- return new LoreError("usage", message, hint);
320
- }
@@ -1,346 +0,0 @@
1
- /**
2
- * commands/scaffold.ts — `lore scaffold <target> [--force]`: generate a downstream documentation
3
- * consumer's config, additively and outside `docs/` (cli-surface §"Consumer scaffolding", ADR-0010).
4
- *
5
- * The thin, side-effecting layer over the pure {@link buildMkdocsScaffold} / {@link buildDocusaurusScaffold} /
6
- * {@link buildObsidianScaffold} (`core/consumer-scaffold.ts`): it resolves the repo root, the active profile, and a clock, asks core for the exact bytes, and
7
- * applies them via `fswrite.ts`'s shared {@link writeAllOrRollback}. Unlike `lore init` (which
8
- * silently skips an already-present file), scaffolding is **never-silent-clobber**: a planned file
9
- * whose on-disk bytes would differ from what this run would generate blocks the whole run with a
10
- * `conflict` error (exit 5) naming every such collision, and writes nothing — `--force` is required
11
- * to overwrite. But a planned file that already exists with **byte-identical** content is not a
12
- * collision at all (LORE-263): re-running `lore scaffold <target>` on an already-scaffolded,
13
- * untouched bundle is idempotent — exit `0`, nothing written, mirroring `lore sync`'s own
14
- * "0 files changed" no-op model — rather than making the user hand-edit the file or remember
15
- * `--force` just to re-assert a config nothing actually changed. The classification (via
16
- * `fswrite.ts`'s {@link classifyExistingFile}) is per-file, so a partially-recreated bundle (one
17
- * generated file present and unchanged, its sibling separately deleted) recreates only the missing
18
- * file rather than refusing the whole run. This is still all-or-nothing where it matters — the
19
- * plan-wide pre-flight below, backstopped by `writeAllOrRollback`'s own atomic per-file create and
20
- * rollback — so a partial *collision*, or a mid-run I/O failure (e.g. a read-only `docs/`), can
21
- * never leave one scaffolded file refreshed and its sibling stale.
22
- *
23
- * `mkdocs`'s `docs/tags.md` stamps a `timestamp` into its own frontmatter (a real wall-clock read
24
- * by default — see {@link ScaffoldOptions.clock}), which would otherwise defeat the idempotent
25
- * byte-compare above on every real (non-test) bare re-run: two runs a second apart regenerate
26
- * different bytes purely because time moved forward, even though the user touched nothing, so
27
- * `classifyExistingFile` would report "differs" and the run would still hard-error. A bare
28
- * (non `--force`) `mkdocs` run avoids that by reusing the on-disk `docs/tags.md`'s own `timestamp`
29
- * field (via {@link preservedTagsTimestamp}) instead of a fresh `clock()` read, so an otherwise
30
- * untouched bundle compares byte-identical regardless of how much wall-clock time has passed.
31
- * `--force` is unaffected — it always stamps a fresh timestamp, since its whole point is to
32
- * regenerate the file.
33
- *
34
- * `mkdocs`, `docusaurus`, and `obsidian` are implemented; any other target string is a `usage`
35
- * error (exit 2).
36
- */
37
-
38
- import { existsSync, readFileSync, statSync } from "node:fs";
39
- import { basename, join, resolve } from "node:path";
40
- import { tryReadFrontmatter } from "../core/concept";
41
- import {
42
- buildDocusaurusScaffold,
43
- buildMkdocsScaffold,
44
- buildObsidianScaffold,
45
- type ConsumerScaffoldFile,
46
- type ConsumerScaffoldOptions,
47
- type ConsumerScaffoldPlan,
48
- TAGS_INDEX_REL_PATH,
49
- } from "../core/consumer-scaffold";
50
- import { loadProfile } from "../core/profile";
51
- import { EXIT_OK, LoreError, type Writer } from "../errors";
52
- import { emit, type OutputContext, type Renderable } from "../output";
53
- import { parseCommandArgs, usage } from "./args";
54
- import { classifyExistingFile, writeAllOrRollback } from "./fswrite";
55
-
56
- /** Options for {@link runScaffold}; `root`, `clock`, and the stream are injectable for tests. */
57
- export interface ScaffoldOptions {
58
- /** The repo root to scaffold into. */
59
- root: string;
60
- /** The resolved output mode/color (from `output.ts`). */
61
- output: OutputContext;
62
- /** The command's normalized positional + flag tokens from Commander. */
63
- args: readonly string[];
64
- /** stdout sink; defaults to `process.stdout`. */
65
- stdout?: Writer;
66
- /**
67
- * Clock seam for `docs/tags.md`'s timestamp; defaults to the real wall clock. Only actually
68
- * consulted for a fresh stamp on a `--force` run, or on a bare run with no usable on-disk
69
- * `docs/tags.md` timestamp to preserve — see {@link preservedTagsTimestamp}.
70
- */
71
- clock?: () => Date;
72
- }
73
-
74
- /** One file the scaffold wrote (or, under `--force`, overwrote). */
75
- export interface ScaffoldResultFile {
76
- /** Repo-relative POSIX path. */
77
- readonly path: string;
78
- /** Whether this run created the file fresh or overwrote a pre-existing one (`--force` only). */
79
- readonly action: "created" | "updated";
80
- }
81
-
82
- /** The result of a `lore scaffold` run. */
83
- export interface ScaffoldResult {
84
- /** The target that was scaffolded (`"mkdocs"`, `"docusaurus"`, or `"obsidian"`). */
85
- readonly target: string;
86
- /** Whether `--force` was passed. */
87
- readonly force: boolean;
88
- /**
89
- * Every file this run actually wrote, in scaffold order. A planned file already on disk with
90
- * byte-identical content is never included here (LORE-263) — it was left untouched, not written —
91
- * so an idempotent no-op re-run (every planned file already matches) reports an empty array here,
92
- * exactly like `lore sync`'s own "0 files changed".
93
- */
94
- readonly files: readonly ScaffoldResultFile[];
95
- /** Extra guidance lines to print after the summary (empty unless the target's plan carries any — see {@link ConsumerScaffoldPlan.notes}). */
96
- readonly notes: readonly string[];
97
- }
98
-
99
- /**
100
- * Every implemented target's pure plan builder — the single source of truth for which targets
101
- * `lore scaffold` can actually build. {@link TARGETS} is *derived* from this map's keys, so a
102
- * target can never be accepted by argument validation without a registered builder to route to —
103
- * the failure mode a hand-written per-target `if`/ternary chain invites the moment a third target
104
- * is added.
105
- */
106
- const BUILDERS: Record<string, (options: ConsumerScaffoldOptions) => ConsumerScaffoldPlan> = {
107
- mkdocs: buildMkdocsScaffold,
108
- docusaurus: buildDocusaurusScaffold,
109
- obsidian: buildObsidianScaffold,
110
- };
111
-
112
- /** The consumer targets `lore scaffold` recognizes as valid (derived from {@link BUILDERS}'s own keys). */
113
- export const TARGETS = new Set(Object.keys(BUILDERS));
114
-
115
- /** The validated arguments {@link applyScaffold} needs — `root`/`target`/`force` plus an injectable clock. */
116
- export interface ApplyScaffoldOptions {
117
- /** The repo root to scaffold into. */
118
- root: string;
119
- /** One of {@link TARGETS} (`"mkdocs"`, `"docusaurus"`, `"obsidian"`). */
120
- target: string;
121
- /** `--force`: overwrite an existing generated config, skipping the collision preflight. */
122
- force: boolean;
123
- /** Clock seam for `docs/tags.md`'s timestamp; defaults to the real wall clock. */
124
- clock?: () => Date;
125
- }
126
-
127
- /**
128
- * Build `target`'s plan, then — unless `force` — classify every planned file against what's already
129
- * on disk (LORE-263) and write it: the pure side-effecting core of `lore scaffold`, extracted
130
- * (LORE-260) so `lore init`'s wizard/flags can fold a downstream-consumer scaffold into one
131
- * onboarding run without going through `runScaffold`'s own arg-parsing/emit (which would print a
132
- * second, separate envelope onto the SAME stdout `lore init` owns — the `--json` contract requires
133
- * stdout be exclusively `init`'s own envelope, cli-contract §4).
134
- *
135
- * - any planned file whose on-disk bytes DIFFER from what this run would generate, or a
136
- * structural directory blocker, refuses the whole run with a `conflict` error (exit `5`) naming
137
- * every such collision and pointing at `--force`, writing nothing;
138
- * - otherwise, only the planned files that are genuinely ABSENT are written — a planned file
139
- * already on disk with byte-identical content is left untouched, so a bare re-run of an
140
- * unchanged bundle writes nothing at all and still exits `0` (idempotent no-op).
141
- *
142
- * `force` skips this classification entirely and overwrites every planned file, unchanged from
143
- * before. `target` must already be validated against {@link TARGETS} — an unregistered target throws
144
- * an internal `Error`, not a `usage` {@link LoreError} (the caller owns argument validation).
145
- */
146
- export function applyScaffold(options: ApplyScaffoldOptions): ScaffoldResult {
147
- const { root, target, force } = options;
148
- const clock = options.clock ?? (() => new Date());
149
- // Only mkdocs's builder reads `profile` (to decide docs/tags.md's $schema modeline) — loading
150
- // it unconditionally for every target would make `lore scaffold docusaurus` fail on a malformed
151
- // .lore/profile.toml/json it never reads, contradicting ConsumerScaffoldOptions.profile's own
152
- // "unused by buildDocusaurusScaffold" contract.
153
- const profile = target === "mkdocs" ? loadProfile({ root }) : undefined;
154
- const build = BUILDERS[target];
155
- if (!build) {
156
- // Callers validate `target` against TARGETS (derived from BUILDERS' own keys) before calling —
157
- // this can only fire if that invariant is ever broken.
158
- throw new Error(`internal: no builder registered for target "${target}"`);
159
- }
160
- // A bare mkdocs run reuses docs/tags.md's own on-disk timestamp (when there's one to preserve)
161
- // instead of always stamping a fresh clock() read — see the module docstring and
162
- // preservedTagsTimestamp's own doc for why this is load-bearing for LORE-263's idempotency
163
- // guarantee. --force always wants a fresh stamp (it's regenerating the file outright), and
164
- // docusaurus/obsidian ignore `timestamp` entirely, so neither needs this.
165
- const timestamp = target === "mkdocs" && !force ? preservedTagsTimestamp(root, clock) : clock().toISOString();
166
- const plan = build({
167
- timestamp,
168
- siteName: basename(resolve(root)),
169
- profile,
170
- });
171
-
172
- // Under --force every planned file is (re)written, exactly as before LORE-263; under a bare
173
- // run it narrows to only the files actually missing (below) once the collision check clears.
174
- let filesToWrite: readonly ConsumerScaffoldFile[] = plan.files;
175
-
176
- if (!force) {
177
- const blockedDirs = plan.dirs.filter((dir) => {
178
- const abs = join(root, dir);
179
- return existsSync(abs) && !statSync(abs).isDirectory();
180
- });
181
- // Per-file, not per-plan: classifyExistingFile tells "absent" (needs creating), "unchanged"
182
- // (already matches — leave it alone, not a collision) and "differs" (a real user edit — a
183
- // collision) apart, rather than the old plain-existence check that treated every already-there
184
- // file as a collision regardless of its content.
185
- const statuses = plan.files.map((file) => ({
186
- file,
187
- status: classifyExistingFile(join(root, file.path), file.contents),
188
- }));
189
- const differingFiles = statuses.filter((s) => s.status === "differs").map((s) => s.file.path);
190
- const collisions = [...blockedDirs, ...differingFiles];
191
- if (collisions.length > 0) {
192
- throw new LoreError(
193
- "conflict",
194
- `${target} config already exists: ${collisions.join(", ")}`,
195
- conflictHint(blockedDirs.length > 0, differingFiles.length > 0),
196
- { target, paths: collisions },
197
- );
198
- }
199
- filesToWrite = statuses.filter((s) => s.status === "missing").map((s) => s.file);
200
- }
201
-
202
- const files = writeAllOrRollback(root, plan.dirs, filesToWrite, { force });
203
-
204
- return { target, force, files, notes: plan.notes ?? [] };
205
- }
206
-
207
- /**
208
- * Run `lore scaffold <target>`: the thin CLI layer over {@link applyScaffold} — parse the arguments,
209
- * apply the scaffold, render the result, and return the exit code. An unknown target, or a bad
210
- * flag/extra argument, throws a `usage` {@link LoreError} (exit `2`) before {@link applyScaffold}
211
- * ever runs.
212
- */
213
- export function runScaffold(options: ScaffoldOptions): number {
214
- const parsed = parseScaffoldArgs(options.args);
215
- const result = applyScaffold({
216
- root: options.root,
217
- target: parsed.target,
218
- force: parsed.force,
219
- clock: options.clock,
220
- });
221
- emit(scaffoldRenderable(result), options.output, options.stdout);
222
- return EXIT_OK;
223
- }
224
-
225
- /**
226
- * The timestamp a bare (non `--force`) `lore scaffold mkdocs` run stamps into `docs/tags.md`'s
227
- * regenerated frontmatter (LORE-263 follow-up): the on-disk file's own `timestamp` field, when
228
- * there's one to reuse, rather than always a fresh `clock()` read. Without this, an otherwise
229
- * completely untouched `docs/tags.md` would regenerate different bytes on every real re-run
230
- * (the timestamp always advances with the wall clock) even though nothing the user controls
231
- * changed, so `classifyExistingFile` would report "differs" and the run would hard-error exactly
232
- * the way idempotent-when-unchanged is supposed to prevent. This only decides which timestamp
233
- * string goes into the freshly rebuilt plan bytes — the actual byte comparison against what's on
234
- * disk still happens afterward via {@link classifyExistingFile}, so a file that differs for any
235
- * OTHER reason (a hand-edited title, body, or `$schema` modeline) is still correctly classified
236
- * "differs" and still blocks the run.
237
- *
238
- * Reads via the shared {@link tryReadFrontmatter} (frontmatter mapping only, no schema
239
- * validation) rather than the full `parseConcept`, since a file that's otherwise a byte-identical
240
- * regenerate is exactly the case this must handle, and schema validation is an orthogonal
241
- * concern. Falls back to `clock().toISOString()` — the timestamp a fresh scaffold has always
242
- * used — whenever there's nothing usable to reuse: `docs/tags.md` is absent or unreadable, its
243
- * YAML frontmatter doesn't parse at all (a genuinely malformed file must still flow through to
244
- * the normal "differs" collision path with *some* deterministic timestamp, not fail this whole
245
- * command), or its `timestamp` field isn't present as a string.
246
- */
247
- function preservedTagsTimestamp(root: string, clock: () => Date): string {
248
- const fresh = () => clock().toISOString();
249
- let raw: string;
250
- try {
251
- raw = readFileSync(join(root, TAGS_INDEX_REL_PATH), "utf8");
252
- } catch {
253
- return fresh(); // absent, or unreadable -- nothing on disk to preserve
254
- }
255
- let frontmatter: Record<string, unknown> | null;
256
- try {
257
- frontmatter = tryReadFrontmatter(TAGS_INDEX_REL_PATH, raw);
258
- } catch {
259
- return fresh(); // unparseable YAML -- let the normal "differs" collision path handle the file itself
260
- }
261
- const timestamp = frontmatter?.timestamp;
262
- return typeof timestamp === "string" ? timestamp : fresh();
263
- }
264
-
265
- /**
266
- * Build the preflight `conflict` error's remedy hint, differentiated by which kind(s) of
267
- * collision were found. A structural directory blocker — a plain (non-directory) file occupying
268
- * a path the plan needs to be a directory — is NOT fixed by `--force`: under `--force` this same
269
- * preflight is skipped entirely and the run reaches `writeAllOrRollback` -> `ensureDir`
270
- * (fswrite.ts) -> `mkdirSync(dir, { recursive: true })`, which throws `EEXIST` on the same
271
- * non-directory entry, remapped by `ioError` to a second `conflict` (fswrite.ts's own
272
- * `conflictError`). So the hint must never tell the user `--force` will overwrite a dir-blocker —
273
- * only removing/renaming the blocking entry does. A planned file whose on-disk bytes DIFFER from
274
- * what this run would generate (LORE-263 — a byte-identical match is never a collision at all, see
275
- * {@link classifyExistingFile}) is genuinely fixed by `--force` (writeAllOrRollback overwrites it
276
- * in place), so that remedy is preserved whenever at least one such file collision is present.
277
- */
278
- function conflictHint(hasDirBlocker: boolean, hasFileCollision: boolean): string {
279
- if (hasDirBlocker && hasFileCollision) {
280
- return "pass --force to overwrite the existing file(s); separately, remove or rename the non-directory entry blocking the planned directory (--force cannot fix that one)";
281
- }
282
- if (hasDirBlocker) {
283
- return "remove or rename the non-directory entry that is blocking the planned directory, then re-run";
284
- }
285
- return "pass --force to overwrite, or remove the existing file(s) first";
286
- }
287
-
288
- // ── Argument parsing ───────────────────────────────────────────────────────────
289
-
290
- /** The parsed form of `lore scaffold`'s arguments. */
291
- interface ScaffoldArgs {
292
- /** The validated, implemented target (`"mkdocs"` or `"docusaurus"` today). */
293
- target: string;
294
- /** `--force`: overwrite an existing generated config. */
295
- force: boolean;
296
- }
297
-
298
- /**
299
- * Parse `scaffold`'s tokens into the `<target>` positional and `--force`, via the shared
300
- * shared Commander-backed parser every no-value-flag command uses. An unknown flag, an
301
- * extra positional, or a missing target is a `usage` error.
302
- */
303
- function parseScaffoldArgs(args: readonly string[]): ScaffoldArgs {
304
- const { positionals, flags } = parseCommandArgs(args, "scaffold");
305
-
306
- const target = positionals[0];
307
- if (target === undefined) {
308
- throw usage(
309
- "`lore scaffold` needs a target",
310
- "pass a target, e.g. `lore scaffold mkdocs` (mkdocs | docusaurus | obsidian)",
311
- );
312
- }
313
- if (positionals.length > 1) {
314
- throw usage(`unexpected argument "${positionals[1]}"`, "run `lore scaffold <target> [--force]`");
315
- }
316
- if (!TARGETS.has(target)) {
317
- throw usage(`unknown scaffold target "${target}"`, "valid targets are mkdocs, docusaurus, obsidian");
318
- }
319
- return { target, force: flags.has("force") };
320
- }
321
-
322
- // ── Output ─────────────────────────────────────────────────────────────────────
323
-
324
- /** The per-result-type rendering bundle for `scaffold` (output.ts dispatches on the mode). */
325
- function scaffoldRenderable(data: ScaffoldResult): Renderable<ScaffoldResult> {
326
- return { kind: "scaffold.result", data, pretty: render, plain: render };
327
- }
328
-
329
- /**
330
- * One `<action> <path>` line per file, then a one-line summary, then any target-specific guidance
331
- * notes. A bare (non `--force`) re-run where every planned file already matched what would be
332
- * generated (LORE-263) writes nothing at all — `files.length === 0` only happens in that idempotent
333
- * case, since a `--force` run always (re)writes the whole, non-empty plan — so that case gets a
334
- * distinct "nothing to do" line instead of reading as "scaffolded ... (0 files)", mirroring `lore
335
- * sync`'s own "0 files changed" no-op summary.
336
- */
337
- function render(data: ScaffoldResult): string {
338
- const lines = data.files.map((file) => `${file.action} ${file.path}`);
339
- lines.push(
340
- data.files.length === 0
341
- ? `${data.target} config already up to date — nothing to do`
342
- : `scaffolded ${data.target} config (${data.files.length} file${data.files.length === 1 ? "" : "s"})`,
343
- );
344
- lines.push(...data.notes);
345
- return lines.join("\n");
346
- }