@opum-ai/lore 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
package/src/core/links.ts DELETED
@@ -1,681 +0,0 @@
1
- /**
2
- * links.ts — the canonical lore cross-link form, in one place.
3
- *
4
- * Every internal cross-link lore writes — in scaffolded concepts, regenerated
5
- * index/log files, managed `lore:tasks` blocks, and graph-aware rewrites — is the
6
- * single form ADR-0010 pins as the only one that resolves across GitHub, Obsidian
7
- * (and feeds its graph/backlinks), MkDocs/Material, and Docusaurus simultaneously:
8
- *
9
- * - **relative** — computed from the *linking file's directory*
10
- * (`../reference/orders.md`), because the bundle has no single deploy root
11
- * (GitHub serves the repo path, MkDocs/Docusaurus a site subpath, Obsidian the
12
- * vault root);
13
- * - **URL-encoded** — spaces and other reserved characters percent-encoded
14
- * per path segment (`order%20schema.md`), or GitHub and several parsers fail to
15
- * resolve the target;
16
- * - **`.md`-suffixed** — the link points at the *file*, not an extensionless route
17
- * (extensionless breaks GitHub and Obsidian);
18
- * - **no leading slash** — never `/reference/orders.md`, which points at each
19
- * consumer's *server root* and so breaks somewhere every time;
20
- * - **no wikilinks** — never `[[orders]]`, which renders as literal text off
21
- * Obsidian.
22
- *
23
- * This module is the shared home for that form so it can never be spelled two ways:
24
- * {@link normalizeLink} *writes* it ({@link `lore new`}, sync/index generation,
25
- * `lore link`, managed blocks, and the rewrite half of `lore rename`/`supersede`),
26
- * and {@link validateLink} *detects* deviations from it (the per-link half of `lore
27
- * check`'s portability lint). The segment encoder ({@link encodePathSegments}) is
28
- * shared with the `resource:` URL stamper (template.ts), and the low-level
29
- * destination classifiers ({@link isExternalTarget}, {@link stripFragment},
30
- * {@link stripQuery}, {@link decodeTarget}) are reused by the bundle graph's
31
- * cross-link resolution — so linting and resolving agree on what a link destination
32
- * *is*, and every place that encodes a path encodes it the same way.
33
- *
34
- * Per the core contract (design §2.1) everything here is pure: string in, string
35
- * or typed finding out — no filesystem, no printing, no flags, no `process.exit`.
36
- * A non-portable input is reported as a {@link LinkFinding}, never thrown; these
37
- * functions do not fail.
38
- *
39
- * Scope (the rest of `links.ts` in design §2.1 lands with its consumers, not here):
40
- * whole-graph link/anchor resolution (`validateLinks(graph)` via
41
- * remark-validate-links) pairs with `lore check` (LORE-30); inbound-rewrite
42
- * orchestration (`rewriteInbound(graph, from, to)`, which composes
43
- * {@link normalizeLink}) pairs with `lore rename`/`supersede` (LORE-35); and the
44
- * body-text portability scan (wikilinks, embeds, Obsidian-isms, MDX `<`/`{`) is
45
- * part of `lore check`'s lint (LORE-30). This module ships the link-form
46
- * primitives those compose.
47
- */
48
-
49
- import { posix } from "node:path";
50
- import { singleLine, stripAnsiAndControls } from "../errors";
51
- import { idFromPath } from "./concept";
52
-
53
- // ── Shared patterns ───────────────────────────────────────────────────────────
54
- // Hoisted to module scope so each is compiled once rather than re-built per call.
55
-
56
- /** A trailing `.md` in **canonical lowercase** only. */
57
- const MD_SUFFIX_CANONICAL = /\.md$/;
58
- /** A trailing `.md` in any case (`.md`/`.MD`/`.Md`) — the writer's id-stripping rule. */
59
- const MD_SUFFIX_ANY_CASE = /\.md$/i;
60
- /** The markdown-significant characters {@link encodeURIComponent} leaves raw (`! ' ( ) *`). */
61
- const MARKDOWN_SIGNIFICANT = /[!'()*]/g;
62
- /** A `%` that is **not** the start of a valid two-hex-digit escape. */
63
- const MALFORMED_PERCENT = /%(?![0-9A-Fa-f]{2})/;
64
- /** A path segment in canonical form: only RFC-3986 unreserved chars or valid `%XX` escapes. */
65
- const CANONICAL_SEGMENT = /^(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})*$/;
66
- /** A raw character that breaks a markdown link destination (whitespace or a paren). */
67
- const RAW_BREAKER = /[\s()]/;
68
- /** An RFC-3986 `scheme:` prefix — what makes a destination look like an absolute URL. */
69
- const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
70
-
71
- // ── Writing the canonical form ──────────────────────────────────────────────────
72
-
73
- /**
74
- * Compute the canonical lore cross-link **destination** from one file to another:
75
- * the value that goes inside `[text](…)`, in the relative · URL-encoded ·
76
- * `.md`-suffixed · no-leading-slash form (ADR-0010).
77
- *
78
- * **Inputs are file *paths*, not link destinations.** `fromPath` and `toPath` must
79
- * be expressed in the **same coordinate space** — both bundle-root-relative
80
- * (`stories/x.md` → `reference/orders.md`) or both repo-relative
81
- * (`docs/stories/x.md` → `docs/reference/orders.md`) — because the result is
82
- * `posix.relative(dirname(fromPath), toPath)`: pure path arithmetic that happily
83
- * crosses subtrees (`../../backlog/tasks/…`) when the two live apart, which is
84
- * exactly what a managed `lore:tasks` block linking a `docs/` story to a Backlog
85
- * task file needs. The coordinate space is a **caller precondition** the two-string
86
- * arithmetic cannot itself verify; a mismatched pair yields a wrong relative link,
87
- * not an error. `toPath` is a path, so it must **not** carry a `#fragment` or
88
- * `?query` — pass a heading via `anchor` (a caller rewriting an existing link, e.g.
89
- * `lore rename`, splits the fragment off first and feeds it here).
90
- *
91
- * Mechanics, each pinned to one property of the form:
92
- *
93
- * - **`.md`-suffixed** — `toPath` is normalized and coerced to a single canonical
94
- * lowercase `.md` ({@link ensureMarkdownSuffix}), so a caller may pass a path
95
- * (`reference/orders.md`), a bare concept id (`reference/orders`), or even a
96
- * wrong-case `reference/orders.MD` and get the one canonical file link.
97
- * - **relative, no leading slash** — `posix.relative` is computed from the linking
98
- * file's *directory* and never returns a leading slash, so both properties hold
99
- * by construction (a sibling becomes `orders.md`, a parent-dir target
100
- * `../reference/orders.md`).
101
- * - **URL-encoded** — every path segment is percent-encoded by
102
- * {@link encodePathSegments}, which escapes a space to `%20` *and* the markdown-
103
- * significant `! ' ( ) *` that bare `encodeURIComponent` leaves raw (an
104
- * unbalanced `)` would otherwise truncate the link destination on
105
- * CommonMark/Python-Markdown), while leaving the `/` separators and `.`/`..`
106
- * steps literal.
107
- *
108
- * `anchor`, when given, is appended as `#<anchor>` **verbatim** — the caller passes
109
- * an already-resolved GitHub heading slug (lowercased, spaces → `-`, punctuation
110
- * stripped), which by construction needs no encoding; re-encoding it would mangle a
111
- * correct slug, since GitHub anchors are not percent-encoded.
112
- *
113
- * **Deterministic, not idempotent on its own output.** The same `(fromPath, toPath,
114
- * anchor)` always yields the same destination, so a regenerate-and-compare pass
115
- * over canonical links is a no-op — but note the inputs are *paths*: feeding this
116
- * function's *output* (an already-relative, already-encoded destination) back in as
117
- * `toPath` is misuse and would double-encode and re-base it.
118
- *
119
- * @param fromPath the linking file (its directory anchors the relative path).
120
- * @param toPath the target file or bare concept id (a missing `.md` is added; no `#`/`?`).
121
- * @param anchor optional pre-slugified heading anchor, appended as `#anchor`.
122
- * @returns the canonical relative destination string for `[text](…)`.
123
- */
124
- export function normalizeLink(fromPath: string, toPath: string, anchor?: string): string {
125
- // Precondition guard (the docstring's coordinate-space rule the arithmetic can't verify):
126
- // an **absolute** operand has no place in lore's relative coordinate space and would make the
127
- // result depend on process.cwd() — reject it loudly as the caller bug it is rather than emit a
128
- // silently-wrong link.
129
- if (posix.isAbsolute(fromPath) || posix.isAbsolute(toPath)) {
130
- throw new Error(`normalizeLink expects relative paths (got from="${fromPath}", to="${toPath}")`);
131
- }
132
- // Root both operands at a fixed virtual "/" before the relative computation so the output is
133
- // cwd-independent even when a path carries `..` segments — `posix.relative` otherwise resolves
134
- // a relative operand against process.cwd(), which cancels only for two in-tree paths. `posix.join`
135
- // also collapses `.`/`..`, so no separate normalize is needed.
136
- const fromDir = posix.join("/", posix.dirname(fromPath));
137
- const toFile = posix.join("/", ensureMarkdownSuffix(toPath));
138
- const relative = posix.relative(fromDir, toFile);
139
- const encoded = encodePathSegments(relative);
140
- return anchor ? `${encoded}#${anchor}` : encoded;
141
- }
142
-
143
- /**
144
- * POSIX-normalize a target and coerce it to a single **canonical lowercase `.md`**
145
- * suffix. A bundle never holds a `.MD`/`.Md` file (the walk matches lowercase `.md`
146
- * only) and {@link idFromPath} strips `.md` case-insensitively, so the writer must
147
- * re-add it in canonical case: a wrong-case `orders.MD` becomes `orders.md`, a
148
- * bare id `orders` becomes `orders.md`, and an already-correct `orders.md` is
149
- * unchanged.
150
- */
151
- function ensureMarkdownSuffix(path: string): string {
152
- // idFromPath POSIX-normalizes and strips a case-insensitive `.md`; re-adding the canonical
153
- // lowercase suffix yields the one canonical file link and reuses the single path→id rule (so a
154
- // bare id, a correct `.md`, and a wrong-case `.MD` all converge) instead of re-rolling it here.
155
- return `${idFromPath(path)}.md`;
156
- }
157
-
158
- /**
159
- * Percent-encode each `/`-separated segment of a relative path for use as a
160
- * markdown link destination, leaving the separators and `.`/`..` steps literal.
161
- *
162
- * Exported as the **single** path-segment encoder so body cross-links
163
- * ({@link normalizeLink}) and the `resource:` frontmatter URL (template.ts
164
- * `resourceFor`) encode a path the exact same way and can never drift — the whole
165
- * reason this module exists. Each segment goes through {@link encodePathSegment},
166
- * which extends {@link encodeURIComponent} to also escape the markdown-significant
167
- * `! ' ( ) *` it leaves raw.
168
- */
169
- export function encodePathSegments(path: string): string {
170
- return path.split("/").map(encodePathSegment).join("/");
171
- }
172
-
173
- /**
174
- * Percent-encode one path segment to the canonical form: {@link encodeURIComponent}
175
- * plus the five characters it leaves raw (`! ' ( ) *`), which are unreserved in a
176
- * URI but significant in a markdown link destination — an unbalanced `)` truncates
177
- * the destination on CommonMark/Python-Markdown. Hex is uppercase, matching
178
- * `encodeURIComponent`, so {@link normalizeLink}'s output and the
179
- * {@link validateLink} round-trip agree on the canonical bytes.
180
- */
181
- function encodePathSegment(segment: string): string {
182
- return encodeURIComponent(segment).replace(
183
- MARKDOWN_SIGNIFICANT,
184
- (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
185
- );
186
- }
187
-
188
- // ── Linting the canonical form ──────────────────────────────────────────────────
189
-
190
- /**
191
- * The portable-markdown path **part** of a destination — everything before a
192
- * `#fragment` or `?query`. The single composition of {@link stripFragment} and
193
- * {@link stripQuery} (fragment first, so a `?` that lives *inside* a fragment is
194
- * not mistaken for a query), shared by the linter ({@link validateLink}) and the
195
- * bundle resolver so both split a destination into path / suffix the same way.
196
- */
197
- export function pathPart(target: string): string {
198
- return stripQuery(stripFragment(target));
199
- }
200
-
201
- /**
202
- * The way a link destination departs from the canonical form. Each value is one
203
- * property of the ADR-0010 link rule, so a caller can branch on the *kind* of
204
- * portability problem rather than parse the message.
205
- */
206
- export type LinkIssue = "leading-slash" | "missing-extension" | "unencoded" | "accidental-colon" | "directory-link";
207
-
208
- /**
209
- * One portability problem with a single link destination. `target` is the
210
- * offending destination as authored, `issue` the machine-branchable kind, and
211
- * `message` a human-readable one-liner for the warning stream. A destination can
212
- * produce more than one finding (a `/reference/orders` link is both leading-slash
213
- * *and* missing-extension).
214
- */
215
- export interface LinkFinding {
216
- /** The link destination as authored (the value inside `[text](…)`). */
217
- readonly target: string;
218
- /** Which property of the canonical form the destination violates. */
219
- readonly issue: LinkIssue;
220
- /** A human-readable description of the violation, for the warning stream. */
221
- readonly message: string;
222
- }
223
-
224
- /**
225
- * Classify a single markdown link **destination** against the canonical internal
226
- * cross-link form, returning a finding per violation (an empty array means the
227
- * destination is already portable). This is the per-link primitive `lore check`'s
228
- * portability lint composes over a bundle's body links (LORE-30); the broader
229
- * body-text scan for wikilinks, embeds, Obsidian-isms, and MDX hazards is the
230
- * lint's own concern, not this function's — a wikilink never parses to a link
231
- * destination, so it cannot reach here.
232
- *
233
- * **External destinations are clean here.** An empty target, a bare `#anchor`, a
234
- * protocol-relative `//host/…`, or a `scheme:`-qualified URL ({@link isExternalTarget})
235
- * is not an internal cross-link, so it yields no findings — linting an external URL
236
- * for a `.md` suffix would be nonsense. This uses the **same** classifier the bundle
237
- * graph's resolver applies, so writer, resolver, and linter agree on what counts as
238
- * external. The **one** exception (LORE-48) is the pathological destination whose first
239
- * segment carries a colon (`notes:2026.md`): the shared classifier reads it as a
240
- * `scheme:` URL (ADR-0010) — a form lore never writes — so it is flagged here as an
241
- * `accidental-colon` filename rather than skipped in silence by both linter and resolver.
242
- *
243
- * The checks mirror the machine-checkable properties of the form (the "relative"
244
- * property is implied by "no leading slash"; "no wikilinks" is a body scan):
245
- *
246
- * - **`leading-slash`** — a `/`-absolute destination, which resolves against each
247
- * consumer's differing server root.
248
- * - **`accidental-colon`** — a relative file whose first segment carries a colon
249
- * ({@link accidentalColonFile}), read as a `scheme:` URL and otherwise unlinted.
250
- * - **`directory-link`** — a non-external destination ending in `/` (`../reference/`), or
251
- * whose final path segment is bare `.`/`..` navigation (`.`, `..`, `../..`, `foo/..`):
252
- * it resolves to a file on no renderer and is almost always a dropped filename.
253
- * - **`missing-extension`** — an internal target missing the canonical **lowercase**
254
- * `.md` suffix ({@link lacksMarkdownSuffix}): no extension at all
255
- * (`../reference/orders`), a wrong-case `.md` (`orders.MD`), or a dotted last
256
- * segment whose "extension" is not on the closed {@link KNOWN_ASSET_EXTENSIONS}
257
- * list (`orders.v2`, presumed a dropped `.md` suffix rather than an opaque asset —
258
- * LORE-152). A directory link is its own `directory-link` finding (above); a
259
- * dotfile (`../config/.gitignore`) and a recognized asset extension
260
- * (`../img/x.png`) are left alone. The third case is the one place this lint and
261
- * the bundle resolver deliberately **disagree**: the resolver still requires a
262
- * literal `.md` suffix to form an edge at all, so it never flags `orders.v2` as
263
- * broken even when no `orders.v2.md` file exists — this finding is the only
264
- * mechanism left to catch that shape. The extension is judged on the *decoded*
265
- * path, so the linter and the resolver (which decodes first) at least agree on
266
- * what the path *is*.
267
- * - **`unencoded`** — a destination that will not survive a markdown parser
268
- * ({@link encodingProblem}): a raw space or paren in the path *or* in the
269
- * `#fragment`/`?query`, a malformed `%`-escape, or an interior `//`. A valid but
270
- * non-canonical encoding (`a%41b.md`, lowercase `%c3`) is **clean** — over-encoding
271
- * renders everywhere, so the lint flags what *breaks*, not every deviation from the
272
- * writer's exact bytes.
273
- *
274
- * @param target the link destination to classify (the value inside `[text](…)`).
275
- * @returns a {@link LinkFinding} per violation; empty when portable or external.
276
- */
277
- export function validateLink(target: string): LinkFinding[] {
278
- const findings: LinkFinding[] = [];
279
- const trimmed = target.trim();
280
- if (isExternalTarget(trimmed)) {
281
- // A scheme:-qualified destination is external and not linted — with one exception. lore never
282
- // writes a colon inside a path segment, so a `scheme:` whose tail is a relative `.md` file
283
- // (`notes:2026.md`) is almost certainly a filename with an accidental colon, which both this
284
- // linter and the bundle resolver would otherwise skip in silence. Flag that one case;
285
- // everything else (`http:`, `mailto:`, a bare `#anchor`, a protocol-relative `//host`) is
286
- // genuinely external.
287
- if (accidentalColonFile(trimmed)) {
288
- findings.push({
289
- target,
290
- issue: "accidental-colon",
291
- message: `link "${sanitizeForMessage(target)}" looks like a relative file whose name contains a ":"; it is read as a "scheme:" URL and skipped by link resolution — remove the colon (lore filenames never contain one)`,
292
- });
293
- }
294
- return findings;
295
- }
296
-
297
- if (trimmed.startsWith("/")) {
298
- findings.push({
299
- target,
300
- issue: "leading-slash",
301
- message: `link "${sanitizeForMessage(target)}" is /-absolute; use a relative path (it resolves against each consumer's differing root)`,
302
- });
303
- }
304
-
305
- // Split into the path part and the raw `#fragment`/`?query` remainder, the same
306
- // way the bundle resolver does (shared {@link pathPart}). Both halves are scanned:
307
- // a destination-breaking char in a fragment (`orders.md#Archival Policy`) truncates
308
- // the link just as surely as one in the path.
309
- const path = pathPart(trimmed);
310
- const suffix = trimmed.slice(path.length);
311
-
312
- if (isDirectoryLink(path)) {
313
- findings.push({
314
- target,
315
- issue: "directory-link",
316
- message: `link "${sanitizeForMessage(target)}" points at a directory (a trailing "/", or a bare "."/".." navigation segment); name the .md file instead (a directory link resolves on no renderer — likely a dropped filename)`,
317
- });
318
- } else if (lacksMarkdownSuffix(path)) {
319
- findings.push({
320
- target,
321
- issue: "missing-extension",
322
- message: `link "${sanitizeForMessage(target)}" is missing the .md suffix; GitHub and Obsidian link to the file and need the lowercase .md extension`,
323
- });
324
- }
325
-
326
- const problem = encodingProblem(path, suffix);
327
- if (problem !== null) {
328
- findings.push({ target, issue: "unencoded", message: encodingMessage(target, problem) });
329
- }
330
-
331
- return findings;
332
- }
333
-
334
- /**
335
- * The extensions of non-`.md` files a docs bundle legitimately links to directly:
336
- * images, common document/archive/media formats, and the handful of source-file
337
- * extensions a doc might reference (this repo's own `docs/adr/0006` links straight at
338
- * `../../src/core/profile.ts`). Judged as a **closed, known list** rather than "the
339
- * last segment has a dot at all" — the rule {@link lacksMarkdownSuffix} used to apply,
340
- * which made a dotted-but-extensionless concept link (`orders.v2`, meant for
341
- * `orders.v2.md`) indistinguishable from a real asset link and so let it through in
342
- * silence (LORE-152). The trade-off this closed list accepts: a real asset whose
343
- * extension is not on it is a false positive {@link lacksMarkdownSuffix} cannot avoid
344
- * without a filesystem probe, which {@link validateLink} — pure by contract — does
345
- * not have.
346
- */
347
- const KNOWN_ASSET_EXTENSIONS = new Set([
348
- // images
349
- "png",
350
- "jpg",
351
- "jpeg",
352
- "gif",
353
- "svg",
354
- "webp",
355
- "bmp",
356
- "ico",
357
- "avif",
358
- "tif",
359
- "tiff",
360
- // documents
361
- "pdf",
362
- "csv",
363
- "txt",
364
- "rtf",
365
- "doc",
366
- "docx",
367
- "xls",
368
- "xlsx",
369
- "ppt",
370
- "pptx",
371
- // data/config
372
- "json",
373
- "yaml",
374
- "yml",
375
- "toml",
376
- "xml",
377
- // archives
378
- "zip",
379
- "tar",
380
- "gz",
381
- "tgz",
382
- "rar",
383
- "7z",
384
- // media
385
- "mp4",
386
- "mp3",
387
- "wav",
388
- "mov",
389
- "webm",
390
- // source (a doc legitimately links straight at the code it describes)
391
- "ts",
392
- "tsx",
393
- "js",
394
- "jsx",
395
- "sh",
396
- "py",
397
- "go",
398
- "rs",
399
- ]);
400
-
401
- /**
402
- * Whether a destination's path part lacks the canonical lowercase `.md` suffix and
403
- * so looks like a dropped-extension concept link — judged on the **decoded** path
404
- * (`orders%2Emd` decodes to `orders.md`), so the linter and the bundle resolver,
405
- * which {@link decodeTarget}s before resolving, agree on what the extension *is*.
406
- *
407
- * The canonical form is **lowercase** `.md` only; three shapes are flagged:
408
- *
409
- * - **no extension at all** (`../reference/orders`) — a dropped-suffix concept link;
410
- * - **a wrong-case `.md`** (`orders.MD`/`orders.Md`) — meant to be the portable
411
- * suffix but case-broken on a case-sensitive host;
412
- * - **a dotted last segment whose "extension" is not on the closed
413
- * {@link KNOWN_ASSET_EXTENSIONS} list** (`orders.v2`) — presumed a dropped `.md`
414
- * suffix rather than an opaque asset (LORE-152).
415
- *
416
- * Two shapes are deliberately **left alone**, so the lint does not cry wolf on a real,
417
- * non-concept link:
418
- *
419
- * - an empty path (a pure `#fragment`/`?query` destination), or a **directory** link
420
- * (a trailing `/`, e.g. `../reference/`, or a bare `.`/`..` navigation segment —
421
- * {@link isDirectoryLink} — which is why this function's own dotfile check below
422
- * never has to tell `.`/`..` apart from a real dotfile: it never sees them);
423
- * - a **dotfile** (`../config/.gitignore`) or a recognized **asset** extension
424
- * (`../img/x.png`, {@link KNOWN_ASSET_EXTENSIONS}) — a real non-concept link, not a
425
- * dropped suffix.
426
- *
427
- * The third flagged shape is where this function and the bundle resolver part ways:
428
- * the resolver still requires a literal `.md` suffix to form an edge at all, so a
429
- * genuinely broken `orders.v2` link (no matching `orders.v2.md`) never surfaces as a
430
- * dangling edge in `lore check`'s link-existence pass — this finding is the only
431
- * mechanism left to catch it.
432
- */
433
- function lacksMarkdownSuffix(rawPath: string): boolean {
434
- const path = decodeTarget(rawPath);
435
- if (path === "" || path.endsWith("/")) {
436
- return false; // pure fragment/query, or a directory link — no file suffix to demand
437
- }
438
- const last = path.slice(path.lastIndexOf("/") + 1);
439
- if (last === "" || last.startsWith(".")) {
440
- return false; // a dotfile (.gitignore) is an asset, not a dropped suffix
441
- }
442
- if (MD_SUFFIX_CANONICAL.test(last)) {
443
- return false; // canonical lowercase .md
444
- }
445
- if (MD_SUFFIX_ANY_CASE.test(last)) {
446
- return true; // wrong-case .MD/.Md — meant to be .md, 404s on a case-sensitive host
447
- }
448
- const dot = last.lastIndexOf(".");
449
- if (dot === -1) {
450
- return true; // no extension at all → dropped suffix
451
- }
452
- // A recognized asset extension is a real non-.md link, left alone; any other dotted
453
- // segment (`orders.v2`) is presumed a dropped .md suffix, not an opaque asset (LORE-152).
454
- return !KNOWN_ASSET_EXTENSIONS.has(last.slice(dot + 1).toLowerCase());
455
- }
456
-
457
- /** The URL schemes a docs bundle legitimately links with — never an accidental-colon filename. */
458
- const KNOWN_URL_SCHEMES = new Set([
459
- "http",
460
- "https",
461
- "ftp",
462
- "ftps",
463
- "file",
464
- "mailto",
465
- "tel",
466
- "sms",
467
- "data",
468
- "ws",
469
- "wss",
470
- "git",
471
- "ssh",
472
- "irc",
473
- "urn",
474
- ]);
475
-
476
- /**
477
- * Whether a destination is a relative file whose **first path segment carries a colon**
478
- * (`notes:2026.md`), which {@link isExternalTarget} reads as a `scheme:` URL and so leaves
479
- * unlinted. Real URLs are excluded three ways, because `.md` is a live TLD (Moldova) so "ends in
480
- * `.md`" alone is not enough:
481
- *
482
- * - a **known** URL scheme (`http`, `mailto`, `file`, …) is a real link, never a filename;
483
- * - a hierarchical tail beginning with `/` (`http://…`, `file:///…`) is a real URL;
484
- * - a tail containing `@` (`mailto:user@example.md`) is a mailbox, not a path.
485
- *
486
- * Only an *unknown* scheme whose tail is a bare relative-looking `.md` path is taken to be a
487
- * filename with an accidental colon — the one case lore never writes and the resolver silently
488
- * skips. (A colon in a *later* segment, `dir/notes:2026.md`, is not a scheme and is already
489
- * flagged `unencoded`.)
490
- */
491
- function accidentalColonFile(target: string): boolean {
492
- const path = pathPart(target);
493
- const scheme = URL_SCHEME.exec(path);
494
- if (scheme === null) {
495
- return false; // a bare #anchor or `//host` reaches here with no scheme to mistake for a colon
496
- }
497
- const name = scheme[0].slice(0, -1).toLowerCase(); // the scheme word, without its trailing ":"
498
- if (KNOWN_URL_SCHEMES.has(name)) {
499
- return false; // a genuine URL scheme, not a mistyped filename
500
- }
501
- const tail = path.slice(scheme[0].length);
502
- return !tail.startsWith("/") && !tail.includes("@") && MD_SUFFIX_ANY_CASE.test(tail);
503
- }
504
-
505
- /**
506
- * Whether a (non-external) destination's path part names a **directory** — a trailing `/` after
507
- * decoding (`../reference/`), *or* a final path segment that is bare navigation, exactly `.` or
508
- * `..` (`.`, `..`, `../..`, `foo/..`, `foo/.`). Both shapes name a directory, not the canonical
509
- * `.md` file the link form requires, and resolve inconsistently across consumers (GitHub browses
510
- * to the directory; Obsidian will not resolve a `..` to a note) — so both warn. The pure
511
- * leading-slash `/` (already flagged `leading-slash`) and an empty path (a bare fragment) are
512
- * excluded so neither double-reports.
513
- *
514
- * A bare/trailing `.`/`..` segment is deliberately checked **here**, not left for
515
- * {@link lacksMarkdownSuffix}'s dotfile exemption to (mis)judge: that exemption tests
516
- * `startsWith(".")`, which would otherwise read a final segment of exactly `.`/`..` as a genuine
517
- * dotfile like `.gitignore` and wave it through. Catching it in this earlier check keeps the
518
- * dotfile exemption itself unchanged and correct for real dotfiles.
519
- */
520
- function isDirectoryLink(rawPath: string): boolean {
521
- const path = decodeTarget(rawPath);
522
- if (path === "" || path === "/") {
523
- return false;
524
- }
525
- if (path.endsWith("/")) {
526
- return true;
527
- }
528
- const last = path.slice(path.lastIndexOf("/") + 1);
529
- return last === "." || last === "..";
530
- }
531
-
532
- /**
533
- * The kind of encoding problem a destination carries, or `null` when its path and
534
- * suffix are both portable. One finding per destination is enough to flag the form,
535
- * so the first problem found (in this fixed precedence) wins:
536
- *
537
- * - **`empty-segment`** — an *interior* `//` (`a//b.md`): an empty path step that is
538
- * non-canonical (a leading `//` is protocol-relative and already external; a
539
- * trailing `/` is a directory link, not an empty segment).
540
- * - **`malformed`** — a `%` not followed by two hex digits (`order%2.md`): broken
541
- * percent-escaping, where the generic "encode a space" advice would be wrong.
542
- * - **`raw`** — a **path segment** that is not in the canonical form
543
- * ({@link isCanonicalSegment}: only RFC-3986 unreserved characters and valid
544
- * `%`-escapes), or a destination-breaking character (ASCII whitespace, `(`/`)`) in
545
- * the `#fragment`/`?query`. The path check is what keeps the linter and
546
- * {@link encodePathSegment writer} in agreement: a raw space, paren, or any of the
547
- * markdown-significant `! ' *` the writer percent-encodes is flagged, so a bundle the
548
- * lint calls clean is one the writer would re-emit byte-for-byte.
549
- *
550
- * The scan still accepts a **valid but non-canonical** encoding (`a%41b.md` = `aAb.md`,
551
- * a lowercase `%c3`): over-encoding survives every renderer and round-trips, so it is
552
- * portable even though {@link normalizeLink} would not emit it. The `#fragment` is held
553
- * to the looser "no destination-breaker" rule, not full canonical encoding — a GitHub
554
- * anchor slug (`#café`) is not percent-encoded and must not be flagged.
555
- */
556
- function encodingProblem(rawPath: string, suffix: string): "empty-segment" | "malformed" | "raw" | null {
557
- const segments = rawPath.split("/");
558
- for (let i = 1; i < segments.length - 1; i++) {
559
- if (segments[i] === "") {
560
- return "empty-segment"; // interior // — endpoints are a leading slash / trailing dir slash
561
- }
562
- }
563
- if (hasMalformedPercent(rawPath) || hasMalformedPercent(suffix)) {
564
- return "malformed";
565
- }
566
- if (segments.some((segment) => segment !== "" && !isCanonicalSegment(segment)) || hasRawBreaker(suffix)) {
567
- return "raw";
568
- }
569
- return null;
570
- }
571
-
572
- /** The human-readable message for an {@link encodingProblem} kind, naming the offending `target`. */
573
- function encodingMessage(target: string, problem: "empty-segment" | "malformed" | "raw"): string {
574
- const clean = sanitizeForMessage(target);
575
- switch (problem) {
576
- case "empty-segment":
577
- return `link "${clean}" has an empty path segment ("//"); use a single "/" separator`;
578
- case "malformed":
579
- return `link "${clean}" has a malformed percent-escape (a "%" not followed by two hex digits); fix or remove it`;
580
- case "raw":
581
- return `link "${clean}" has an unencoded character; percent-encode reserved characters (e.g. space -> %20)`;
582
- }
583
- }
584
-
585
- /**
586
- * Collapse a bundle-authored link `target` to a single line with no ANSI escape sequences or
587
- * other control bytes, for safe embedding into a {@link LinkFinding} `message`. `target` is a
588
- * raw destination string lifted verbatim from a markdown link — `[text](target)` — so nothing
589
- * about the bundle guarantees it is display-safe: a crafted or corrupted file can carry an
590
- * ESC-led ANSI/OSC sequence or a stray C0/C1/DEL control byte in the destination text, and
591
- * `message` is documented as a plain human-readable one-liner for the warning stream, not a
592
- * raw byte passthrough. {@link LinkFinding.target} itself is left untouched — it is the
593
- * destination *as authored*, for a caller that wants to act on the exact bytes — only the
594
- * printable `message` is sanitized (LORE-153).
595
- *
596
- * Runs {@link singleLine} first (folds line terminators), then strips what it leaves via the
597
- * shared {@link stripAnsiAndControls} (LORE-181) — the single home for that two-pass strip,
598
- * also used by `output.ts`, `commands/query.ts`, and `core/validate.ts` — imported from
599
- * `errors.ts` rather than `output.ts`: this module stays filesystem/output-layer-free by design
600
- * (module doc above — pure, no printing), and `errors.ts` is layer-neutral.
601
- */
602
- function sanitizeForMessage(text: string): string {
603
- return stripAnsiAndControls(singleLine(text));
604
- }
605
-
606
- /** Whether a string carries a `%` that is not the start of a valid two-hex-digit escape. */
607
- function hasMalformedPercent(s: string): boolean {
608
- return MALFORMED_PERCENT.test(s);
609
- }
610
-
611
- /**
612
- * Whether a path segment is in the canonical percent-encoded form: every character is
613
- * either an RFC-3986 **unreserved** char (`A-Za-z0-9-._~`, which includes the `.`/`..`
614
- * relative steps) or a valid `%XX` escape. This is exactly the alphabet
615
- * {@link encodePathSegment} can emit, so a raw space, a `(`/`)`, or any of the
616
- * markdown-significant `! ' *` the writer escapes makes a segment non-canonical — while an
617
- * over-encoded `%41` or a lowercase `%c3` still passes (both are valid `%`-escapes).
618
- */
619
- function isCanonicalSegment(segment: string): boolean {
620
- return CANONICAL_SEGMENT.test(segment);
621
- }
622
-
623
- /** Whether a string carries a raw character that breaks a markdown link destination (whitespace or a paren). */
624
- function hasRawBreaker(s: string): boolean {
625
- return RAW_BREAKER.test(s);
626
- }
627
-
628
- // ── Destination classification (shared with the bundle graph) ────────────────────
629
-
630
- /**
631
- * Whether a destination is **not** an internal concept reference: empty, a bare
632
- * `#anchor`, a protocol-relative URL (`//host/…`), or a `scheme:`-qualified URL
633
- * (`http:`, `mailto:`, …, per RFC-3986 — including the pathological relative file
634
- * whose first segment carries a colon, which lore's canonical relative `.md` links
635
- * never do, ADR-0010). The single classifier shared by the portability linter
636
- * ({@link validateLink}) and link resolution (the bundle graph's body-link and
637
- * frontmatter-ref handling), so the linter and resolver agree on what counts as
638
- * external. ({@link normalizeLink}, the writer, takes a known concept *path* and so
639
- * needs no classification — it never sees an arbitrary destination.)
640
- */
641
- export function isExternalTarget(s: string): boolean {
642
- return s === "" || s.startsWith("#") || s.startsWith("//") || URL_SCHEME.test(s);
643
- }
644
-
645
- /** Drop a `#fragment` (and anything after it) from a destination. */
646
- export function stripFragment(target: string): string {
647
- const hash = target.indexOf("#");
648
- return hash === -1 ? target : target.slice(0, hash);
649
- }
650
-
651
- /** Drop a `?query` (and anything after it) from a destination. */
652
- export function stripQuery(target: string): string {
653
- const question = target.indexOf("?");
654
- return question === -1 ? target : target.slice(0, question);
655
- }
656
-
657
- /**
658
- * URL-decode a destination **per path segment**, degrading a segment to its raw text
659
- * if it is not valid percent-encoding. Splitting on the *literal* `/` first (the only
660
- * structural separators the raw target actually has) and decoding each piece in
661
- * isolation stops an *encoded* separator — `%2F`/`%2f` — from ever surfacing as a bare
662
- * `/` in the result: {@link decodeURIComponent} would otherwise turn a single-segment
663
- * target like `orders%2Fv2.md` into `orders/v2.md`, forging a second path segment the
664
- * link's literal text never named and letting it resolve to an unrelated concept
665
- * (`orders/v2`) the author never linked to. A `/` a segment's decode *does* produce is
666
- * re-escaped back to `%2F` before rejoining, so the output always has exactly as many
667
- * structural `/` boundaries as the input — legitimately-encoded characters within a
668
- * segment (a space, `%23`) still decode normally either way.
669
- */
670
- export function decodeTarget(target: string): string {
671
- return target.split("/").map(decodeSegmentKeepingSlash).join("/");
672
- }
673
-
674
- /** Decode one `/`-free destination segment, folding any decoded `/` back to `%2F` so it cannot pass as a structural separator; degrades to the raw segment on malformed percent-encoding. */
675
- function decodeSegmentKeepingSlash(segment: string): string {
676
- try {
677
- return decodeURIComponent(segment).replace(/\//g, "%2F");
678
- } catch {
679
- return segment;
680
- }
681
- }