@intentset/core 0.10.0 → 0.13.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 (53) hide show
  1. package/dist/artifact.d.ts.map +1 -1
  2. package/dist/artifact.js +27 -3
  3. package/dist/artifact.js.map +1 -1
  4. package/dist/content-hash.d.ts +9 -0
  5. package/dist/content-hash.d.ts.map +1 -0
  6. package/dist/content-hash.js +57 -0
  7. package/dist/content-hash.js.map +1 -0
  8. package/dist/delivery.d.ts +11 -0
  9. package/dist/delivery.d.ts.map +1 -0
  10. package/dist/delivery.js +12 -0
  11. package/dist/delivery.js.map +1 -0
  12. package/dist/export.d.ts +9 -1
  13. package/dist/export.d.ts.map +1 -1
  14. package/dist/export.js +15 -6
  15. package/dist/export.js.map +1 -1
  16. package/dist/glob.d.ts +18 -0
  17. package/dist/glob.d.ts.map +1 -0
  18. package/dist/glob.js +62 -0
  19. package/dist/glob.js.map +1 -0
  20. package/dist/index.d.ts +5 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/read-export.d.ts.map +1 -1
  25. package/dist/read-export.js +8 -1
  26. package/dist/read-export.js.map +1 -1
  27. package/dist/select.d.ts +168 -0
  28. package/dist/select.d.ts.map +1 -0
  29. package/dist/select.js +586 -0
  30. package/dist/select.js.map +1 -0
  31. package/dist/slices.d.ts +50 -0
  32. package/dist/slices.d.ts.map +1 -0
  33. package/dist/slices.js +111 -0
  34. package/dist/slices.js.map +1 -0
  35. package/dist/types.d.ts +17 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +10 -1
  38. package/dist/types.js.map +1 -1
  39. package/dist/validate.d.ts.map +1 -1
  40. package/dist/validate.js +40 -1
  41. package/dist/validate.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/artifact.ts +37 -2
  44. package/src/content-hash.ts +56 -0
  45. package/src/delivery.ts +11 -0
  46. package/src/export.ts +20 -11
  47. package/src/glob.ts +60 -0
  48. package/src/index.ts +5 -0
  49. package/src/read-export.ts +9 -0
  50. package/src/select.ts +761 -0
  51. package/src/slices.ts +149 -0
  52. package/src/types.ts +18 -1
  53. package/src/validate.ts +39 -1
package/src/artifact.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { closingFenceIndex } from "./carrier.ts";
19
19
  import type { Diagnostic, Location } from "./diagnostics.ts";
20
20
  import { sha256Hex } from "./hash.ts";
21
+ import { contentHashOf } from "./content-hash.ts";
21
22
  import { type Locate, NO_LOCATION, aType, capitalize, compareStrings, isRecord, makeDiagnostic } from "./report.ts";
22
23
  import {
23
24
  ARTIFACT_TYPES,
@@ -28,6 +29,9 @@ import {
28
29
  BASELINE_UNKNOWN,
29
30
  CLAIM_KINDS,
30
31
  type Claim,
32
+ DELIVERIES,
33
+ DELIVERY_TYPES,
34
+ type Delivery,
31
35
  type DocumentInput,
32
36
  ID_PATTERN,
33
37
  LINK_KINDS,
@@ -79,6 +83,7 @@ const OPTIONAL_COMMON = [
79
83
  "revision",
80
84
  "reviewedAt",
81
85
  "reviewedBy",
86
+ "delivery",
82
87
  "extensions",
83
88
  "tips",
84
89
  "slice",
@@ -206,6 +211,7 @@ export function readDocument(input: DocumentInput): ReadDocument {
206
211
  meta,
207
212
  path: input.path,
208
213
  sourceHash: sha256Hex(input.source),
214
+ contentHash: contentHashOf(root, bodyOf(input.source)),
209
215
  body: bodyOf(input.source),
210
216
  headings: input.headings,
211
217
  foreign,
@@ -383,6 +389,24 @@ function readMeta(s: Record<string, unknown>, problem: Problem): ArtifactMeta |
383
389
  if (typeof s.reviewedBy === "string") reviewedBy = s.reviewedBy;
384
390
  else problem(`${base}/reviewedBy`, "`reviewedBy` must be a string.", "Name the reviewer or review record.");
385
391
  }
392
+ let delivery: Delivery | undefined;
393
+ if (Object.hasOwn(s, "delivery") && s.delivery !== null) {
394
+ if (type !== null && !(DELIVERY_TYPES as readonly string[]).includes(type)) {
395
+ problem(
396
+ `${base}/delivery`,
397
+ `\`delivery\` is written on ${aType(type)}; only ${DELIVERY_TYPES.slice(0, -1).join(", ")} and ${DELIVERY_TYPES[DELIVERY_TYPES.length - 1]} records say whether their work is built.`,
398
+ "Remove `delivery`; it states whether the code a record describes exists (Core §7).",
399
+ );
400
+ } else if (typeof s.delivery === "string" && (DELIVERIES as readonly string[]).includes(s.delivery)) {
401
+ delivery = s.delivery as Delivery;
402
+ } else {
403
+ problem(
404
+ `${base}/delivery`,
405
+ "`delivery` must be planned or built.",
406
+ "Write `delivery: built` when the code the record describes exists, `delivery: planned` when it does not yet, or leave it out (Core §7).",
407
+ );
408
+ }
409
+ }
386
410
  let extensions: Record<string, unknown> | undefined;
387
411
  if (Object.hasOwn(s, "extensions") && s.extensions !== null) {
388
412
  if (!isRecord(s.extensions)) {
@@ -462,6 +486,7 @@ function readMeta(s: Record<string, unknown>, problem: Problem): ArtifactMeta |
462
486
  if (revision !== undefined) meta.revision = revision;
463
487
  if (reviewedAt !== undefined) meta.reviewedAt = reviewedAt;
464
488
  if (reviewedBy !== undefined) meta.reviewedBy = reviewedBy;
489
+ if (delivery !== undefined) meta.delivery = delivery;
465
490
  if (extensions !== undefined) meta.extensions = extensions;
466
491
  if (tips !== undefined) meta.tips = tips;
467
492
  if (slice !== undefined) meta.slice = slice;
@@ -472,6 +497,8 @@ function readMeta(s: Record<string, unknown>, problem: Problem): ArtifactMeta |
472
497
 
473
498
  interface ListOptions {
474
499
  minItems?: number;
500
+ /** What to do about a list shorter than `minItems`, when "add a value" is not enough of an answer. */
501
+ minItemsRemediation?: string;
475
502
  pattern?: RegExp;
476
503
  /** Code for a repeated entry; CORE001 unless the list is a relationship. */
477
504
  duplicateCode?: string;
@@ -529,7 +556,7 @@ function stringList(
529
556
  problem(
530
557
  field,
531
558
  `\`${name}\` must list at least ${options.minItems} entr${options.minItems === 1 ? "y" : "ies"}.`,
532
- `Add at least one value to ${name}.`,
559
+ options.minItemsRemediation ?? `Add at least one value to ${name}.`,
533
560
  );
534
561
  }
535
562
  return null;
@@ -601,7 +628,15 @@ function readAvailability(value: unknown, field: string, problem: Problem): Avai
601
628
  ok = false;
602
629
  continue;
603
630
  }
604
- const list = stringList(value[key], `${field}/${key}`, key, problem, { minItems: key === "flags" ? 0 : 1 });
631
+ // Availability intersects on every dimension (Core §7), so an empty one would apply the artifact nowhere; a
632
+ // product with no roles or editions of its own still names one of each, and registers it.
633
+ const list = stringList(value[key], `${field}/${key}`, key, problem, {
634
+ minItems: key === "flags" ? 0 : 1,
635
+ minItemsRemediation:
636
+ key === "products"
637
+ ? "Name the product the artifact belongs to."
638
+ : `List at least one of the ${key} the artifact applies to, from the registries; a product with no ${key} of its own still names one (such as ${key === "roles" ? "user" : key === "editions" ? "standard" : "v1"}) and registers it.`,
639
+ });
605
640
  if (list === null) ok = false;
606
641
  else out[key] = list;
607
642
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A record's content hash (publication §3, ADR 0018): what a review pins
3
+ * when it pins `content-v1`. It covers what the record promises, and leaves
4
+ * out where the record stands in its lifecycle and who reviewed it, so that
5
+ * approving, implementing or marking delivery does not send every guide that
6
+ * explains the record back to review.
7
+ *
8
+ * The hash is SHA-256 over the canonical JSON (ADR 0005) of
9
+ * `{ body, frontmatter, ending }`:
10
+ *
11
+ * - `frontmatter` is the whole parsed frontmatter, `markset` and unknown
12
+ * top-level keys included, without `status`, `delivery`, `reviewedBy` and
13
+ * `reviewedAt` under `intentset`, and without the `intentset.org/review`
14
+ * extension (and `extensions` itself when nothing else is in it);
15
+ * - `body` is everything after the frontmatter, as written;
16
+ * - `ending` is the status when it is `deprecated` or `retired`, and absent
17
+ * otherwise, so a record that is going away still moves the hash: what a
18
+ * guide explains is ending, and its reader needs to know.
19
+ *
20
+ * Because it is taken over parsed values, a change of key order, quoting or
21
+ * indentation in the frontmatter does not move it.
22
+ */
23
+ import { canonicalJson, sha256Hex } from "./hash.ts";
24
+
25
+ /** The `intentset` fields a content hash leaves out: lifecycle and review, not promise. */
26
+ export const CONTENT_HASH_OMITS = ["status", "delivery", "reviewedBy", "reviewedAt"] as const;
27
+ /** The extension a content hash leaves out: the review's own pins. */
28
+ export const CONTENT_HASH_OMITS_EXTENSION = "intentset.org/review";
29
+ /** The statuses a content hash names, because a record in them is going away. */
30
+ export const ENDING_STATUSES = ["deprecated", "retired"] as const;
31
+
32
+ /** The content hash of a record from its parsed frontmatter and its body (publication §3, ADR 0018). */
33
+ export function contentHashOf(frontmatter: Record<string, unknown>, body: string): string {
34
+ const copy: Record<string, unknown> = { ...frontmatter };
35
+ const section = frontmatter.intentset;
36
+ let ending: string | undefined;
37
+ if (isRecord(section)) {
38
+ const intentset: Record<string, unknown> = { ...section };
39
+ if (typeof section.status === "string" && (ENDING_STATUSES as readonly string[]).includes(section.status)) {
40
+ ending = section.status;
41
+ }
42
+ for (const key of CONTENT_HASH_OMITS) delete intentset[key];
43
+ if (isRecord(section.extensions)) {
44
+ const extensions: Record<string, unknown> = { ...section.extensions };
45
+ delete extensions[CONTENT_HASH_OMITS_EXTENSION];
46
+ if (Object.keys(extensions).length === 0) delete intentset.extensions;
47
+ else intentset.extensions = extensions;
48
+ }
49
+ copy.intentset = intentset;
50
+ }
51
+ return sha256Hex(canonicalJson({ body, frontmatter: copy, ending }));
52
+ }
53
+
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Core §7, ADR 0017: a record's effective delivery. Its `delivery` when it states one; otherwise `built` when its
3
+ * status claims an implementation (`implemented`, `released`, `deprecated`); otherwise null, not stated. `approved`
4
+ * claims nothing about the code, so an approved record without the field is not stated.
5
+ */
6
+ import { BUILT_STATUSES, type Delivery, type Status } from "./types.ts";
7
+
8
+ export function deliveryOf(meta: { delivery?: Delivery | null; status: Status }): Delivery | null {
9
+ if (meta.delivery !== undefined && meta.delivery !== null) return meta.delivery;
10
+ return (BUILT_STATUSES as readonly string[]).includes(meta.status) ? "built" : null;
11
+ }
package/src/export.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type ExportEnvelope,
21
21
  type ExportImpactHit,
22
22
  type ExportReports,
23
+ type Graph,
23
24
  LINK_KINDS,
24
25
  type Level,
25
26
  type LinkKind,
@@ -60,7 +61,7 @@ export interface ExportMeta {
60
61
  /** spec/export.md: the `intentset/export/0.3` envelope over a validation result. */
61
62
  export function exportGraph(result: ValidationResult, registries: Registries, meta: ExportMeta): ExportEnvelope {
62
63
  const { graph } = result;
63
- const withheld = withheldIds(result, meta.includeRestricted === true);
64
+ const withheld = withheldIds(graph, meta.includeRestricted === true);
64
65
  const ids = [...graph.artifacts.keys()].filter((id) => !withheld.ids.has(id)).sort(compareStrings);
65
66
  const errors = result.diagnostics.filter((d) => d.severity === "error").length;
66
67
  const warnings = result.diagnostics.length - errors;
@@ -95,11 +96,24 @@ export function exportGraph(result: ValidationResult, registries: Registries, me
95
96
  diagnostics: result.diagnostics.length - diagnostics.length,
96
97
  },
97
98
  registries: exportRegistries(registries, withheld.ids),
98
- artifacts: ids.map((id) => exportArtifact(result, id, meta.includeBodies === true, withheld.ids)),
99
+ artifacts: ids.map((id) => exportArtifact(graph, id, meta.includeBodies === true, withheld.ids)),
99
100
  reports: withholdReports(meta.reports ?? {}, withheld),
100
101
  };
101
102
  }
102
103
 
104
+ /**
105
+ * The artifacts an export of `graph` would carry, without bodies, and how many it would withhold: the projection
106
+ * `select` reads a graph through (spec/list.md §2), so a list from the tree and a list from its export agree.
107
+ */
108
+ export function projectArtifacts(
109
+ graph: Graph,
110
+ includeRestricted: boolean,
111
+ ): { artifacts: ExportArtifact[]; withheld: number } {
112
+ const withheld = withheldIds(graph, includeRestricted);
113
+ const ids = [...graph.artifacts.keys()].filter((id) => !withheld.ids.has(id)).sort(compareStrings);
114
+ return { artifacts: ids.map((id) => exportArtifact(graph, id, false, withheld.ids)), withheld: withheld.ids.size };
115
+ }
116
+
103
117
  interface Withheld {
104
118
  ids: Set<string>;
105
119
  /** Source paths of the withheld artifacts, for diagnostics that name a file but no artifact. */
@@ -110,11 +124,11 @@ interface Withheld {
110
124
 
111
125
  const WITHHELD_VISIBILITIES: readonly Visibility[] = ["restricted"];
112
126
 
113
- function withheldIds(result: ValidationResult, includeRestricted: boolean): Withheld {
127
+ function withheldIds(graph: Graph, includeRestricted: boolean): Withheld {
114
128
  const ids = new Set<string>();
115
129
  const paths = new Set<string>();
116
130
  if (!includeRestricted) {
117
- for (const [id, artifact] of result.graph.artifacts) {
131
+ for (const [id, artifact] of graph.artifacts) {
118
132
  if (WITHHELD_VISIBILITIES.includes(artifact.meta.visibility)) {
119
133
  ids.add(id);
120
134
  paths.add(artifact.path);
@@ -158,13 +172,7 @@ function withholdDiagnostics(diagnostics: readonly Diagnostic[], withheld: Withh
158
172
  return out;
159
173
  }
160
174
 
161
- function exportArtifact(
162
- result: ValidationResult,
163
- id: string,
164
- includeBody: boolean,
165
- withheld: ReadonlySet<string>,
166
- ): ExportArtifact {
167
- const { graph } = result;
175
+ function exportArtifact(graph: Graph, id: string, includeBody: boolean, withheld: ReadonlySet<string>): ExportArtifact {
168
176
  const artifact = graph.artifacts.get(id);
169
177
  if (artifact === undefined) throw new Error(`graph has no artifact ${id}`);
170
178
  const { meta } = artifact;
@@ -211,6 +219,7 @@ function exportArtifact(
211
219
  type: meta.type,
212
220
  title: meta.title,
213
221
  status: meta.status,
222
+ delivery: meta.delivery ?? null,
214
223
  owner: meta.owner,
215
224
  visibility: meta.visibility,
216
225
  audiences: [...meta.audiences].sort(compareStrings),
package/src/glob.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The pattern language of `.intentset/config.yaml` scope and ignore lists,
3
+ * which is VSA §3's: repository-relative POSIX paths made of literal segments,
4
+ * `*` within one segment, and `**` across zero or more segments. Nothing else
5
+ * is special: no `?`, no classes, no braces, no negation, so there is no
6
+ * precedence to explain. A pattern and a path match segment by segment, and
7
+ * `*` never crosses a `/`.
8
+ *
9
+ * Core holds it because what a slice claims is part of the model's own
10
+ * definitions (Core §10, ADR 0016): `claimingSlices` and `select` match claims
11
+ * with it, and the CLI re-exports it. The architecture package keeps its own
12
+ * copy for claims, as it did before the move.
13
+ */
14
+
15
+ /** VSA §3: true when the repository-relative POSIX `path` matches `pattern`. */
16
+ export function matchGlob(pattern: string, path: string): boolean {
17
+ const p = normalize(pattern).split("/");
18
+ const s = path.split("/");
19
+ // Memo of (pattern index, path index) pairs already known not to match, so
20
+ // several `**` in one pattern stay polynomial rather than exponential.
21
+ const failed = new Set<number>();
22
+ const width = s.length + 1;
23
+ const match = (i: number, j: number): boolean => {
24
+ if (failed.has(i * width + j)) return false;
25
+ let ok: boolean;
26
+ if (i === p.length) ok = j === s.length;
27
+ else if (p[i] === "**") ok = match(i + 1, j) || (j < s.length && match(i, j + 1));
28
+ else ok = j < s.length && matchSegment(p[i], s[j]) && match(i + 1, j + 1);
29
+ if (!ok) failed.add(i * width + j);
30
+ return ok;
31
+ };
32
+ return match(0, 0);
33
+ }
34
+
35
+ /** True when any of `patterns` matches `path`. */
36
+ export function matchAny(patterns: readonly string[], path: string): boolean {
37
+ return patterns.some((pattern) => matchGlob(pattern, path));
38
+ }
39
+
40
+ /** A leading `./` names the same path; repeated `/` separate nothing. */
41
+ function normalize(pattern: string): string {
42
+ return pattern.replace(/^(?:\.\/)+/, "").replace(/\/{2,}/g, "/");
43
+ }
44
+
45
+ const SEGMENTS = new Map<string, RegExp>();
46
+
47
+ /** One segment: literal text, with each `*` standing for any run of characters other than `/`. */
48
+ function matchSegment(pattern: string, name: string): boolean {
49
+ if (!pattern.includes("*")) return pattern === name;
50
+ let regex = SEGMENTS.get(pattern);
51
+ if (regex === undefined) {
52
+ const body = pattern
53
+ .split("*")
54
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
55
+ .join("[^/]*");
56
+ regex = new RegExp(`^${body}$`);
57
+ SEGMENTS.set(pattern, regex);
58
+ }
59
+ return regex.test(name);
60
+ }
package/src/index.ts CHANGED
@@ -2,13 +2,18 @@ export * from "./artifact.ts";
2
2
  export * from "./carrier.ts";
3
3
  export * from "./config.ts";
4
4
  export * from "./context.ts";
5
+ export * from "./delivery.ts";
5
6
  export * from "./diagnostics.ts";
6
7
  export * from "./export.ts";
8
+ export * from "./glob.ts";
7
9
  export * from "./graph.ts";
10
+ export * from "./content-hash.ts";
8
11
  export * from "./hash.ts";
9
12
  export * from "./impact.ts";
10
13
  export * from "./read-export.ts";
11
14
  export * from "./registries.ts";
15
+ export * from "./select.ts";
16
+ export * from "./slices.ts";
12
17
  export * from "./types.ts";
13
18
  export * from "./validate.ts";
14
19
  export * from "./yaml.ts";
@@ -23,6 +23,8 @@ import {
23
23
  EVIDENCE_BINDINGS,
24
24
  EXPORT_EVIDENCE_STATUSES,
25
25
  EXPORT_REPORTS,
26
+ DELIVERIES,
27
+ DELIVERY_TYPES,
26
28
  type ExportEnvelope,
27
29
  type ExportReportName,
28
30
  ID_PATTERN,
@@ -414,6 +416,7 @@ class Shape {
414
416
  "type",
415
417
  "title",
416
418
  "status",
419
+ "delivery",
417
420
  "owner",
418
421
  "visibility",
419
422
  "audiences",
@@ -439,6 +442,12 @@ class Shape {
439
442
  this.oneOf(a.type, `${at}/type`, ARTIFACT_TYPES);
440
443
  this.string(a.title, `${at}/title`);
441
444
  this.oneOf(a.status, `${at}/status`, STATUSES);
445
+ this.nullable(a.delivery, `${at}/delivery`, (d, p) => {
446
+ this.oneOf(d, p, DELIVERIES);
447
+ if (typeof a.type === "string" && !(DELIVERY_TYPES as readonly string[]).includes(a.type)) {
448
+ this.fail(p, `Expected null: ${a.type} records do not carry delivery.`);
449
+ }
450
+ });
442
451
  this.string(a.owner, `${at}/owner`);
443
452
  this.oneOf(a.visibility, `${at}/visibility`, VISIBILITIES);
444
453
  this.strings(a.audiences, `${at}/audiences`, { minItems: 1 });