@opum-ai/lore 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/commands/replace.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/scaffold.ts
DELETED
|
@@ -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
|
-
}
|