@intentset/publisher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/body.d.ts +18 -0
  2. package/dist/body.d.ts.map +1 -0
  3. package/dist/body.js +34 -0
  4. package/dist/body.js.map +1 -0
  5. package/dist/chunks.d.ts +24 -0
  6. package/dist/chunks.d.ts.map +1 -0
  7. package/dist/chunks.js +116 -0
  8. package/dist/chunks.js.map +1 -0
  9. package/dist/diagnostic.d.ts +25 -0
  10. package/dist/diagnostic.d.ts.map +1 -0
  11. package/dist/diagnostic.js +21 -0
  12. package/dist/diagnostic.js.map +1 -0
  13. package/dist/generate.d.ts +89 -0
  14. package/dist/generate.d.ts.map +1 -0
  15. package/dist/generate.js +157 -0
  16. package/dist/generate.js.map +1 -0
  17. package/dist/index.d.ts +15 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +15 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/project.d.ts +56 -0
  22. package/dist/project.d.ts.map +1 -0
  23. package/dist/project.js +187 -0
  24. package/dist/project.js.map +1 -0
  25. package/dist/publish.d.ts +42 -0
  26. package/dist/publish.d.ts.map +1 -0
  27. package/dist/publish.js +132 -0
  28. package/dist/publish.js.map +1 -0
  29. package/dist/references.d.ts +32 -0
  30. package/dist/references.d.ts.map +1 -0
  31. package/dist/references.js +196 -0
  32. package/dist/references.js.map +1 -0
  33. package/dist/request.d.ts +63 -0
  34. package/dist/request.d.ts.map +1 -0
  35. package/dist/request.js +156 -0
  36. package/dist/request.js.map +1 -0
  37. package/dist/review.d.ts +58 -0
  38. package/dist/review.d.ts.map +1 -0
  39. package/dist/review.js +77 -0
  40. package/dist/review.js.map +1 -0
  41. package/dist/yaml.d.ts +18 -0
  42. package/dist/yaml.d.ts.map +1 -0
  43. package/dist/yaml.js +90 -0
  44. package/dist/yaml.js.map +1 -0
  45. package/package.json +36 -0
  46. package/src/body.ts +32 -0
  47. package/src/chunks.ts +132 -0
  48. package/src/diagnostic.ts +44 -0
  49. package/src/generate.ts +244 -0
  50. package/src/index.ts +70 -0
  51. package/src/project.ts +254 -0
  52. package/src/publish.ts +177 -0
  53. package/src/references.ts +226 -0
  54. package/src/request.ts +239 -0
  55. package/src/review.ts +121 -0
  56. package/src/yaml.ts +85 -0
package/src/project.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The audience and release projection (Core §9, catalog P01, P03-P06).
3
+ *
4
+ * Deny by default: an artifact is eligible only when every check passes, and
5
+ * the first that fails is recorded as the reason it was excluded, so a report
6
+ * can count exclusions by kind. The checks, in order:
7
+ *
8
+ * not-requested the request names ids and this is not one of them
9
+ * not-knowledge only knowledge is ever published as a document
10
+ * draft, retired Core §7: neither is current publication
11
+ * visibility outside what the projection admits
12
+ * audience the request's audience is not one the knowledge is written for
13
+ * availability-missing no availability block: fails closed (Core §4, §7)
14
+ * product ... edition the request's value is not in that dimension's list (AND across, OR within)
15
+ * flag the knowledge needs a flag the request does not name (all named flags required)
16
+ * needs-review a source changed since review, a pin is missing, or no reviewer (review.ts)
17
+ *
18
+ * An explicitly requested ID that is not eligible is CORE008. Its message
19
+ * names the reason class, and nothing about the artifact beyond its ID. When
20
+ * the artifact's visibility is outside the projection, or no artifact has the
21
+ * ID, the message is the same in both cases and carries no path: a caller
22
+ * without the authorization cannot learn whether an internal ID exists.
23
+ */
24
+ import { type Artifact, type Diagnostic, type Graph, type Registries, graphHash } from "@intentset/core";
25
+ import { compareStrings, publicationDiagnostic } from "./diagnostic.ts";
26
+ import { type PublicationRequest, admits, checkRequest } from "./request.ts";
27
+ import { type ReviewStatus, reviewStatus } from "./review.ts";
28
+
29
+ export const EXCLUSION_REASONS = [
30
+ "not-requested",
31
+ "not-knowledge",
32
+ "draft",
33
+ "retired",
34
+ "visibility",
35
+ "audience",
36
+ "availability-missing",
37
+ "product",
38
+ "release",
39
+ "role",
40
+ "edition",
41
+ "flag",
42
+ "needs-review",
43
+ ] as const;
44
+ export type ExclusionReason = (typeof EXCLUSION_REASONS)[number];
45
+
46
+ /** The snapshot a publication is made from (Core §7): the commit, or null when none could be read, and the graph hash. */
47
+ export interface Snapshot {
48
+ commit: string | null;
49
+ graphHash: string;
50
+ }
51
+
52
+ export interface ProjectOptions {
53
+ snapshot: Snapshot;
54
+ /** Review statuses already computed, by knowledge ID; `reviewStatus` is used for any not given. */
55
+ reviews?: ReadonlyMap<string, ReviewStatus>;
56
+ }
57
+
58
+ export interface Exclusion {
59
+ id: string;
60
+ reason: ExclusionReason;
61
+ }
62
+
63
+ export interface Projection {
64
+ /** Eligible knowledge, sorted by ID. */
65
+ eligible: Artifact[];
66
+ /** Every other artifact in the graph with the first check it failed, sorted by ID. Empty when the request was refused. */
67
+ excluded: Exclusion[];
68
+ diagnostics: Diagnostic[];
69
+ /** The checked request, or null when it was refused (PUB001). */
70
+ request: PublicationRequest | null;
71
+ }
72
+
73
+ const PUBLISHABLE_STATUSES = new Set(["approved", "implemented", "released", "deprecated"]);
74
+
75
+ /** Core §9: select what this request may publish, before any text is sent to a renderer. */
76
+ export function project(
77
+ graph: Graph,
78
+ registries: Registries,
79
+ request: PublicationRequest,
80
+ options: ProjectOptions,
81
+ ): Projection {
82
+ const checked = checkRequest(request, graph, registries);
83
+ const diagnostics = [...checked.diagnostics];
84
+ if (graphHash(graph) !== options.snapshot.graphHash) {
85
+ diagnostics.push(
86
+ publicationDiagnostic({
87
+ code: "PUB001",
88
+ artifact: null,
89
+ path: null,
90
+ message: "The snapshot's graph hash is not the hash of the graph being published.",
91
+ remediation:
92
+ "Publish from the graph the snapshot names; provenance must identify the exact snapshot (Core §7, §9).",
93
+ }),
94
+ );
95
+ }
96
+ if (checked.request === null || diagnostics.length > 0) {
97
+ return { eligible: [], excluded: [], diagnostics, request: null };
98
+ }
99
+ const req = checked.request;
100
+ const requested = req.ids === undefined ? null : new Set(req.ids);
101
+ const reviews = new Map<string, ReviewStatus>(options.reviews ?? []);
102
+ const review = (artifact: Artifact): ReviewStatus => {
103
+ let status = reviews.get(artifact.meta.id);
104
+ if (status === undefined) {
105
+ status = reviewStatus(graph, artifact);
106
+ reviews.set(artifact.meta.id, status);
107
+ }
108
+ return status;
109
+ };
110
+
111
+ const eligible: Artifact[] = [];
112
+ const excluded: Exclusion[] = [];
113
+ const reasons = new Map<string, ExclusionReason>();
114
+ for (const id of [...graph.artifacts.keys()].sort(compareStrings)) {
115
+ const artifact = graph.artifacts.get(id) as Artifact;
116
+ const reason = requested !== null && !requested.has(id) ? "not-requested" : exclusionReason(artifact, req, review);
117
+ if (reason === null) eligible.push(artifact);
118
+ else {
119
+ excluded.push({ id, reason });
120
+ reasons.set(id, reason);
121
+ }
122
+ }
123
+
124
+ for (const id of req.ids ?? []) {
125
+ const reason = reasons.get(id);
126
+ const artifact = graph.artifacts.get(id);
127
+ if (artifact !== undefined && reason === undefined) continue;
128
+ diagnostics.push(refusal(id, artifact, reason ?? null, req, artifact === undefined ? null : review(artifact)));
129
+ }
130
+ return { eligible, excluded, diagnostics, request: req };
131
+ }
132
+
133
+ /** The first check an artifact fails for this request, or null when it is eligible. Requested-ness is the caller's. */
134
+ export function exclusionReason(
135
+ artifact: Artifact,
136
+ request: PublicationRequest,
137
+ review: (artifact: Artifact) => ReviewStatus,
138
+ ): Exclude<ExclusionReason, "not-requested"> | null {
139
+ const { meta } = artifact;
140
+ if (meta.type !== "knowledge") return "not-knowledge";
141
+ if (meta.status === "draft") return "draft";
142
+ if (meta.status === "retired") return "retired";
143
+ if (!PUBLISHABLE_STATUSES.has(meta.status)) return "draft";
144
+ if (!admits(request, meta.visibility)) return "visibility";
145
+ if (!meta.audiences.includes(request.audience)) return "audience";
146
+ const availability = meta.availability;
147
+ if (availability === undefined) return "availability-missing";
148
+ if (!availability.products.includes(request.product)) return "product";
149
+ if (!availability.releases.includes(request.release)) return "release";
150
+ if (!availability.roles.includes(request.role)) return "role";
151
+ if (!availability.editions.includes(request.edition)) return "edition";
152
+ if (!availability.flags.every((flag) => request.flags.includes(flag))) return "flag";
153
+ if (review(artifact).status !== "current") return "needs-review";
154
+ return null;
155
+ }
156
+
157
+ /** CORE008 for one requested ID. */
158
+ function refusal(
159
+ id: string,
160
+ artifact: Artifact | undefined,
161
+ reason: ExclusionReason | null,
162
+ request: PublicationRequest,
163
+ review: ReviewStatus | null,
164
+ ): Diagnostic {
165
+ if (artifact === undefined || !admits(request, artifact.meta.visibility)) {
166
+ return publicationDiagnostic({
167
+ code: "CORE008",
168
+ artifact: id,
169
+ path: null,
170
+ message: `${id} was requested but is not publishable (visibility): no artifact with that ID is visible to a ${request.visibility} projection.`,
171
+ remediation:
172
+ "Request only knowledge this projection admits; anything else needs a separately authorized projection (Core §9).",
173
+ });
174
+ }
175
+ const { meta } = artifact;
176
+ const at = { artifact: id, path: artifact.path };
177
+ const refuse = (field: string | undefined, detail: string, remediation: string) =>
178
+ publicationDiagnostic({
179
+ code: "CORE008",
180
+ ...at,
181
+ field,
182
+ message: `${id} was requested but is not publishable (${reason}): ${detail}.`,
183
+ remediation,
184
+ });
185
+ switch (reason) {
186
+ case "not-knowledge":
187
+ return refuse(
188
+ "/intentset/type",
189
+ `it is a ${meta.type}, and only knowledge is published`,
190
+ "Request the knowledge that explains it; engineering artifacts are never published as documents (Core §9).",
191
+ );
192
+ case "draft":
193
+ return refuse(
194
+ "/intentset/status",
195
+ `its status is ${meta.status}, and drafts are never published`,
196
+ "Review the knowledge and set its status to approved or later before publishing it (Core §7, §9).",
197
+ );
198
+ case "retired":
199
+ return refuse(
200
+ "/intentset/status",
201
+ "it is retired, which is excluded from current publication",
202
+ "Publish the successor named in replacedBy instead (Core §7).",
203
+ );
204
+ case "audience":
205
+ return refuse(
206
+ "/intentset/audiences",
207
+ `it is not written for the ${request.audience} audience`,
208
+ "Request an audience the knowledge lists, or add this one after reviewing the text for it (Core §9).",
209
+ );
210
+ case "availability-missing":
211
+ return refuse(
212
+ "/intentset/availability",
213
+ "it declares no availability, so publication fails closed",
214
+ "Add availability with products, releases, roles, editions and flags (Core §7).",
215
+ );
216
+ case "product":
217
+ case "release":
218
+ case "role":
219
+ case "edition":
220
+ return refuse(
221
+ `/intentset/availability/${reason}s`,
222
+ `it is not available for ${reason} ${request[reason]}`,
223
+ `Request a ${reason} the knowledge lists, or extend its availability after review; values match exactly (Core §7).`,
224
+ );
225
+ case "flag": {
226
+ const needed = (meta.availability?.flags ?? [])
227
+ .filter((flag) => !request.flags.includes(flag))
228
+ .sort(compareStrings);
229
+ return refuse(
230
+ "/intentset/availability/flags",
231
+ `it requires flag${needed.length === 1 ? "" : "s"} ${needed.join(", ")}, which the request does not name`,
232
+ "Request every flag the knowledge names; all named flags are required (Core §7).",
233
+ );
234
+ }
235
+ default:
236
+ return refuse(
237
+ review !== null && review.reviewer === null
238
+ ? "/intentset/reviewedBy"
239
+ : `/intentset/extensions/intentset.org~1review`,
240
+ `it needs review because ${describeReview(review)}`,
241
+ "Review the knowledge against its current sources, then update reviewedBy, reviewedAt and the intentset.org/review pins (Core §9).",
242
+ );
243
+ }
244
+ }
245
+
246
+ function describeReview(review: ReviewStatus | null): string {
247
+ if (review === null) return "its review could not be read";
248
+ const parts: string[] = [];
249
+ if (review.changed.length > 0) parts.push(`${review.changed.join(", ")} changed since it was reviewed`);
250
+ if (review.missing.length > 0) parts.push(`its review pins nothing for ${review.missing.join(", ")}`);
251
+ if (review.reviewer === null) parts.push("it names no reviewer");
252
+ if (review.reviewedAt === null) parts.push("it gives no review date");
253
+ return parts.length === 0 ? "its review is not current" : parts.join(", and ");
254
+ }
package/src/publish.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Publication (Core §9): project, check references, generate, render.
3
+ *
4
+ * canonical files -> validated graph -> exact snapshot
5
+ * -> authorized audience projection (project.ts)
6
+ * -> reviewed knowledge (review.ts)
7
+ * -> references and titles checked (references.ts)
8
+ * -> generated Markset (generate.ts) -> HTML
9
+ *
10
+ * Selection happens before any text reaches a renderer. A document whose
11
+ * references, titles or Markset fail is not emitted. Links are checked against
12
+ * what is actually being published, so a document that links to one refused
13
+ * here is refused in turn; this repeats until nothing more is refused.
14
+ *
15
+ * The index is what a customer-facing tool may read (Core §10): the snapshot,
16
+ * the request without its authorization flags, the published IDs, and the
17
+ * number of artifacts excluded for each reason. It names nothing that was not
18
+ * published.
19
+ *
20
+ * Deterministic: the same graph, request, snapshot and `publishedAt` give the
21
+ * same bytes.
22
+ */
23
+ import { readFileSync } from "node:fs";
24
+ import {
25
+ type Artifact,
26
+ type Diagnostic,
27
+ type Graph,
28
+ type Registries,
29
+ canonicalJson,
30
+ hasErrors,
31
+ sortDiagnostics,
32
+ } from "@intentset/core";
33
+ import { parseDocument } from "@markset-lang/parser";
34
+ import { defaultStylesheetPath, renderPage } from "@markset-lang/render-html";
35
+ import { compareStrings } from "./diagnostic.ts";
36
+ import { type GeneratedDocument, type PublicationMeta, generateDocument } from "./generate.ts";
37
+ import { type ExclusionReason, type Snapshot, project } from "./project.ts";
38
+ import { checkReferences, checkTitles } from "./references.ts";
39
+ import { type IndexedRequest, type PublicationRequest, admits, indexedRequest } from "./request.ts";
40
+ import type { ReviewStatus } from "./review.ts";
41
+
42
+ /** Why a document that passed the projection was still not published. */
43
+ export const REFUSAL_REASONS = ["reference", "title", "directive", "markset"] as const;
44
+ export type RefusalReason = (typeof REFUSAL_REASONS)[number];
45
+
46
+ export interface PublishOptions {
47
+ snapshot: Snapshot;
48
+ /** The publication timestamp written into every document; an input, so output is reproducible. */
49
+ publishedAt: string;
50
+ /** Also render each document as a complete HTML page with Markset's default stylesheet inlined. */
51
+ renderHtml?: boolean;
52
+ reviews?: ReadonlyMap<string, ReviewStatus>;
53
+ }
54
+
55
+ export interface PublishedDocument extends GeneratedDocument {
56
+ html?: string;
57
+ }
58
+
59
+ export interface PublicationIndex {
60
+ snapshot: Snapshot;
61
+ /** The request without authorization flags, or null when it was refused. */
62
+ request: IndexedRequest | null;
63
+ published: string[];
64
+ /** Exclusions by reason, keys sorted, zero counts left out. */
65
+ excludedCounts: Record<string, number>;
66
+ }
67
+
68
+ export interface PublishResult {
69
+ documents: PublishedDocument[];
70
+ index: PublicationIndex;
71
+ diagnostics: Diagnostic[];
72
+ ok: boolean;
73
+ }
74
+
75
+ export function publish(
76
+ graph: Graph,
77
+ registries: Registries,
78
+ request: PublicationRequest,
79
+ options: PublishOptions,
80
+ ): PublishResult {
81
+ const projection = project(graph, registries, request, { snapshot: options.snapshot, reviews: options.reviews });
82
+ const diagnostics = [...projection.diagnostics];
83
+ const counts = new Map<string, number>();
84
+ const count = (reason: ExclusionReason | RefusalReason) => counts.set(reason, (counts.get(reason) ?? 0) + 1);
85
+ // Only what the projection could see is counted: a customer index that said
86
+ // "not-knowledge: 12" would tell its reader how many internal records exist.
87
+ // A refused request admits nothing, so it counts nothing (invariant 4).
88
+ for (const exclusion of projection.excluded) {
89
+ const visibility = graph.artifacts.get(exclusion.id)?.meta.visibility;
90
+ if (projection.request !== null && visibility !== undefined && admits(projection.request, visibility)) {
91
+ count(exclusion.reason);
92
+ }
93
+ }
94
+
95
+ const req = projection.request;
96
+ const documents: PublishedDocument[] = [];
97
+ if (req !== null) {
98
+ const candidates = projection.eligible;
99
+ const allowedTitles = new Set(candidates.map((artifact) => artifact.meta.title.trim()));
100
+ const refused = new Map<string, RefusalReason>();
101
+ const generated = new Map<string, GeneratedDocument>();
102
+
103
+ for (const artifact of candidates) {
104
+ const titles = checkTitles(artifact, graph, req, allowedTitles);
105
+ const result = generateDocument(graph, artifact, req, options);
106
+ diagnostics.push(...titles, ...result.diagnostics);
107
+ if (result.document !== null) generated.set(artifact.meta.id, result.document);
108
+ if (titles.length > 0) refused.set(artifact.meta.id, "title");
109
+ else if (result.diagnostics.some((d) => d.code === "PUB004")) refused.set(artifact.meta.id, "directive");
110
+ else if (result.document === null) refused.set(artifact.meta.id, "markset");
111
+ }
112
+
113
+ let published = new Set(candidates.map((artifact) => artifact.meta.id).filter((id) => !refused.has(id)));
114
+ let round: Artifact[] = candidates;
115
+ while (round.length > 0) {
116
+ const failed: string[] = [];
117
+ for (const artifact of round) {
118
+ const found = checkReferences(artifact, graph, req, published);
119
+ if (found.length === 0) continue;
120
+ diagnostics.push(...found);
121
+ failed.push(artifact.meta.id);
122
+ refused.set(artifact.meta.id, "reference");
123
+ }
124
+ published = new Set([...published].filter((id) => !failed.includes(id)));
125
+ round = failed.length === 0 ? [] : candidates.filter((artifact) => published.has(artifact.meta.id));
126
+ }
127
+
128
+ for (const reason of refused.values()) count(reason);
129
+ for (const id of [...published].sort(compareStrings)) {
130
+ const document: PublishedDocument = { ...(generated.get(id) as GeneratedDocument) };
131
+ if (options.renderHtml === true) document.html = renderDocument(document.markset, document.publication);
132
+ documents.push(document);
133
+ }
134
+ }
135
+
136
+ const excludedCounts: Record<string, number> = {};
137
+ for (const reason of [...counts.keys()].sort(compareStrings)) excludedCounts[reason] = counts.get(reason) as number;
138
+ const sorted = sortDiagnostics(diagnostics);
139
+ return {
140
+ documents,
141
+ index: {
142
+ snapshot: { commit: options.snapshot.commit, graphHash: options.snapshot.graphHash },
143
+ request: req === null ? null : indexedRequest(req),
144
+ published: documents.map((document) => document.id),
145
+ excludedCounts,
146
+ },
147
+ diagnostics: sorted,
148
+ ok: !hasErrors(sorted),
149
+ };
150
+ }
151
+
152
+ let stylesheet: string | null = null;
153
+
154
+ /** Markset's default stylesheet, read once. */
155
+ function defaultStylesheet(): string {
156
+ stylesheet ??= readFileSync(defaultStylesheetPath, "utf8");
157
+ return stylesheet;
158
+ }
159
+
160
+ /**
161
+ * A complete page through render-html, with the provenance block carried into
162
+ * the head, after the charset, as one `<meta name="intentset-publication">`
163
+ * whose content is the block's canonical JSON, so the HTML keeps what the
164
+ * Markset source keeps.
165
+ */
166
+ export function renderDocument(markset: string, publication: PublicationMeta): string {
167
+ const page = renderPage(parseDocument(markset).ast, { stylesheet: { inline: defaultStylesheet() } });
168
+ const meta = `<meta name="intentset-publication" content="${escapeAttribute(canonicalJson(publication))}">\n`;
169
+ const anchor = '<meta charset="utf-8">\n';
170
+ const at = page.indexOf(anchor);
171
+ if (at === -1) throw new Error("render-html returned a page without a charset declaration");
172
+ return page.slice(0, at + anchor.length) + meta + page.slice(at + anchor.length);
173
+ }
174
+
175
+ function escapeAttribute(value: string): string {
176
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
177
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * References out of a knowledge body (Core §9, catalog P02).
3
+ *
4
+ * A published document may refer only to what its projection may show. Three
5
+ * kinds of reference are found:
6
+ *
7
+ * id a bare artifact ID in the text, word-bounded, code included
8
+ * link a Markdown link, image or definition, or an href/src in raw HTML,
9
+ * whose destination resolves to an artifact's file
10
+ * path an artifact's repository path written out in the text
11
+ *
12
+ * An ID reference fails when the artifact's visibility is outside the
13
+ * projection: the ID itself is then something this audience is not shown. A
14
+ * link or path reference fails when the artifact is not published in this
15
+ * projection, since the reader would be sent to, or told of, a canonical
16
+ * file. The product the request names is not a leak when mentioned by ID.
17
+ *
18
+ * Separately, the text may not contain the title of an artifact the
19
+ * projection does not admit (three or more words, case-sensitive, any run of
20
+ * whitespace between words), unless that title is also the title of
21
+ * something being published. That is PUB002.
22
+ *
23
+ * Every message names the referencing document and the referenced ID, and
24
+ * never the referenced artifact's title or path.
25
+ */
26
+ import { posix } from "node:path";
27
+ import { type Artifact, type Diagnostic, type Graph, ID_PATTERN } from "@intentset/core";
28
+ import { parseDocument } from "@markset-lang/parser";
29
+ import { bodyLineOffset, fileLocation, lineAt } from "./body.ts";
30
+ import { compareStrings, publicationDiagnostic } from "./diagnostic.ts";
31
+ import { type PublicationRequest, admits } from "./request.ts";
32
+
33
+ export type ReferenceKind = "id" | "link" | "path";
34
+
35
+ export interface Reference {
36
+ /** The referenced artifact's ID. */
37
+ id: string;
38
+ via: ReferenceKind;
39
+ /** 1-based line in the body. */
40
+ line: number;
41
+ }
42
+
43
+ /** ID_PATTERN, unanchored and bounded so it does not match inside a longer token. */
44
+ const VIA_WORDS: Record<ReferenceKind, string> = { id: "ID", link: "link", path: "path" };
45
+
46
+ /** "ID", "ID and link", "ID, link and path". */
47
+ function listing(words: readonly string[]): string {
48
+ return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
49
+ }
50
+
51
+ const ID_IN_TEXT = new RegExp(`(?<![A-Za-z0-9_-])${ID_PATTERN.source.slice(1, -1)}(?![A-Za-z0-9_-])`, "g");
52
+ const HTML_TARGET = /\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
53
+
54
+ /** Every reference from a knowledge body to another artifact in the graph, sorted by ID, kind, line. */
55
+ export function findReferences(artifact: Artifact, graph: Graph): Reference[] {
56
+ const self = artifact.meta.id;
57
+ const body = artifact.body;
58
+ const found: Reference[] = [];
59
+ const add = (id: string, via: ReferenceKind, line: number) => {
60
+ if (id !== self && graph.artifacts.has(id)) found.push({ id, via, line });
61
+ };
62
+
63
+ for (const match of body.matchAll(ID_IN_TEXT)) add(match[0], "id", lineAt(body, match.index));
64
+
65
+ const byPath = new Map<string, string>();
66
+ for (const [id, other] of graph.artifacts) byPath.set(other.path, id);
67
+ const visit = (node: unknown): void => {
68
+ if (typeof node !== "object" || node === null) return;
69
+ const n = node as {
70
+ type?: string;
71
+ url?: unknown;
72
+ value?: unknown;
73
+ position?: { start: { line: number } };
74
+ children?: unknown[];
75
+ };
76
+ const line = n.position?.start.line ?? 1;
77
+ const targets: string[] = [];
78
+ if ((n.type === "link" || n.type === "image" || n.type === "definition") && typeof n.url === "string")
79
+ targets.push(n.url);
80
+ if (n.type === "html" && typeof n.value === "string") {
81
+ for (const match of n.value.matchAll(HTML_TARGET)) targets.push(match[1] ?? match[2] ?? "");
82
+ }
83
+ for (const url of targets) {
84
+ const path = resolveTarget(url, artifact.path);
85
+ const id = path === null ? undefined : byPath.get(path);
86
+ if (id !== undefined) add(id, "link", line);
87
+ }
88
+ for (const child of n.children ?? []) visit(child);
89
+ };
90
+ visit(parseDocument(body).ast);
91
+
92
+ for (const [id, other] of graph.artifacts) {
93
+ let from = 0;
94
+ for (;;) {
95
+ const index = body.indexOf(other.path, from);
96
+ if (index === -1) break;
97
+ const before = index === 0 ? "" : body[index - 1];
98
+ const after = body[index + other.path.length] ?? "";
99
+ if (!/[A-Za-z0-9_./-]/.test(before) && !/[A-Za-z0-9_/-]/.test(after)) {
100
+ add(id, "path", lineAt(body, index));
101
+ break;
102
+ }
103
+ from = index + 1;
104
+ }
105
+ }
106
+
107
+ return found.sort((a, b) => compareStrings(a.id, b.id) || compareStrings(a.via, b.via) || a.line - b.line);
108
+ }
109
+
110
+ /**
111
+ * Resolve a link destination against the linking document to a repository
112
+ * path. Null for anything that is not a repository file: a URL with a scheme,
113
+ * a protocol-relative URL, a fragment, or a path that leaves the repository.
114
+ */
115
+ export function resolveTarget(url: string, fromPath: string): string | null {
116
+ const trimmed = url.trim();
117
+ if (
118
+ trimmed === "" ||
119
+ trimmed.startsWith("#") ||
120
+ trimmed.startsWith("//") ||
121
+ /^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)
122
+ ) {
123
+ return null;
124
+ }
125
+ let target = trimmed.split("#")[0].split("?")[0];
126
+ try {
127
+ target = decodeURIComponent(target);
128
+ } catch {
129
+ // A malformed escape is compared as written.
130
+ }
131
+ if (target === "") return null;
132
+ const joined = target.startsWith("/") ? target.replace(/^\/+/, "") : posix.join(posix.dirname(fromPath), target);
133
+ const normal = posix.normalize(joined);
134
+ if (normal === ".." || normal.startsWith("../")) return null;
135
+ return normal.replace(/^\.\//, "");
136
+ }
137
+
138
+ /**
139
+ * CORE008 for each referenced artifact this projection may not show: one
140
+ * diagnostic per referenced ID, at the first failing reference's line.
141
+ */
142
+ export function checkReferences(
143
+ artifact: Artifact,
144
+ graph: Graph,
145
+ request: PublicationRequest,
146
+ published: ReadonlySet<string>,
147
+ ): Diagnostic[] {
148
+ const offset = bodyLineOffset(artifact);
149
+ const failing = new Map<string, Reference[]>();
150
+ for (const ref of findReferences(artifact, graph)) {
151
+ const target = graph.artifacts.get(ref.id) as Artifact;
152
+ const fails =
153
+ ref.via === "id"
154
+ ? ref.id !== request.product && !admits(request, target.meta.visibility)
155
+ : !published.has(ref.id);
156
+ if (!fails) continue;
157
+ const list = failing.get(ref.id);
158
+ if (list === undefined) failing.set(ref.id, [ref]);
159
+ else list.push(ref);
160
+ }
161
+ const diagnostics: Diagnostic[] = [];
162
+ for (const id of [...failing.keys()].sort(compareStrings)) {
163
+ const refs = failing.get(id) as Reference[];
164
+ const vias = [...new Set(refs.map((ref) => VIA_WORDS[ref.via]))];
165
+ const first = Math.min(...refs.map((ref) => ref.line));
166
+ diagnostics.push(
167
+ publicationDiagnostic({
168
+ code: "CORE008",
169
+ artifact: artifact.meta.id,
170
+ path: artifact.path,
171
+ location: fileLocation(offset, first),
172
+ message: `${artifact.meta.id} refers to ${id} by ${listing(vias)}, which this ${request.visibility} projection does not publish.`,
173
+ remediation:
174
+ "Remove the reference, or replace it with reviewed wording safe for this audience; a published document may refer only to what its projection shows (Core §9).",
175
+ }),
176
+ );
177
+ }
178
+ return diagnostics;
179
+ }
180
+
181
+ /**
182
+ * PUB002 for each artifact outside the projection whose title (three or more
183
+ * words) the body contains, unless that title is in `allowedTitles` (the
184
+ * document's own and those of what is being published).
185
+ */
186
+ export function checkTitles(
187
+ artifact: Artifact,
188
+ graph: Graph,
189
+ request: PublicationRequest,
190
+ allowedTitles: ReadonlySet<string>,
191
+ ): Diagnostic[] {
192
+ const offset = bodyLineOffset(artifact);
193
+ const diagnostics: Diagnostic[] = [];
194
+ for (const id of [...graph.artifacts.keys()].sort(compareStrings)) {
195
+ const other = graph.artifacts.get(id) as Artifact;
196
+ if (id === artifact.meta.id || admits(request, other.meta.visibility)) continue;
197
+ const title = other.meta.title.trim();
198
+ if (title.split(/\s+/).length < 3 || allowedTitles.has(title)) continue;
199
+ const match = titlePattern(title).exec(artifact.body);
200
+ if (match === null) continue;
201
+ diagnostics.push(
202
+ publicationDiagnostic({
203
+ code: "PUB002",
204
+ artifact: artifact.meta.id,
205
+ path: artifact.path,
206
+ location: fileLocation(offset, lineAt(artifact.body, match.index)),
207
+ message: `${artifact.meta.id} contains the title of ${id}, which this ${request.visibility} projection does not admit.`,
208
+ remediation:
209
+ "Reword the guidance so it does not repeat the title of an artifact this audience may not see (Core §9).",
210
+ }),
211
+ );
212
+ }
213
+ return diagnostics;
214
+ }
215
+
216
+ /** The title as a case-sensitive pattern: any whitespace between words, word-bounded at either end that is a word character. */
217
+ export function titlePattern(title: string): RegExp {
218
+ const words = title.trim().split(/\s+/).map(escapeRegExp);
219
+ const start = /^[\p{L}\p{N}_]/u.test(title.trim()) ? "(?<![\\p{L}\\p{N}_])" : "";
220
+ const end = /[\p{L}\p{N}_]$/u.test(title.trim()) ? "(?![\\p{L}\\p{N}_])" : "";
221
+ return new RegExp(`${start}${words.join("\\s+")}${end}`, "u");
222
+ }
223
+
224
+ function escapeRegExp(text: string): string {
225
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
226
+ }