@opum-ai/lore 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/core/validate.ts
DELETED
|
@@ -1,517 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* validate.ts — the **pure**, aggregating engine behind `lore validate`.
|
|
3
|
-
*
|
|
4
|
-
* Where {@link tryParseConcept}/{@link validateFrontmatter} are *fail-fast* (they throw the
|
|
5
|
-
* first error-tier problem they hit, which is right for the write path — lore must never
|
|
6
|
-
* emit bytes it would refuse to read back), `lore validate` is a **reporter**: it surfaces
|
|
7
|
-
* *every file's* findings in one pass — fail-fast aborts the whole run on the first bad file;
|
|
8
|
-
* this never does — so an author or a pre-commit hook sees the whole bundle's picture at once
|
|
9
|
-
* (cli-surface §validate, ADR-0007). Within a single file the frontmatter tier still
|
|
10
|
-
* short-circuits: a parse failure yields the one frontmatter error (plus the raw-text
|
|
11
|
-
* quote-safety scan), and the per-type section checks — which presuppose a parsed type/body —
|
|
12
|
-
* run only once the frontmatter parses. This module turns the fail-fast machinery into an
|
|
13
|
-
* aggregator: it runs the existing frontmatter engine per file, collects what it throws or
|
|
14
|
-
* warns as {@link Finding}s, and layers the two checks ADR-0007 adds on top — **per-type
|
|
15
|
-
* required sections** and **frontmatter quote-safety**.
|
|
16
|
-
*
|
|
17
|
-
* It stays within the core contract (lore-design §2.1): pure, filesystem-free, no printing
|
|
18
|
-
* or `process.exit`. The command layer (`commands/validate.ts`) owns file discovery and I/O
|
|
19
|
-
* and hands raw text here; this module owns the *judgement* and returns structured data.
|
|
20
|
-
*
|
|
21
|
-
* The tiers (ADR-0007 "How lore checks conformance"):
|
|
22
|
-
*
|
|
23
|
-
* - **Tier 1 — OKF §9 (error):** frontmatter parses and `type` is present/non-empty. Reuses
|
|
24
|
-
* {@link tryParseConcept}, whose thrown `validation` {@link LoreError} becomes one finding.
|
|
25
|
-
* - **Tier 2 — per-type shape (error):** a *known* type's strict Zod schema (surfaced through
|
|
26
|
-
* the same {@link tryParseConcept} throw) **plus** its {@link requiredSectionsFor required
|
|
27
|
-
* body sections} (this module).
|
|
28
|
-
* - **Tier 3 — extensions (warning):** an unknown `type`, an extra key on a known type, or a
|
|
29
|
-
* missing/over-long `summary` — collected from the {@link WarningCollector}.
|
|
30
|
-
* - **Cross-cutting — quote-safety:** unquoted frontmatter scalars that a YAML-1.1 consumer
|
|
31
|
-
* would coerce to a non-string (or that carry a YAML indicator), so the value is
|
|
32
|
-
* parser-dependent across the bundle's target renderers ({@link quoteSafetyFindings}).
|
|
33
|
-
*
|
|
34
|
-
* A file that is **not a concept** (no frontmatter, an empty/`null` fence, or a bare
|
|
35
|
-
* scalar/list — e.g. a hand-written `index.md`/`log.md`) is **skipped**, not failed: exactly
|
|
36
|
-
* the {@link tryParseConcept} distinction {@link loadBundle} draws, so a pre-commit hook that
|
|
37
|
-
* globs `*.md` does not trip over a non-concept file.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
41
|
-
import { LoreError, singleLine, stripAnsiAndControls, WarningCollector } from "../errors";
|
|
42
|
-
import { effectiveProfileFor, nodeText } from "./bundle";
|
|
43
|
-
import { type Concept, tryParseConcept } from "./concept";
|
|
44
|
-
import type { Finding as BaseFinding, Severity } from "./finding";
|
|
45
|
-
import { decodeTarget } from "./links";
|
|
46
|
-
import { defaultProfile, type Profile } from "./profile";
|
|
47
|
-
import { ROOT_INDEX_PATH } from "./scaffold";
|
|
48
|
-
import { requiredSectionsFor } from "./schema";
|
|
49
|
-
import { expectedResource } from "./template";
|
|
50
|
-
|
|
51
|
-
export type { Severity };
|
|
52
|
-
|
|
53
|
-
/** Which check produced a {@link Finding}, for machine consumers and grouped display. */
|
|
54
|
-
export type FindingRule = "frontmatter" | "required-section" | "quote-safety" | "resource";
|
|
55
|
-
|
|
56
|
-
/** One tiered problem found in a single file — the shared {@link BaseFinding} narrowed to `validate`'s rules. */
|
|
57
|
-
export type Finding = BaseFinding<FindingRule>;
|
|
58
|
-
|
|
59
|
-
/** The validation outcome for one file. */
|
|
60
|
-
export interface FileReport {
|
|
61
|
-
/** The file's repo-relative POSIX path, as given to {@link validateConceptText}. */
|
|
62
|
-
readonly path: string;
|
|
63
|
-
/** The resolved concept `type`, or `undefined` for a skipped non-concept or an unparseable file. */
|
|
64
|
-
readonly type?: string;
|
|
65
|
-
/** Every finding for this file, in tier order (frontmatter → sections → quote-safety). */
|
|
66
|
-
readonly findings: readonly Finding[];
|
|
67
|
-
/** `true` when the file is not a concept and was not validated (no findings contributed). */
|
|
68
|
-
readonly skipped: boolean;
|
|
69
|
-
/** `true` when the file carries no error-severity finding (a warning-only or clean/skipped file). */
|
|
70
|
-
readonly ok: boolean;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The aggregate report across every validated file — the `validate.report` payload. */
|
|
74
|
-
export interface ValidateReport {
|
|
75
|
-
/** Per-file reports, in the order the files were supplied. */
|
|
76
|
-
readonly files: readonly FileReport[];
|
|
77
|
-
/** Total error-severity findings across all files. */
|
|
78
|
-
readonly errorCount: number;
|
|
79
|
-
/** Total warning-severity findings across all files. */
|
|
80
|
-
readonly warningCount: number;
|
|
81
|
-
/** Files skipped as non-concepts. */
|
|
82
|
-
readonly skippedCount: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Validate one file's raw bytes into a {@link FileReport}, never throwing for a *content*
|
|
87
|
-
* problem — every tier is collected as a {@link Finding}.
|
|
88
|
-
*
|
|
89
|
-
* The frontmatter tiers reuse {@link parseConcept}: a thrown `validation` {@link LoreError}
|
|
90
|
-
* (missing/invalid `type`, a mistyped known field, unparseable YAML) becomes one error
|
|
91
|
-
* finding; the {@link WarningCollector} it fills (unknown type/key, summary) becomes warning
|
|
92
|
-
* findings. A non-concept (no usable frontmatter) is **skipped** via {@link tryParseConcept}.
|
|
93
|
-
* On a clean parse the two ADR-0007 additions run: {@link requiredSectionFindings} and
|
|
94
|
-
* {@link quoteSafetyFindings}.
|
|
95
|
-
*
|
|
96
|
-
* `path` is resolved to its **judging profile** via {@link import("./bundle").effectiveProfileFor}
|
|
97
|
-
* before any of that runs: the bundle-root {@link ROOT_INDEX_PATH} (this module's repo-relative
|
|
98
|
-
* spelling) is always judged against the built-in {@link defaultProfile}, never `profile`
|
|
99
|
-
* (LORE-144) — see that helper for why, and for why it lives in `bundle.ts` rather than here
|
|
100
|
-
* (LORE-192: this module already imports {@link nodeText} from `bundle.ts`, so the reverse import
|
|
101
|
-
* would cycle).
|
|
102
|
-
*
|
|
103
|
-
* A non-{@link LoreError} (a genuine bug) is *not* swallowed — it propagates, so a crash is
|
|
104
|
-
* never silently dressed up as a validation finding.
|
|
105
|
-
*/
|
|
106
|
-
export function validateConceptText(path: string, raw: string, profile: Profile = defaultProfile()): FileReport {
|
|
107
|
-
const effective = effectiveProfileFor(path, ROOT_INDEX_PATH, profile);
|
|
108
|
-
// Parse exactly once. tryParseConcept fills the collector with tier-3 warnings, returns null for
|
|
109
|
-
// a non-concept (skip), and throws a `validation` LoreError for a real-but-malformed concept —
|
|
110
|
-
// so a single call draws every distinction the reporter needs without re-parsing the same bytes.
|
|
111
|
-
const warnings = new WarningCollector();
|
|
112
|
-
let concept: Concept | null;
|
|
113
|
-
try {
|
|
114
|
-
concept = tryParseConcept(path, raw, { warnings, profile: effective });
|
|
115
|
-
} catch (err) {
|
|
116
|
-
// A genuine bug (a non-LoreError) must never be dressed up as a validation finding — propagate
|
|
117
|
-
// it (the invariant this module states). A malformed concept becomes one error finding; its
|
|
118
|
-
// `type` is recovered best-effort from the raw frontmatter so a `--type` run still attributes
|
|
119
|
-
// (and so never silently drops) a known-type-but-invalid file — the gate it exists to enforce.
|
|
120
|
-
if (!(err instanceof LoreError)) {
|
|
121
|
-
throw err;
|
|
122
|
-
}
|
|
123
|
-
const findings: Finding[] = [{ severity: "error", rule: "frontmatter", message: err.message }];
|
|
124
|
-
// Quote-safety is a raw-text scan needing no parsed concept, so it still runs and the author
|
|
125
|
-
// sees YAML hazards in the same pass; per-type section checks presuppose a parsed type/body
|
|
126
|
-
// and are deferred until the frontmatter parses.
|
|
127
|
-
findings.push(...quoteSafetyFindings(raw));
|
|
128
|
-
return { path, type: recoverType(raw), findings, skipped: false, ok: false };
|
|
129
|
-
}
|
|
130
|
-
if (concept === null) {
|
|
131
|
-
return { path, findings: [], skipped: true, ok: true };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const findings: Finding[] = [];
|
|
135
|
-
for (const message of warnings.list()) {
|
|
136
|
-
findings.push({ severity: "warning", rule: "frontmatter", message });
|
|
137
|
-
}
|
|
138
|
-
findings.push(...requiredSectionFindings(concept.type, concept.body, effective));
|
|
139
|
-
findings.push(...resourceDriftFindings(path, concept, effective));
|
|
140
|
-
findings.push(...quoteSafetyFindings(raw));
|
|
141
|
-
|
|
142
|
-
return finalize(path, concept.type, findings);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Validate a list of `{ path, raw }` files into one aggregate {@link ValidateReport}, optionally
|
|
147
|
-
* narrowed to a single `type`. Pure over its inputs (the command layer does the reading), so the
|
|
148
|
-
* aggregation and the `--type` filter are testable without the filesystem.
|
|
149
|
-
*
|
|
150
|
-
* `type` (the `--type <T>` flag, already canonicalized by the caller) narrows the report to one
|
|
151
|
-
* concept type — but **never** at the cost of hiding a broken file from the gate: see
|
|
152
|
-
* {@link keepForType}.
|
|
153
|
-
*/
|
|
154
|
-
export function validateFiles(
|
|
155
|
-
files: readonly { path: string; raw: string }[],
|
|
156
|
-
type?: string,
|
|
157
|
-
profile: Profile = defaultProfile(),
|
|
158
|
-
): ValidateReport {
|
|
159
|
-
const wanted = type?.toLowerCase();
|
|
160
|
-
const reports: FileReport[] = [];
|
|
161
|
-
for (const file of files) {
|
|
162
|
-
const report = validateConceptText(file.path, file.raw, profile);
|
|
163
|
-
if (wanted !== undefined && !keepForType(report, wanted)) {
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
reports.push(report);
|
|
167
|
-
}
|
|
168
|
-
return summarize(reports);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Whether a per-file report belongs in a `--type <wanted>` run. An **error** file is **always
|
|
173
|
-
* kept**: silently dropping a broken file would turn the very gate `--type` scopes green over a
|
|
174
|
-
* malformed concept, and a malformed file's true type can never be trusted to be *not* `wanted`
|
|
175
|
-
* (its frontmatter did not parse). A clean concept is kept only when its type matches; a skipped
|
|
176
|
-
* non-concept is dropped (it is genuinely not `wanted`). The error file's {@link recoverType}d
|
|
177
|
-
* type is for display only — never a reason to filter it out.
|
|
178
|
-
*/
|
|
179
|
-
function keepForType(report: FileReport, wanted: string): boolean {
|
|
180
|
-
if (!report.ok && !report.skipped) {
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
return report.type?.toLowerCase() === wanted;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Tally per-file reports into the aggregate counts (errors, warnings, skips). */
|
|
187
|
-
export function summarize(files: readonly FileReport[]): ValidateReport {
|
|
188
|
-
let errorCount = 0;
|
|
189
|
-
let warningCount = 0;
|
|
190
|
-
let skippedCount = 0;
|
|
191
|
-
for (const file of files) {
|
|
192
|
-
if (file.skipped) {
|
|
193
|
-
skippedCount++;
|
|
194
|
-
}
|
|
195
|
-
for (const finding of file.findings) {
|
|
196
|
-
if (finding.severity === "error") {
|
|
197
|
-
errorCount++;
|
|
198
|
-
} else {
|
|
199
|
-
warningCount++;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return { files, errorCount, warningCount, skippedCount };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Assemble a non-skipped {@link FileReport}, deriving `ok` from the absence of any error-severity
|
|
208
|
-
* finding (a warning-only file is still `ok` — warnings never fail a file, cli-contract §4.1).
|
|
209
|
-
*/
|
|
210
|
-
function finalize(path: string, type: string, findings: readonly Finding[]): FileReport {
|
|
211
|
-
const ok = !findings.some((finding) => finding.severity === "error");
|
|
212
|
-
return { path, type, findings, skipped: false, ok };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* A best-effort `type` read from the raw frontmatter of a file that failed to parse, so a `--type`
|
|
217
|
-
* run can attribute (and therefore never silently drop) a known-type-but-invalid concept. Scans the
|
|
218
|
-
* fenced block for a top-level `type:` line, stripping a trailing comment and surrounding quotes;
|
|
219
|
-
* returns `undefined` when no `type` is recoverable (unparseable YAML, or a missing `type`). The
|
|
220
|
-
* value is raw (not canonicalized) — it is matched case-insensitively, only for filtering/display.
|
|
221
|
-
*/
|
|
222
|
-
function recoverType(raw: string): string | undefined {
|
|
223
|
-
const block = frontmatterBlock(raw);
|
|
224
|
-
if (block === null) {
|
|
225
|
-
return undefined;
|
|
226
|
-
}
|
|
227
|
-
for (const line of block.split("\n")) {
|
|
228
|
-
const match = /^type:[ \t]+(.*)$/.exec(line);
|
|
229
|
-
if (match === null) {
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
const value = unquoteScalar(stripInlineComment((match[1] ?? "").trim()));
|
|
233
|
-
return value === "" ? undefined : value;
|
|
234
|
-
}
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Strip a trailing YAML comment (` #…`, the `#` preceded by whitespace) from a raw scalar value. */
|
|
239
|
-
function stripInlineComment(value: string): string {
|
|
240
|
-
return value.replace(/\s+#.*$/, "").trimEnd();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Remove a matching pair of surrounding quotes from a scalar value, else return it unchanged. */
|
|
244
|
-
function unquoteScalar(value: string): string {
|
|
245
|
-
const quote = value[0];
|
|
246
|
-
if (value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
247
|
-
return value.slice(1, -1);
|
|
248
|
-
}
|
|
249
|
-
return value;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ── Tier 2: required body sections ─────────────────────────────────────────────—
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* The required-section findings for a concept body: one **error** per
|
|
256
|
-
* {@link requiredSectionsFor required `##` heading} the body does not carry. Matching is on
|
|
257
|
-
* {@link normalizeHeading normalized} heading text (trimmed, interior whitespace collapsed,
|
|
258
|
-
* lower-cased), so `## status` and `## Acceptance criteria` (a double space that renders
|
|
259
|
-
* identically) both satisfy their requirement. A type with no required sections (every unknown
|
|
260
|
-
* type, and Epic/Spec/Runbook/Reference under the minimal policy) yields nothing.
|
|
261
|
-
*/
|
|
262
|
-
function requiredSectionFindings(type: string, body: string, profile: Profile): Finding[] {
|
|
263
|
-
const required = requiredSectionsFor(type, profile);
|
|
264
|
-
if (required.length === 0) {
|
|
265
|
-
return [];
|
|
266
|
-
}
|
|
267
|
-
const present = new Set(h2Headings(body).map(normalizeHeading));
|
|
268
|
-
const findings: Finding[] = [];
|
|
269
|
-
for (const section of required) {
|
|
270
|
-
if (!present.has(normalizeHeading(section))) {
|
|
271
|
-
findings.push({
|
|
272
|
-
severity: "error",
|
|
273
|
-
rule: "required-section",
|
|
274
|
-
message: `${type} is missing the required "## ${section}" section`,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return findings;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── Cross-cutting: resource drift ──────────────────────────────────────────────—
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* The resource-drift finding for a concept whose stamped `resource` no longer matches what its
|
|
285
|
-
* path + the profile's `resource_base` would produce (LORE-47 / AC#4) — one **warning** when a
|
|
286
|
-
* **present** string `resource` differs from {@link expectedResource}. Unlike `index.md`/`log.md`,
|
|
287
|
-
* a stamped `resource` is not regenerated, so a later rename or `resource_base` change silently
|
|
288
|
-
* leaves a stale URL; this surfaces that drift in the same pass.
|
|
289
|
-
*
|
|
290
|
-
* It judges only what lore itself would stamp: a file with no `resource`, a non-string `resource`,
|
|
291
|
-
* or one where lore would stamp nothing here ({@link expectedResource} `undefined` — no
|
|
292
|
-
* `resource_base`, an index file, or a type that owns its own `resource` field) yields nothing, so
|
|
293
|
-
* an author-owned `resource` is never second-guessed. Advisory tier (`resource` is advisory
|
|
294
|
-
* metadata, not a shape constraint), so it reports the staleness without failing the file.
|
|
295
|
-
*/
|
|
296
|
-
function resourceDriftFindings(path: string, concept: Concept, profile: Profile): Finding[] {
|
|
297
|
-
const actual = concept.frontmatter.resource;
|
|
298
|
-
if (typeof actual !== "string") {
|
|
299
|
-
return [];
|
|
300
|
-
}
|
|
301
|
-
const expected = expectedResource(concept.type, path, profile);
|
|
302
|
-
if (expected === undefined || actual === expected) {
|
|
303
|
-
return [];
|
|
304
|
-
}
|
|
305
|
-
// Compare decode-tolerantly: the shared segment encoder (links.ts `encodePathSegment`) escapes the
|
|
306
|
-
// markdown-significant `! ' ( ) *` that `encodeURIComponent` leaves raw, so a `resource` stamped
|
|
307
|
-
// before LORE-28 (or hand-authored with those chars literal) differs from the freshly-encoded
|
|
308
|
-
// `expected` only in percent-encoding — an equivalent URL, not drift. Decoding both collapses that
|
|
309
|
-
// difference while a real path/`resource_base` change still decodes to a different string.
|
|
310
|
-
if (decodeTarget(actual) === decodeTarget(expected)) {
|
|
311
|
-
return [];
|
|
312
|
-
}
|
|
313
|
-
return [
|
|
314
|
-
{
|
|
315
|
-
severity: "warning",
|
|
316
|
-
rule: "resource",
|
|
317
|
-
// `actual` is sanitized here, not above: the drift comparisons must judge the *raw*
|
|
318
|
-
// bundle-authored value (sanitizing first could mask real drift or falsely collapse two
|
|
319
|
-
// distinct URLs into one), but `Finding.message` is documented as "a single-line, actionable
|
|
320
|
-
// description" and every other finding builder only ever interpolates lore-computed values
|
|
321
|
-
// (`type`, `expected`) — `resource` is the one message that embeds an author-controlled raw
|
|
322
|
-
// string straight from frontmatter. Sanitizing once here, at the only place that string
|
|
323
|
-
// reaches a message, keeps that contract for every consumer (CLI text, `--json`, a future
|
|
324
|
-
// renderer) instead of trusting each print site to re-derive the same defense (LORE-161).
|
|
325
|
-
message: `resource "${sanitizeForMessage(actual)}" is stale; this path under the profile's resource_base is "${expected}" — update it or remove the \`resource\` key`,
|
|
326
|
-
},
|
|
327
|
-
];
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Collapse an author-controlled frontmatter string to a single line with no ANSI escape sequences
|
|
332
|
-
* or other control bytes, for safe embedding into a {@link Finding} `message`. {@link singleLine}
|
|
333
|
-
* (cli-contract §5.2's own single-line discipline) only folds line *terminators* (CR/LF/U+2028/
|
|
334
|
-
* U+2029); a YAML double-quoted scalar can also smuggle an ESC-led ANSI sequence or another C0/C1/
|
|
335
|
-
* DEL control byte (e.g. `resource: "…\x1b[31m…"`), which `singleLine` leaves untouched. Runs
|
|
336
|
-
* `singleLine` first, then strips what it leaves via the shared {@link stripAnsiAndControls}
|
|
337
|
-
* (LORE-181) — the single home for that two-pass strip, also used by `output.ts`,
|
|
338
|
-
* `commands/query.ts`, and `core/links.ts` — imported from `errors.ts` rather than `output.ts`:
|
|
339
|
-
* this module must stay filesystem/output-layer-free (module doc above), and `errors.ts` is
|
|
340
|
-
* layer-neutral.
|
|
341
|
-
*/
|
|
342
|
-
function sanitizeForMessage(text: string): string {
|
|
343
|
-
return stripAnsiAndControls(singleLine(text));
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Normalize a heading or section name for comparison: trim, collapse interior whitespace, lower-case. */
|
|
347
|
-
function normalizeHeading(text: string): string {
|
|
348
|
-
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* The text of every depth-2 (`##`) heading that is a **direct, top-level child of the document
|
|
353
|
-
* root** — i.e. a genuine section heading, not one nested inside a blockquote or list item —
|
|
354
|
-
* in document order. Extraction defers to a CommonMark parser (`mdast-util-from-markdown`) so a
|
|
355
|
-
* `## ` that appears inside a fenced/indented code block is **not** mistaken for a heading —
|
|
356
|
-
* matching how {@link loadBundle} extracts links. Unlike {@link nodeText}'s own traversal (which
|
|
357
|
-
* still walks a matched heading's inline children to assemble its text), this only iterates the
|
|
358
|
-
* root's immediate `children` — it does not recurse into container nodes — so `> ## Status`
|
|
359
|
-
* (inside a blockquote) or a `## `-looking line inside a list item never counts as a top-level
|
|
360
|
-
* section, even though both nest a real `heading` node somewhere in the tree.
|
|
361
|
-
*/
|
|
362
|
-
function h2Headings(body: string): string[] {
|
|
363
|
-
const headings: string[] = [];
|
|
364
|
-
for (const node of fromMarkdown(body).children) {
|
|
365
|
-
if (node.type === "heading" && node.depth === 2) {
|
|
366
|
-
headings.push(nodeText(node));
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
return headings;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ── Cross-cutting: frontmatter quote-safety ────────────────────────────────────—
|
|
373
|
-
|
|
374
|
-
/** YAML indicator characters that make an unquoted scalar parser-dependent or reserved. */
|
|
375
|
-
const INDICATOR_CHARS: ReadonlySet<string> = new Set(["@", "`", "!", "&", "*", "|", ">", ":"]);
|
|
376
|
-
|
|
377
|
-
/** A bare YAML-1.1 boolean alias a non-1.2 consumer coerces away from a string. */
|
|
378
|
-
const YAML11_BOOLEAN = /^(yes|no|on|off|y|n)$/i;
|
|
379
|
-
|
|
380
|
-
/** A `|`/`>` block-scalar **header** (optional indent digit + chomping indicator), authored on purpose. */
|
|
381
|
-
const BLOCK_SCALAR_HEADER = /^[|>][1-9]?[+-]?$/;
|
|
382
|
-
|
|
383
|
-
/** A bare `YYYY-MM-DD` date a YAML-1.1 consumer parses as a timestamp. */
|
|
384
|
-
const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* The quote-safety findings for a file's raw frontmatter (ADR-0007 "frontmatter quote-safety").
|
|
388
|
-
* lore's *own* output is already safe — it serializes structurally and js-yaml quotes whatever
|
|
389
|
-
* needs it ([ADR-0011](../../docs/adr/0011-frontmatter-serialization-stability.md)) — so this
|
|
390
|
-
* check exists for **author-written** frontmatter, whose ambiguous scalars round-trip
|
|
391
|
-
* differently across the bundle's target consumers (GitHub, Obsidian, MkDocs, Docusaurus), some
|
|
392
|
-
* of which still parse YAML 1.1.
|
|
393
|
-
*
|
|
394
|
-
* It is a deliberately **best-effort line scan** over the raw fenced block, not a re-parse: by
|
|
395
|
-
* the time js-yaml has parsed the value, the ambiguity is already resolved, so the raw token is
|
|
396
|
-
* the only place the hazard is visible. To stay false-positive-free on real bundles it inspects
|
|
397
|
-
* only **simple top-level `key: value` lines** with a single-line unquoted scalar value;
|
|
398
|
-
* quoted values, block scalars (`|`/`>`), flow collections (`[`/`{`), list items, nested
|
|
399
|
-
* (indented) lines, and empty values are left alone (a documented limitation — nested/list
|
|
400
|
-
* scalars are not analyzed). Returns `[]` for a file with no frontmatter fence.
|
|
401
|
-
*/
|
|
402
|
-
export function quoteSafetyFindings(raw: string): Finding[] {
|
|
403
|
-
const findings: Finding[] = [];
|
|
404
|
-
for (const value of topLevelScalarValues(raw)) {
|
|
405
|
-
const finding = quoteSafetyForValue(value);
|
|
406
|
-
if (finding !== null) {
|
|
407
|
-
findings.push(finding);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
return findings;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Judge one unquoted scalar value, or `null` when it is quote-safe. */
|
|
414
|
-
function quoteSafetyForValue(value: string): Finding | null {
|
|
415
|
-
const first = value[0] ?? "";
|
|
416
|
-
// An intentional block-scalar header (`|`, `>-`, `|2`, …) is safe; only `>foo`/`|foo` with
|
|
417
|
-
// content abutting the indicator is suspect (it is not a valid header), so it falls through.
|
|
418
|
-
if ((first === "|" || first === ">") && BLOCK_SCALAR_HEADER.test(value)) {
|
|
419
|
-
return null;
|
|
420
|
-
}
|
|
421
|
-
if (INDICATOR_CHARS.has(first)) {
|
|
422
|
-
return error(
|
|
423
|
-
`unquoted value starts with the YAML indicator "${first}" ("${value}"); quote it so every consumer reads a string`,
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
if (YAML11_BOOLEAN.test(value)) {
|
|
427
|
-
return error(`unquoted "${value}" is a boolean to YAML 1.1 consumers; quote it to keep the string "${value}"`);
|
|
428
|
-
}
|
|
429
|
-
if (value.includes(": ")) {
|
|
430
|
-
return error(
|
|
431
|
-
`unquoted value contains a colon ("${value}"); YAML reads "key: a: b" as a nested mapping — quote the value`,
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
if (BARE_DATE.test(value)) {
|
|
435
|
-
return warning(`unquoted "${value}" parses as a date to YAML 1.1 consumers; quote it to keep it a string`);
|
|
436
|
-
}
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** Build an error-severity quote-safety {@link Finding}. */
|
|
441
|
-
function error(message: string): Finding {
|
|
442
|
-
return { severity: "error", rule: "quote-safety", message };
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** Build a warning-severity quote-safety {@link Finding}. */
|
|
446
|
-
function warning(message: string): Finding {
|
|
447
|
-
return { severity: "warning", rule: "quote-safety", message };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* The unquoted single-line scalar values of the top-level `key: value` lines in a file's raw
|
|
452
|
-
* frontmatter fence, in order. Returns `[]` when the file has no leading `---` fence. Only the
|
|
453
|
-
* lines this scan can judge safely are returned; everything else (see
|
|
454
|
-
* {@link quoteSafetyFindings}) is skipped.
|
|
455
|
-
*/
|
|
456
|
-
function topLevelScalarValues(raw: string): string[] {
|
|
457
|
-
const block = frontmatterBlock(raw);
|
|
458
|
-
if (block === null) {
|
|
459
|
-
return [];
|
|
460
|
-
}
|
|
461
|
-
const values: string[] = [];
|
|
462
|
-
for (const line of block.split("\n")) {
|
|
463
|
-
// Top-level only: a leading space/tab means a nested or list value, which this scan does
|
|
464
|
-
// not analyze. A blank line or a YAML comment carries no scalar.
|
|
465
|
-
if (line === "" || /^\s/.test(line) || line.startsWith("#")) {
|
|
466
|
-
continue;
|
|
467
|
-
}
|
|
468
|
-
const match = /^[A-Za-z0-9_][\w.-]*:(?:[ \t]+(.*))?$/.exec(line);
|
|
469
|
-
if (match === null) {
|
|
470
|
-
continue; // not a simple `key:`/`key: value` line (e.g. a `- item`, or `?`-complex key)
|
|
471
|
-
}
|
|
472
|
-
let value = (match[1] ?? "").trim();
|
|
473
|
-
// An empty value (`key:`) or a comment-only value (`key: # note`, which YAML reads as null)
|
|
474
|
-
// carries no scalar to judge.
|
|
475
|
-
if (value === "" || value.startsWith("#")) {
|
|
476
|
-
continue;
|
|
477
|
-
}
|
|
478
|
-
const quote = value[0];
|
|
479
|
-
if (quote === '"' || quote === "'") {
|
|
480
|
-
continue; // already quoted — explicitly a string, safe
|
|
481
|
-
}
|
|
482
|
-
if (value.startsWith("[") || value.startsWith("{")) {
|
|
483
|
-
continue; // flow collection — a structured list/map, not a scalar string
|
|
484
|
-
}
|
|
485
|
-
// Strip a trailing YAML comment only now, on a known-unquoted scalar: a `#` with no leading
|
|
486
|
-
// space (a URL fragment `a#b`) is part of the value and must survive, while ` # note` is a
|
|
487
|
-
// comment YAML discards — analyzing it would be a false positive (a colon there is not a hazard).
|
|
488
|
-
value = stripInlineComment(value);
|
|
489
|
-
if (value !== "") {
|
|
490
|
-
values.push(value);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
return values;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Extract the raw text **between** the opening and closing `---` fences, or `null` when the file
|
|
498
|
-
* does not open with a frontmatter fence. Applies the same leading normalization
|
|
499
|
-
* {@link parseConcept} does (strip BOM(s), CRLF/CR → LF, drop leading whitespace) so a
|
|
500
|
-
* BOM/Windows/whitespace-padded concept is analyzed identically to how it is parsed, then takes
|
|
501
|
-
* the lines up to the next line that is exactly `---`.
|
|
502
|
-
*/
|
|
503
|
-
function frontmatterBlock(raw: string): string | null {
|
|
504
|
-
const normalized = raw
|
|
505
|
-
.replace(/^\uFEFF+/, "")
|
|
506
|
-
.replace(/\r\n?/g, "\n")
|
|
507
|
-
.replace(/^\s+/, "");
|
|
508
|
-
if (!normalized.startsWith("---\n")) {
|
|
509
|
-
return null;
|
|
510
|
-
}
|
|
511
|
-
const rest = normalized.slice(4);
|
|
512
|
-
const end = rest.indexOf("\n---");
|
|
513
|
-
if (end === -1) {
|
|
514
|
-
return null; // no closing fence — not a well-formed concept; parse-tier handles it
|
|
515
|
-
}
|
|
516
|
-
return rest.slice(0, end);
|
|
517
|
-
}
|