@intentset/core 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 (76) hide show
  1. package/dist/artifact.d.ts +28 -0
  2. package/dist/artifact.d.ts.map +1 -0
  3. package/dist/artifact.js +621 -0
  4. package/dist/artifact.js.map +1 -0
  5. package/dist/carrier.d.ts +21 -0
  6. package/dist/carrier.d.ts.map +1 -0
  7. package/dist/carrier.js +70 -0
  8. package/dist/carrier.js.map +1 -0
  9. package/dist/config.d.ts +17 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +74 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/context.d.ts +28 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +223 -0
  16. package/dist/context.js.map +1 -0
  17. package/dist/diagnostics.d.ts +37 -0
  18. package/dist/diagnostics.d.ts.map +1 -0
  19. package/dist/diagnostics.js +22 -0
  20. package/dist/diagnostics.js.map +1 -0
  21. package/dist/export.d.ts +23 -0
  22. package/dist/export.d.ts.map +1 -0
  23. package/dist/export.js +144 -0
  24. package/dist/export.js.map +1 -0
  25. package/dist/graph.d.ts +17 -0
  26. package/dist/graph.d.ts.map +1 -0
  27. package/dist/graph.js +51 -0
  28. package/dist/graph.js.map +1 -0
  29. package/dist/hash.d.ts +16 -0
  30. package/dist/hash.d.ts.map +1 -0
  31. package/dist/hash.js +55 -0
  32. package/dist/hash.js.map +1 -0
  33. package/dist/impact.d.ts +6 -0
  34. package/dist/impact.d.ts.map +1 -0
  35. package/dist/impact.js +89 -0
  36. package/dist/impact.js.map +1 -0
  37. package/dist/index.d.ts +15 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +15 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/registries.d.ts +19 -0
  42. package/dist/registries.d.ts.map +1 -0
  43. package/dist/registries.js +137 -0
  44. package/dist/registries.js.map +1 -0
  45. package/dist/report.d.ts +34 -0
  46. package/dist/report.d.ts.map +1 -0
  47. package/dist/report.js +58 -0
  48. package/dist/report.js.map +1 -0
  49. package/dist/types.d.ts +266 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +90 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/validate.d.ts +6 -0
  54. package/dist/validate.d.ts.map +1 -0
  55. package/dist/validate.js +573 -0
  56. package/dist/validate.js.map +1 -0
  57. package/dist/yaml.d.ts +48 -0
  58. package/dist/yaml.d.ts.map +1 -0
  59. package/dist/yaml.js +413 -0
  60. package/dist/yaml.js.map +1 -0
  61. package/package.json +36 -0
  62. package/src/artifact.ts +835 -0
  63. package/src/carrier.ts +81 -0
  64. package/src/config.ts +96 -0
  65. package/src/context.ts +240 -0
  66. package/src/diagnostics.ts +60 -0
  67. package/src/export.ts +173 -0
  68. package/src/graph.ts +49 -0
  69. package/src/hash.ts +53 -0
  70. package/src/impact.ts +90 -0
  71. package/src/index.ts +14 -0
  72. package/src/registries.ts +171 -0
  73. package/src/report.ts +84 -0
  74. package/src/types.ts +357 -0
  75. package/src/validate.ts +655 -0
  76. package/src/yaml.ts +406 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * Reading one document into an Artifact (Core §3, §4, §6, §7; ADR 0002).
3
+ *
4
+ * `readArtifact` checks, in typed code, every constraint of
5
+ * spec/frontmatter.schema.json plus the required narrative sections. Each
6
+ * schema constraint is CORE001 with a JSON pointer, except two that the
7
+ * schema also states and Core assigns elsewhere: a missing navigation parent
8
+ * is CORE004 (Core §5, the chain does not reach a product) and a missing
9
+ * `availability` on a behavior or knowledge is CORE005 (Core §7). A
10
+ * duplicate ID inside one link array is CORE003 (Core §5, duplicate edge).
11
+ * A test asserts that the schema and this code agree on validity.
12
+ *
13
+ * The record is returned whenever its identity and required common fields
14
+ * are sound; an optional structure that fails its checks is reported and
15
+ * left out of the metadata, so one bad link does not remove an artifact from
16
+ * the graph and cascade into unresolved references elsewhere.
17
+ */
18
+ import { closingFenceIndex } from "./carrier.ts";
19
+ import type { Diagnostic, Location } from "./diagnostics.ts";
20
+ import { sha256Hex } from "./hash.ts";
21
+ import { type Locate, NO_LOCATION, aType, capitalize, compareStrings, isRecord, makeDiagnostic } from "./report.ts";
22
+ import {
23
+ ARTIFACT_TYPES,
24
+ type Artifact,
25
+ type ArtifactMeta,
26
+ type ArtifactType,
27
+ type Availability,
28
+ CLAIM_KINDS,
29
+ type Claim,
30
+ type DocumentInput,
31
+ ID_PATTERN,
32
+ LINK_KINDS,
33
+ type LinkKind,
34
+ PARENT_TARGETS,
35
+ REQUIRED_SECTIONS,
36
+ SPEC_VERSION,
37
+ STATUSES,
38
+ type SliceMeta,
39
+ VISIBILITIES,
40
+ type VerificationMeta,
41
+ } from "./types.ts";
42
+ import { parseYamlDetailed, pointerToken } from "./yaml.ts";
43
+
44
+ /** Core §3's file-size limit for one record. A product record is prose; a megabyte is far beyond any real one. */
45
+ export const DOCUMENT_MAX_BYTES = 1024 * 1024;
46
+
47
+ export interface ReadDocument {
48
+ artifact: Artifact | null;
49
+ diagnostics: Diagnostic[];
50
+ /** The ID the document declares when it is a well-formed string, even if the record could not be read. */
51
+ id: string | null;
52
+ /** File line of a frontmatter JSON pointer, when the key exists. */
53
+ locate: Locate;
54
+ }
55
+
56
+ const REQUIRED_COMMON = [
57
+ "spec",
58
+ "profile",
59
+ "id",
60
+ "type",
61
+ "title",
62
+ "status",
63
+ "owner",
64
+ "visibility",
65
+ "audiences",
66
+ ] as const;
67
+ const OPTIONAL_COMMON = [
68
+ "parent",
69
+ "links",
70
+ "availability",
71
+ "revision",
72
+ "reviewedAt",
73
+ "reviewedBy",
74
+ "extensions",
75
+ "slice",
76
+ "verification",
77
+ ] as const;
78
+ const KNOWN_KEYS = new Set<string>([...REQUIRED_COMMON, ...OPTIONAL_COMMON]);
79
+ const LINK_FIELDS = LINK_KINDS.filter((kind) => kind !== "parent") as Exclude<LinkKind, "parent">[];
80
+ const AVAILABILITY_KEYS = ["products", "releases", "roles", "editions", "flags"] as const;
81
+ const EXTENSION_KEY = /^[^/]+\/.+$/;
82
+
83
+ /** Core §3, §4, §6 and §7 (ADR 0002): read one document into an Artifact, reporting CORE001 and its kin with origin "profile". */
84
+ export function readArtifact(input: DocumentInput): { artifact: Artifact | null; diagnostics: Diagnostic[] } {
85
+ const { artifact, diagnostics } = readDocument(input);
86
+ return { artifact, diagnostics };
87
+ }
88
+
89
+ /** Core §3, §4, §6 and §7: `readArtifact` plus the positions the validator needs to place its own diagnostics on lines. */
90
+ export function readDocument(input: DocumentInput): ReadDocument {
91
+ const diagnostics: Diagnostic[] = [];
92
+ const report = (fields: {
93
+ code?: string;
94
+ severity?: "error" | "warning";
95
+ artifact: string | null;
96
+ location?: Location;
97
+ field?: string;
98
+ message: string;
99
+ remediation: string;
100
+ }) => {
101
+ diagnostics.push(
102
+ makeDiagnostic({
103
+ code: fields.code ?? "CORE001",
104
+ severity: fields.severity,
105
+ origin: "profile",
106
+ artifact: fields.artifact,
107
+ path: input.path,
108
+ location: fields.location,
109
+ field: fields.field,
110
+ message: fields.message,
111
+ remediation: fields.remediation,
112
+ }),
113
+ );
114
+ };
115
+
116
+ // Core §3: implementations MUST limit file size. Checked before anything is parsed.
117
+ const bytes = Buffer.byteLength(input.source, "utf8");
118
+ if (bytes > DOCUMENT_MAX_BYTES) {
119
+ report({
120
+ artifact: null,
121
+ message: `The document is ${bytes} bytes, more than the ${DOCUMENT_MAX_BYTES / 1024 / 1024} MiB a record may be.`,
122
+ remediation: "Split the record, or move large material such as data or images out of the document and link it.",
123
+ });
124
+ return { artifact: null, diagnostics, id: null, locate: NO_LOCATION };
125
+ }
126
+
127
+ const frontmatter = input.frontmatter;
128
+ if (frontmatter === null) {
129
+ report({
130
+ artifact: null,
131
+ location: { line: 1 },
132
+ message: "The document has no frontmatter block between `---` lines at the top of the file.",
133
+ remediation: "Start the file with `---`, the `markset: 0` and `intentset:` keys, and a closing `---` line.",
134
+ });
135
+ return { artifact: null, diagnostics, id: null, locate: NO_LOCATION };
136
+ }
137
+
138
+ const parsed = parseYamlDetailed(frontmatter.text);
139
+ if (parsed.error) {
140
+ report({
141
+ artifact: null,
142
+ location: { line: frontmatter.line + parsed.error.line - 1 },
143
+ message: `The frontmatter is not accepted: ${parsed.error.message}`,
144
+ remediation: "Write the frontmatter in the strict subset: block mappings, sequences and quoted or plain scalars.",
145
+ });
146
+ return { artifact: null, diagnostics, id: declaredId(frontmatter.text), locate: NO_LOCATION };
147
+ }
148
+
149
+ const root = parsed.value;
150
+ const locate: Locate = (pointer) => {
151
+ const line = parsed.lines.get(pointer);
152
+ return line === undefined ? undefined : { line: frontmatter.line + line - 1 };
153
+ };
154
+ const section = root.intentset;
155
+ const id = isRecord(section) && typeof section.id === "string" && ID_PATTERN.test(section.id) ? section.id : null;
156
+ const problem = (field: string, message: string, remediation: string, code = "CORE001") =>
157
+ report({ code, artifact: id, location: locate(field), field, message, remediation });
158
+
159
+ if (!Object.hasOwn(root, "markset")) {
160
+ problem(
161
+ "/markset",
162
+ "The frontmatter has no `markset` key.",
163
+ "Add `markset: 0`, the Markset version the document is written against.",
164
+ );
165
+ } else if (root.markset !== 0) {
166
+ problem(
167
+ "/markset",
168
+ "`markset` must be the number 0.",
169
+ "Write `markset: 0`, unquoted: it is a version, not a flag.",
170
+ );
171
+ }
172
+ if (!Object.hasOwn(root, "intentset")) {
173
+ problem(
174
+ "/intentset",
175
+ "The frontmatter has no `intentset` block.",
176
+ "Add an `intentset:` mapping with the common fields of Core §4.",
177
+ );
178
+ return { artifact: null, diagnostics, id, locate };
179
+ }
180
+ if (!isRecord(section)) {
181
+ problem("/intentset", "`intentset` must be a mapping.", "Write the common fields as keys under `intentset:`.");
182
+ return { artifact: null, diagnostics, id, locate };
183
+ }
184
+
185
+ const meta = readMeta(section, problem);
186
+ const usable = meta !== null;
187
+ if (usable) checkSections(input, meta, id, report);
188
+
189
+ if (!usable) return { artifact: null, diagnostics, id, locate };
190
+ const foreign: Record<string, unknown> = {};
191
+ for (const key of Object.keys(root).sort(compareStrings)) {
192
+ if (key !== "markset" && key !== "intentset") foreign[key] = root[key];
193
+ }
194
+ return {
195
+ artifact: {
196
+ meta,
197
+ path: input.path,
198
+ sourceHash: sha256Hex(input.source),
199
+ body: bodyOf(input.source),
200
+ headings: input.headings,
201
+ foreign,
202
+ },
203
+ diagnostics,
204
+ id,
205
+ locate,
206
+ };
207
+ }
208
+
209
+ type Problem = (field: string, message: string, remediation: string, code?: string) => void;
210
+
211
+ /** The `intentset` block against Core §4 and the schema; null when identity or a required common field is unusable. */
212
+ function readMeta(s: Record<string, unknown>, problem: Problem): ArtifactMeta | null {
213
+ let usable = true;
214
+ const base = "/intentset";
215
+
216
+ for (const key of Object.keys(s).sort(compareStrings)) {
217
+ if (!KNOWN_KEYS.has(key)) {
218
+ problem(
219
+ `${base}/${pointerToken(key)}`,
220
+ `Unknown key \`${key}\` under intentset.`,
221
+ "Remove it, or move it under `extensions` with a namespaced key such as `org.example/change`.",
222
+ );
223
+ }
224
+ }
225
+ for (const key of [...REQUIRED_COMMON, ...OPTIONAL_COMMON]) {
226
+ if (Object.hasOwn(s, key) && s[key] === null) {
227
+ problem(`${base}/${key}`, `\`${key}\` is present with no value.`, `Give \`${key}\` a value, or remove the key.`);
228
+ }
229
+ }
230
+ for (const key of REQUIRED_COMMON) {
231
+ if (!Object.hasOwn(s, key)) {
232
+ problem(`${base}/${key}`, `Required field \`${key}\` is missing.`, `Add \`${key}\` under intentset (Core §4).`);
233
+ }
234
+ if (!Object.hasOwn(s, key) || s[key] === null) usable = false;
235
+ }
236
+
237
+ if (Object.hasOwn(s, "spec") && s.spec !== null && s.spec !== SPEC_VERSION) {
238
+ const hint = typeof s.spec === "number" ? " Quote it: unquoted, it reads as a number." : "";
239
+ problem(
240
+ `${base}/spec`,
241
+ `\`spec\` must be the string "${SPEC_VERSION}".${hint}`,
242
+ `Write \`spec: '${SPEC_VERSION}'\`.`,
243
+ );
244
+ usable = false;
245
+ }
246
+
247
+ const idValue = s.id;
248
+ if (idValue !== undefined && idValue !== null && (typeof idValue !== "string" || !ID_PATTERN.test(idValue))) {
249
+ problem(
250
+ `${base}/id`,
251
+ `\`id\` must match ${ID_PATTERN.source}.`,
252
+ "Use upper-case letters and digits in dash-separated groups, such as BEH-ASMT-SCHEDULE.",
253
+ );
254
+ usable = false;
255
+ }
256
+
257
+ const typeValue = s.type;
258
+ const type =
259
+ typeof typeValue === "string" && (ARTIFACT_TYPES as readonly string[]).includes(typeValue)
260
+ ? (typeValue as ArtifactType)
261
+ : null;
262
+ if (typeValue !== undefined && typeValue !== null && type === null) {
263
+ problem(
264
+ `${base}/type`,
265
+ `\`type\` must be one of ${ARTIFACT_TYPES.join(", ")}.`,
266
+ "Choose the Core §2 type the artifact is.",
267
+ );
268
+ usable = false;
269
+ }
270
+
271
+ if (Object.hasOwn(s, "profile") && s.profile !== null) {
272
+ if (typeof s.profile !== "string") {
273
+ problem(`${base}/profile`, "`profile` must be a string.", "Write `profile: intentset/<type>/0.1`.");
274
+ usable = false;
275
+ } else if (type !== null && s.profile !== `intentset/${type}/${SPEC_VERSION}`) {
276
+ problem(
277
+ `${base}/profile`,
278
+ `\`profile\` must equal intentset/${type}/${SPEC_VERSION} for ${aType(type)}.`,
279
+ `Set profile to intentset/${type}/${SPEC_VERSION}, or change type.`,
280
+ );
281
+ usable = false;
282
+ }
283
+ }
284
+
285
+ if (Object.hasOwn(s, "title") && s.title !== null && (typeof s.title !== "string" || s.title === "")) {
286
+ problem(
287
+ `${base}/title`,
288
+ "`title` must be a non-empty string.",
289
+ "Give the artifact a title; quote it if it would read as a number.",
290
+ );
291
+ usable = false;
292
+ }
293
+ if (Object.hasOwn(s, "status") && s.status !== null && !(STATUSES as readonly unknown[]).includes(s.status)) {
294
+ problem(`${base}/status`, `\`status\` must be one of ${STATUSES.join(", ")}.`, "Use the Core §7 lifecycle status.");
295
+ usable = false;
296
+ }
297
+ if (Object.hasOwn(s, "owner") && s.owner !== null && (typeof s.owner !== "string" || s.owner === "")) {
298
+ problem(
299
+ `${base}/owner`,
300
+ "`owner` must be a non-empty string.",
301
+ "Name one accountable team or role from the owner registry.",
302
+ );
303
+ usable = false;
304
+ }
305
+ if (
306
+ Object.hasOwn(s, "visibility") &&
307
+ s.visibility !== null &&
308
+ !(VISIBILITIES as readonly unknown[]).includes(s.visibility)
309
+ ) {
310
+ problem(
311
+ `${base}/visibility`,
312
+ `\`visibility\` must be one of ${VISIBILITIES.join(", ")}.`,
313
+ "Choose the narrowest visibility that fits; publication fails closed.",
314
+ );
315
+ usable = false;
316
+ }
317
+ let audiences: string[] | null = null;
318
+ if (Object.hasOwn(s, "audiences") && s.audiences !== null) {
319
+ audiences = stringList(s.audiences, `${base}/audiences`, "audiences", problem, { minItems: 1 });
320
+ if (audiences === null) usable = false;
321
+ }
322
+
323
+ let parent: string | undefined;
324
+ if (Object.hasOwn(s, "parent") && s.parent !== null) {
325
+ if (typeof s.parent === "string" && ID_PATTERN.test(s.parent)) parent = s.parent;
326
+ else
327
+ problem(
328
+ `${base}/parent`,
329
+ "`parent` must be an artifact ID.",
330
+ "Name the navigation parent's ID, such as CAP-ASMT-ASSIGN.",
331
+ );
332
+ } else if (!Object.hasOwn(s, "parent") && type !== null && PARENT_TARGETS[type] !== undefined) {
333
+ problem(
334
+ `${base}/parent`,
335
+ `${capitalize(aType(type))} has no \`parent\`, so its navigation chain does not reach a product.`,
336
+ `Add \`parent\` naming the ${PARENT_TARGETS[type]?.join(" or ")} it belongs to (Core §5).`,
337
+ "CORE004",
338
+ );
339
+ }
340
+
341
+ const links = readLinks(s.links, base, problem);
342
+ let availability: Availability | undefined;
343
+ if (Object.hasOwn(s, "availability") && s.availability !== null) {
344
+ availability = readAvailability(s.availability, `${base}/availability`, problem) ?? undefined;
345
+ } else if (!Object.hasOwn(s, "availability") && (type === "behavior" || type === "knowledge")) {
346
+ problem(
347
+ `${base}/availability`,
348
+ `${capitalize(aType(type))} must declare \`availability\` with products, releases, roles, editions and flags.`,
349
+ "Add the availability block; empty flags is `flags: []`, never an omission (Core §7).",
350
+ "CORE005",
351
+ );
352
+ }
353
+
354
+ let revision: number | undefined;
355
+ if (Object.hasOwn(s, "revision") && s.revision !== null) {
356
+ if (typeof s.revision === "number" && Number.isInteger(s.revision) && s.revision >= 1) revision = s.revision;
357
+ else
358
+ problem(
359
+ `${base}/revision`,
360
+ "`revision` must be a positive integer.",
361
+ "Write an unquoted whole number from 1 upward.",
362
+ );
363
+ }
364
+ let reviewedAt: string | undefined;
365
+ if (Object.hasOwn(s, "reviewedAt") && s.reviewedAt !== null) {
366
+ if (typeof s.reviewedAt === "string") reviewedAt = s.reviewedAt;
367
+ else problem(`${base}/reviewedAt`, "`reviewedAt` must be a string.", "Quote the date, for example '2026-10-02'.");
368
+ }
369
+ let reviewedBy: string | undefined;
370
+ if (Object.hasOwn(s, "reviewedBy") && s.reviewedBy !== null) {
371
+ if (typeof s.reviewedBy === "string") reviewedBy = s.reviewedBy;
372
+ else problem(`${base}/reviewedBy`, "`reviewedBy` must be a string.", "Name the reviewer or review record.");
373
+ }
374
+ let extensions: Record<string, unknown> | undefined;
375
+ if (Object.hasOwn(s, "extensions") && s.extensions !== null) {
376
+ if (!isRecord(s.extensions)) {
377
+ problem(`${base}/extensions`, "`extensions` must be a mapping.", "Write namespaced keys under `extensions:`.");
378
+ } else {
379
+ extensions = {};
380
+ for (const key of Object.keys(s.extensions).sort(compareStrings)) {
381
+ if (EXTENSION_KEY.test(key)) extensions[key] = s.extensions[key];
382
+ else
383
+ problem(
384
+ `${base}/extensions/${pointerToken(key)}`,
385
+ `Extension key \`${key}\` is not namespaced.`,
386
+ "Prefix it with a namespace and a slash, such as `org.example/change`.",
387
+ );
388
+ }
389
+ }
390
+ }
391
+
392
+ let slice: SliceMeta | undefined;
393
+ if (Object.hasOwn(s, "slice") && s.slice !== null) {
394
+ slice = readSlice(s.slice, `${base}/slice`, problem) ?? undefined;
395
+ } else if (!Object.hasOwn(s, "slice") && type === "slice") {
396
+ problem(
397
+ `${base}/slice`,
398
+ "A slice must carry the `slice` metadata block.",
399
+ "Add `slice:` with kind, domain, entrypoints, layers, claims and usesResources (VSA §3).",
400
+ );
401
+ }
402
+ let verification: VerificationMeta | undefined;
403
+ if (Object.hasOwn(s, "verification") && s.verification !== null) {
404
+ verification = readVerification(s.verification, `${base}/verification`, problem) ?? undefined;
405
+ } else if (!Object.hasOwn(s, "verification") && type === "verification") {
406
+ problem(
407
+ `${base}/verification`,
408
+ "A verification must carry the `verification` metadata block.",
409
+ "Add `verification:` with method, locator and selector (Core §8).",
410
+ );
411
+ }
412
+
413
+ if (!usable || type === null || audiences === null) return null;
414
+ const meta: ArtifactMeta = {
415
+ spec: SPEC_VERSION,
416
+ profile: s.profile as string,
417
+ id: s.id as string,
418
+ type,
419
+ title: s.title as string,
420
+ status: s.status as ArtifactMeta["status"],
421
+ owner: s.owner as string,
422
+ visibility: s.visibility as ArtifactMeta["visibility"],
423
+ audiences,
424
+ links,
425
+ };
426
+ if (parent !== undefined) meta.parent = parent;
427
+ if (availability !== undefined) meta.availability = availability;
428
+ if (revision !== undefined) meta.revision = revision;
429
+ if (reviewedAt !== undefined) meta.reviewedAt = reviewedAt;
430
+ if (reviewedBy !== undefined) meta.reviewedBy = reviewedBy;
431
+ if (extensions !== undefined) meta.extensions = extensions;
432
+ if (slice !== undefined) meta.slice = slice;
433
+ if (verification !== undefined) meta.verification = verification;
434
+ return meta;
435
+ }
436
+
437
+ interface ListOptions {
438
+ minItems?: number;
439
+ pattern?: RegExp;
440
+ /** Code for a repeated entry; CORE001 unless the list is a relationship. */
441
+ duplicateCode?: string;
442
+ /** What to do about a repeated entry, when the list holds something other than IDs. */
443
+ duplicateRemediation?: string;
444
+ }
445
+
446
+ /**
447
+ * An array of non-empty unique strings. Bad and repeated entries are reported
448
+ * and left out, so one bad ID does not detach the others; null when the value
449
+ * is not an array or too few good entries remain.
450
+ */
451
+ function stringList(
452
+ value: unknown,
453
+ field: string,
454
+ name: string,
455
+ problem: Problem,
456
+ options: ListOptions = {},
457
+ ): string[] | null {
458
+ if (!Array.isArray(value)) {
459
+ problem(field, `\`${name}\` must be a list.`, `Write ${name} as a sequence, for example \`${name}: [a, b]\`.`);
460
+ return null;
461
+ }
462
+ const out: string[] = [];
463
+ value.forEach((item, i) => {
464
+ if (typeof item !== "string" || item === "") {
465
+ problem(
466
+ `${field}/${i}`,
467
+ `Entry ${i} of \`${name}\` must be a non-empty string.`,
468
+ "Quote the value if it reads as a number or boolean.",
469
+ );
470
+ return;
471
+ }
472
+ if (options.pattern !== undefined && !options.pattern.test(item)) {
473
+ problem(
474
+ `${field}/${i}`,
475
+ `Entry ${i} of \`${name}\` is not an artifact ID.`,
476
+ "Use an ID such as BEH-ASMT-SCHEDULE.",
477
+ );
478
+ return;
479
+ }
480
+ if (out.includes(item)) {
481
+ problem(
482
+ `${field}/${i}`,
483
+ `\`${item}\` appears twice in \`${name}\`.`,
484
+ options.duplicateRemediation ?? "Keep one entry per ID.",
485
+ options.duplicateCode ?? "CORE001",
486
+ );
487
+ return;
488
+ }
489
+ out.push(item);
490
+ });
491
+ if (options.minItems !== undefined && out.length < options.minItems) {
492
+ if (value.length < options.minItems) {
493
+ problem(
494
+ field,
495
+ `\`${name}\` must list at least ${options.minItems} entr${options.minItems === 1 ? "y" : "ies"}.`,
496
+ `Add at least one value to ${name}.`,
497
+ );
498
+ }
499
+ return null;
500
+ }
501
+ return out;
502
+ }
503
+
504
+ function readLinks(value: unknown, base: string, problem: Problem): ArtifactMeta["links"] {
505
+ const links: ArtifactMeta["links"] = {};
506
+ if (value === undefined || value === null) return links;
507
+ if (!isRecord(value)) {
508
+ problem(
509
+ `${base}/links`,
510
+ "`links` must be a mapping of relationship names to ID lists.",
511
+ "Write `links:` with keys from the Core §5 table.",
512
+ );
513
+ return links;
514
+ }
515
+ for (const key of Object.keys(value).sort(compareStrings)) {
516
+ if (!(LINK_FIELDS as readonly string[]).includes(key)) {
517
+ problem(
518
+ `${base}/links/${pointerToken(key)}`,
519
+ `Unknown relationship \`${key}\` under links.`,
520
+ `Use one of ${LINK_FIELDS.join(", ")}; inverse edges are derived, never written (Core §5).`,
521
+ );
522
+ }
523
+ }
524
+ for (const kind of LINK_FIELDS) {
525
+ if (!Object.hasOwn(value, kind)) continue;
526
+ const list = stringList(value[kind], `${base}/links/${kind}`, kind, problem, {
527
+ pattern: ID_PATTERN,
528
+ duplicateCode: "CORE003",
529
+ });
530
+ if (list !== null) links[kind] = list;
531
+ }
532
+ return links;
533
+ }
534
+
535
+ function readAvailability(value: unknown, field: string, problem: Problem): Availability | null {
536
+ if (!isRecord(value)) {
537
+ problem(
538
+ field,
539
+ "`availability` must be a mapping with products, releases, roles, editions and flags.",
540
+ "Write the five dimension lists under `availability:`.",
541
+ );
542
+ return null;
543
+ }
544
+ let ok = true;
545
+ for (const key of Object.keys(value).sort(compareStrings)) {
546
+ if (!(AVAILABILITY_KEYS as readonly string[]).includes(key)) {
547
+ problem(
548
+ `${field}/${pointerToken(key)}`,
549
+ `Unknown availability dimension \`${key}\`.`,
550
+ `The dimensions are ${AVAILABILITY_KEYS.join(", ")}.`,
551
+ );
552
+ ok = false;
553
+ }
554
+ }
555
+ const out: Partial<Availability> = {};
556
+ for (const key of AVAILABILITY_KEYS) {
557
+ if (!Object.hasOwn(value, key) || value[key] === null) {
558
+ problem(
559
+ `${field}/${key}`,
560
+ `Availability dimension \`${key}\` is missing.`,
561
+ key === "flags"
562
+ ? "Write `flags: []` when no flag is required; no implicit wildcard exists."
563
+ : `List the ${key} the artifact applies to.`,
564
+ );
565
+ ok = false;
566
+ continue;
567
+ }
568
+ const list = stringList(value[key], `${field}/${key}`, key, problem, { minItems: key === "flags" ? 0 : 1 });
569
+ if (list === null) ok = false;
570
+ else out[key] = list;
571
+ }
572
+ return ok ? (out as Availability) : null;
573
+ }
574
+
575
+ function readSlice(value: unknown, field: string, problem: Problem): SliceMeta | null {
576
+ if (!isRecord(value)) {
577
+ problem(
578
+ field,
579
+ "`slice` must be a mapping.",
580
+ "Write kind, domain, entrypoints, layers, claims and usesResources under `slice:`.",
581
+ );
582
+ return null;
583
+ }
584
+ let ok = true;
585
+ const known = ["kind", "rationale", "domain", "entrypoints", "layers", "claims", "usesResources"];
586
+ for (const key of Object.keys(value).sort(compareStrings)) {
587
+ if (!known.includes(key)) {
588
+ problem(
589
+ `${field}/${pointerToken(key)}`,
590
+ `Unknown slice key \`${key}\`.`,
591
+ key === "entrypoint"
592
+ ? "A slice lists its public surfaces, one per package it spans: write `entrypoints: [path]` (VSA §3)."
593
+ : `The slice keys are ${known.join(", ")} (VSA §3).`,
594
+ );
595
+ ok = false;
596
+ }
597
+ }
598
+ for (const key of ["kind", "domain", "entrypoints", "layers", "claims", "usesResources"]) {
599
+ if (!Object.hasOwn(value, key) || value[key] === null) {
600
+ problem(`${field}/${key}`, `Slice field \`${key}\` is missing.`, `Add \`${key}\` under slice (VSA §3).`);
601
+ ok = false;
602
+ }
603
+ }
604
+ if (Object.hasOwn(value, "kind") && value.kind !== null && value.kind !== "product" && value.kind !== "technical") {
605
+ problem(
606
+ `${field}/kind`,
607
+ "`slice.kind` must be product or technical.",
608
+ "A slice that owns behaviors is product; one that owns only mechanisms is technical.",
609
+ );
610
+ ok = false;
611
+ }
612
+ let rationale: string | undefined;
613
+ if (Object.hasOwn(value, "rationale")) {
614
+ if (typeof value.rationale === "string") rationale = value.rationale;
615
+ else {
616
+ problem(
617
+ `${field}/rationale`,
618
+ "`slice.rationale` must be a string.",
619
+ "Explain in one sentence why the slice is technical.",
620
+ );
621
+ ok = false;
622
+ }
623
+ }
624
+ if (Object.hasOwn(value, "domain") && value.domain !== null && typeof value.domain !== "string") {
625
+ problem(
626
+ `${field}/domain`,
627
+ "`slice.domain` must be a string.",
628
+ "Name the product-oriented grouping the slice belongs to.",
629
+ );
630
+ ok = false;
631
+ }
632
+ let entrypoints: string[] = [];
633
+ if (Object.hasOwn(value, "entrypoints") && value.entrypoints !== null) {
634
+ const list = stringList(value.entrypoints, `${field}/entrypoints`, "entrypoints", problem, {
635
+ minItems: 1,
636
+ duplicateRemediation: "List each entrypoint once.",
637
+ });
638
+ if (list === null) ok = false;
639
+ else entrypoints = list;
640
+ }
641
+ const layers: Record<string, string[]> = {};
642
+ if (Object.hasOwn(value, "layers") && value.layers !== null) {
643
+ if (!isRecord(value.layers)) {
644
+ problem(
645
+ `${field}/layers`,
646
+ "`slice.layers` must be a mapping of layer names to pattern lists.",
647
+ "Write each layer as `name: [pattern, ...]`; `{}` declares none.",
648
+ );
649
+ ok = false;
650
+ } else {
651
+ for (const layer of Object.keys(value.layers).sort(compareStrings)) {
652
+ const list = stringList(
653
+ value.layers[layer],
654
+ `${field}/layers/${pointerToken(layer)}`,
655
+ `layers.${layer}`,
656
+ problem,
657
+ );
658
+ if (list === null) ok = false;
659
+ else layers[layer] = list;
660
+ }
661
+ }
662
+ }
663
+ const claims: Claim[] = [];
664
+ if (Object.hasOwn(value, "claims") && value.claims !== null) {
665
+ if (!Array.isArray(value.claims)) {
666
+ problem(
667
+ `${field}/claims`,
668
+ "`slice.claims` must be a list of `{ kind, path }` records.",
669
+ "Write each claim as `- kind: source` and `path: ...`.",
670
+ );
671
+ ok = false;
672
+ } else {
673
+ value.claims.forEach((claim, i) => {
674
+ const read = readClaim(claim, `${field}/claims/${i}`, problem);
675
+ if (read === null) ok = false;
676
+ else claims.push(read);
677
+ });
678
+ }
679
+ }
680
+ let usesResources: string[] = [];
681
+ if (Object.hasOwn(value, "usesResources") && value.usesResources !== null) {
682
+ const list = stringList(value.usesResources, `${field}/usesResources`, "usesResources", problem, {
683
+ pattern: ID_PATTERN,
684
+ });
685
+ if (list === null) ok = false;
686
+ else usesResources = list;
687
+ }
688
+ if (!ok) return null;
689
+ const slice: SliceMeta = {
690
+ kind: value.kind as SliceMeta["kind"],
691
+ domain: value.domain as string,
692
+ entrypoints,
693
+ layers,
694
+ claims,
695
+ usesResources,
696
+ };
697
+ if (rationale !== undefined) slice.rationale = rationale;
698
+ return slice;
699
+ }
700
+
701
+ function readClaim(value: unknown, field: string, problem: Problem): Claim | null {
702
+ if (!isRecord(value)) {
703
+ problem(field, "A claim must be a mapping with kind and path.", "Write `- kind: source` and `path: src/...`.");
704
+ return null;
705
+ }
706
+ let ok = true;
707
+ for (const key of Object.keys(value).sort(compareStrings)) {
708
+ if (key !== "kind" && key !== "path") {
709
+ problem(`${field}/${pointerToken(key)}`, `Unknown claim key \`${key}\`.`, "A claim has kind and path only.");
710
+ ok = false;
711
+ }
712
+ }
713
+ if (!(CLAIM_KINDS as readonly unknown[]).includes(value.kind)) {
714
+ problem(
715
+ `${field}/kind`,
716
+ `\`claims.kind\` must be one of ${CLAIM_KINDS.join(", ")}.`,
717
+ "Choose the kind of files the pattern claims.",
718
+ );
719
+ ok = false;
720
+ }
721
+ if (typeof value.path !== "string" || value.path === "") {
722
+ problem(
723
+ `${field}/path`,
724
+ "`claims.path` must be a non-empty pattern.",
725
+ "Give a repository-relative POSIX pattern such as src/features/x/**.",
726
+ );
727
+ ok = false;
728
+ }
729
+ return ok ? { kind: value.kind as Claim["kind"], path: value.path as string } : null;
730
+ }
731
+
732
+ function readVerification(value: unknown, field: string, problem: Problem): VerificationMeta | null {
733
+ if (!isRecord(value)) {
734
+ problem(
735
+ field,
736
+ "`verification` must be a mapping with method, locator and selector.",
737
+ "Write the three keys under `verification:` (Core §8).",
738
+ );
739
+ return null;
740
+ }
741
+ let ok = true;
742
+ for (const key of Object.keys(value).sort(compareStrings)) {
743
+ if (!["method", "locator", "selector"].includes(key)) {
744
+ problem(
745
+ `${field}/${pointerToken(key)}`,
746
+ `Unknown verification key \`${key}\`.`,
747
+ "The keys are method, locator and selector.",
748
+ );
749
+ ok = false;
750
+ }
751
+ }
752
+ if (value.method !== "automated" && value.method !== "manual") {
753
+ problem(
754
+ `${field}/method`,
755
+ "`verification.method` must be automated or manual.",
756
+ "Say whether a tool or a reviewer performs the check.",
757
+ );
758
+ ok = false;
759
+ }
760
+ for (const key of ["locator", "selector"] as const) {
761
+ if (typeof value[key] !== "string" || value[key] === "") {
762
+ problem(
763
+ `${field}/${key}`,
764
+ `\`verification.${key}\` must be a non-empty string.`,
765
+ key === "locator"
766
+ ? "Give the repository-relative path of the check."
767
+ : "Give the stable test or review-case identifier.",
768
+ );
769
+ ok = false;
770
+ }
771
+ }
772
+ if (!ok) return null;
773
+ return {
774
+ method: value.method as VerificationMeta["method"],
775
+ locator: value.locator as string,
776
+ selector: value.selector as string,
777
+ };
778
+ }
779
+
780
+ /** Core §6: a level-one heading equal to the title and the type's level-two sections, outside code fences. */
781
+ function checkSections(
782
+ input: DocumentInput,
783
+ meta: ArtifactMeta,
784
+ id: string | null,
785
+ report: (fields: { artifact: string | null; location?: Location; message: string; remediation: string }) => void,
786
+ ): void {
787
+ const title = meta.title.replace(/\s+/g, " ").trim();
788
+ const level1 = input.headings.filter((h) => h.depth === 1);
789
+ if (!level1.some((h) => h.text === title)) {
790
+ const first = level1[0];
791
+ report({
792
+ artifact: id,
793
+ location: first === undefined ? undefined : { line: first.line },
794
+ message:
795
+ first === undefined
796
+ ? `The document has no level-one heading; Core §6 requires one equal to the title "${title}".`
797
+ : `The level-one heading "${first.text}" does not match the title "${title}".`,
798
+ remediation: `Add or correct the heading \`# ${title}\` so it equals intentset.title.`,
799
+ });
800
+ }
801
+ const level2 = new Set(input.headings.filter((h) => h.depth === 2).map((h) => h.text));
802
+ for (const section of REQUIRED_SECTIONS[meta.type]) {
803
+ if (level2.has(section)) continue;
804
+ report({
805
+ artifact: id,
806
+ message: `Required section "${section}" is missing from ${aType(meta.type)} document.`,
807
+ remediation: `Add a level-two heading \`## ${section}\` with substantive prose (Core §6).`,
808
+ });
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Core §5 and §11: the ID a frontmatter that the reader rejected appears to declare, an `id:`
814
+ * line inside the top-level `intentset:` block. It is used only so references
815
+ * to the record are not also reported as unresolved; it is never reported as
816
+ * the record's identity, since the record was not read.
817
+ */
818
+ export function declaredId(text: string): string | null {
819
+ let inside = false;
820
+ for (const line of text.split(/\r?\n/)) {
821
+ if (/^\S/.test(line)) inside = /^intentset:\s*(?:#.*)?$/.test(line);
822
+ if (!inside) continue;
823
+ const match = /^ +id: *(['"]?)([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\1 *(?:#.*)?$/.exec(line);
824
+ if (match !== null) return match[2];
825
+ }
826
+ return null;
827
+ }
828
+
829
+ /** Everything after the closing frontmatter fence, line endings kept. */
830
+ function bodyOf(source: string): string {
831
+ const text = source.startsWith("") ? source.slice(1) : source;
832
+ const lines = text.split("\n");
833
+ const close = closingFenceIndex(lines);
834
+ return close === -1 ? text : lines.slice(close + 1).join("\n");
835
+ }