@intentset/core 0.5.0 → 0.6.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.
@@ -26,10 +26,14 @@ import {
26
26
  ID_PATTERN,
27
27
  LEVELS,
28
28
  LINK_KINDS,
29
+ MEASURE_DIRECTIONS,
30
+ METRIC_PATTERN,
29
31
  OWNERSHIP_REGIONS,
30
32
  STATUSES,
31
33
  SUPPORTED_EXPORT_CONTRACTS,
32
34
  ARTIFACT_TYPES,
35
+ TIP_MAX_LENGTH,
36
+ TIP_PATTERN,
33
37
  VISIBILITIES,
34
38
  } from "./types.ts";
35
39
 
@@ -364,9 +368,18 @@ class Shape {
364
368
 
365
369
  registries(value: unknown): void {
366
370
  const at = "/registries";
367
- const r = this.object(value, at, ["owners", "audiences", "releases", "roles", "editions", "flags", "resources"]);
371
+ const r = this.object(value, at, [
372
+ "owners",
373
+ "audiences",
374
+ "releases",
375
+ "roles",
376
+ "editions",
377
+ "flags",
378
+ "evidenceSources",
379
+ "resources",
380
+ ]);
368
381
  if (r === null) return;
369
- for (const key of ["owners", "audiences", "releases", "roles", "editions", "flags"])
382
+ for (const key of ["owners", "audiences", "releases", "roles", "editions", "flags", "evidenceSources"])
370
383
  this.strings(r[key], `${at}/${key}`);
371
384
  this.array(r.resources, `${at}/resources`, (resource, p) => {
372
385
  const x = this.object(resource, p, ["id", "path", "owner", "consumers"]);
@@ -401,6 +414,8 @@ class Shape {
401
414
  "availability",
402
415
  "slice",
403
416
  "verification",
417
+ "measure",
418
+ "tips",
404
419
  "extensions",
405
420
  ],
406
421
  ["body"],
@@ -444,6 +459,27 @@ class Shape {
444
459
  this.string(v.locator, `${p}/locator`);
445
460
  this.string(v.selector, `${p}/selector`);
446
461
  });
462
+ this.nullable(a.measure, `${at}/measure`, (x, p) => {
463
+ const m = this.object(x, p, ["metric", "baseline", "target", "window", "source", "direction"]);
464
+ if (m === null) return;
465
+ this.string(m.metric, `${p}/metric`, METRIC_PATTERN, "a metric identifier such as median_time_to_intervention");
466
+ for (const key of ["baseline", "target", "window", "source"]) this.string(m[key], `${p}/${key}`);
467
+ this.nullable(m.direction, `${p}/direction`, (d, q) => this.oneOf(d, q, MEASURE_DIRECTIONS));
468
+ });
469
+ this.nullable(a.tips, `${at}/tips`, (x, p) => {
470
+ if (!isRecord(x)) {
471
+ this.fail(p, "Expected an object of tips by explained ID.");
472
+ return;
473
+ }
474
+ for (const key of Object.keys(x).sort(compareStrings)) {
475
+ const q = `${p}/${key}`;
476
+ if (!ID_PATTERN.test(key)) this.fail(q, "Expected an artifact ID as the key.");
477
+ const tip = x[key];
478
+ if (typeof tip !== "string" || tip.length > TIP_MAX_LENGTH || !TIP_PATTERN.test(tip)) {
479
+ this.fail(q, `Expected one line of plain text, at most ${TIP_MAX_LENGTH} characters.`);
480
+ }
481
+ }
482
+ });
447
483
  this.nullable(a.extensions, `${at}/extensions`, (x, p) => this.extensions(x, p));
448
484
  if (Object.hasOwn(a, "body") && typeof a.body !== "string") this.fail(`${at}/body`, "Expected a string.");
449
485
  }
package/src/registries.ts CHANGED
@@ -1,6 +1,6 @@
1
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
2
+ * The repository registries (Core §4, §6, §7; VSA §3): owners, audiences, the
3
+ * four release dimensions, the evidence sources measures name, and the shared-resource inventory. The file is
4
4
  * `registries.yaml` in the shape of examples/scheduling/registries.yaml.
5
5
  * A missing or empty file is empty registries, which the validator reads as
6
6
  * "not declared" rather than "nothing allowed".
@@ -10,7 +10,7 @@ import { compareStrings, isRecord, makeDiagnostic } from "./report.ts";
10
10
  import { EMPTY_REGISTRIES, ID_PATTERN, type Registries, type Resource } from "./types.ts";
11
11
  import { parseYamlDetailed } from "./yaml.ts";
12
12
 
13
- const LIST_KEYS = ["owners", "audiences", "releases", "roles", "editions", "flags"] as const;
13
+ const LIST_KEYS = ["owners", "audiences", "releases", "roles", "editions", "flags", "evidenceSources"] as const;
14
14
  const KNOWN_KEYS = new Set<string>([...LIST_KEYS, "resources"]);
15
15
 
16
16
  /**
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ export const ARTIFACT_TYPES = [
10
10
  "product",
11
11
  "intent",
12
12
  "outcome",
13
+ "measure",
13
14
  "capability",
14
15
  "behavior",
15
16
  "rule",
@@ -75,6 +76,7 @@ export const LINK_ENDPOINTS: Record<
75
76
  export const PARENT_TARGETS: Partial<Record<ArtifactType, readonly ArtifactType[]>> = {
76
77
  intent: ["product"],
77
78
  outcome: ["intent"],
79
+ measure: ["outcome"],
78
80
  capability: ["outcome", "capability"],
79
81
  behavior: ["capability"],
80
82
  };
@@ -84,6 +86,7 @@ export const REQUIRED_SECTIONS: Record<ArtifactType, readonly string[]> = {
84
86
  product: ["Scope"],
85
87
  intent: ["Rationale"],
86
88
  outcome: ["Measure"],
89
+ measure: ["Method"],
87
90
  capability: ["Overview"],
88
91
  behavior: ["Behavior", "Preconditions", "Outcomes"],
89
92
  rule: ["Constraint"],
@@ -135,6 +138,28 @@ export interface VerificationMeta {
135
138
  selector: string;
136
139
  }
137
140
 
141
+ export const MEASURE_DIRECTIONS = ["increase", "decrease"] as const;
142
+ export type MeasureDirection = (typeof MEASURE_DIRECTIONS)[number];
143
+
144
+ /** Core §6: a metric identifier, lower case, words joined by `_` or `-`. */
145
+ export const METRIC_PATTERN = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/;
146
+
147
+ /** The literal a measure's baseline carries when none has been taken yet (Core §6). */
148
+ export const BASELINE_UNKNOWN = "unknown";
149
+
150
+ /** Core §6 measure metadata: how the parent outcome is judged. */
151
+ export interface MeasureMeta {
152
+ metric: string;
153
+ /** A value, or `unknown` (BASELINE_UNKNOWN). */
154
+ baseline: string;
155
+ target: string;
156
+ /** When the measure is read, relative to a release or a date; prose, not parsed. */
157
+ window: string;
158
+ /** An entry in the repository's `evidenceSources` registry. */
159
+ source: string;
160
+ direction?: MeasureDirection;
161
+ }
162
+
138
163
  /** The `intentset` block of a document's frontmatter after validation, Core §4. */
139
164
  export interface ArtifactMeta {
140
165
  spec: typeof SPEC_VERSION;
@@ -158,6 +183,7 @@ export interface ArtifactMeta {
158
183
  tips?: Record<string, string>;
159
184
  slice?: SliceMeta;
160
185
  verification?: VerificationMeta;
186
+ measure?: MeasureMeta;
161
187
  }
162
188
 
163
189
  export interface Heading {
@@ -234,6 +260,8 @@ export interface Registries {
234
260
  roles: string[];
235
261
  editions: string[];
236
262
  flags: string[];
263
+ /** Where a measure's evidence is expected to come from (Core §6): an analytics product, a study, a survey. */
264
+ evidenceSources: string[];
237
265
  resources: Resource[];
238
266
  }
239
267
 
@@ -244,6 +272,7 @@ export const EMPTY_REGISTRIES: Registries = {
244
272
  roles: [],
245
273
  editions: [],
246
274
  flags: [],
275
+ evidenceSources: [],
247
276
  resources: [],
248
277
  };
249
278
 
@@ -303,11 +332,12 @@ export interface ImpactReport {
303
332
  }
304
333
 
305
334
  /**
306
- * The versioned export envelope (spec/export.md; spec/export.schema.json). 0.2 is
307
- * the first version a consumer may pin: 0.1 carried untyped report slots and
308
- * restricted artifacts by default (ADR 0008).
335
+ * The versioned export envelope (spec/export.md; spec/export.schema.json). 0.2 was
336
+ * the first version a consumer could pin: 0.1 carried untyped report slots and
337
+ * restricted artifacts by default (ADR 0008). 0.3 added the measure type, the
338
+ * `evidenceSources` registry, and `measure` and `tips` on every artifact (ADR 0012).
309
339
  */
310
- export const EXPORT_CONTRACT = "intentset/export/0.2";
340
+ export const EXPORT_CONTRACT = "intentset/export/0.3";
311
341
 
312
342
  /** The contracts this implementation reads. A consumer rejects every other value (spec/export.md §5). */
313
343
  export const SUPPORTED_EXPORT_CONTRACTS: readonly string[] = [EXPORT_CONTRACT];
@@ -338,6 +368,10 @@ export interface ExportArtifact {
338
368
  availability: Availability | null;
339
369
  slice: SliceMeta | null;
340
370
  verification: VerificationMeta | null;
371
+ /** Null unless the artifact is a measure; `direction` is null when the author set none. */
372
+ measure: (Omit<MeasureMeta, "direction"> & { direction: MeasureDirection | null }) | null;
373
+ /** Null unless the artifact is knowledge with tips (Core §9). */
374
+ tips: Record<string, string> | null;
341
375
  extensions: Record<string, unknown> | null;
342
376
  /** Present only when the export was asked to include bodies. */
343
377
  body?: string;
package/src/validate.ts CHANGED
@@ -414,6 +414,18 @@ function checkLifecycle(context: Context): void {
414
414
  });
415
415
  }
416
416
  }
417
+ if (meta.measure !== undefined) {
418
+ if (registries.evidenceSources.length === 0) use("evidenceSources");
419
+ else if (!registries.evidenceSources.includes(meta.measure.source)) {
420
+ reportOn(context, artifact, {
421
+ code: "CORE005",
422
+ field: "/intentset/measure/source",
423
+ message: `measure source ${meta.measure.source} is not in the evidenceSources registry.`,
424
+ remediation:
425
+ "Add the analytics product, study or survey to registries.yaml evidenceSources, or name a registered source.",
426
+ });
427
+ }
428
+ }
417
429
  meta.slice?.usesResources.forEach((resource, i) => {
418
430
  if (registries.resources.length === 0) use("resources");
419
431
  else if (!resourceIds.has(resource)) {
@@ -465,12 +477,22 @@ interface Requirement {
465
477
  remediation: string;
466
478
  /** Core §5 names no active obligation for this type; only the draft warning applies. */
467
479
  draftOnly?: boolean;
480
+ /** Only edges from these types count: an outcome is judged by its measures, not by the capabilities under it. */
481
+ fromTypes?: readonly ArtifactType[];
468
482
  }
469
483
 
470
484
  function requirement(artifact: Artifact): Requirement | null {
471
485
  const { meta } = artifact;
472
486
  const id = meta.id;
473
487
  switch (meta.type) {
488
+ case "outcome":
489
+ return {
490
+ kinds: ["parent"],
491
+ direction: "in",
492
+ fromTypes: ["measure"],
493
+ message: "no measure names it as parent, so nothing says how it will be judged",
494
+ remediation: `Add a measure record with \`parent: ${id}\` carrying metric, baseline, target, window and source (Core §6).`,
495
+ };
474
496
  case "rule":
475
497
  return {
476
498
  kinds: ["governedBy"],
@@ -529,18 +551,27 @@ function requirement(artifact: Artifact): Requirement | null {
529
551
  }
530
552
 
531
553
  /**
532
- * Core §5 and VSA §3: active rules, scenarios, verifications, knowledge and
533
- * product slices are attached (CORE003). Their drafts, and draft contracts
534
- * and decisions nothing refers to, are unattached drafts (CORE009, warning).
554
+ * Core §5, §6 and VSA §3: active rules, scenarios, verifications, knowledge and
555
+ * product slices are attached, and an active outcome has a measure (CORE003).
556
+ * Their drafts, and draft contracts and decisions nothing refers to, are
557
+ * unattached drafts (CORE009, warning).
535
558
  */
536
559
  function checkAttachment(context: Context): void {
537
560
  const { graph } = context;
538
561
  for (const artifact of sortedArtifacts(graph)) {
539
562
  const need = requirement(artifact);
540
563
  if (need === null || artifact.meta.status === "retired") continue;
541
- const edges = need.kinds.flatMap((kind) =>
542
- need.direction === "in" ? incomingEdges(graph, artifact.meta.id, kind) : outgoing(graph, artifact.meta.id, kind),
543
- );
564
+ const edges = need.kinds
565
+ .flatMap((kind) =>
566
+ need.direction === "in"
567
+ ? incomingEdges(graph, artifact.meta.id, kind)
568
+ : outgoing(graph, artifact.meta.id, kind),
569
+ )
570
+ .filter((edge) => {
571
+ if (need.fromTypes === undefined) return true;
572
+ const from = graph.artifacts.get(edge.from);
573
+ return from !== undefined && need.fromTypes.includes(from.meta.type);
574
+ });
544
575
  if (edges.length > 0) continue;
545
576
  const field = need.direction === "out" ? `/intentset/links/${need.kinds[0]}` : undefined;
546
577
  if (artifact.meta.status === "draft") {
@@ -572,7 +603,7 @@ export function patternProblem(pattern: string): string | null {
572
603
  return null;
573
604
  }
574
605
 
575
- /** VSA §3 and Core §8: type-specific blocks on their own type only; technical slices give a rationale and implement nothing; patterns are plain. */
606
+ /** VSA §3, Core §6 and §8: type-specific blocks on their own type only; technical slices give a rationale and implement nothing; patterns are plain. */
576
607
  function checkSlices(context: Context): void {
577
608
  const { graph } = context;
578
609
  for (const artifact of sortedArtifacts(graph)) {
@@ -586,6 +617,13 @@ function checkSlices(context: Context): void {
586
617
  "Remove the verification block, or change the type (Core §8).",
587
618
  );
588
619
  }
620
+ if (artifact.meta.measure !== undefined && artifact.meta.type !== "measure") {
621
+ profile(
622
+ "/intentset/measure",
623
+ `${id} is ${aType(artifact.meta.type)} and carries measure metadata, which only a measure has.`,
624
+ "Remove the measure block, or change the type (Core §6).",
625
+ );
626
+ }
589
627
  const slice = artifact.meta.slice;
590
628
  if (slice === undefined) continue;
591
629
  if (artifact.meta.type !== "slice") {