@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/chunks.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Retrieval chunks (Core §9): each published document split by level-two
3
+ * heading, then by paragraph where a section is longer than `maxChars`. Every
4
+ * chunk carries its document's publication block unchanged, so the access
5
+ * filters and provenance that held for the document hold for each piece of it
6
+ * and a retriever filters on the same fields before anything is assembled.
7
+ *
8
+ * Only published documents are chunked: chunks are made from the output of
9
+ * `publish`, never from canonical files.
10
+ */
11
+ import { collectHeadings } from "@intentset/core";
12
+ import type { PublicationMeta } from "./generate.ts";
13
+
14
+ export interface ChunkOptions {
15
+ /** Upper bound on a chunk's text length; a single word longer than it is cut. Default 1200. */
16
+ maxChars?: number;
17
+ }
18
+
19
+ export interface Chunk {
20
+ /** `<document ID>/<n>`, n from 1 in document order. */
21
+ id: string;
22
+ document: string;
23
+ /** The document's level-one heading, or its ID when it has none. */
24
+ title: string;
25
+ /** The level-two heading the chunk falls under, or null before the first one. */
26
+ heading: string | null;
27
+ text: string;
28
+ publication: PublicationMeta;
29
+ }
30
+
31
+ /** What `toChunks` needs of a published document. */
32
+ export interface ChunkSource {
33
+ id: string;
34
+ markset: string;
35
+ publication: PublicationMeta;
36
+ }
37
+
38
+ export function toChunks(documents: readonly ChunkSource[], options: ChunkOptions = {}): Chunk[] {
39
+ const maxChars = options.maxChars ?? 1200;
40
+ if (!Number.isInteger(maxChars) || maxChars < 1) throw new RangeError("maxChars must be a positive integer");
41
+ const chunks: Chunk[] = [];
42
+ for (const document of [...documents].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
43
+ const lines = bodyLines(document.markset);
44
+ const headings = collectHeadings(lines, 0);
45
+ const title = headings.find((h) => h.depth === 1)?.text ?? document.id;
46
+ const skip = new Set(headings.filter((h) => h.depth <= 2).map((h) => h.line - 1));
47
+ const sections: { heading: string | null; lines: string[] }[] = [{ heading: null, lines: [] }];
48
+ const starts = new Map(headings.filter((h) => h.depth === 2).map((h) => [h.line - 1, h.text]));
49
+ lines.forEach((line, i) => {
50
+ const heading = starts.get(i);
51
+ if (heading !== undefined) sections.push({ heading, lines: [] });
52
+ else if (!skip.has(i)) sections[sections.length - 1].lines.push(line);
53
+ });
54
+ let n = 0;
55
+ for (const section of sections) {
56
+ for (const text of pack(blocks(section.lines), maxChars)) {
57
+ n++;
58
+ chunks.push({
59
+ id: `${document.id}/${n}`,
60
+ document: document.id,
61
+ title,
62
+ heading: section.heading,
63
+ text,
64
+ publication: document.publication,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ return chunks;
70
+ }
71
+
72
+ /** The body of a generated document: after the frontmatter and the generated-file marker. */
73
+ function bodyLines(markset: string): string[] {
74
+ const lines = markset.split("\n");
75
+ let start = 0;
76
+ if (lines[0] === "---") {
77
+ const close = lines.indexOf("---", 1);
78
+ start = close === -1 ? 0 : close + 1;
79
+ }
80
+ if (lines[start]?.startsWith("<!-- Generated by intentset publish")) start++;
81
+ return lines.slice(start);
82
+ }
83
+
84
+ /** Blank-line separated blocks, with a fenced code block kept whole. */
85
+ function blocks(lines: readonly string[]): string[] {
86
+ const out: string[] = [];
87
+ let current: string[] = [];
88
+ let fence: string | null = null;
89
+ const flush = () => {
90
+ if (current.some((line) => line.trim() !== "")) out.push(current.join("\n").trim());
91
+ current = [];
92
+ };
93
+ for (const line of lines) {
94
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1] ?? null;
95
+ if (fence === null && marker !== null) fence = marker;
96
+ else if (fence !== null && marker !== null && marker[0] === fence[0] && marker.length >= fence.length) fence = null;
97
+ if (fence === null && marker === null && line.trim() === "") flush();
98
+ else current.push(line);
99
+ }
100
+ flush();
101
+ return out;
102
+ }
103
+
104
+ /** Greedy packing of blocks into texts no longer than `max`, splitting a block only when it alone is longer. */
105
+ function pack(items: readonly string[], max: number): string[] {
106
+ const out: string[] = [];
107
+ let current = "";
108
+ for (const item of items.flatMap((block) => split(block, max))) {
109
+ if (current === "") current = item;
110
+ else if (current.length + 2 + item.length <= max) current = `${current}\n\n${item}`;
111
+ else {
112
+ out.push(current);
113
+ current = item;
114
+ }
115
+ }
116
+ if (current !== "") out.push(current);
117
+ return out;
118
+ }
119
+
120
+ /** A block cut at whitespace into pieces no longer than `max`; a word longer than `max` is cut where it must be. */
121
+ function split(block: string, max: number): string[] {
122
+ const out: string[] = [];
123
+ let rest = block;
124
+ while (rest.length > max) {
125
+ const cut = Math.max(rest.lastIndexOf(" ", max), rest.lastIndexOf("\n", max));
126
+ const at = cut > 0 ? cut : max;
127
+ out.push(rest.slice(0, at).trimEnd());
128
+ rest = rest.slice(at).trimStart();
129
+ }
130
+ if (rest !== "") out.push(rest);
131
+ return out;
132
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Publication diagnostics (ADR 0004): one shape, origin "publication",
3
+ * members in contract order, optional members present only when known.
4
+ *
5
+ * One rule governs every message built with this: a diagnostic may name an
6
+ * artifact the projection does not admit by its ID, never by its title or
7
+ * its path (Core §9, CLAUDE.md invariant 4). Callers pass `path: null` for
8
+ * such an artifact.
9
+ */
10
+ import type { Diagnostic, Location } from "@intentset/core";
11
+
12
+ export interface PublicationFields {
13
+ code: string;
14
+ severity?: "error" | "warning";
15
+ artifact: string | null;
16
+ path: string | null;
17
+ location?: Location;
18
+ field?: string;
19
+ message: string;
20
+ remediation: string;
21
+ }
22
+
23
+ export function publicationDiagnostic(fields: PublicationFields): Diagnostic {
24
+ return {
25
+ code: fields.code,
26
+ severity: fields.severity ?? "error",
27
+ origin: "publication",
28
+ artifact: fields.artifact,
29
+ path: fields.path,
30
+ ...(fields.location !== undefined ? { location: fields.location } : {}),
31
+ ...(fields.field !== undefined ? { field: fields.field } : {}),
32
+ message: fields.message,
33
+ remediation: fields.remediation,
34
+ };
35
+ }
36
+
37
+ export function compareStrings(a: string, b: string): number {
38
+ return a < b ? -1 : a > b ? 1 : 0;
39
+ }
40
+
41
+ /** Sorted, duplicates removed. */
42
+ export function sortedUnique(values: Iterable<string>): string[] {
43
+ return [...new Set(values)].sort(compareStrings);
44
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Generated Markset for one published knowledge artifact (Core §9,
3
+ * spec/publication.md).
4
+ *
5
+ * The document is the knowledge body, unchanged, under frontmatter that
6
+ * carries `markset: 0` and a top-level `intentset-publication` block: the
7
+ * generated profile `intentset/publication/0.1`, marked derived, with the
8
+ * provenance Core §9 requires. It is a top-level key, not `intentset:`, so
9
+ * nothing reads a generated document as a canonical record. A comment line
10
+ * after the frontmatter says the same to a person opening the file.
11
+ *
12
+ * Provenance names only what the projection may show. The knowledge's own
13
+ * path appears only in a projection that admits internal records; sources
14
+ * outside the projection are counted, not named; availability is the
15
+ * request's own values, so a document does not list the other editions,
16
+ * roles or releases its knowledge also serves.
17
+ *
18
+ * The result is checked as Markset before it is emitted: a Markset error is
19
+ * PUB003, and a `:::` directive outside Markset's vocabulary is PUB004
20
+ * (CLAUDE.md invariant 5: profiles never add a directive).
21
+ */
22
+ import type { Artifact, Diagnostic, Graph, Visibility } from "@intentset/core";
23
+ import { marksetCarrier } from "@intentset/markset-adapter";
24
+ import { BLOCK_DIRECTIVE_NAMES, parseDocument } from "@markset-lang/parser";
25
+ import { bodyLineOffset } from "./body.ts";
26
+ import { publicationDiagnostic, sortedUnique } from "./diagnostic.ts";
27
+ import type { Snapshot } from "./project.ts";
28
+ import { type PublicationRequest, admits } from "./request.ts";
29
+ import { type YamlValue, emitYaml } from "./yaml.ts";
30
+
31
+ export const PUBLICATION_PROFILE = "intentset/publication/0.1";
32
+ /** The top-level frontmatter key of a generated document. */
33
+ export const PUBLICATION_KEY = "intentset-publication";
34
+
35
+ export interface SourceStamp {
36
+ id: string;
37
+ /** The artifact's `revision`, or null when it declares none. */
38
+ revision: number | null;
39
+ sourceHash: string;
40
+ }
41
+
42
+ /** The `intentset-publication` block, in the order it is written. */
43
+ export interface PublicationMeta {
44
+ profile: typeof PUBLICATION_PROFILE;
45
+ derived: true;
46
+ /** The projection the document was generated for; the document is as restricted as that projection. */
47
+ projection: Visibility;
48
+ /** The knowledge itself; `path` only in a projection that admits internal records. */
49
+ source: SourceStamp & { path?: string };
50
+ /** Explained artifacts the projection admits, sorted by ID. */
51
+ sources: SourceStamp[];
52
+ /** How many explained artifacts are outside the projection and so not named. */
53
+ withheldSources: number;
54
+ snapshot: Snapshot;
55
+ audience: string;
56
+ /** The request's values, and the flags the knowledge requires (every one of them named by the request). */
57
+ availability: { product: string; release: string; role: string; edition: string; flags: string[] };
58
+ reviewer: string;
59
+ reviewedAt: string;
60
+ publishedAt: string;
61
+ }
62
+
63
+ export interface GenerateOptions {
64
+ snapshot: Snapshot;
65
+ publishedAt: string;
66
+ }
67
+
68
+ export interface GeneratedDocument {
69
+ id: string;
70
+ /** `<ID>.md`. */
71
+ path: string;
72
+ markset: string;
73
+ publication: PublicationMeta;
74
+ }
75
+
76
+ /** The line after the frontmatter that marks a generated file. */
77
+ export function markerLine(id: string): string {
78
+ return `<!-- Generated by intentset publish from ${id}; do not edit. -->`;
79
+ }
80
+
81
+ /** The provenance block for a knowledge artifact already selected by the projection and current in review. */
82
+ export function publicationMeta(
83
+ graph: Graph,
84
+ artifact: Artifact,
85
+ request: PublicationRequest,
86
+ options: GenerateOptions,
87
+ ): PublicationMeta {
88
+ const { meta } = artifact;
89
+ if (meta.reviewedBy === undefined || meta.reviewedAt === undefined) {
90
+ throw new Error(`${meta.id} has no review; only knowledge the projection selected is generated`);
91
+ }
92
+ const source: PublicationMeta["source"] = {
93
+ id: meta.id,
94
+ revision: meta.revision ?? null,
95
+ sourceHash: artifact.sourceHash,
96
+ };
97
+ if (admits(request, "internal")) source.path = artifact.path;
98
+ const sources: SourceStamp[] = [];
99
+ let withheldSources = 0;
100
+ for (const id of sortedUnique(meta.links.explains ?? [])) {
101
+ const explained = graph.artifacts.get(id);
102
+ if (explained === undefined || !admits(request, explained.meta.visibility)) withheldSources++;
103
+ else sources.push({ id, revision: explained.meta.revision ?? null, sourceHash: explained.sourceHash });
104
+ }
105
+ return {
106
+ profile: PUBLICATION_PROFILE,
107
+ derived: true,
108
+ projection: request.visibility,
109
+ source,
110
+ sources,
111
+ withheldSources,
112
+ snapshot: { commit: options.snapshot.commit, graphHash: options.snapshot.graphHash },
113
+ audience: request.audience,
114
+ availability: {
115
+ product: request.product,
116
+ release: request.release,
117
+ role: request.role,
118
+ edition: request.edition,
119
+ flags: sortedUnique((meta.availability?.flags ?? []).filter((flag) => request.flags.includes(flag))),
120
+ },
121
+ reviewer: meta.reviewedBy,
122
+ reviewedAt: meta.reviewedAt,
123
+ publishedAt: options.publishedAt,
124
+ };
125
+ }
126
+
127
+ /** The document text, and the 1-based line the body's first line lands on. */
128
+ export function marksetSource(publication: PublicationMeta, body: string): { source: string; bodyStartLine: number } {
129
+ const yaml = emitYaml({ markset: 0, [PUBLICATION_KEY]: publication as unknown as YamlValue });
130
+ const head = `---\n${yaml}---\n${markerLine(publication.source.id)}\n`;
131
+ const separated = body.startsWith("\n");
132
+ const headLines = head.split("\n").length - 1;
133
+ return {
134
+ source: separated ? head + body : `${head}\n${body}`,
135
+ bodyStartLine: headLines + (separated ? 1 : 2),
136
+ };
137
+ }
138
+
139
+ /** Generate and check one document. A document with any error is not returned. */
140
+ export function generateDocument(
141
+ graph: Graph,
142
+ artifact: Artifact,
143
+ request: PublicationRequest,
144
+ options: GenerateOptions,
145
+ ): { document: GeneratedDocument | null; diagnostics: Diagnostic[] } {
146
+ const id = artifact.meta.id;
147
+ const path = `${id}.md`;
148
+ let publication: PublicationMeta;
149
+ let generated: { source: string; bodyStartLine: number };
150
+ try {
151
+ publication = publicationMeta(graph, artifact, request, options);
152
+ generated = marksetSource(publication, artifact.body);
153
+ } catch (error) {
154
+ const reason = error instanceof Error ? error.message : String(error);
155
+ return {
156
+ document: null,
157
+ diagnostics: [
158
+ publicationDiagnostic({
159
+ code: "PUB003",
160
+ artifact: id,
161
+ path: artifact.path,
162
+ message: `The generated document for ${id} could not be written as Markset: ${reason}.`,
163
+ remediation: "Correct the metadata value named in the message; provenance is written in plain YAML scalars.",
164
+ }),
165
+ ],
166
+ };
167
+ }
168
+ const diagnostics = checkGenerated(artifact, generated.source, generated.bodyStartLine);
169
+ if (diagnostics.length > 0) return { document: null, diagnostics };
170
+ return { document: { id, path, markset: generated.source, publication }, diagnostics };
171
+ }
172
+
173
+ /** PUB004 for a directive outside Markset's vocabulary, PUB003 for every other Markset error. */
174
+ export function checkGenerated(artifact: Artifact, source: string, bodyStartLine: number): Diagnostic[] {
175
+ const id = artifact.meta.id;
176
+ const offset = bodyLineOffset(artifact);
177
+ const locate = (line: number | undefined) =>
178
+ line === undefined || offset === null || line < bodyStartLine
179
+ ? undefined
180
+ : { line: line - bodyStartLine + 1 + offset };
181
+ const lines = source.split("\n");
182
+ const parsed = parseDocument(source);
183
+ const diagnostics: Diagnostic[] = [];
184
+
185
+ const directiveLines = new Set<number>();
186
+ const visit = (node: unknown): void => {
187
+ if (typeof node !== "object" || node === null) return;
188
+ const n = node as {
189
+ type?: string;
190
+ name?: string | null;
191
+ position?: { start: { line: number } };
192
+ children?: unknown[];
193
+ };
194
+ if (n.type === "directive") {
195
+ const line = n.position?.start.line;
196
+ const name =
197
+ n.name ??
198
+ (line === undefined ? null : (/^\s*:{3,}\s*([A-Za-z][A-Za-z0-9_-]*)/.exec(lines[line - 1])?.[1] ?? null));
199
+ if (name !== null && !BLOCK_DIRECTIVE_NAMES.has(name)) {
200
+ if (line !== undefined) directiveLines.add(line);
201
+ diagnostics.push(
202
+ publicationDiagnostic({
203
+ code: "PUB004",
204
+ artifact: id,
205
+ path: artifact.path,
206
+ location: locate(line),
207
+ message: `${id} uses the directive :::${name}, which is not in Markset's vocabulary.`,
208
+ remediation: `Use one of Markset's constructs (${[...BLOCK_DIRECTIVE_NAMES].join(", ")}) or plain Markdown; an Intentset profile never adds a directive (Core §9).`,
209
+ }),
210
+ );
211
+ }
212
+ }
213
+ for (const child of n.children ?? []) visit(child);
214
+ };
215
+ visit(parsed.ast);
216
+
217
+ if (parsed.frontmatter?.markset !== 0) {
218
+ diagnostics.push(
219
+ publicationDiagnostic({
220
+ code: "PUB003",
221
+ artifact: id,
222
+ path: artifact.path,
223
+ message: `The generated frontmatter for ${id} does not read as Markset version 0.`,
224
+ remediation:
225
+ "Report this as a publisher defect; the generated frontmatter must be readable by Markset's own reader.",
226
+ }),
227
+ );
228
+ }
229
+ for (const d of marksetCarrier(`${id}.md`, source).syntax) {
230
+ if (d.severity !== "error" || (d.location !== undefined && directiveLines.has(d.location.line))) continue;
231
+ diagnostics.push(
232
+ publicationDiagnostic({
233
+ code: "PUB003",
234
+ artifact: id,
235
+ path: artifact.path,
236
+ location: locate(d.location?.line),
237
+ message: `The generated document for ${id} is not valid Markset: ${d.code}, ${d.message.replace(/\.$/, "")}.`,
238
+ remediation:
239
+ "Fix the Markset syntax in the knowledge body; the document is not emitted until it parses without errors.",
240
+ }),
241
+ );
242
+ }
243
+ return diagnostics;
244
+ }
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @intentset/publisher: the audience and release projection that turns
3
+ * reviewed knowledge into Markset documents with provenance (Core §9,
4
+ * spec/publication.md).
5
+ */
6
+ export { bodyLineOffset } from "./body.ts";
7
+ export { type Chunk, type ChunkOptions, type ChunkSource, toChunks } from "./chunks.ts";
8
+ export {
9
+ type GenerateOptions,
10
+ type GeneratedDocument,
11
+ PUBLICATION_KEY,
12
+ PUBLICATION_PROFILE,
13
+ type PublicationMeta,
14
+ type SourceStamp,
15
+ checkGenerated,
16
+ generateDocument,
17
+ markerLine,
18
+ marksetSource,
19
+ publicationMeta,
20
+ } from "./generate.ts";
21
+ export {
22
+ EXCLUSION_REASONS,
23
+ type Exclusion,
24
+ type ExclusionReason,
25
+ type ProjectOptions,
26
+ type Projection,
27
+ type Snapshot,
28
+ exclusionReason,
29
+ project,
30
+ } from "./project.ts";
31
+ export {
32
+ type PublicationIndex,
33
+ type PublishOptions,
34
+ type PublishResult,
35
+ type PublishedDocument,
36
+ REFUSAL_REASONS,
37
+ type RefusalReason,
38
+ publish,
39
+ renderDocument,
40
+ } from "./publish.ts";
41
+ export {
42
+ type Reference,
43
+ type ReferenceKind,
44
+ checkReferences,
45
+ checkTitles,
46
+ findReferences,
47
+ resolveTarget,
48
+ titlePattern,
49
+ } from "./references.ts";
50
+ export {
51
+ type IndexedRequest,
52
+ PROJECTION_ADMITS,
53
+ type PublicationRequest,
54
+ REQUEST_DIMENSIONS,
55
+ type RequestDimension,
56
+ admits,
57
+ checkRequest,
58
+ indexedRequest,
59
+ } from "./request.ts";
60
+ export {
61
+ CURRENT_PIN,
62
+ REVIEW_EXTENSION,
63
+ type ReviewRecord,
64
+ type ReviewStatus,
65
+ bindReviewPins,
66
+ readReviewRecord,
67
+ reviewSources,
68
+ reviewStatus,
69
+ } from "./review.ts";
70
+ export { YamlEmitError, type YamlValue, emitYaml } from "./yaml.ts";