@opum-ai/lore 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,1031 +0,0 @@
1
- /**
2
- * commands/check.ts — `lore check [paths…]`: the read-only coherence drift gate.
3
- *
4
- * The thin, side-effecting layer over the pure {@link checkBundle} engine (ADR-0007,
5
- * cli-surface §check): it parses the command's own arguments, **discovers** the bundle's
6
- * markdown files (a default whole-bundle walk of `docs/`, or the explicit `[paths…]` a CI
7
- * step passes), reads their bytes, asks core for the link/anchor + portability findings,
8
- * renders the report, and returns the exit code. All file discovery and I/O live here; all
9
- * judgement lives in `core/check.ts`.
10
- *
11
- * `check` is a **gate**, so a coherence failure is not a thrown {@link LoreError}: it emits
12
- * the full `check.report` on stdout and then *returns* exit `6` when any broken internal
13
- * link or rotted anchor exists (or any portability warning under `--strict`). Portability
14
- * findings alone are advisory and do not fail the gate (ADR-0007). Only a *usage* error
15
- * (bad flag) or an *I/O* failure (an unreadable path) throws, funneling through the router's
16
- * one error seam like every command.
17
- *
18
- * Scope: this ships all four ADR-0007 passes. Internal link/anchor validation and the portability
19
- * lint (now including MDX-hazard and filename-portability findings, LORE-48) are deterministic and
20
- * dependency-free. Status reconciliation and managed-block drift (LORE-27) reuse the exact pure
21
- * engines `lore sync` writes with ({@link reconcileStatus}, {@link regenerateTaskBlock}, via the
22
- * `commands/reconcile-shared.ts` gather shared with `sync`) but only diff against disk — this
23
- * command never writes. Both are **errors**, always gating (unlike the warn-only portability lint).
24
- * Finally, the **opt-in** `--external` URL liveness probe: the one non-deterministic, network-
25
- * touching path, kept out of the gate entirely (advisory only, never changes the exit code — not
26
- * even under `--strict`, ADR-0007) with its IO living here, never in pure `core/` (ADR-0014).
27
- */
28
-
29
- import { lookup as dnsLookup } from "node:dns/promises";
30
- import { statSync } from "node:fs";
31
- import { join, posix } from "node:path";
32
- import type { BacklogAdapter } from "../adapters/backlog";
33
- import { loadAgentProfiles, validateAgentProfileReferences } from "../core/agent-profile";
34
- import { loadBundle, toRefList, walkFiles } from "../core/bundle";
35
- import {
36
- type CheckFinding,
37
- type CheckInputFile,
38
- type CheckReport,
39
- checkBundle,
40
- classifyAddress,
41
- collectExternalLinks,
42
- type ExternalLink,
43
- isAddressLiteral,
44
- reconcileDriftFindings,
45
- tallySeverity,
46
- } from "../core/check";
47
- import { type Concept, parseConcept, tryReadFrontmatter } from "../core/concept";
48
- import { loadProfile, type Profile } from "../core/profile";
49
- import { DOCS_DIR } from "../core/scaffold";
50
- import {
51
- ANSI,
52
- EXIT_CODES,
53
- EXIT_OK,
54
- ioError,
55
- LoreError,
56
- paint,
57
- stripAnsiAndControls,
58
- WarningCollector,
59
- type Writer,
60
- } from "../errors";
61
- import { emit, type OutputContext, type Renderable } from "../output";
62
- import { parseCommandArgs } from "./args";
63
- import { canonicalIdentity, readSource } from "./discover";
64
- import { dedupeTaskIds, defaultAdapter } from "./link";
65
- import {
66
- gatherReconciliation,
67
- linkedConcepts,
68
- mapWithConcurrency,
69
- type ReconcileConfig,
70
- resolveReconcileConfig,
71
- resolveTaskDetails,
72
- type TaskResolution,
73
- } from "./reconcile-shared";
74
-
75
- /**
76
- * A minimal fetch — the global `fetch` satisfies it, and tests inject a deterministic fake.
77
- * `redirect` (when passed `"manual"`) asks the implementation NOT to auto-follow a 3xx; the
78
- * response then carries `location` (the raw `Location` header, if any) so the caller — LORE-71's
79
- * SSRF guard — can re-validate the redirect's destination before deciding whether to follow it
80
- * itself. Both are optional so every pre-existing fake (which only ever returns `{ ok, status }`
81
- * and never redirects) still satisfies the type unchanged.
82
- */
83
- export type FetchLike = (
84
- url: string,
85
- init?: { signal?: AbortSignal; redirect?: "manual" },
86
- ) => Promise<{ ok: boolean; status: number; location?: string | null }>;
87
-
88
- /**
89
- * Resolves a hostname to its IP address(es) — the injectable DNS seam LORE-71's SSRF guard uses
90
- * to classify a URL's REAL destination before fetching it (a hostname alone reveals nothing; an
91
- * attacker-controlled DNS record is exactly what needs checking). Defaults to real `node:dns`;
92
- * tests inject a fake so a "this hostname resolves to a blocked address" case needs no live DNS.
93
- */
94
- export type ResolveHost = (hostname: string) => Promise<readonly string[]>;
95
-
96
- /** Options for {@link runCheck}; `root`, the streams, and `fetch` are injectable for tests. */
97
- export interface CheckOptions {
98
- /** The repo root the bundle (and any relative target paths) resolve against. */
99
- root: string;
100
- /** The resolved output mode/color (from `output.ts`). */
101
- output: OutputContext;
102
- /** The command's normalized positional + flag tokens from Commander. */
103
- args: readonly string[];
104
- /** stdout sink; defaults to `process.stdout`. */
105
- stdout?: Writer;
106
- /** stderr sink for discovery advisories (a skipped symlink, an unreadable sub-directory); defaults to `process.stderr`. */
107
- stderr?: Writer;
108
- /** The fetch used by `--external` liveness; defaults to the global `fetch`. Injected in tests so they touch no network. */
109
- fetch?: FetchLike;
110
- /** DNS resolution for `--external` liveness's SSRF guard (LORE-71); defaults to real `node:dns`. Injected in tests so a blocked-hostname case needs no live DNS. */
111
- resolveHost?: ResolveHost;
112
- /** The Backlog adapter for status/managed-block reconciliation; defaults to the real `backlog` binary on PATH. Only constructed when at least one discovered concept links a task. */
113
- adapter?: BacklogAdapter;
114
- }
115
-
116
- /** The parsed form of `lore check`'s arguments. */
117
- interface CheckArgs {
118
- /** Explicit bundle roots to check; empty means the default `docs/` bundle. */
119
- paths: string[];
120
- /** `--strict`: treat any portability warning as a failure for the exit code. */
121
- strict: boolean;
122
- /** `--external`: opt into non-deterministic external-URL liveness (advisory only; never gates). */
123
- external: boolean;
124
- }
125
-
126
- /**
127
- * Run `lore check`: parse the arguments, discover and read the bundle's markdown, check it,
128
- * emit the `check.report`, and return the exit code — `0` when coherent (portability warnings
129
- * alone are advisory), `6` when any broken internal link/anchor exists (or any warning under
130
- * `--strict`). A bad flag throws a `usage` {@link LoreError} (exit `2`); an unreadable bundle
131
- * root a `not_found`/`denied`.
132
- *
133
- * **Return type.** The deterministic gate is synchronous: when nothing discovered links a Backlog
134
- * task and `--external` is absent, `runCheck` returns a `number` directly (the contract every
135
- * existing caller and test relies on). Otherwise it returns a `Promise<number>` — the same gate
136
- * exit code, resolved only after status/managed-block reconciliation (LORE-27) and/or the
137
- * **non-deterministic** liveness probe have finished. Liveness results **never** change the exit
138
- * code (not even under `--strict`); reconciliation drift **is** part of the gate (like broken
139
- * links/anchors, always an error). The network IO for liveness lives here, never in core
140
- * (ADR-0014). All gate throws (`usage`/`not_found`/`denied`/`validation`) happen either on the
141
- * synchronous path or propagate through the returned promise's rejection, so the router's one
142
- * error seam still catches them either way.
143
- *
144
- * Discovery advisories (a `.md` skipped behind a symlink, an unreadable sub-directory) are flushed
145
- * to stderr immediately after discovery — before the scan phase even runs — so they survive even
146
- * when `checkBundles` throws in the scan phase or reconciliation later rejects (LORE-191); never
147
- * silently swallowed, but, like every advisory, do not change the exit code. The flush itself runs
148
- * in a `finally` around `collectBundles` (LORE-197): `collectBundles` walks its bundle roots
149
- * SEQUENTIALLY, feeding `advisories` as it goes, and a LATER root's `not_found`/`denied`/`usage`
150
- * throw (or a late `readSource` throw) must not discard an EARLIER root's already-collected
151
- * advisories — the `finally` guarantees the one flush still runs before that throw propagates.
152
- */
153
- export function runCheck(options: CheckOptions): number | Promise<number> {
154
- const parsed = parseCheckArgs(options.args);
155
- // Loaded once, up front — mirrors `context.ts`/`graph.ts`'s own LORE-84 precedent of failing
156
- // loud on a malformed profile before any other work runs. `collectBundles`'s file discovery
157
- // below is a single repo (`options.root`) with possibly several bundle DIRECTORIES within it
158
- // (`--external`/multi-path), never several separate repos with their own profiles, so one load
159
- // covers every root this command can ever scan (LORE-89).
160
- const profile = loadProfile({ root: options.root });
161
- const agentProfiles = loadAgentProfiles(options.root);
162
- if (agentProfiles.profiles.size > 0) {
163
- validateAgentProfileReferences(agentProfiles, loadBundle(join(options.root, DOCS_DIR), { profile }));
164
- }
165
- const advisories = new WarningCollector();
166
- let bundles: Bundle[];
167
- try {
168
- bundles = collectBundles(options.root, parsed.paths, advisories);
169
- } finally {
170
- // Every discovery-time advisory that COULD be collected by now already is — `collectBundles`'s
171
- // own walk is the only source that ever feeds `advisories` (nothing past this point adds to
172
- // it) — so flush once, right here, in a `finally` so it runs on BOTH the return path (before
173
- // the scan phase below even starts: `checkBundles` is NOT guaranteed non-throwing — post-
174
- // LORE-138 a real input, e.g. a `---toml` frontmatter fence, makes `bodyText` re-throw a plain
175
- // `Error` out of the scan — and reconciliation further below can reject too) AND the throw path
176
- // (a LATER bundle root's `not_found`/`denied`/`usage` failure inside `collectBundles` itself
177
- // must not silently discard an EARLIER root's already-collected advisories — LORE-197, the one
178
- // gap LORE-191 left open one grain earlier than the scan phase). `advisories.flush` is
179
- // non-draining, so this must be the ONLY flush site for `advisories` — flushing it again later
180
- // would re-emit every already-flushed line. This means stderr (advisories) is written BEFORE
181
- // stdout (the report) — the opposite of this command's pre-LORE-27 order — deliberately: mirrors
182
- // `sync.ts`'s own precedent (it flushes right after `loadBundle`, well before its own final
183
- // `emit`), and "advisories survive a later throw" is a stronger guarantee to keep than "stdout
184
- // precedes stderr" (already an unreliable assumption for any CLI merging two independently-
185
- // buffered streams).
186
- advisories.flush({ color: options.output.color, stderr: options.stderr });
187
- }
188
-
189
- const baseReport = checkBundles(bundles);
190
-
191
- // Reuse the SAME already-read files (no second directory walk, no second read) to find which are
192
- // `tasks:`-linked concepts per bundle root, so status/managed-block reconciliation can run the same
193
- // way the link/anchor pass already does. `tryConceptsForBundle` NEVER throws — a bundle root's own
194
- // scan failure (a malformed-AND-`tasks:`-linked concept) is carried as `error` on its own result,
195
- // isolated from every OTHER root's scan, mirroring how the async drift computation below already
196
- // isolates root failures from each other (an earlier version of this scan used a bare `.map()` that
197
- // let one root's throw abort every other root's scan too, discarding drift that was never even
198
- // computed — LORE-27 round 9). Cheap to check without any Backlog IO: `linkedConcepts` is pure.
199
- const conceptBundleResults = bundles.map((bundle) => tryConceptsForBundle(bundle, profile));
200
- const needsReconciliation = conceptBundleResults.some(
201
- (result) => result.error !== null || linkedConcepts(result.concepts).length > 0,
202
- );
203
-
204
- if (!needsReconciliation && !parsed.external) {
205
- emit(reportRenderable(baseReport), options.output, options.stdout);
206
- return exitFor(baseReport, parsed.strict);
207
- }
208
-
209
- const adapter = needsReconciliation ? (options.adapter ?? defaultAdapter(options.root)) : undefined;
210
- const multi = bundles.length > 1;
211
- // `driftPromise` never REJECTS — a per-root failure (a scan failure, a missing linked task, a
212
- // malformed managed block, a bad status-flow config) is carried as `DriftResult.error` instead, so
213
- // whatever DID resolve (this root's or another root's drift findings, and the already-computed
214
- // `baseReport`) is never silently discarded the way a rejected promise would discard it. `error` is
215
- // still re-thrown below, after emitting — it's a real gate failure, not best-effort like liveness.
216
- const driftPromise: Promise<DriftResult> = needsReconciliation
217
- ? computeDriftFindings(options.root, conceptBundleResults, multi, adapter as BacklogAdapter)
218
- : Promise.resolve({ findings: [], error: null });
219
-
220
- if (!parsed.external) {
221
- return driftPromise.then(({ findings, error }) => {
222
- // `complete: false` whenever this root's (or another root's) reconciliation errored mid-run
223
- // (LORE-112) — the ONLY signal in the emitted report that distinguishes a partial-failure run
224
- // from a genuinely clean one, since a short-circuited failure can still leave `errorCount === 0`
225
- // (the failure happened before any finding was ever produced for it).
226
- const report = { ...mergeFindings(baseReport, findings), complete: error === null };
227
- emit(reportRenderable(report), options.output, options.stdout);
228
- if (error !== null) {
229
- throw error;
230
- }
231
- return exitFor(report, parsed.strict);
232
- });
233
- }
234
-
235
- // Opt-in liveness: probe every external http(s) URL off the (already-fixed) gate path, fold the
236
- // results into the report as advisory `external-link` findings, then emit once so the `--json`
237
- // envelope carries gate + liveness together. The exit code stays the gate's, untouched by liveness.
238
- // Kicked off CONCURRENTLY with `driftPromise` (a `Promise.all`, not a `.then` chain) — the two are
239
- // fully independent I/O (a Backlog subprocess round-trip vs. HTTP fetches over already-read bytes),
240
- // so serializing them would needlessly inflate `--external`'s wall-clock time on any bundle that
241
- // also reconciles.
242
- const worklist = bundles.flatMap((bundle) => prefixLinks(collectExternalLinks(bundle.files), bundle.label, multi));
243
- const livenessPromise = probeLiveness(
244
- worklist,
245
- options.fetch ?? defaultFetch,
246
- options.resolveHost ?? defaultResolveHost,
247
- ).then(
248
- (findings): LivenessResult => ({ ok: true, findings }),
249
- (err: unknown): LivenessResult => ({ ok: false, err }),
250
- );
251
- return Promise.all([driftPromise, livenessPromise]).then(([{ findings, error }, liveness]) => {
252
- // Same `complete: false` rule as the non-`--external` path above (LORE-112) — liveness's own
253
- // best-effort outcome never affects this; only `driftPromise`'s error does.
254
- const report = { ...mergeFindings(baseReport, findings), complete: error === null };
255
- if (liveness.ok) {
256
- emit(reportRenderable({ ...report, externalFindings: liveness.findings }), options.output, options.stdout);
257
- } else {
258
- // Liveness is best-effort: a probe failure becomes a finding, never a thrown error, so this
259
- // only fires on an unexpected fault. Surface it through the same seam and keep the gate code.
260
- emit(reportRenderable(report), options.output, options.stdout);
261
- // A fresh collector for this one late advisory: the main `advisories` was already flushed
262
- // above (non-draining — reusing it here would re-emit every earlier discovery advisory too).
263
- const late = new WarningCollector();
264
- late.add(
265
- `external-link liveness aborted: ${liveness.err instanceof Error ? liveness.err.message : String(liveness.err)}`,
266
- );
267
- late.flush({ color: options.output.color, stderr: options.stderr });
268
- }
269
- if (error !== null) {
270
- throw error;
271
- }
272
- return exitFor(report, parsed.strict);
273
- });
274
- }
275
-
276
- /** The best-effort outcome of the opt-in `--external` liveness probe — never a rejection, so it composes with `Promise.all` without losing `driftPromise`'s own result. */
277
- type LivenessResult =
278
- | { readonly ok: true; readonly findings: CheckFinding[] }
279
- | { readonly ok: false; readonly err: unknown };
280
-
281
- /** One bundle root's concept-scan outcome: the `tasks:`-linked concepts it found, or its own scan failure. */
282
- interface ConceptBundleResult {
283
- readonly bundle: Bundle;
284
- readonly concepts: Concept[];
285
- readonly error: unknown | null;
286
- }
287
-
288
- /**
289
- * Best-effort, per-bundle-root parse of already-read files into `tasks:`-linked {@link Concept}s,
290
- * for deciding reconciliation eligibility. NEVER throws — a scan failure is carried as `error`
291
- * instead, isolated from every OTHER bundle root's scan and from the already-computed `baseReport`,
292
- * which must survive regardless of whether any one root's scan fails ({@link computeDriftFindings}
293
- * folds this the same way it folds an async per-root failure). Isolated PER FILE too, the same way:
294
- * each file's own parse is its own try/catch, so a LATER file's failure never discards concepts
295
- * already collected from EARLIER files in the same root (an earlier version of this function shared
296
- * one try/catch across the whole loop, so any failure silently dropped every already-collected
297
- * concept for that root, not just the one that actually failed — LORE-27 round 10).
298
- *
299
- * Each file's frontmatter is first PEEKED via the cheap, non-validating {@link tryReadFrontmatter}
300
- * (no Zod schema check) — a file with no `tasks:` link (the vast majority of any bundle: ADRs,
301
- * specs, index/log) is never reconciliation-relevant regardless of whether its frontmatter would
302
- * otherwise validate, so it is skipped WITHOUT paying for full parse+validation at all.
303
- *
304
- * Only a file that DOES declare `tasks:` is fully parsed+validated ({@link parseConcept}, which
305
- * throws loud on a malformed mapping) — `lore sync` would refuse to touch that exact file too, so
306
- * silently treating it as un-linked would be a real false-negative against `check`'s own drift gate,
307
- * the one case where `check` really would otherwise disagree with what `sync` does. An
308
- * unparseable-YAML file (`tryReadFrontmatter` itself throws — there is no mapping to peek a `tasks:`
309
- * field from, so it cannot be assumed innocent) is treated the same as "declares `tasks:`".
310
- *
311
- * `parseConcept` is given the project's own `profile` (LORE-89) so this scan validates against the
312
- * SAME schema `lore query`/`validate`/`sync` already do (`loadBundle`, LORE-84) — before this fix it
313
- * always fell back to the built-in default profile, so a project-defined required field could pass
314
- * `check` silently while `validate`/`query`/`sync` correctly rejected the identical file, the exact
315
- * three-way disagreement ADR-0007 designed `check` to never have with the rest of the toolchain.
316
- */
317
- function tryConceptsForBundle(bundle: Bundle, profile: Profile): ConceptBundleResult {
318
- const concepts: Concept[] = [];
319
- let error: unknown | null = null;
320
- for (const file of bundle.files) {
321
- try {
322
- const raw = tryReadFrontmatter(file.path, file.raw);
323
- if (raw === null || toRefList(raw.tasks).length === 0) {
324
- continue;
325
- }
326
- concepts.push(parseConcept(file.path, file.raw, { profile }));
327
- } catch (err) {
328
- if (error === null) {
329
- error = err; // first, in file order; keep scanning so later files' concepts still count too
330
- }
331
- }
332
- }
333
- return { bundle, concepts, error };
334
- }
335
-
336
- /** The gate's exit code from a {@link CheckReport}: `6` on any error, or any warning under `--strict`. */
337
- function exitFor(report: CheckReport, strict: boolean): number {
338
- return report.errorCount > 0 || (strict && report.warningCount > 0) ? EXIT_CODES.validation : EXIT_OK;
339
- }
340
-
341
- /** Append findings (bundle-label-prefixed by the caller already) into a {@link CheckReport}'s counts. */
342
- function mergeFindings(report: CheckReport, extra: readonly CheckFinding[]): CheckReport {
343
- if (extra.length === 0) {
344
- return report;
345
- }
346
- const added = tallySeverity(extra);
347
- return {
348
- ...report,
349
- findings: [...report.findings, ...extra],
350
- errorCount: report.errorCount + added.errorCount,
351
- warningCount: report.warningCount + added.warningCount,
352
- };
353
- }
354
-
355
- // ── Status + managed-block reconciliation drift (LORE-27) ────────────────────────
356
-
357
- /**
358
- * The outcome of {@link computeDriftFindings}: every drift finding that WAS successfully computed
359
- * (across every bundle root, regardless of whether another root failed), plus the first failure (if
360
- * any), in bundle-argument order — never wall-clock/settlement order, so which root's error is
361
- * reported is deterministic and reproducible across runs, not a race. Deliberately never a rejected
362
- * promise: `findings` must survive a `error !== null` outcome so the caller can still emit the full
363
- * report before propagating the failure (a `Promise.all` rejection would discard every OTHER root's
364
- * already-resolved findings, and the caller's already-computed `baseReport`, silently).
365
- */
366
- interface DriftResult {
367
- readonly findings: CheckFinding[];
368
- readonly error: unknown | null;
369
- }
370
-
371
- /**
372
- * The pooled reconciliation inputs {@link computeDriftFindings} resolves once for the whole run
373
- * (LORE-50). `config`/`details`/`configError` are all `undefined`/`null` when no bundle root has any
374
- * eligible concept — nothing to pool, and `computeDriftFindings`'s per-root calls fall through to
375
- * `gatherReconciliation`'s own empty-eligible short-circuit, same as if pooling had never run.
376
- *
377
- * `configError` is deliberately NOT treated as an overall failure here — a config-validation throw
378
- * is caught (config resolution is synchronous) and carried as data, never left to reject this
379
- * `resolveSharedReconciliation` call, so `computeDriftFindings` can still run every bundle root's OWN
380
- * per-root pass (a root with no eligible concept never even sees `configError`; a root WITH eligible
381
- * concepts still fails at the exact same fail-fast point a fresh per-root config read would have hit,
382
- * via `gatherReconciliation`'s `configErrorOverride`). Short-circuiting `computeDriftFindings` itself
383
- * on a bare `configError !== null` (an earlier version of this pooling did exactly that) would
384
- * discard every root's own already-known concept-scan error in favor of the shared config failure,
385
- * breaking the "first error, in bundle-argument order" contract {@link DriftResult} documents — a
386
- * root whose OWN scan failed must still report ITS OWN error first, same as before pooling existed.
387
- * Never `undefined` when non-null: normalized the same way {@link resolveTaskDetails} normalizes a
388
- * rejection reason, so it can never collide with the "no error" sentinel downstream.
389
- */
390
- interface PooledReconciliation {
391
- readonly config: ReconcileConfig | undefined;
392
- readonly details: ReadonlyMap<string, TaskResolution> | undefined;
393
- readonly configError: Error | null;
394
- }
395
-
396
- /**
397
- * Pool every bundle root's eligible concepts to resolve the status-flow config and the union of
398
- * linked task ids each exactly once, regardless of how many roots are checked. Concepts already
399
- * carrying their own scan `error` are still included via `bundle.concepts` (whatever the scan DID
400
- * successfully collect before failing) — the same partial-collection contract `tryConceptsForBundle`
401
- * already documents, so pooling never under-resolves an id a root's surviving concepts still need.
402
- *
403
- * Task-id resolution is skipped entirely when config resolution fails: every root with an eligible
404
- * concept would fail at the config step before ever reaching a task id anyway (`gatherReconciliation`'s
405
- * own fail-fast order), so attempting it here would only pay for Backlog IO whose result can never
406
- * be used.
407
- *
408
- * {@link resolveTaskDetails} is documented to never throw, but is called through a caller-supplied
409
- * `adapter` this module does not control — a wrapper/decorator adapter that throws SYNCHRONOUSLY
410
- * (rather than rejecting) would otherwise reject this whole pooling step, and from there
411
- * `computeDriftFindings` itself, silently discarding the already-computed link/anchor report on
412
- * whatever emits it (`runCheck`'s `driftPromise.then` has no `.catch`). Guarded the same way the
413
- * config resolution above is: on failure, `details` stays `undefined` and every bundle root simply
414
- * falls back to resolving its own ids independently (paying the per-root cost this pooling exists to
415
- * avoid, but ONLY in this unexpected-failure case) — `computeDriftFindings`'s own per-root
416
- * `Promise.allSettled` loop already isolates whatever happens next, same as before pooling existed.
417
- */
418
- async function resolveSharedReconciliation(
419
- root: string,
420
- conceptBundleResults: readonly ConceptBundleResult[],
421
- adapter: BacklogAdapter,
422
- ): Promise<PooledReconciliation> {
423
- const allEligible = conceptBundleResults.flatMap(({ concepts }) => linkedConcepts(concepts));
424
- if (allEligible.length === 0) {
425
- return { config: undefined, details: undefined, configError: null };
426
- }
427
-
428
- let config: ReconcileConfig;
429
- try {
430
- config = resolveReconcileConfig(root);
431
- } catch (err) {
432
- return { config: undefined, details: undefined, configError: err instanceof Error ? err : new Error(String(err)) };
433
- }
434
-
435
- const allTaskIds = dedupeTaskIds(allEligible.flatMap((e) => e.linked));
436
- let details: ReadonlyMap<string, TaskResolution> | undefined;
437
- try {
438
- details = await resolveTaskDetails(adapter, allTaskIds);
439
- } catch {
440
- details = undefined;
441
- }
442
- return { config, details, configError: null };
443
- }
444
-
445
- /**
446
- * The status/managed-block drift findings for every `tasks:`-linked concept across every bundle
447
- * root, reusing the same `commands/reconcile-shared.ts` gather `lore sync` writes with. Every root
448
- * is resolved concurrently (`Promise.allSettled`, mirroring how the raw-file passes already treat
449
- * roots as independent), sharing one `adapter` instance so its capability probe runs at most once
450
- * regardless of how many roots are checked.
451
- *
452
- * The status-flow config and every distinct linked task id are each resolved AT MOST ONCE across
453
- * the WHOLE run (LORE-50), not once per bundle root: before the per-root loop, every root's eligible
454
- * concepts are pooled ({@link resolveSharedReconciliation}) to compute the config once and the union
455
- * of linked ids once, and both are handed to every root's own {@link driftFindingsForBundle} call as
456
- * overrides. This does not weaken per-root isolation: a shared config failure fails every root
457
- * identically to before (every root reads the SAME file, so today it already fails the same way N
458
- * times); a shared task-id failure only fails the root(s) whose OWN linked concepts reference that id
459
- * ({@link gatherReconciliation}'s `detailsOverride` still throws per-call, scoped to that call's own
460
- * ids) — a root that never links the failing id is untouched, exactly as if it had resolved that id
461
- * itself. The pooling step itself is synchronous-safe: a config-validation throw (or an unexpected
462
- * pooled task-resolution failure) is caught inside `resolveSharedReconciliation`, never left to reject
463
- * this function outright, so `computeDriftFindings` keeps its own "never rejects" contract below.
464
- *
465
- * A root whose synchronous concept-scan already failed ({@link ConceptBundleResult.error}) still has
466
- * `driftFindingsForBundle` run over whatever concepts it DID successfully collect before the failure
467
- * (never skipped outright — an earlier version treated any scan error as "reject this whole root,
468
- * process nothing," discarding real drift on concepts the scan had already parsed fine before hitting
469
- * the one that failed). The root's own scan error is then combined with whatever
470
- * `driftFindingsForBundle` found, preferring the scan error when both exist (it is the logically
471
- * earlier problem — some of this root's concepts were never even examined because of it).
472
- */
473
- async function computeDriftFindings(
474
- root: string,
475
- conceptBundleResults: readonly ConceptBundleResult[],
476
- multi: boolean,
477
- adapter: BacklogAdapter,
478
- ): Promise<DriftResult> {
479
- const pooled = await resolveSharedReconciliation(root, conceptBundleResults, adapter);
480
-
481
- const settled = await Promise.allSettled(
482
- conceptBundleResults.map(async ({ bundle, concepts, error }) => {
483
- const drift = await driftFindingsForBundle(root, bundle, concepts, multi, adapter, pooled);
484
- return { findings: drift.findings, error: error ?? drift.error };
485
- }),
486
- );
487
- const findings: CheckFinding[] = [];
488
- let error: unknown | null = null;
489
- for (const result of settled) {
490
- if (result.status === "fulfilled") {
491
- findings.push(...result.value.findings);
492
- if (error === null) {
493
- error = result.value.error; // this root's own scan error, or a per-concept reconciliation
494
- // failure within it (still findings alongside it either way)
495
- }
496
- } else if (error === null) {
497
- error = result.reason; // this root's own scan failed, or gatherReconciliation itself rejected for
498
- // it (a missing task, a bad status-flow config) -- that root's findings are unrecoverable at this
499
- // granularity, same as before; every OTHER root's findings (and this one's own
500
- // reconcileDriftFindings partial results, when IT throws instead) are still preserved above.
501
- }
502
- }
503
- return { findings, error };
504
- }
505
-
506
- /**
507
- * The drift findings for one bundle root, looking up each target's original bytes from the
508
- * already-read `bundle.files` (no re-read). Each concept's own {@link reconcileDriftFindings} call is
509
- * isolated: a later concept's malformed managed-block markers (or any other per-concept judgment
510
- * failure) does not discard findings already computed for EARLIER concepts in this same root — only
511
- * the first such error (in `targets` order) is carried, the rest are dropped as unreachable once a
512
- * fail-fast is already pending, mirroring {@link computeDriftFindings}'s own cross-root policy at
513
- * this finer, within-root grain.
514
- *
515
- * `pooled` is {@link computeDriftFindings}'s pooled, resolved-once-per-run input (LORE-50); its three
516
- * fields are passed straight through to {@link gatherReconciliation} as its overrides, so this root
517
- * never re-reads the config file or re-fetches a task id another root already resolved. When
518
- * `pooled.configError` is set (and this root has any eligible concept at all), `gatherReconciliation`
519
- * throws it immediately — the same fail-fast point a fresh config read would have hit for this root,
520
- * without a second read; a root with no eligible concept never reaches that check regardless.
521
- */
522
- export async function driftFindingsForBundle(
523
- root: string,
524
- bundle: Bundle,
525
- concepts: readonly Concept[],
526
- multi: boolean,
527
- adapter: BacklogAdapter,
528
- pooled: PooledReconciliation,
529
- ): Promise<{ findings: CheckFinding[]; error: unknown | null }> {
530
- const targets = await gatherReconciliation(
531
- root,
532
- concepts,
533
- adapter,
534
- pooled.config,
535
- pooled.details,
536
- pooled.configError ?? undefined,
537
- );
538
- const rawByPath = new Map(bundle.files.map((f) => [f.path, f.raw]));
539
- const fixable = isDocsRoot(bundle.label);
540
- // Built from the SAME normalized form `isDocsRoot` compared against `DOCS_DIR` (LORE-113) — not
541
- // the raw `bundle.label` — so a non-canonical-but-equivalent label (a trailing slash, a
542
- // backslash, or a different case) can never yield a `docPath` at odds with the `fixable` verdict
543
- // just computed from that identical label, two treatments of one string diverging within this
544
- // same function.
545
- const normalizedLabel = normalizeBundleLabel(bundle.label);
546
- const findings: CheckFinding[] = [];
547
- let error: unknown | null = null;
548
- for (const { concept, newStatus, rows } of targets) {
549
- const docPath = `${normalizedLabel}/${concept.path}`;
550
- const original = rawByPath.get(concept.path) as string;
551
- try {
552
- const drift = reconcileDriftFindings({
553
- path: concept.path,
554
- currentStatus: concept.frontmatter.status,
555
- newStatus,
556
- original,
557
- rows,
558
- docPath,
559
- fixable,
560
- });
561
- for (const finding of drift) {
562
- findings.push(prefixFinding(finding, bundle.label, multi));
563
- }
564
- } catch (err) {
565
- if (error === null) {
566
- error = err; // first, in `targets` order; keep processing the rest so THEIR findings survive too
567
- }
568
- }
569
- }
570
- return { findings, error };
571
- }
572
-
573
- /** Prefix a finding's `file` with its bundle-root label in multi-root mode; unchanged for a single root. */
574
- function prefixFinding<T extends { readonly file: string }>(finding: T, label: string, multi: boolean): T {
575
- return multi ? { ...finding, file: `${label}/${finding.file}` } : finding;
576
- }
577
-
578
- /**
579
- * Canonicalize a bundle root label so an equivalent-but-non-canonical spelling (`./docs`, a
580
- * trailing slash OR backslash, a different case) collapses to the same string as any other
581
- * spelling of the identical root: backslashes to forward slashes FIRST (`posix.normalize` only
582
- * recognizes `/` as a separator, so a Windows-idiom trailing `docs\` would otherwise survive
583
- * untouched), redundant segments collapsed, any trailing slash(es) stripped, then lowercased — on
584
- * the case-insensitive filesystems most local dev happens on (macOS, Windows), a differently-cased
585
- * spelling (`Docs`) resolves to the identical directory as `docs`, so treating it as a *different*
586
- * root would be the misleading answer, not the careful one.
587
- *
588
- * Shared by {@link isDocsRoot}'s comparison against `DOCS_DIR` and `driftFindingsForBundle`'s
589
- * `docPath` construction (LORE-113) — both read `bundle.label`, and must agree on what it
590
- * canonicalizes to, or `docPath` could embed a spelling `isDocsRoot`/`fixable` disagrees with.
591
- */
592
- function normalizeBundleLabel(label: string): string {
593
- return posix.normalize(label.replace(/\\/g, "/")).replace(/\/+$/, "").toLowerCase();
594
- }
595
-
596
- /**
597
- * Whether a bundle root (as the user named it — or the default) IS the `docs/` bundle `lore sync`
598
- * operates on, canonicalized ({@link normalizeBundleLabel}) so an equivalent-but-non-canonical
599
- * spelling is still recognized — a literal string-prefix match against a compound `docPath` would
600
- * miss these and silently omit `reconcileDriftFindings`' "run `lore sync`" hint for a root it
601
- * actually can fix.
602
- */
603
- export function isDocsRoot(label: string): boolean {
604
- return normalizeBundleLabel(label) === DOCS_DIR.toLowerCase();
605
- }
606
-
607
- // ── Argument parsing ───────────────────────────────────────────────────────────
608
-
609
- /**
610
- * Parse `check`'s tokens into target bundle roots and its flags. Commander has already
611
- * stripped lore's global flags, so anything `--`-prefixed here is a command flag: an
612
- * unrecognized one is a `usage` error. A `--` ends option parsing so a path may begin with
613
- * `-`.
614
- */
615
- function parseCheckArgs(args: readonly string[]): CheckArgs {
616
- const parsed = parseCommandArgs(args, "check");
617
- return {
618
- paths: parsed.positionals,
619
- strict: parsed.flags.has("strict"),
620
- external: parsed.flags.has("external"),
621
- };
622
- }
623
-
624
- // ── File discovery ─────────────────────────────────────────────────────────────
625
-
626
- /** One discovered bundle root: the path the user named (for display), its files, and filename findings. */
627
- interface Bundle {
628
- /** The bundle root as given (e.g. `docs`), used to disambiguate findings across roots. */
629
- readonly label: string;
630
- /** The root's `.md` files, each keyed by its **bundle-root-relative** path. */
631
- readonly files: CheckInputFile[];
632
- /** Warn-only filename-portability findings for this root (leading `_`, `.mdx`), independent of content. */
633
- readonly filenameFindings: readonly CheckFinding[];
634
- }
635
-
636
- /**
637
- * Discover and read each bundle root's markdown as an **independent** {@link Bundle} — files
638
- * keyed by their path **within that root**, so a link resolves against its own bundle the same
639
- * way `loadBundle` derives ids. With no explicit paths the single bundle is `docs/`; otherwise
640
- * each path names a bundle root (duplicate roots are de-duplicated). The walk reuses the same
641
- * robust traversal the bundle loader uses (sorted, symlink-safe; its skipped-symlink/
642
- * unreadable-subdir advisories flow to `warnings`), widened to `.md` **and** `.mdx` so the
643
- * filename-portability lint can see a stray `.mdx` — only the `.md` files are content-checked.
644
- *
645
- * Each root is a separate bundle with its **own id namespace** — so two roots that share a
646
- * relative path (e.g. a per-bundle `index.md`) never collide or shadow one another, and each is
647
- * checked in full. Cross-root links are out of scope, the same as any bundle-escaping link.
648
- *
649
- * Roots are de-duplicated by {@link canonicalIdentity} (the same realpath-fold `replace.ts`,
650
- * `validate.ts`, and `rename.ts` already dedup file paths with) — not by the raw joined string —
651
- * so a symlink alias or a case-variant spelling on a case-insensitive filesystem (`check docs
652
- * Docs`) that reaches the same physical directory is walked once, not twice. `canonicalIdentity`
653
- * swallows its own `realpath` failure and falls back to the path verbatim, so a **nonexistent**
654
- * root's identity is just its own joined path (never collides with anything else already seen)
655
- * and dedup never short-circuits {@link expandRoot}'s `not_found`/`denied` classification below.
656
- */
657
- function collectBundles(root: string, paths: readonly string[], warnings: WarningCollector): Bundle[] {
658
- const roots = paths.length > 0 ? paths : [DOCS_DIR];
659
- const bundles: Bundle[] = [];
660
- const seenRoots = new Set<string>();
661
- for (const bundleRoot of roots) {
662
- const absRoot = join(root, bundleRoot);
663
- const identity = canonicalIdentity(absRoot);
664
- if (seenRoots.has(identity)) {
665
- continue; // the same physical root named twice (directly, via symlink, or case) is one bundle
666
- }
667
- seenRoots.add(identity);
668
- const docFiles = expandRoot(absRoot, bundleRoot, warnings);
669
- const files = docFiles
670
- .filter(isMarkdownPath)
671
- .map((rel) => ({ path: rel, raw: readSource(join(absRoot, rel), `${bundleRoot}/${rel}`) }));
672
- const filenameFindings = docFiles.flatMap(filenameHazards);
673
- bundles.push({ label: bundleRoot, files, filenameFindings });
674
- }
675
- return bundles;
676
- }
677
-
678
- /**
679
- * Check every discovered {@link Bundle} independently and merge into one {@link CheckReport}. Each
680
- * bundle's deterministic findings and its warn-only filename findings are concatenated, and the
681
- * latter are folded into `warningCount`. When more than one root is checked, every finding's `file`
682
- * is prefixed with its bundle label so two roots' same-named files stay distinguishable; a single
683
- * bundle's findings keep the plain bundle-relative path.
684
- */
685
- function checkBundles(bundles: readonly Bundle[]): CheckReport {
686
- const multi = bundles.length > 1;
687
- const findings: CheckFinding[] = [];
688
- let fileCount = 0;
689
- for (const bundle of bundles) {
690
- const report = checkBundle(bundle.files);
691
- fileCount += report.fileCount;
692
- for (const finding of [...report.findings, ...bundle.filenameFindings]) {
693
- findings.push(prefixFinding(finding, bundle.label, multi));
694
- }
695
- }
696
- const { errorCount, warningCount } = tallySeverity(findings);
697
- // No reconciliation has run yet at this point (this is `baseReport`, the link/anchor + portability
698
- // pass only) — definitionally complete; `runCheck` is the only place that ever downgrades this to
699
- // `false`, once `driftPromise` resolves with a non-null error (LORE-112).
700
- return { findings, errorCount, warningCount, fileCount, complete: true };
701
- }
702
-
703
- /** Whether a discovered doc path is a content-checkable `.md` (lowercase, matching the bundle loader) rather than a `.mdx`. */
704
- function isMarkdownPath(rel: string): boolean {
705
- return /\.md$/.test(rel);
706
- }
707
-
708
- /**
709
- * The warn-only filename-portability findings for one discovered doc path (portable-markdown.md
710
- * §MDX): a leading-underscore **segment** (file or folder) that Docusaurus treats as an ignored
711
- * partial, and a `.mdx` extension (lore keeps docs as `.md` so they render on
712
- * GitHub/Obsidian/MkDocs). Both are advisory; the file is named relative to its bundle root.
713
- */
714
- function filenameHazards(rel: string): CheckFinding[] {
715
- const findings: CheckFinding[] = [];
716
- const underscored = rel.split("/").find((segment) => segment.startsWith("_"));
717
- if (underscored !== undefined) {
718
- findings.push({
719
- severity: "warning",
720
- rule: "portability",
721
- file: rel,
722
- message: `non-portable name "${underscored}"; Docusaurus ignores a leading-underscore file or folder as a partial — drop the leading "_"`,
723
- });
724
- }
725
- if (/\.mdx$/i.test(rel)) {
726
- findings.push({
727
- severity: "warning",
728
- rule: "portability",
729
- file: rel,
730
- message: `non-portable ".mdx" file; keep docs as .md so they render on GitHub/Obsidian/MkDocs (lore never writes .mdx)`,
731
- });
732
- }
733
- return findings;
734
- }
735
-
736
- /**
737
- * Expand one bundle root to the bundle-relative `.md`/`.mdx` paths it holds — a sorted, symlink-safe
738
- * walk (its advisories routed to `warnings`). A root that does not exist is a `not_found`
739
- * {@link LoreError} (exit `3`) naming the path the user gave, so a typo'd bundle root fails
740
- * loud rather than silently checking nothing; a permission failure is `denied` (exit `4`).
741
- *
742
- * A bundle root must be a **directory**: unlike per-file `lore validate`, `check` is a
743
- * whole-bundle gate — cross-link and anchor resolution only mean something across a bundle's
744
- * files — so a single-file path is a `usage` error pointing at the bundle directory instead.
745
- */
746
- function expandRoot(absRoot: string, given: string, warnings: WarningCollector): string[] {
747
- let stat: ReturnType<typeof statSync>;
748
- try {
749
- stat = statSync(absRoot);
750
- } catch (cause) {
751
- ioError(cause, {
752
- denied: { message: `cannot access "${given}"`, hint: "check filesystem permissions on that path" },
753
- notFound: { message: `path "${given}" does not exist`, hint: "check the path and try again" },
754
- input: { path: given },
755
- rethrowUnknown: true,
756
- });
757
- }
758
- if (!stat.isDirectory()) {
759
- throw usage(
760
- `"${given}" is not a directory`,
761
- "pass a bundle directory (e.g. docs/); `check` validates the whole bundle",
762
- );
763
- }
764
- return walkFiles(absRoot, warnings, isDocName);
765
- }
766
-
767
- /** Whether a file name is a discoverable doc: a lowercase `.md` (content) or any-case `.mdx` (filename lint only). */
768
- function isDocName(name: string): boolean {
769
- return /\.md$/.test(name) || /\.mdx$/i.test(name);
770
- }
771
-
772
- // ── External-URL liveness (opt-in, non-deterministic, never gates) ────────────────
773
-
774
- /** Per-request liveness timeout — a slow or hung host is reported, not waited on indefinitely. */
775
- const LIVENESS_TIMEOUT_MS = 5000;
776
- /** How many liveness probes run at once — bounded so a large bundle does not open a socket per URL. */
777
- const LIVENESS_CONCURRENCY = 8;
778
- /** Redirects a single URL may FOLLOW before the probe gives up (so up to `MAX_REDIRECTS + 1` fetches total: the original request plus this many hops) — bounds a malicious/misconfigured redirect chain, mirroring browsers' own conservative caps. */
779
- const MAX_REDIRECTS = 10;
780
- /**
781
- * The most DISTINCT URLs a single `--external` pass will actually probe. `LIVENESS_CONCURRENCY`
782
- * and `LIVENESS_TIMEOUT_MS` only bound how FAST the worklist drains, not how BIG it may be — a
783
- * bundle with tens of thousands of external links would still enqueue and probe every one of
784
- * them, so the whole pass's wall-clock time (and open-socket count) would scale unboundedly with
785
- * the bundle's size. URLs beyond this cap are never probed; each is instead reported as its own
786
- * advisory `external-link` finding ({@link probeLiveness}) so an oversized worklist degrades
787
- * visibly rather than just running for an unbounded amount of time.
788
- */
789
- const LIVENESS_MAX_URLS = 500;
790
-
791
- /**
792
- * The real network probe: the global `fetch`, always requesting manual redirect handling (never
793
- * `"follow"`) so LORE-71's SSRF guard in {@link probeOne} sees — and re-validates — every hop
794
- * itself, rather than letting `fetch()` silently chase a redirect to a blocked destination.
795
- */
796
- const defaultFetch: FetchLike = async (url, init) => {
797
- const response = await fetch(url, { signal: init?.signal, redirect: "manual" });
798
- const result = { ok: response.ok, status: response.status, location: response.headers.get("location") };
799
- // Release the underlying socket WITHOUT reading it, once headers/status/location are captured.
800
- // This function is called once per hop (`probeOne` re-invokes it for a followed 3xx redirect and
801
- // again for the terminal response), so this single, unconditional cancel covers BOTH paths —
802
- // there's no separate "redirect" vs. "terminal" branch here to duplicate it into. Left undrained,
803
- // each of up to `LIVENESS_MAX_URLS` responses per `--external` pass would retain its connection
804
- // until GC. Cancelling (not reading) preserves LORE-71's SSRF invariant (`probeOne`'s own docs,
805
- // above): the probe still never reads or reports a byte of body content.
806
- await response.body?.cancel().catch(() => {});
807
- return result;
808
- };
809
-
810
- /** The real DNS resolver: `node:dns`'s `lookup`, narrowed to the {@link ResolveHost} the SSRF guard needs (every address a hostname resolves to, not just the first). */
811
- const defaultResolveHost: ResolveHost = async (hostname) => {
812
- const records = await dnsLookup(hostname, { all: true });
813
- return records.map((record) => record.address);
814
- };
815
-
816
- /** Prefix each external link's `file` with its bundle label in multi-bundle mode, matching the gate findings. */
817
- function prefixLinks(links: ExternalLink[], label: string, multi: boolean): ExternalLink[] {
818
- return links.map((link) => prefixFinding(link, label, multi));
819
- }
820
-
821
- /**
822
- * Probe every external URL in the worklist for liveness and return one advisory `external-link`
823
- * warning per dead/unreachable/blocked/skipped occurrence (a live, allowed URL yields none). Each
824
- * **distinct** URL is probed at most once (deduplicated), under a bounded concurrency and a
825
- * per-request timeout, then the result is fanned back out to every `(file, url)` that referenced
826
- * it — so a URL linked from five files is probed once but reported five times. Pure-ish: all
827
- * network goes through the injected `fetchFn`/`resolveHost`, and a probe rejection becomes a
828
- * finding, never a throw.
829
- *
830
- * The worklist's distinct URLs are capped at {@link LIVENESS_MAX_URLS}: anything beyond the first
831
- * `LIVENESS_MAX_URLS` (in first-seen order) is never probed at all — it is reported directly as a
832
- * "was not probed: exceeded the liveness cap" finding, the same way a blocked or dead URL is
833
- * reported, so an oversized worklist is visible in the report rather than just making the whole
834
- * pass run for an unbounded amount of time.
835
- */
836
- async function probeLiveness(
837
- links: readonly ExternalLink[],
838
- fetchFn: FetchLike,
839
- resolveHost: ResolveHost,
840
- ): Promise<CheckFinding[]> {
841
- const uniqueUrls = [...new Set(links.map((link) => link.url))];
842
- const probedUrls = uniqueUrls.slice(0, LIVENESS_MAX_URLS);
843
- const skippedUrls = new Set(uniqueUrls.slice(LIVENESS_MAX_URLS));
844
- const failureByUrl = new Map<string, string | null>(); // null = alive; string = the failure reason
845
- await mapWithConcurrency(probedUrls, LIVENESS_CONCURRENCY, async (url) => {
846
- failureByUrl.set(url, await probeOne(url, fetchFn, resolveHost));
847
- });
848
- const findings: CheckFinding[] = [];
849
- for (const link of links) {
850
- const failure = skippedUrls.has(link.url)
851
- ? `was not probed: exceeded the liveness cap of ${LIVENESS_MAX_URLS} distinct URLs`
852
- : failureByUrl.get(link.url);
853
- if (failure != null) {
854
- findings.push({
855
- severity: "warning",
856
- rule: "external-link",
857
- file: link.file,
858
- message: `external link "${link.url}" ${failure}`,
859
- });
860
- }
861
- }
862
- return findings;
863
- }
864
-
865
- /**
866
- * Whether `url`'s destination (its hostname, resolved to every IP address it answers to) is
867
- * loopback/link-local/private/reserved (LORE-71's SSRF guard) — `null` when it's allowed. An
868
- * unparseable URL or a non-`http(s)` scheme is treated as blocked too (belt-and-braces: the
869
- * worklist only ever contains `http(s)` links per {@link collectExternalLinks}'s own filter, so
870
- * this should never actually fire, but the guard must never assume its only caller is honest). A
871
- * DNS-resolution failure is NOT blocked here — that's an ordinary liveness failure ({@link
872
- * probeOne}'s own catch already reports "is unreachable"), not a security refusal, so it
873
- * deliberately propagates rather than being swallowed into a false "blocked" verdict.
874
- *
875
- * **Known, accepted limitation** (confirmed by independent adversarial review, not a gap this
876
- * function can close on its own): this validates the address(es) `resolveHost` returns RIGHT NOW,
877
- * but the actual `fetch()` a hop later does its OWN independent DNS resolution when it connects —
878
- * a classic TOCTOU/DNS-rebinding window (a short-TTL record answering safely here and differently
879
- * at connect time). Accepted because `--external` is advisory-only CI liveness (never gates, and
880
- * `probeOne` never reads/reports a response body — only `status`/`location`), so the worst a
881
- * successful rebind achieves is a bare GET landing on an internal host, not data exfiltration
882
- * through the report. Fully closing this would need pinning the actual socket connection to the
883
- * validated address (e.g. a custom low-level dispatcher), which is a materially bigger change than
884
- * this task's own AC calls for.
885
- */
886
- async function blockedDestination(url: string, resolveHost: ResolveHost): Promise<string | null> {
887
- let parsed: URL;
888
- try {
889
- parsed = new URL(url);
890
- } catch {
891
- return "has an unparseable URL";
892
- }
893
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
894
- return `uses a disallowed scheme "${parsed.protocol}"`;
895
- }
896
- // `URL#hostname` keeps an IPv6 literal's brackets (e.g. "[::1]") — the package-backed literal
897
- // parser/classifier need the bare address. A literal IP is classified DIRECTLY, never through
898
- // `resolveHost`: it is not a name to resolve, and deferring to `resolveHost` here would let an
899
- // injected DNS fake that ignores its `hostname` argument (a reasonable "allow everything" test
900
- // default) accidentally paper over a literal blocked-IP URL — the guard's correctness must not
901
- // depend on `resolveHost` actually inspecting its input.
902
- const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
903
- const addresses = isAddressLiteral(hostname) ? [hostname] : await resolveHost(hostname);
904
- // The real `node:dns` resolver throws (never resolves empty) when a hostname has no records, so
905
- // this shouldn't fire against `defaultResolveHost` — but a custom `ResolveHost` returning `[]`
906
- // for "nothing found" (a plausible alternative contract) must fail CLOSED, not vacuously pass
907
- // the `for` loop below and let the fetch through unvalidated.
908
- if (addresses.length === 0) {
909
- return "resolved to no addresses";
910
- }
911
- for (const address of addresses) {
912
- const verdict = classifyAddress(address);
913
- if (verdict.blocked) {
914
- return `resolves to a blocked address (${address}, ${verdict.reason})`;
915
- }
916
- }
917
- return null;
918
- }
919
-
920
- /**
921
- * Probe one URL: `null` when it (or the live end of a redirect chain starting from it) answers
922
- * `2xx`, else a short reason (blocked, a non-OK status, a timeout, or an unreachable host).
923
- *
924
- * SSRF guard (LORE-71): EVERY hop — the original URL and each redirect target — is destination-
925
- * checked via {@link blockedDestination} BEFORE `fetchFn` is ever called for it. `fetchFn` is
926
- * always asked for manual redirect handling ({@link FetchLike}'s `redirect: "manual"`), so a 3xx
927
- * response's `Location` is inspected and re-validated by THIS function rather than silently
928
- * auto-followed by the underlying HTTP client (AC2) — a blocked redirect target stops the chain
929
- * immediately, with no request ever issued for it (AC1/AC3). Bounded to {@link MAX_REDIRECTS}
930
- * hops so a redirect loop can't hang the probe forever.
931
- */
932
- async function probeOne(url: string, fetchFn: FetchLike, resolveHost: ResolveHost): Promise<string | null> {
933
- let current = url;
934
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
935
- try {
936
- // The destination check and the fetch share ONE try/catch: a DNS-resolution fault (a
937
- // genuinely non-existent host, a resolver timeout) is an ordinary liveness failure, not a
938
- // security refusal — it must land in the same "is unreachable" bucket a fetch fault would,
939
- // never escape uncaught (which would silently drop this URL's finding — see LORE-71 notes).
940
- const blocked = await blockedDestination(current, resolveHost);
941
- if (blocked !== null) {
942
- return `was not probed: ${blocked}`;
943
- }
944
- const response = await fetchFn(current, { signal: AbortSignal.timeout(LIVENESS_TIMEOUT_MS), redirect: "manual" });
945
- if (response.status >= 300 && response.status < 400 && response.location) {
946
- // Resolve a relative Location against the CURRENT url (redirects are commonly relative).
947
- current = new URL(response.location, current).toString();
948
- continue;
949
- }
950
- return response.ok ? null : `is dead (HTTP ${response.status})`;
951
- } catch (cause) {
952
- if (cause instanceof Error && cause.name === "TimeoutError") {
953
- return `did not respond within ${LIVENESS_TIMEOUT_MS}ms`;
954
- }
955
- return `is unreachable (${cause instanceof Error ? cause.message : String(cause)})`;
956
- }
957
- }
958
- return `exceeded ${MAX_REDIRECTS} redirects`;
959
- }
960
-
961
- // ── Output ─────────────────────────────────────────────────────────────────────
962
-
963
- /** The per-result-type rendering bundle for `check` (output.ts dispatches on the mode). */
964
- function reportRenderable(data: CheckReport): Renderable<CheckReport> {
965
- return {
966
- kind: "check.report",
967
- data,
968
- // Pretty and plain share a layout; only color differs (plain is always ANSI-free), so a
969
- // single renderer keyed on the color flag keeps the two views from ever drifting.
970
- pretty: (report, opts) => renderReport(report, opts.color),
971
- plain: (report) => renderReport(report, false),
972
- };
973
- }
974
-
975
- /** One line per gate finding, then the opt-in external-liveness findings, then a summary line. */
976
- function renderReport(data: CheckReport, color: boolean): string {
977
- const lines: string[] = data.findings.map((finding) => findingLine(finding, color));
978
- for (const finding of data.externalFindings ?? []) {
979
- lines.push(findingLine(finding, color));
980
- }
981
- lines.push(summaryLine(data, color));
982
- return lines.join("\n");
983
- }
984
-
985
- /**
986
- * One finding line: `<severity> <file> [<rule>]: <message>`.
987
- *
988
- * `finding.file` and `finding.message` are untrusted: `message` embeds a raw link target or
989
- * fragment lifted verbatim from authored markdown (`core/check.ts`'s broken-link/broken-anchor
990
- * findings), so either field can carry an ANSI escape sequence or an embedded newline. Left
991
- * unstripped, an escape sequence could repaint or move the cursor, and a newline would forge an
992
- * extra finding row once `renderReport` joins lines with `\n`. Both fields are run through the
993
- * shared {@link stripAnsiAndControls} (LORE-181's single home for this strip) before
994
- * interpolation, matching every sibling surface (`output.ts`, `commands/query.ts`,
995
- * `core/validate.ts`, `core/links.ts`).
996
- */
997
- function findingLine(finding: CheckFinding, color: boolean): string {
998
- const tone = finding.severity === "error" ? ANSI.red : ANSI.yellow;
999
- const file = stripAnsiAndControls(finding.file);
1000
- const message = stripAnsiAndControls(finding.message);
1001
- return `${paint(finding.severity, tone, color)} ${file} [${finding.rule}]: ${message}`;
1002
- }
1003
-
1004
- /**
1005
- * The trailing summary: file/error/warning counts (the error count painted when nonzero), plus a
1006
- * separate `N external issue(s)` segment when `--external` ran — kept distinct from the gate's
1007
- * `warning` count because external-liveness results never affect the exit code.
1008
- */
1009
- function summaryLine(data: CheckReport, color: boolean): string {
1010
- const errors = `${data.errorCount} ${plural(data.errorCount, "error")}`;
1011
- const painted = data.errorCount > 0 ? paint(errors, ANSI.red, color) : errors;
1012
- const parts = [
1013
- `${data.fileCount} ${plural(data.fileCount, "file")}`,
1014
- painted,
1015
- `${data.warningCount} ${plural(data.warningCount, "warning")}`,
1016
- ];
1017
- if (data.externalFindings !== undefined) {
1018
- parts.push(`${data.externalFindings.length} external ${plural(data.externalFindings.length, "issue")}`);
1019
- }
1020
- return parts.join(", ");
1021
- }
1022
-
1023
- /** Pluralize a noun by count (`1 error`, `2 errors`). */
1024
- function plural(count: number, noun: string): string {
1025
- return count === 1 ? noun : `${noun}s`;
1026
- }
1027
-
1028
- /** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
1029
- function usage(message: string, hint: string): LoreError {
1030
- return new LoreError("usage", message, hint);
1031
- }