@intentset/core 0.10.0 → 0.12.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 (48) hide show
  1. package/dist/artifact.d.ts.map +1 -1
  2. package/dist/artifact.js +25 -3
  3. package/dist/artifact.js.map +1 -1
  4. package/dist/delivery.d.ts +11 -0
  5. package/dist/delivery.d.ts.map +1 -0
  6. package/dist/delivery.js +12 -0
  7. package/dist/delivery.js.map +1 -0
  8. package/dist/export.d.ts +9 -1
  9. package/dist/export.d.ts.map +1 -1
  10. package/dist/export.js +15 -6
  11. package/dist/export.js.map +1 -1
  12. package/dist/glob.d.ts +18 -0
  13. package/dist/glob.d.ts.map +1 -0
  14. package/dist/glob.js +62 -0
  15. package/dist/glob.js.map +1 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/read-export.d.ts.map +1 -1
  21. package/dist/read-export.js +8 -1
  22. package/dist/read-export.js.map +1 -1
  23. package/dist/select.d.ts +166 -0
  24. package/dist/select.d.ts.map +1 -0
  25. package/dist/select.js +579 -0
  26. package/dist/select.js.map +1 -0
  27. package/dist/slices.d.ts +50 -0
  28. package/dist/slices.d.ts.map +1 -0
  29. package/dist/slices.js +111 -0
  30. package/dist/slices.js.map +1 -0
  31. package/dist/types.d.ts +15 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js +10 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/validate.d.ts.map +1 -1
  36. package/dist/validate.js +40 -1
  37. package/dist/validate.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/artifact.ts +35 -2
  40. package/src/delivery.ts +11 -0
  41. package/src/export.ts +20 -11
  42. package/src/glob.ts +60 -0
  43. package/src/index.ts +4 -0
  44. package/src/read-export.ts +9 -0
  45. package/src/select.ts +753 -0
  46. package/src/slices.ts +149 -0
  47. package/src/types.ts +16 -1
  48. package/src/validate.ts +39 -1
package/src/slices.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Slices, the code they claim and the records that describe them (Core §10),
3
+ * the definitions `review`'s drift list, `context <path>` and `select`'s
4
+ * `describing` and `claims` fields share. They moved here from the CLI in
5
+ * 0.11.0 (ADR 0016), so a consumer calls Intentset's definition rather than
6
+ * copying it.
7
+ *
8
+ * A slice's code is what its `source`, `backend` and `contract` claims match;
9
+ * its `verification` and `documentation` claims are checks and prose, not
10
+ * implementation. The records that describe a slice are the slice itself, the
11
+ * behaviors it implements, the rules that govern them, the scenarios that
12
+ * illustrate them, the verifications of any of those, the contracts it
13
+ * exposes, and the decisions that inform the slice, its behaviors or its
14
+ * contracts. Knowledge is left out: it explains the model to an audience, and
15
+ * review pins already mark it for review when the model changes.
16
+ */
17
+ import { matchGlob } from "./glob.ts";
18
+ import { compareStrings } from "./report.ts";
19
+ import {
20
+ type ArtifactType,
21
+ type Artifact,
22
+ type Claim,
23
+ type Graph,
24
+ LINK_ENDPOINTS,
25
+ type LinkKind,
26
+ PARENT_TARGETS,
27
+ } from "./types.ts";
28
+
29
+ const CODE_CLAIMS = new Set(["source", "backend", "contract"]);
30
+
31
+ /** Every slice that is not retired and has slice metadata, sorted by ID. */
32
+ export function activeSlices(graph: Graph): Artifact[] {
33
+ return [...graph.artifacts.values()]
34
+ .filter((a) => a.meta.type === "slice" && a.meta.status !== "retired" && a.meta.slice !== undefined)
35
+ .sort((a, b) => compareStrings(a.meta.id, b.meta.id));
36
+ }
37
+
38
+ /** The slices with any claim matching `path`, sorted by ID. */
39
+ export function claimingSlices(graph: Graph, path: string): Artifact[] {
40
+ return activeSlices(graph).filter((slice) => claimsPath(slice.meta.slice?.claims ?? [], path));
41
+ }
42
+
43
+ /** True when any of `claims` matches the repository-relative `path` (VSA §3). */
44
+ export function claimsPath(claims: readonly Claim[], path: string): boolean {
45
+ return claims.some((claim) => matchGlob(claim.path, path));
46
+ }
47
+
48
+ /** True when one of the slice's source, backend or contract claims matches `path`. */
49
+ export function isSliceCode(slice: Artifact, path: string): boolean {
50
+ return slice.meta.slice?.claims.some((claim) => CODE_CLAIMS.has(claim.kind) && matchGlob(claim.path, path)) ?? false;
51
+ }
52
+
53
+ /** The IDs of the records that describe a slice, the slice included, sorted. Only IDs present in the graph. */
54
+ export function describingRecords(graph: Graph, slice: Artifact): string[] {
55
+ return [...describing(graphIndex(graph), slice.meta.id, false).ids].sort(compareStrings);
56
+ }
57
+
58
+ /**
59
+ * What the describing traversal reads: authored links in each direction among the records it can see, and which of
60
+ * them have link ends it cannot see. A graph hides nothing; an export hides what it withheld (spec/list.md §3).
61
+ */
62
+ export interface LinkIndex {
63
+ has(id: string): boolean;
64
+ type(id: string): ArtifactType | undefined;
65
+ /** Visible targets of the authored `kind` links leaving `id`, as authored (an unresolved ID included). */
66
+ targets(id: string, kind: LinkKind): readonly string[];
67
+ /** Visible sources of the authored `kind` links arriving at `id`. */
68
+ sources(id: string, kind: LinkKind): readonly string[];
69
+ /** True when `id` has a link end at a withheld artifact. */
70
+ hidden(id: string): boolean;
71
+ /** Visible records of `type` with a link end at a withheld artifact, sorted. */
72
+ hiddenOfType(type: ArtifactType): readonly string[];
73
+ /** True when the source withheld any artifact. */
74
+ withholds: boolean;
75
+ }
76
+
77
+ /** A graph as a LinkIndex: everything visible, nothing hidden. */
78
+ export function graphIndex(graph: Graph): LinkIndex {
79
+ return {
80
+ has: (id) => graph.artifacts.has(id),
81
+ type: (id) => graph.artifacts.get(id)?.meta.type,
82
+ targets: (id, kind) => (graph.out.get(id) ?? []).filter((edge) => edge.kind === kind).map((edge) => edge.to),
83
+ sources: (id, kind) => (graph.in.get(id) ?? []).filter((edge) => edge.kind === kind).map((edge) => edge.from),
84
+ hidden: () => false,
85
+ hiddenOfType: () => [],
86
+ withholds: false,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * True when a record of `type` could have a `kind` link in `direction` at all (Core §5's endpoints), so a withheld
92
+ * end of that record could be one. A rule authors no `governedBy`, so its withheld ends never change that answer.
93
+ */
94
+ export function linkPossible(type: ArtifactType, direction: "out" | "in", kind: LinkKind): boolean {
95
+ if (kind === "replacedBy") return true;
96
+ if (kind === "parent") {
97
+ return direction === "out"
98
+ ? PARENT_TARGETS[type] !== undefined
99
+ : Object.values(PARENT_TARGETS).some((targets) => targets?.includes(type));
100
+ }
101
+ const ends = LINK_ENDPOINTS[kind];
102
+ return (direction === "out" ? ends.from : ends.to).includes(type);
103
+ }
104
+
105
+ /** A set of reached records, and whether a withheld record may also have been reached. */
106
+ interface Reach {
107
+ ids: Set<string>;
108
+ wild: boolean;
109
+ }
110
+
111
+ /**
112
+ * The describing traversal from `sliceId` (Core §10). Exact (`loose` false), it reaches what the visible links reach.
113
+ * Loose, it also reaches every record a withheld artifact could have connected: where a reached record has a withheld
114
+ * end of the kind a step follows, the step may reach a withheld record, and from a withheld record every visible
115
+ * record of the step's type that has a withheld end of its own. Loose minus exact is what is undetermined.
116
+ */
117
+ export function describing(index: LinkIndex, sliceId: string, loose: boolean): Reach {
118
+ const start: Reach = index.has(sliceId)
119
+ ? { ids: new Set([sliceId]), wild: false }
120
+ : { ids: new Set(), wild: loose && index.withholds };
121
+ const step = (from: Reach, kind: LinkKind, direction: "out" | "in", type: ArtifactType): Reach => {
122
+ const ids = new Set<string>();
123
+ let wild = false;
124
+ for (const id of from.ids) {
125
+ for (const next of direction === "out" ? index.targets(id, kind) : index.sources(id, kind)) ids.add(next);
126
+ const own = index.type(id);
127
+ if (loose && own !== undefined && index.hidden(id) && linkPossible(own, direction, kind)) wild = true;
128
+ }
129
+ if (loose && from.wild) {
130
+ wild = true;
131
+ for (const id of index.hiddenOfType(type)) ids.add(id);
132
+ }
133
+ return { ids, wild };
134
+ };
135
+ const union = (...reaches: Reach[]): Reach => ({
136
+ ids: new Set(reaches.flatMap((reach) => [...reach.ids])),
137
+ wild: reaches.some((reach) => reach.wild),
138
+ });
139
+
140
+ const behaviors = step(start, "implements", "out", "behavior");
141
+ const rules = step(behaviors, "governedBy", "out", "rule");
142
+ const scenarios = step(behaviors, "illustrates", "in", "scenario");
143
+ const claims = union(behaviors, rules, scenarios);
144
+ const verifications = step(claims, "verifies", "in", "verification");
145
+ const contracts = step(start, "exposes", "out", "contract");
146
+ const decisions = step(union(start, behaviors, contracts), "informedBy", "out", "decision");
147
+ const all = union(start, claims, verifications, contracts, decisions);
148
+ return { ids: new Set([...all.ids].filter((id) => index.has(id))), wild: all.wild };
149
+ }
package/src/types.ts CHANGED
@@ -29,6 +29,17 @@ export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
29
29
  export const STATUSES = ["draft", "approved", "implemented", "released", "deprecated", "retired"] as const;
30
30
  export type Status = (typeof STATUSES)[number];
31
31
 
32
+ /**
33
+ * Core §7, ADR 0017: whether the work a record describes exists, apart from its approval. `planned`: it does not
34
+ * yet; `built`: the code it describes exists. Written only on the types in `DELIVERY_TYPES`; absent, it is not stated.
35
+ */
36
+ export const DELIVERIES = ["planned", "built"] as const;
37
+ export type Delivery = (typeof DELIVERIES)[number];
38
+ /** The types that describe work code delivers, and so may carry `delivery`. */
39
+ export const DELIVERY_TYPES = ["capability", "behavior", "rule", "scenario", "slice", "contract"] as const;
40
+ /** The statuses that claim an implementation, and so make delivery `built` when the field is absent (Core §7). */
41
+ export const BUILT_STATUSES = ["implemented", "released", "deprecated"] as const;
42
+
32
43
  export const VISIBILITIES = ["public", "customer", "internal", "restricted"] as const;
33
44
  export type Visibility = (typeof VISIBILITIES)[number];
34
45
 
@@ -182,6 +193,8 @@ export interface ArtifactMeta {
182
193
  revision?: number;
183
194
  reviewedAt?: string;
184
195
  reviewedBy?: string;
196
+ /** Core §7, ADR 0017: whether the work exists, on the types in `DELIVERY_TYPES` only. */
197
+ delivery?: Delivery;
185
198
  /** Namespaced extensions, preserved and never interpreted. */
186
199
  extensions?: Record<string, unknown>;
187
200
  /** Knowledge only (Core §9): one sentence per explained ID, keys sorted. */
@@ -383,7 +396,7 @@ export interface ImpactReport {
383
396
  * subject it was judged at, each record's binding and subject hash, and a
384
397
  * verification's `exercises` (ADR 0014).
385
398
  */
386
- export const EXPORT_CONTRACT = "intentset/export/0.4";
399
+ export const EXPORT_CONTRACT = "intentset/export/0.5";
387
400
 
388
401
  /** The contracts this implementation reads. A consumer rejects every other value (spec/export.md §5). */
389
402
  export const SUPPORTED_EXPORT_CONTRACTS: readonly string[] = [EXPORT_CONTRACT];
@@ -397,6 +410,8 @@ export interface ExportArtifact {
397
410
  type: ArtifactType;
398
411
  title: string;
399
412
  status: Status;
413
+ /** The record's `delivery` as authored, null when it states none (Core §7, ADR 0017); `deliveryOf` gives the effective value. */
414
+ delivery: Delivery | null;
400
415
  owner: string;
401
416
  visibility: Visibility;
402
417
  audiences: string[];
package/src/validate.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * are reported as CORE002; neither is merged with the other.
12
12
  */
13
13
  import { type ReadDocument, readDocument } from "./artifact.ts";
14
+ import { deliveryOf } from "./delivery.ts";
14
15
  import { type Diagnostic, hasErrors, sortDiagnostics } from "./diagnostics.ts";
15
16
  import { buildGraph, incomingEdges, outgoing } from "./graph.ts";
16
17
  import { type Locate, aType, capitalize, compareStrings, describeTypes, makeDiagnostic } from "./report.ts";
@@ -449,6 +450,27 @@ function checkLifecycle(context: Context): void {
449
450
  });
450
451
  }
451
452
  });
453
+ if (meta.delivery === "planned" && deliveryOf({ status: meta.status }) === "built") {
454
+ reportOn(context, artifact, {
455
+ code: "CORE005",
456
+ field: "/intentset/delivery",
457
+ message: `${meta.id} says its work is planned while its status, ${meta.status}, claims an implementation.`,
458
+ remediation:
459
+ "Write `delivery: built`, or remove `delivery`, when the code exists; return the record to an earlier status, with a review note, when it does not (Core §7).",
460
+ });
461
+ }
462
+ if (meta.type === "behavior" && deliveryOf(meta) === "built") {
463
+ for (const edge of incomingEdges(graph, meta.id, "implements")) {
464
+ const slice = graph.artifacts.get(edge.from);
465
+ if (slice?.meta.delivery !== "planned") continue;
466
+ reportOn(context, artifact, {
467
+ code: "CORE005",
468
+ field: meta.delivery === undefined ? "/intentset/status" : "/intentset/delivery",
469
+ message: `${meta.id}'s work is built, but ${slice.meta.id}, which implements it, says its work is planned.`,
470
+ remediation: `Write \`delivery: built\` on ${slice.meta.id} once the code it claims exists, or correct ${meta.id}'s delivery (Core §7).`,
471
+ });
472
+ }
473
+ }
452
474
  if (meta.status === "retired" && (meta.links.replacedBy ?? []).length === 0) {
453
475
  reportOn(context, artifact, {
454
476
  code: "CORE005",
@@ -701,13 +723,29 @@ function checkOwnership(context: Context): void {
701
723
  const { graph } = context;
702
724
  for (const artifact of sortedArtifacts(graph)) {
703
725
  const { meta } = artifact;
704
- if (meta.type !== "behavior" || !OWNED_STATUSES.has(meta.status)) continue;
726
+ if (meta.type !== "behavior") continue;
727
+ // ADR 0017: a behavior that is not yet owned by status but says its work is built is held to having an owner,
728
+ // as a warning, so built code nobody owns shows before anyone approves it.
729
+ const builtDraft = !OWNED_STATUSES.has(meta.status) && meta.status !== "retired" && deliveryOf(meta) === "built";
730
+ if (!OWNED_STATUSES.has(meta.status) && !builtDraft) continue;
705
731
  const owners = incomingEdges(graph, meta.id, "implements")
706
732
  .map((edge) => graph.artifacts.get(edge.from))
707
733
  .filter((slice): slice is Artifact => slice?.meta.slice?.kind === "product")
708
734
  .map((slice) => slice.meta.id)
709
735
  .sort(compareStrings);
710
736
  if (owners.length === 1) continue;
737
+ if (builtDraft) {
738
+ if (owners.length > 0) continue;
739
+ reportOn(context, artifact, {
740
+ code: "CORE006",
741
+ severity: "warning",
742
+ field: "/intentset/delivery",
743
+ message: `${capitalize(meta.status)} behavior ${meta.id} says its work is built, and no product slice implements it.`,
744
+ remediation:
745
+ "Add the behavior to the implements of the product slice whose code delivers it, so the built code has an owner before the behavior is approved (Core §5, §7).",
746
+ });
747
+ continue;
748
+ }
711
749
  reportOn(context, artifact, {
712
750
  code: "CORE006",
713
751
  message: