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