@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.
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,476 +0,0 @@
1
- /**
2
- * commands/new.ts — `lore new <type> "<title>"`: scaffold a typed concept file.
3
- *
4
- * The thin command layer over the pure {@link buildNewConcept} (lore-design §2.2): it parses
5
- * the command's own arguments, resolves *which* template to render (a user template under
6
- * `.lore/templates/`, else the built-in for the type), computes the conventional output path,
7
- * asks core for the bytes, and writes them **never-clobbering** through the shared
8
- * {@link createIfAbsent}. All side effects (read the template, write the file) live here; all
9
- * byte computation lives in `core/template.ts`.
10
- *
11
- * The two load-bearing behaviors (LORE-18 ACs): a no-flag run renders from a built-in that
12
- * **validates clean by construction** (AC#1, guaranteed by the templates in core), and a
13
- * user template at `.lore/templates/<name>.md` **overrides** the built-in wholesale (AC#2).
14
- * Unlike `init`, an existing target is a `conflict` (exit `5`), not an idempotent skip: `new`
15
- * creates a *new* concept and must never overwrite or silently no-op onto an existing file.
16
- */
17
-
18
- import { existsSync, readFileSync } from "node:fs";
19
- import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
20
- import { idFromPath } from "../core/concept";
21
- import { loadProfile, type Profile, templateConfinementViolation } from "../core/profile";
22
- import { DOCS_DIR } from "../core/scaffold";
23
- import { canonicalType, isKnownType, SCHEMAS_DIR, schemaFileName, schemaModeline, typeDirectory } from "../core/schema";
24
- import { buildNewConcept, builtinTemplateFor, slugify } from "../core/template";
25
- import { EXIT_OK, errnoCode, LoreError, WarningCollector, type Writer } from "../errors";
26
- import { emit, type OutputContext, type Renderable } from "../output";
27
- import { assertNotReservedStem, optionValues, parseCommandArgs } from "./args";
28
- import { createIfAbsent, ensureDir, findSymlinkSegment } from "./fswrite";
29
-
30
- /** Where user templates live, relative to the repo root. */
31
- const TEMPLATES_DIR = ".lore/templates";
32
-
33
- /** The reserved bundle-root index `lore init` owns (the sole `okf_version` carrier); `lore new` must not write it. */
34
- const RESERVED_ROOT_INDEX = `${DOCS_DIR}/index.md`;
35
-
36
- /** A valid concept type token: starts with a letter, then letters/digits/dashes/underscores — no spaces or path separators. */
37
- const VALID_TYPE = /^[A-Za-z][A-Za-z0-9_-]*$/;
38
-
39
- /**
40
- * The stub `summary` injected when no `--summary` is given. Short and present, so a no-flag
41
- * run validates clean (a missing or over-long summary would warn), while clearly flagging
42
- * the one line the author should replace.
43
- */
44
- const SUMMARY_STUB = "Add a one-line summary of this concept.";
45
-
46
- /** The result of a `new` run: the created concept's id, path, and type. */
47
- export interface NewResult {
48
- /** The new concept's bundle-relative id (path under `docs/` minus `.md`), e.g. `stories/bulk-archive`. */
49
- id: string;
50
- /** The repo-relative POSIX path written, e.g. `docs/stories/bulk-archive.md`. */
51
- path: string;
52
- /** The resolved concept `type` (canonical for a known type, verbatim for a producer extension). */
53
- type: string;
54
- }
55
-
56
- /** Options for {@link runNew}; `root`, `clock`, and the streams are injectable for tests. */
57
- export interface NewOptions {
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
- /** Clock seam for the `timestamp` token; defaults to the real wall clock. */
65
- clock?: () => Date;
66
- /** stdout sink; defaults to `process.stdout`. */
67
- stdout?: Writer;
68
- /** stderr sink for advisory warnings; defaults to `process.stderr`. */
69
- stderr?: Writer;
70
- }
71
-
72
- /** The parsed form of `lore new`'s arguments. */
73
- interface NewArgs {
74
- type: string;
75
- title: string;
76
- vars: Record<string, string>;
77
- template?: string;
78
- summary?: string;
79
- tags?: string;
80
- out?: string;
81
- }
82
-
83
- /**
84
- * Run `lore new`: parse the arguments, resolve the template, render the concept, and write it
85
- * never-clobbering. Returns the exit code (`0`). A missing positional or bad flag throws a
86
- * `usage` {@link LoreError} (exit `2`); an unfilled `{{placeholder}}` or invalid frontmatter a
87
- * `validation` error (exit `6`); an existing target a `conflict` (exit `5`).
88
- */
89
- export function runNew(options: NewOptions): number {
90
- const clock = options.clock ?? (() => new Date());
91
- const parsed = parseNewArgs(options.args);
92
- const profile = loadProfile({ root: options.root });
93
- const type = canonicalType(parsed.type, profile);
94
- // A profile-declared type is valid by definition — including a multi-word/space-containing name
95
- // like "QA Plan" (its path segments come from the LOWER-KEBAB slug, which is always safe). The
96
- // VALID_TYPE shape check only gates an *ad-hoc* unknown type, whose raw token would otherwise
97
- // become a directory/filename segment verbatim.
98
- if (!isKnownType(type, profile) && !VALID_TYPE.test(type)) {
99
- throw usage(
100
- `"${parsed.type}" is not a valid type`,
101
- "a type must start with a letter and contain only letters, digits, dashes, or underscores — or be declared in .lore/profile.toml",
102
- );
103
- }
104
-
105
- const docPath = resolveDocPath(parsed, type, options.root);
106
- const bodyTemplate = resolveTemplate(parsed, type, options.root, profile);
107
-
108
- const build = buildNewConcept({
109
- docPath,
110
- type,
111
- title: parsed.title,
112
- summary: parsed.summary ?? SUMMARY_STUB,
113
- timestamp: clock().toISOString(),
114
- tags: parseTags(parsed.tags),
115
- bodyTemplate,
116
- vars: parsed.vars,
117
- modeline: resolveModeline(type, docPath, options.root, profile),
118
- profile,
119
- });
120
-
121
- const absPath = join(options.root, docPath);
122
- // Check for an existing target before creating any parent directories, so an aborted
123
- // (conflicting) run leaves no empty scaffold dirs behind. `createIfAbsent`'s atomic `wx`
124
- // write remains the authority that closes the time-of-check/time-of-use race.
125
- if (existsSync(absPath)) {
126
- throw conflict(docPath);
127
- }
128
- ensureDir(options.root, posix.dirname(docPath));
129
- if (!createIfAbsent(absPath, build.contents, docPath)) {
130
- throw conflict(docPath);
131
- }
132
-
133
- flushWarnings(build.warnings, options.output, options.stderr);
134
- emit(newRenderable({ id: bundleId(docPath), path: docPath, type: build.type }), options.output, options.stdout);
135
- return EXIT_OK;
136
- }
137
-
138
- /** A `conflict` {@link LoreError} (exit `5`) for a target path that already exists. */
139
- function conflict(docPath: string): LoreError {
140
- return new LoreError(
141
- "conflict",
142
- `${docPath} already exists`,
143
- "choose a different title, pass --out <path>, or remove the existing file",
144
- { path: docPath },
145
- );
146
- }
147
-
148
- /**
149
- * The editor modeline for a known type whose exported schema actually exists on disk, else
150
- * `undefined`. An unknown type has no schema; a doc written into a never-`init`-ed bundle has no
151
- * `.lore/schemas/` either — in both cases lore writes no modeline rather than one pointing at a
152
- * `$schema` file that is not there.
153
- */
154
- function resolveModeline(type: string, docPath: string, root: string, profile: Profile): string | undefined {
155
- if (!isKnownType(type, profile)) {
156
- return undefined;
157
- }
158
- if (!existsSync(join(root, SCHEMAS_DIR, schemaFileName(type)))) {
159
- return undefined;
160
- }
161
- return schemaModeline(docPath, type);
162
- }
163
-
164
- // ── Argument parsing ───────────────────────────────────────────────────────────
165
-
166
- /**
167
- * Parse `new`'s tokens into positionals (`<type> <title>`) and its flags. The router has
168
- * already stripped lore's global flags, so anything `--`-prefixed here is a command flag: an
169
- * unrecognized one is a `usage` error, as is a malformed `--var` or the wrong positional count.
170
- * Both `--flag value` and `--flag=value` forms are accepted; a value-taking flag refuses to
171
- * consume a following flag-looking token as its value (so `--summary --tags x` reports the
172
- * missing summary value rather than silently eating `--tags`). A `--` ends option parsing so a
173
- * title may begin with `-` (`lore new adr -- "-5 minute timeout"`).
174
- */
175
- function parseNewArgs(args: readonly string[]): NewArgs {
176
- const parsed = parseCommandArgs(args, "new");
177
- const positionals = parsed.positionals;
178
- const vars: Record<string, string> = Object.create(null);
179
- for (const raw of optionValues(parsed, "var")) addVar(vars, raw);
180
- const template = optionValues(parsed, "template").at(-1);
181
- const summary = optionValues(parsed, "summary").at(-1);
182
- const tags = optionValues(parsed, "tags").at(-1);
183
- const out = optionValues(parsed, "out").at(-1);
184
-
185
- const type = positionals[0];
186
- if (type === undefined || type.trim() === "") {
187
- throw usage("`lore new` needs a type", 'run `lore new <type> "<title>"`, e.g. lore new adr "Use soft deletes"');
188
- }
189
- const title = positionals[1];
190
- if (title === undefined || title.trim() === "") {
191
- throw usage("`lore new` needs a title", 'run `lore new <type> "<title>"` with a quoted title');
192
- }
193
- if (positionals.length > 2) {
194
- throw usage(
195
- `unexpected argument "${positionals[2]}"`,
196
- 'pass exactly a type and a title; quote a multi-word title: lore new <type> "<title>"',
197
- );
198
- }
199
- return { type: type.trim(), title: title.trim(), vars, template, summary, tags, out };
200
- }
201
-
202
- /**
203
- * Record one `--var key=value` pair. The key must be a non-empty placeholder name
204
- * (`[A-Za-z0-9_.-]+`, the {@link renderTemplate} token grammar); the value is everything
205
- * after the first `=` (so it may itself contain `=`). `vars` is a null-prototype object, so a
206
- * key like `__proto__` lands as an own property without polluting any prototype.
207
- */
208
- function addVar(vars: Record<string, string>, raw: string): void {
209
- const eq = raw.indexOf("=");
210
- if (eq <= 0) {
211
- throw usage(`--var must be key=value, got "${raw}"`, "supply a non-empty key, e.g. --var owner=payments");
212
- }
213
- const key = raw.slice(0, eq);
214
- if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
215
- throw usage(
216
- `--var key "${key}" is not a valid placeholder name`,
217
- "use letters, digits, dots, dashes, or underscores for the key",
218
- );
219
- }
220
- vars[key] = raw.slice(eq + 1);
221
- }
222
-
223
- /** Split a `--tags a,b,c` value into a trimmed, non-empty list (absent flag → `undefined`). */
224
- function parseTags(tags: string | undefined): string[] | undefined {
225
- if (tags === undefined) {
226
- return undefined;
227
- }
228
- return tags
229
- .split(",")
230
- .map((tag) => tag.trim())
231
- .filter((tag) => tag !== "");
232
- }
233
-
234
- // ── Path + template resolution ───────────────────────────────────────────────────
235
-
236
- /**
237
- * Compute the new doc's repo-relative POSIX path. With `--out` the caller's path wins
238
- * (resolved and confined to the repo by {@link resolveOutPath}); otherwise it is the
239
- * conventional `docs/<typeDirectory>/<slug-of-title>.md`. A title with no slug-able content
240
- * and no `--out` is a `usage` error rather than a `-.md` file.
241
- *
242
- * The default path is checked against the same {@link assertNotReservedStem} guard
243
- * {@link resolveOutPath} applies (LORE-174): a title that slugifies to `index` or `log` (e.g.
244
- * `lore new reference "Index"`) would otherwise land on a stem `rename`/`supersede`/`link` treat
245
- * as lore-generated and refuse to touch, bypassing the policy LORE-114 added only on the `--out`
246
- * path. The default path always carries a non-empty type-directory segment (every known
247
- * {@link typeDirectory} maps to a non-empty string, and every ad-hoc type's slug is non-empty
248
- * since `VALID_TYPE` requires a leading letter), so it can never collide with the bundle-root
249
- * index `resolveOutPath` guards separately — no `RESERVED_ROOT_INDEX` check is needed here.
250
- */
251
- function resolveDocPath(parsed: NewArgs, type: string, root: string): string {
252
- if (parsed.out !== undefined) {
253
- return resolveOutPath(parsed.out, root);
254
- }
255
- const slug = slugify(parsed.title);
256
- if (slug === "") {
257
- throw usage(`could not derive a filename from title "${parsed.title}"`, "pass an explicit path with --out <path>");
258
- }
259
- const docPath = posix.join(DOCS_DIR, typeDirectory(type), `${slug}.md`);
260
- assertNotReservedStem(idFromPath(docPath), "create");
261
- return docPath;
262
- }
263
-
264
- /**
265
- * Resolve a `--out` value to a repo-relative POSIX path, confining it to the **bundle root**
266
- * (`docs/`) and ending it in `.md` (appended when omitted). A path that escapes the repo
267
- * (`../…`, an absolute path elsewhere), lands outside `docs/`, or targets the reserved
268
- * bundle-root index (`docs/index.md`, owned by `lore init`) is a `usage` error — so `lore new`
269
- * can never write an orphaned file the bundle walk won't see, nor clobber the conformance root.
270
- * The `..` escape is matched by path **segment** (`..` exactly or a leading `../`), so a real
271
- * in-repo path whose first segment merely starts with `..` (e.g. `..notes/x`) is not rejected.
272
- *
273
- * Beyond the root index, ANY basename of `index`/`log` — at any nesting depth — is also rejected,
274
- * via the same {@link assertNotReservedStem} `rename`/`supersede`/`link` share (LORE-114): those
275
- * stems are lore's own generated file names wherever they sit, not just at the bundle root, so
276
- * `lore new` must not let a user create a doc that collides with one. Checked AFTER the
277
- * root-index-specific check above, so `docs/index.md` keeps its own message unaffected.
278
- */
279
- function resolveOutPath(out: string, root: string): string {
280
- const rel = relative(root, resolve(root, out));
281
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
282
- throw usage(`--out path "${out}" must be inside the repo`, "give a path relative to the repo root");
283
- }
284
- let posixRel = rel.split(sep).join("/");
285
- if (!posixRel.endsWith(".md")) {
286
- posixRel = `${posixRel}.md`;
287
- }
288
- if (posixRel !== DOCS_DIR && !posixRel.startsWith(`${DOCS_DIR}/`)) {
289
- throw usage(
290
- `--out path "${out}" must be inside the bundle root (${DOCS_DIR}/)`,
291
- `give a path under ${DOCS_DIR}/, e.g. --out ${DOCS_DIR}/reference/orders.md`,
292
- );
293
- }
294
- if (posixRel === RESERVED_ROOT_INDEX) {
295
- throw usage(
296
- `${RESERVED_ROOT_INDEX} is the reserved bundle-root index`,
297
- "choose another path; `lore init` owns the root index that carries okf_version",
298
- );
299
- }
300
- assertNotReservedStem(idFromPath(posixRel), "create");
301
- return posixRel;
302
- }
303
-
304
- /**
305
- * Resolve the template text. The file base is `--template <name>` when given, else the type;
306
- * the file is `.lore/templates/<base>.md`. The base precedence is: an explicit `--template <name>`,
307
- * else the **profile type's declared `template`** (its filename minus `.md`), else the type name.
308
- * To be correct on **case-sensitive** filesystems (Linux/CI) while staying convenient on
309
- * case-insensitive ones, the lookup tries the name as given (e.g. `Reference.md`, matching the
310
- * docs' canonical-case `<type>` spelling) and then its lower-cased form (`reference.md`, matching
311
- * the schema filenames). A present file is the user template (override, AC#2). If none exists: an
312
- * explicit `--template` is a `not_found` error (the caller asked for a specific template); a
313
- * profile-declared-but-missing template, like the default, falls back to the built-in body.
314
- *
315
- * An explicit `--template` is a user-facing CLI flag (unlike `declared`, a repo-config value) and
316
- * is validated with {@link assertTemplateNameConfined} BEFORE it ever reaches a file path (LORE-69):
317
- * `--template` is documented as a bare name, never a path, so a `..` segment or an absolute value
318
- * is rejected outright rather than spliced into `${TEMPLATES_DIR}/${base}.md` and hoped safe. The
319
- * profile-declared `template` went through the analogous {@link templateConfinementViolation}
320
- * check already, at profile PARSE time (LORE-139), so it is not re-checked for confinement here —
321
- * only the two named-by-user-or-repo-config sources (`--template`, `declared`) get the symlink
322
- * refusal below; the bare-type-name convention lookup (neither given) does not (LORE-91 scope).
323
- */
324
- function resolveTemplate(parsed: NewArgs, type: string, root: string, profile: Profile): string {
325
- const explicitTemplate = parsed.template !== undefined;
326
- if (parsed.template !== undefined) {
327
- assertTemplateNameConfined(parsed.template);
328
- }
329
- const declared = profile.types.get(type)?.template?.replace(/\.md$/i, "");
330
- const base = parsed.template ?? declared ?? type;
331
- // A named template source — the CLI flag or a profile's own declared filename — is refused if
332
- // it resolves through a symlink (LORE-91, widened to `declared` by LORE-185's AC#2); the bare
333
- // type-name convention lookup below carries no such refusal, matching the pre-LORE-185 scope.
334
- const checkSymlink = explicitTemplate || declared !== undefined;
335
- for (const candidate of templateCandidates(base)) {
336
- const relPath = `${TEMPLATES_DIR}/${candidate}.md`;
337
- const text = readTemplateFile(join(root, relPath), relPath, root, checkSymlink);
338
- if (text !== undefined) {
339
- return text;
340
- }
341
- }
342
- if (parsed.template !== undefined) {
343
- throw new LoreError(
344
- "not_found",
345
- `template "${parsed.template}" not found in ${TEMPLATES_DIR}/`,
346
- `create ${TEMPLATES_DIR}/${parsed.template}.md, or omit --template to use the built-in`,
347
- { path: `${TEMPLATES_DIR}/${parsed.template}.md` },
348
- );
349
- }
350
- return builtinTemplateFor(type);
351
- }
352
-
353
- /**
354
- * Reject a `--template` value that could escape `.lore/templates/` once spliced into a file
355
- * path, via the shared {@link templateConfinementViolation} predicate `core/profile.ts` also uses
356
- * for the profile-declared `[[types]].template` value (LORE-139) — LORE-185 consolidated what
357
- * used to be this function's own host-`resolve()`/`relative()` arithmetic (no backslash
358
- * normalization) onto that one pure-posix, backslash-aware implementation, closing a cross-host
359
- * drift: a Windows-style `--template ..\..\secret` escape was only ever caught on an actual win32
360
- * run before — a POSIX test/CI run saw it as one inert, non-escaping filename segment — and is now
361
- * rejected identically on every host. See {@link templateConfinementViolation}'s own docstring for
362
- * the absolute-path and `..`-escape rationale in full (both apply unchanged here).
363
- */
364
- function assertTemplateNameConfined(name: string): void {
365
- const violation = templateConfinementViolation(name);
366
- if (violation === "absolute") {
367
- throw usage(
368
- `--template value "${name}" must not be an absolute path`,
369
- "pass a bare template name, e.g. --template adr",
370
- );
371
- }
372
- if (violation === "escape") {
373
- throw usage(
374
- `--template value "${name}" must not escape ${TEMPLATES_DIR}/`,
375
- "pass a bare template name, e.g. --template adr",
376
- );
377
- }
378
- }
379
-
380
- /** The template filenames to try for a base, the name as given first then its lower-cased form (deduped). */
381
- function templateCandidates(base: string): string[] {
382
- const lower = base.toLowerCase();
383
- return base === lower ? [base] : [base, lower];
384
- }
385
-
386
- /**
387
- * Read a template file as UTF-8, returning `undefined` when it does not exist (`ENOENT`) so
388
- * the caller can fall back to a built-in. A permission failure becomes a `denied`
389
- * {@link LoreError}; any other read fault propagates as an uncaught error.
390
- *
391
- * `checkSymlink` (true for an explicit `--template` (LORE-91) AND, as of LORE-185's AC#2, a
392
- * profile-declared `template` — `resolveTemplate`'s `declared` fallback) refuses — rather than
393
- * silently reading through — a symlinked candidate anywhere in `relPath`'s segments, closing an
394
- * information-disclosure gap the purely syntactic {@link templateConfinementViolation} containment
395
- * check cannot: a bare, unsuspicious `--template evil` (or a profile declaring `template = "evil"`)
396
- * whose resolved `.lore/templates/evil.md` is itself a symlink to an arbitrary file outside the
397
- * repo would otherwise have that file's exact content silently embedded in the generated concept.
398
- * Mirrors this codebase's established write-path precedent (`fswrite.ts`'s
399
- * `assertNoSymlinkInPath`/`findSymlinkSegment`, LORE-76/77) rather than inventing a new pattern,
400
- * and its own READ-path precedent (`core/bundle.ts`'s `walkMarkdown`, `commands/replace.ts`) of
401
- * never following a symlink that could resolve outside the repo. `checkSymlink` is still `false`
402
- * for the bare-type-name convention lookup (neither `--template` nor a profile `declared` value) —
403
- * LORE-139 hardened that path's PARSE-time traversal check (via the profile's own compiled
404
- * `template`, confined before `resolveTemplate` ever sees it) but, unlike the two named sources
405
- * above, that implicit lookup names no untrusted value at all, so it was intentionally left out of
406
- * this task's AC#2 scope rather than "left untouched" wholesale, as an earlier draft of this
407
- * comment (pre-LORE-185) claimed.
408
- */
409
- function readTemplateFile(absPath: string, relPath: string, root: string, checkSymlink: boolean): string | undefined {
410
- if (checkSymlink) {
411
- const symlink = findSymlinkSegment(root, relPath);
412
- if (symlink !== null) {
413
- throw new LoreError(
414
- "conflict",
415
- `refusing to read ${relPath}: "${symlink}" is a symlink, not a real directory or file`,
416
- "lore does not read through a symlink (it may resolve outside the repo) — remove or replace it, then re-run",
417
- { path: relPath, symlink },
418
- );
419
- }
420
- }
421
- try {
422
- return readFileSync(absPath, "utf8");
423
- } catch (cause) {
424
- const code = errnoCode(cause);
425
- if (code === "ENOENT") {
426
- return undefined;
427
- }
428
- if (code === "EACCES" || code === "EPERM") {
429
- throw new LoreError("denied", `permission denied reading ${relPath}`, `make ${relPath} readable`, {
430
- path: relPath,
431
- code,
432
- });
433
- }
434
- throw cause;
435
- }
436
- }
437
-
438
- // ── Output ───────────────────────────────────────────────────────────────────────
439
-
440
- /** The bundle-relative id for a written doc: the path under `docs/` minus `.md`. */
441
- function bundleId(docPath: string): string {
442
- const prefix = `${DOCS_DIR}/`;
443
- return idFromPath(docPath.startsWith(prefix) ? docPath.slice(prefix.length) : docPath);
444
- }
445
-
446
- /** Flush advisory warnings to stderr in the shared `warning:` format (non-fatal; never changes the exit code). */
447
- function flushWarnings(warnings: readonly string[], output: OutputContext, stderr: Writer | undefined): void {
448
- if (warnings.length === 0) {
449
- return;
450
- }
451
- const collector = new WarningCollector();
452
- for (const warning of warnings) {
453
- collector.add(warning);
454
- }
455
- collector.flush({ color: output.color, stderr });
456
- }
457
-
458
- /** The per-result-type rendering bundle for `new` (output.ts dispatches on the mode). */
459
- function newRenderable(data: NewResult): Renderable<NewResult> {
460
- return { kind: "new", data, pretty: renderPretty, plain: renderPlain };
461
- }
462
-
463
- /** Human view: a one-line confirmation naming the type, id, and path. */
464
- function renderPretty(data: NewResult): string {
465
- return `Created ${data.type} ${data.id}\n ${data.path}`;
466
- }
467
-
468
- /** ANSI-free, diff-stable view: one `created <path>` line. */
469
- function renderPlain(data: NewResult): string {
470
- return `created ${data.path}`;
471
- }
472
-
473
- /** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
474
- function usage(message: string, hint: string): LoreError {
475
- return new LoreError("usage", message, hint);
476
- }