@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
package/src/graph.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The typed graph (Core §5, §12): authored edges in the direction they are
3
+ * written, and one derived inverse per authored edge, marked derived.
4
+ */
5
+ import { type Artifact, type Edge, type Graph, LINK_KINDS } from "./types.ts";
6
+
7
+ /**
8
+ * Core §5 and §12: build the graph from artifacts whose IDs are already
9
+ * unique. Edges follow document order (the order given) then field order
10
+ * (`parent`, then the Core §5 table). Edges to IDs that are not in the graph
11
+ * are kept as authored; the validator reports them.
12
+ */
13
+ export function buildGraph(artifacts: readonly Artifact[]): Graph {
14
+ const map = new Map<string, Artifact>();
15
+ const edges: Edge[] = [];
16
+ for (const artifact of artifacts) {
17
+ const { meta } = artifact;
18
+ map.set(meta.id, artifact);
19
+ if (meta.parent !== undefined) edges.push({ kind: "parent", from: meta.id, to: meta.parent, derived: false });
20
+ for (const kind of LINK_KINDS) {
21
+ if (kind === "parent") continue;
22
+ for (const to of meta.links[kind] ?? []) edges.push({ kind, from: meta.id, to, derived: false });
23
+ }
24
+ }
25
+ const derived: Edge[] = edges.map((edge) => ({ kind: edge.kind, from: edge.to, to: edge.from, derived: true }));
26
+ const out = new Map<string, Edge[]>();
27
+ const incoming = new Map<string, Edge[]>();
28
+ for (const edge of edges) {
29
+ push(out, edge.from, edge);
30
+ push(incoming, edge.to, edge);
31
+ }
32
+ return { artifacts: map, edges, derived, out, in: incoming };
33
+ }
34
+
35
+ function push(map: Map<string, Edge[]>, key: string, edge: Edge): void {
36
+ const list = map.get(key);
37
+ if (list === undefined) map.set(key, [edge]);
38
+ else list.push(edge);
39
+ }
40
+
41
+ /** Core §5: authored edges of one kind leaving an artifact. */
42
+ export function outgoing(graph: Graph, id: string, kind: Edge["kind"]): Edge[] {
43
+ return (graph.out.get(id) ?? []).filter((edge) => edge.kind === kind);
44
+ }
45
+
46
+ /** Core §5: authored edges of one kind arriving at an artifact, the authored side of a derived inverse. */
47
+ export function incomingEdges(graph: Graph, id: string, kind: Edge["kind"]): Edge[] {
48
+ return (graph.in.get(id) ?? []).filter((edge) => edge.kind === kind);
49
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Canonical JSON and SHA-256 (ADR 0005). Everything hashed goes through
3
+ * `canonicalJson`, so the same value is the same bytes on every machine.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import type { Graph, LinkKind } from "./types.ts";
7
+
8
+ /** ADR 0005: JSON with keys sorted recursively and no whitespace. `undefined` members are omitted, as JSON.stringify omits them. */
9
+ export function canonicalJson(value: unknown): string {
10
+ return JSON.stringify(sortKeysDeep(value));
11
+ }
12
+
13
+ /** ADR 0005: the same value with every object's keys in sorted order, arrays untouched. */
14
+ export function sortKeysDeep(value: unknown): unknown {
15
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
16
+ if (typeof value === "object" && value !== null) {
17
+ const record = value as Record<string, unknown>;
18
+ const out: Record<string, unknown> = {};
19
+ for (const key of Object.keys(record).sort()) {
20
+ if (record[key] !== undefined) out[key] = sortKeysDeep(record[key]);
21
+ }
22
+ return out;
23
+ }
24
+ return value;
25
+ }
26
+
27
+ /** ADR 0005: lowercase hex SHA-256 of a string's UTF-8 bytes or of raw bytes. */
28
+ export function sha256Hex(input: string | Uint8Array): string {
29
+ return createHash("sha256").update(input).digest("hex");
30
+ }
31
+
32
+ /**
33
+ * ADR 0005: SHA-256 of the canonical JSON of the sorted list of
34
+ * `{ id, type, sourceHash, links }`, where `links` holds the artifact's
35
+ * authored edges by kind (`parent` included) with each target list sorted.
36
+ * Timestamps and commits stay outside, so the same files at two commits hash
37
+ * the same.
38
+ */
39
+ export function graphHash(graph: Graph): string {
40
+ const entries = [...graph.artifacts.keys()].sort().map((id) => {
41
+ const artifact = graph.artifacts.get(id);
42
+ if (artifact === undefined) throw new Error(`graph has no artifact ${id}`);
43
+ const links: Partial<Record<LinkKind, string[]>> = {};
44
+ for (const edge of graph.out.get(id) ?? []) {
45
+ const list = links[edge.kind];
46
+ if (list === undefined) links[edge.kind] = [edge.to];
47
+ else list.push(edge.to);
48
+ }
49
+ for (const kind of Object.keys(links) as LinkKind[]) links[kind]?.sort();
50
+ return { id, type: artifact.meta.type, sourceHash: artifact.sourceHash, links };
51
+ });
52
+ return sha256Hex(canonicalJson(entries));
53
+ }
package/src/impact.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Impact traversal (Core §10). From a starting ID, the artifacts one
3
+ * derived edge away are direct; everything reachable beyond them by further
4
+ * reverse traversal, with a visited set, is a candidate; the start's own
5
+ * rules, contracts and decisions are review context; navigation ancestors
6
+ * come through `parent`. Each hit carries the path that reached it, with a
7
+ * reason sentence per step. Reachability is recorded, never asserted as
8
+ * proof that runtime behavior changed.
9
+ */
10
+ import { compareStrings } from "./report.ts";
11
+ import type { Edge, Graph, ImpactHit, ImpactReport, ImpactStep, LinkKind } from "./types.ts";
12
+
13
+ const PHRASES: Record<LinkKind, string> = {
14
+ parent: "has {to} as its navigation parent",
15
+ governedBy: "is governed by {to}",
16
+ illustrates: "illustrates {to}",
17
+ implements: "implements {to}",
18
+ dependsOn: "depends on {to}",
19
+ exposes: "exposes {to}",
20
+ consumes: "consumes {to}",
21
+ verifies: "verifies {to}",
22
+ explains: "explains {to}",
23
+ informedBy: "is informed by {to}",
24
+ requires: "requires {to}",
25
+ supports: "supports {to}",
26
+ replacedBy: "is replaced by {to}",
27
+ };
28
+
29
+ const CONTEXT_KINDS: readonly LinkKind[] = ["governedBy", "exposes", "consumes", "informedBy"];
30
+
31
+ /** Core §10: the reason recorded for one step, a sentence read from the authored edge's source. */
32
+ export function reasonFor(edge: Edge): string {
33
+ return `${edge.from} ${PHRASES[edge.kind].replace("{to}", edge.to)}.`;
34
+ }
35
+
36
+ /** Core §10: the impact report for an artifact. An ID not in the graph yields an empty report. */
37
+ export function impact(graph: Graph, id: string): ImpactReport {
38
+ const report: ImpactReport = { start: id, direct: [], candidates: [], context: [], ancestors: [] };
39
+ if (!graph.artifacts.has(id)) return report;
40
+
41
+ const paths = new Map<string, ImpactStep[]>();
42
+ const queue: string[] = [id];
43
+ paths.set(id, []);
44
+ while (queue.length > 0) {
45
+ const current = queue.shift() as string;
46
+ const sofar = paths.get(current) as ImpactStep[];
47
+ const arriving = [...(graph.in.get(current) ?? [])].sort(
48
+ (a, b) => compareStrings(a.from, b.from) || compareStrings(a.kind, b.kind),
49
+ );
50
+ for (const edge of arriving) {
51
+ if (paths.has(edge.from)) continue;
52
+ paths.set(edge.from, [...sofar, { id: edge.from, via: `${edge.kind}←`, reason: reasonFor(edge) }]);
53
+ queue.push(edge.from);
54
+ }
55
+ }
56
+ for (const hitId of [...paths.keys()].sort(compareStrings)) {
57
+ if (hitId === id) continue;
58
+ const path = paths.get(hitId) as ImpactStep[];
59
+ const hit = toHit(graph, hitId, path);
60
+ if (hit === null) continue;
61
+ if (path.length === 1) report.direct.push(hit);
62
+ else report.candidates.push(hit);
63
+ }
64
+
65
+ const contextEdges = (graph.out.get(id) ?? [])
66
+ .filter((edge) => CONTEXT_KINDS.includes(edge.kind) && graph.artifacts.has(edge.to))
67
+ .sort((a, b) => compareStrings(a.to, b.to) || compareStrings(a.kind, b.kind));
68
+ const seenContext = new Set<string>();
69
+ for (const edge of contextEdges) {
70
+ if (seenContext.has(edge.to)) continue;
71
+ seenContext.add(edge.to);
72
+ const hit = toHit(graph, edge.to, [{ id: edge.to, via: `${edge.kind}→`, reason: reasonFor(edge) }]);
73
+ if (hit !== null) report.context.push(hit);
74
+ }
75
+
76
+ const visited = new Set<string>([id]);
77
+ let cursor = graph.artifacts.get(id)?.meta.parent;
78
+ while (cursor !== undefined && !visited.has(cursor) && graph.artifacts.has(cursor)) {
79
+ visited.add(cursor);
80
+ report.ancestors.push(cursor);
81
+ cursor = graph.artifacts.get(cursor)?.meta.parent;
82
+ }
83
+ return report;
84
+ }
85
+
86
+ function toHit(graph: Graph, id: string, path: ImpactStep[]): ImpactHit | null {
87
+ const artifact = graph.artifacts.get(id);
88
+ if (artifact === undefined) return null;
89
+ return { id, type: artifact.meta.type, title: artifact.meta.title, path };
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from "./artifact.ts";
2
+ export * from "./carrier.ts";
3
+ export * from "./config.ts";
4
+ export * from "./context.ts";
5
+ export * from "./diagnostics.ts";
6
+ export * from "./export.ts";
7
+ export * from "./graph.ts";
8
+ export * from "./hash.ts";
9
+ export * from "./impact.ts";
10
+ export * from "./registries.ts";
11
+ export * from "./types.ts";
12
+ export * from "./validate.ts";
13
+ export * from "./yaml.ts";
14
+ export { compareStrings } from "./report.ts";
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The repository registries (Core §4, §7; VSA §3): owners, audiences, the
3
+ * four release dimensions, and the shared-resource inventory. The file is
4
+ * `registries.yaml` in the shape of examples/scheduling/registries.yaml.
5
+ * A missing or empty file is empty registries, which the validator reads as
6
+ * "not declared" rather than "nothing allowed".
7
+ */
8
+ import type { Diagnostic } from "./diagnostics.ts";
9
+ import { compareStrings, isRecord, makeDiagnostic } from "./report.ts";
10
+ import { EMPTY_REGISTRIES, ID_PATTERN, type Registries, type Resource } from "./types.ts";
11
+ import { parseYamlDetailed } from "./yaml.ts";
12
+
13
+ const LIST_KEYS = ["owners", "audiences", "releases", "roles", "editions", "flags"] as const;
14
+ const KNOWN_KEYS = new Set<string>([...LIST_KEYS, "resources"]);
15
+
16
+ /**
17
+ * Core §4 and §7, VSA §3: read registries.yaml. Shape problems are CFG002
18
+ * (origin "profile"); a YAML rejection is CFG002, also with origin "profile" (origin "syntax" is Markset's alone). Every
19
+ * list comes back sorted. `null` text means the file is absent.
20
+ */
21
+ export function readRegistries(
22
+ text: string | null,
23
+ path: string,
24
+ ): { registries: Registries; diagnostics: Diagnostic[] } {
25
+ const diagnostics: Diagnostic[] = [];
26
+ const registries: Registries = { ...EMPTY_REGISTRIES, resources: [] };
27
+ if (text === null || text.trim() === "") return { registries, diagnostics };
28
+
29
+ const parsed = parseYamlDetailed(text);
30
+ if (parsed.error) {
31
+ diagnostics.push(
32
+ makeDiagnostic({
33
+ code: "CFG002",
34
+ origin: "profile",
35
+ artifact: null,
36
+ path,
37
+ location: { line: parsed.error.line },
38
+ message: `registries.yaml does not parse: ${parsed.error.message}`,
39
+ remediation: "Write the registries as a block mapping of string lists and a resources list.",
40
+ }),
41
+ );
42
+ return { registries, diagnostics };
43
+ }
44
+ const root = parsed.value;
45
+ const report = (field: string, message: string, remediation: string) => {
46
+ const line = parsed.lines.get(field);
47
+ diagnostics.push(
48
+ makeDiagnostic({
49
+ code: "CFG002",
50
+ origin: "profile",
51
+ artifact: null,
52
+ path,
53
+ ...(line !== undefined ? { location: { line } } : {}),
54
+ field,
55
+ message,
56
+ remediation,
57
+ }),
58
+ );
59
+ };
60
+
61
+ for (const key of Object.keys(root).sort(compareStrings)) {
62
+ if (!KNOWN_KEYS.has(key)) {
63
+ report(`/${key}`, `Unknown registry \`${key}\`.`, `Remove it; the registries are ${[...KNOWN_KEYS].join(", ")}.`);
64
+ }
65
+ }
66
+ for (const key of LIST_KEYS) {
67
+ const value = root[key];
68
+ if (value === undefined || value === null) continue;
69
+ const list = stringList(value, `/${key}`, report);
70
+ if (list !== null) registries[key] = list;
71
+ }
72
+ const resources = root.resources;
73
+ if (resources !== undefined && resources !== null) {
74
+ if (!Array.isArray(resources)) {
75
+ report(
76
+ "/resources",
77
+ "`resources` must be a list of resource records.",
78
+ "Write each resource as `- id:`, `path:`, `owner:`, `consumers:`.",
79
+ );
80
+ } else {
81
+ const seen = new Set<string>();
82
+ resources.forEach((entry, i) => {
83
+ const resource = readResource(entry, `/resources/${i}`, report);
84
+ if (resource === null) return;
85
+ if (seen.has(resource.id)) {
86
+ report(`/resources/${i}/id`, `Resource ${resource.id} is declared twice.`, "Keep one record per resource.");
87
+ return;
88
+ }
89
+ seen.add(resource.id);
90
+ registries.resources.push(resource);
91
+ });
92
+ registries.resources.sort((a, b) => compareStrings(a.id, b.id));
93
+ }
94
+ }
95
+ return { registries, diagnostics };
96
+ }
97
+
98
+ type Report = (field: string, message: string, remediation: string) => void;
99
+
100
+ function stringList(value: unknown, field: string, report: Report): string[] | null {
101
+ if (!Array.isArray(value)) {
102
+ report(
103
+ field,
104
+ `\`${field.slice(1)}\` must be a list of strings.`,
105
+ "Write it as a block or flow sequence of strings.",
106
+ );
107
+ return null;
108
+ }
109
+ const out: string[] = [];
110
+ let ok = true;
111
+ value.forEach((item, i) => {
112
+ if (typeof item !== "string" || item === "") {
113
+ report(
114
+ `${field}/${i}`,
115
+ `Entry ${i} of \`${field.slice(1)}\` is not a non-empty string.`,
116
+ "Quote the value if it reads as a number or boolean.",
117
+ );
118
+ ok = false;
119
+ return;
120
+ }
121
+ if (out.includes(item)) {
122
+ report(`${field}/${i}`, `\`${item}\` is listed twice under \`${field.slice(1)}\`.`, "Keep one entry per value.");
123
+ return;
124
+ }
125
+ out.push(item);
126
+ });
127
+ return ok ? out.sort(compareStrings) : null;
128
+ }
129
+
130
+ function readResource(value: unknown, field: string, report: Report): Resource | null {
131
+ if (!isRecord(value)) {
132
+ report(field, "A resource must be a mapping.", "Write it as `- id:`, `path:`, `owner:`, `consumers:`.");
133
+ return null;
134
+ }
135
+ let ok = true;
136
+ for (const key of Object.keys(value).sort(compareStrings)) {
137
+ if (!["id", "path", "owner", "consumers"].includes(key)) {
138
+ report(`${field}/${key}`, `Unknown resource key \`${key}\`.`, "A resource has id, path, owner and consumers.");
139
+ ok = false;
140
+ }
141
+ }
142
+ const { id, path, owner } = value;
143
+ if (typeof id !== "string" || !ID_PATTERN.test(id)) {
144
+ report(
145
+ `${field}/id`,
146
+ "A resource needs an `id` matching the artifact ID pattern.",
147
+ "Use an upper-case ID such as RES-ASSESSMENT-DATA.",
148
+ );
149
+ ok = false;
150
+ }
151
+ if (typeof path !== "string" || path === "") {
152
+ report(
153
+ `${field}/path`,
154
+ "A resource needs a repository-relative `path`.",
155
+ "Name the file that defines the resource.",
156
+ );
157
+ ok = false;
158
+ }
159
+ if (typeof owner !== "string" || owner === "") {
160
+ report(`${field}/owner`, "A resource needs an `owner`.", "Name the technical owner from the owner registry.");
161
+ ok = false;
162
+ }
163
+ let consumers: string[] = [];
164
+ if (value.consumers !== undefined && value.consumers !== null) {
165
+ const list = stringList(value.consumers, `${field}/consumers`, report);
166
+ if (list === null) ok = false;
167
+ else consumers = list;
168
+ }
169
+ if (!ok) return null;
170
+ return { id: id as string, path: path as string, owner: owner as string, consumers };
171
+ }
package/src/report.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Construction of diagnostics with one key order (ADR 0004), so two runs that
3
+ * report the same problem produce the same bytes. Optional members are left
4
+ * out rather than set to undefined, which is what keeps `location` honest:
5
+ * absent means unknown, never invented.
6
+ */
7
+ import type { Diagnostic, Location, Origin, Severity } from "./diagnostics.ts";
8
+
9
+ export interface DiagnosticFields {
10
+ code: string;
11
+ severity?: Severity;
12
+ origin: Origin;
13
+ artifact: string | null;
14
+ path: string | null;
15
+ location?: Location;
16
+ field?: string;
17
+ message: string;
18
+ remediation: string;
19
+ }
20
+
21
+ /** ADR 0004: one diagnostic, members in contract order, optional ones present only when known. */
22
+ export function makeDiagnostic(fields: DiagnosticFields): Diagnostic {
23
+ const diagnostic: Diagnostic = {
24
+ code: fields.code,
25
+ severity: fields.severity ?? "error",
26
+ origin: fields.origin,
27
+ artifact: fields.artifact,
28
+ path: fields.path,
29
+ message: fields.message,
30
+ remediation: fields.remediation,
31
+ };
32
+ if (fields.location !== undefined) diagnostic.location = fields.location;
33
+ if (fields.field !== undefined) diagnostic.field = fields.field;
34
+ return orderKeys(diagnostic);
35
+ }
36
+
37
+ function orderKeys(d: Diagnostic): Diagnostic {
38
+ const ordered: Diagnostic = {
39
+ code: d.code,
40
+ severity: d.severity,
41
+ origin: d.origin,
42
+ artifact: d.artifact,
43
+ path: d.path,
44
+ ...(d.location !== undefined ? { location: d.location } : {}),
45
+ ...(d.field !== undefined ? { field: d.field } : {}),
46
+ message: d.message,
47
+ remediation: d.remediation,
48
+ };
49
+ return ordered;
50
+ }
51
+
52
+ /** A function from a frontmatter JSON pointer to its file line, when the key exists. */
53
+ export type Locate = (pointer: string) => Location | undefined;
54
+
55
+ export const NO_LOCATION: Locate = () => undefined;
56
+
57
+ /** ADR 0004 helper: a plain mapping, not an array or null. */
58
+ export function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ /** ADR 0005: code-unit order, the one order everything iterated is sorted by. */
63
+ export function compareStrings(a: string, b: string): number {
64
+ return a < b ? -1 : a > b ? 1 : 0;
65
+ }
66
+
67
+ /** ADR 0004 messages: a type with its indefinite article, "an intent", "a behavior", "a knowledge artifact". */
68
+ export function aType(type: string): string {
69
+ const noun = type === "knowledge" ? "knowledge artifact" : type;
70
+ return `${/^[aeiou]/.test(noun) ? "an" : "a"} ${noun}`;
71
+ }
72
+
73
+ /** ADR 0004 messages: "an intent", or "a behavior, rule or scenario". */
74
+ export function describeTypes(types: readonly string[]): string {
75
+ if (types.length === 1) return aType(types[0]);
76
+ const [first, ...rest] = types;
77
+ const last = rest.pop();
78
+ return `${[aType(first), ...rest].join(", ")} or ${last}`;
79
+ }
80
+
81
+ /** ADR 0004 messages: a sentence-initial capital. */
82
+ export function capitalize(text: string): string {
83
+ return text.charAt(0).toUpperCase() + text.slice(1);
84
+ }