@intentset/core 0.9.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/select.ts ADDED
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Selecting records (spec/list.md, ADR 0016): a closed, typed selector over a
3
+ * graph or a checked export, and the `intentset/list/0.1` result.
4
+ *
5
+ * The selector is data, not a language. Its fields combine with AND and the
6
+ * values within one field with OR, as availability does (Core §7); matching is
7
+ * exact, except that an ID ending in `*` matches by prefix. A link predicate
8
+ * looks one hop: the record at the far end is matched on its own fields, never
9
+ * on its links. An unknown key is an error, never ignored.
10
+ *
11
+ * Both sources are read through the export's projection (spec/export.md §3),
12
+ * so a list from the tree and a list from its export give the same answer.
13
+ * Where the answer depends on a link end the source withheld, the record is
14
+ * undetermined: counted and listed apart, never a match and never a miss
15
+ * (design invariant 6).
16
+ */
17
+ import { projectArtifacts } from "./export.ts";
18
+ import { matchGlob } from "./glob.ts";
19
+ import { compareStrings, isRecord } from "./report.ts";
20
+ import { deliveryOf } from "./delivery.ts";
21
+ import { describing, type LinkIndex, linkPossible } from "./slices.ts";
22
+ import {
23
+ ARTIFACT_TYPES,
24
+ type ArtifactType,
25
+ type Availability,
26
+ DELIVERIES,
27
+ type Delivery,
28
+ type ExportArtifact,
29
+ type ExportEnvelope,
30
+ type Graph,
31
+ LINK_KINDS,
32
+ type Level,
33
+ type LinkKind,
34
+ PARENT_TARGETS,
35
+ type Registries,
36
+ STATUSES,
37
+ type Status,
38
+ VISIBILITIES,
39
+ type Visibility,
40
+ } from "./types.ts";
41
+
42
+ /** The result's contract (spec/list.md §5). A reader rejects any other value. */
43
+ export const LIST_CONTRACT = "intentset/list/0.1";
44
+
45
+ /** What `deliveries` matches: a record's effective delivery, or `unstated` when it has none (ADR 0017). */
46
+ export const DELIVERY_VALUES = [...DELIVERIES, "unstated"] as const;
47
+ export type DeliveryValue = Delivery | "unstated";
48
+
49
+ /** The availability dimensions a selector matches, `notReleases` the one negative. */
50
+ export const AVAILABILITY_SELECTORS = ["products", "releases", "notReleases", "roles", "editions", "flags"] as const;
51
+ export type AvailabilitySelectorName = (typeof AVAILABILITY_SELECTORS)[number];
52
+ export type AvailabilitySelector = Partial<Record<AvailabilitySelectorName, string[]>>;
53
+
54
+ /** The fields a link's far end may be matched on: a record's own fields, never its links (one hop). */
55
+ export const END_FIELDS = [
56
+ "ids",
57
+ "types",
58
+ "statuses",
59
+ "deliveries",
60
+ "owners",
61
+ "audiences",
62
+ "visibilities",
63
+ "under",
64
+ "availability",
65
+ "hasExtension",
66
+ ] as const;
67
+
68
+ /** Every selector field, in the order the normalized selector is written. */
69
+ export const SELECTOR_FIELDS = [...END_FIELDS, "links", "describing", "claims"] as const;
70
+ export type SelectorField = (typeof SELECTOR_FIELDS)[number];
71
+
72
+ export const LINK_DIRECTIONS = ["out", "in"] as const;
73
+ export const LINK_QUANTIFIERS = ["some", "none", "all"] as const;
74
+ export type LinkQuantifier = (typeof LINK_QUANTIFIERS)[number];
75
+
76
+ /** What a link's far end must be. */
77
+ export interface EndSelector {
78
+ ids?: string[];
79
+ types?: ArtifactType[];
80
+ statuses?: Status[];
81
+ /** The effective delivery (Core §7, ADR 0017), `unstated` for a record that states none. */
82
+ deliveries?: DeliveryValue[];
83
+ owners?: string[];
84
+ audiences?: string[];
85
+ visibilities?: Visibility[];
86
+ under?: string[];
87
+ availability?: AvailabilitySelector;
88
+ hasExtension?: string[];
89
+ }
90
+
91
+ export interface LinkPredicate {
92
+ direction: "out" | "in";
93
+ kind: LinkKind;
94
+ quantifier: LinkQuantifier;
95
+ match?: EndSelector;
96
+ }
97
+
98
+ export interface Selector extends EndSelector {
99
+ links?: LinkPredicate[];
100
+ describing?: string[];
101
+ claims?: string[];
102
+ }
103
+
104
+ export interface SelectorProblem {
105
+ /** JSON pointer into the selector as given; "" for the whole of it. */
106
+ pointer: string;
107
+ message: string;
108
+ }
109
+
110
+ export type ReadSelectorResult = { ok: true; selector: Selector } | { ok: false; problems: SelectorProblem[] };
111
+
112
+ const CLOSED_VALUES: Partial<Record<SelectorField, readonly string[]>> = {
113
+ types: ARTIFACT_TYPES,
114
+ statuses: STATUSES,
115
+ deliveries: DELIVERY_VALUES,
116
+ visibilities: VISIBILITIES,
117
+ };
118
+
119
+ /**
120
+ * spec/list.md §1: read a selector from data (a parsed `--where` file, or what the command line compiled), and
121
+ * normalize it: every list sorted with duplicates removed, a link's quantifier written out. Every problem is
122
+ * collected; one makes the selector unusable.
123
+ */
124
+ export function readSelector(value: unknown): ReadSelectorResult {
125
+ const problems: SelectorProblem[] = [];
126
+ const selector = readFields(value, "", SELECTOR_FIELDS, problems) as Selector;
127
+ return problems.length === 0 ? { ok: true, selector } : { ok: false, problems };
128
+ }
129
+
130
+ function readFields(
131
+ value: unknown,
132
+ at: string,
133
+ allowed: readonly SelectorField[],
134
+ problems: SelectorProblem[],
135
+ ): Selector | EndSelector {
136
+ const out: Record<string, unknown> = {};
137
+ if (!isRecord(value)) {
138
+ problems.push({ pointer: at, message: "A selector is an object of fields." });
139
+ return out;
140
+ }
141
+ for (const key of Object.keys(value).sort(compareStrings)) {
142
+ const pointer = `${at}/${escapePointer(key)}`;
143
+ if (!(allowed as readonly string[]).includes(key)) {
144
+ const hop = (SELECTOR_FIELDS as readonly string[]).includes(key);
145
+ problems.push({
146
+ pointer,
147
+ message: hop
148
+ ? `${key} cannot match a link's far end: a link predicate looks one hop, so its match holds only ${END_FIELDS.join(", ")}.`
149
+ : `${key} is not a selector field; the fields are ${allowed.join(", ")}.`,
150
+ });
151
+ }
152
+ }
153
+ for (const field of allowed) {
154
+ if (!Object.hasOwn(value, field)) continue;
155
+ const pointer = `${at}/${field}`;
156
+ const given = value[field];
157
+ if (field === "availability") {
158
+ const availability = readAvailability(given, pointer, problems);
159
+ if (availability !== null) out.availability = availability;
160
+ } else if (field === "links") {
161
+ const links = readLinks(given, pointer, problems);
162
+ if (links !== null) out.links = links;
163
+ } else {
164
+ const list = readList(given, pointer, problems, CLOSED_VALUES[field]);
165
+ if (list === null) continue;
166
+ if (field === "ids") {
167
+ for (const [i, id] of list.entries()) {
168
+ if (id.indexOf("*") !== -1 && id.indexOf("*") !== id.length - 1) {
169
+ problems.push({
170
+ pointer: `${pointer}/${i}`,
171
+ message: `${id}: only a trailing * is a pattern, matching every ID that starts with what is before it.`,
172
+ });
173
+ }
174
+ }
175
+ }
176
+ out[field] = list;
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function readList(
183
+ value: unknown,
184
+ pointer: string,
185
+ problems: SelectorProblem[],
186
+ closed?: readonly string[],
187
+ ): string[] | null {
188
+ if (!Array.isArray(value) || value.length === 0) {
189
+ problems.push({
190
+ pointer,
191
+ message:
192
+ "Give a nonempty list of values; the values in one field combine with OR, and an empty list would match nothing.",
193
+ });
194
+ return null;
195
+ }
196
+ let ok = true;
197
+ for (const [i, item] of value.entries()) {
198
+ if (typeof item !== "string" || item === "") {
199
+ problems.push({ pointer: `${pointer}/${i}`, message: "Each value is a nonempty string." });
200
+ ok = false;
201
+ } else if (closed !== undefined && !closed.includes(item)) {
202
+ problems.push({ pointer: `${pointer}/${i}`, message: `${item} is not one of ${closed.join(", ")}.` });
203
+ ok = false;
204
+ }
205
+ }
206
+ return ok ? [...new Set(value as string[])].sort(compareStrings) : null;
207
+ }
208
+
209
+ function readAvailability(value: unknown, pointer: string, problems: SelectorProblem[]): AvailabilitySelector | null {
210
+ if (!isRecord(value) || Object.keys(value).length === 0) {
211
+ problems.push({
212
+ pointer,
213
+ message: `availability is an object of one or more of ${AVAILABILITY_SELECTORS.join(", ")}.`,
214
+ });
215
+ return null;
216
+ }
217
+ const out: AvailabilitySelector = {};
218
+ for (const key of Object.keys(value).sort(compareStrings)) {
219
+ if (!(AVAILABILITY_SELECTORS as readonly string[]).includes(key)) {
220
+ problems.push({
221
+ pointer: `${pointer}/${escapePointer(key)}`,
222
+ message: `${key} is not an availability field; the fields are ${AVAILABILITY_SELECTORS.join(", ")}.`,
223
+ });
224
+ }
225
+ }
226
+ for (const name of AVAILABILITY_SELECTORS) {
227
+ if (!Object.hasOwn(value, name)) continue;
228
+ const list = readList(value[name], `${pointer}/${name}`, problems);
229
+ if (list !== null) out[name] = list;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ const LINK_KEYS = ["direction", "kind", "quantifier", "match"];
235
+
236
+ function readLinks(value: unknown, pointer: string, problems: SelectorProblem[]): LinkPredicate[] | null {
237
+ if (!Array.isArray(value) || value.length === 0) {
238
+ problems.push({ pointer, message: "links is a nonempty list of link predicates, each a condition of its own." });
239
+ return null;
240
+ }
241
+ const out: LinkPredicate[] = [];
242
+ for (const [i, item] of value.entries()) {
243
+ const at = `${pointer}/${i}`;
244
+ if (!isRecord(item)) {
245
+ problems.push({ pointer: at, message: "A link predicate is an object: direction, kind, quantifier, match." });
246
+ continue;
247
+ }
248
+ const before = problems.length;
249
+ for (const key of Object.keys(item).sort(compareStrings)) {
250
+ if (!LINK_KEYS.includes(key)) {
251
+ problems.push({
252
+ pointer: `${at}/${escapePointer(key)}`,
253
+ message: `${key} is not part of a link predicate; its keys are ${LINK_KEYS.join(", ")}.`,
254
+ });
255
+ }
256
+ }
257
+ const direction = item.direction;
258
+ if (!(LINK_DIRECTIONS as readonly unknown[]).includes(direction)) {
259
+ problems.push({
260
+ pointer: `${at}/direction`,
261
+ message: "direction is out (links the record authors) or in (links authored to it).",
262
+ });
263
+ }
264
+ const kind = item.kind;
265
+ if (!(LINK_KINDS as readonly unknown[]).includes(kind)) {
266
+ problems.push({ pointer: `${at}/kind`, message: `kind is one of ${LINK_KINDS.join(", ")}.` });
267
+ }
268
+ const quantifier = item.quantifier ?? "some";
269
+ if (!(LINK_QUANTIFIERS as readonly unknown[]).includes(quantifier)) {
270
+ problems.push({ pointer: `${at}/quantifier`, message: `quantifier is one of ${LINK_QUANTIFIERS.join(", ")}.` });
271
+ }
272
+ let match: EndSelector | undefined;
273
+ if (Object.hasOwn(item, "match")) {
274
+ match = readFields(item.match, `${at}/match`, END_FIELDS, problems) as EndSelector;
275
+ } else if (quantifier === "all") {
276
+ problems.push({
277
+ pointer: `${at}/match`,
278
+ message: "all needs a match: every link of a kind is trivially a link of that kind.",
279
+ });
280
+ }
281
+ if (problems.length > before) continue;
282
+ const predicate: LinkPredicate = {
283
+ direction: direction as LinkPredicate["direction"],
284
+ kind: kind as LinkKind,
285
+ quantifier: quantifier as LinkQuantifier,
286
+ };
287
+ if (match !== undefined) predicate.match = match;
288
+ out.push(predicate);
289
+ }
290
+ return problems.length === 0 ? out.sort(comparePredicates) : null;
291
+ }
292
+
293
+ function comparePredicates(a: LinkPredicate, b: LinkPredicate): number {
294
+ return compareStrings(JSON.stringify(sortedKeys(a)), JSON.stringify(sortedKeys(b)));
295
+ }
296
+
297
+ function sortedKeys(value: unknown): unknown {
298
+ if (Array.isArray(value)) return value.map(sortedKeys);
299
+ if (!isRecord(value)) return value;
300
+ return Object.fromEntries(
301
+ Object.keys(value)
302
+ .sort(compareStrings)
303
+ .map((key) => [key, sortedKeys(value[key])]),
304
+ );
305
+ }
306
+
307
+ function escapePointer(key: string): string {
308
+ return key.replace(/~/g, "~0").replace(/\//g, "~1");
309
+ }
310
+
311
+ /** A record as a row of the result: what review asks of it, never its body. */
312
+ export interface ListRow {
313
+ id: string;
314
+ type: ArtifactType;
315
+ title: string;
316
+ status: Status;
317
+ owner: string;
318
+ visibility: Visibility;
319
+ audiences: string[];
320
+ path: string;
321
+ /** Null when there is none, or when it is withheld. */
322
+ parent: string | null;
323
+ availability: Availability | null;
324
+ /** Core §4's review fields: null when the record names none, `not-supplied` when the source does not carry them. */
325
+ reviewedBy: string | null | "not-supplied";
326
+ reviewedAt: string | null | "not-supplied";
327
+ }
328
+
329
+ export interface ListCounts {
330
+ /** Records the source holds and the selector was tried on. */
331
+ considered: number;
332
+ /** Records the source withheld, which were not considered. */
333
+ withheld: number;
334
+ matched: number;
335
+ /** Records whose answer depends on a link end the source withheld. */
336
+ undetermined: number;
337
+ }
338
+
339
+ export interface Selection {
340
+ selector: Selector;
341
+ counts: ListCounts;
342
+ /** The matched records, sorted by ID. */
343
+ rows: ListRow[];
344
+ /** The undetermined records' IDs, sorted. */
345
+ undetermined: string[];
346
+ /** What a reader should know about the answer, in a fixed order. */
347
+ notes: string[];
348
+ }
349
+
350
+ export interface SelectOptions {
351
+ /** A graph only: select over restricted artifacts too. Default false: they are withheld, as an export withholds them. */
352
+ includeRestricted?: boolean;
353
+ /** A graph only: the registries a value is checked against, for the notes. An export carries its own. */
354
+ registries?: Registries;
355
+ }
356
+
357
+ /** Kleene's three values: a definite yes or no, or null when a withheld link end decides. */
358
+ type Answer = boolean | null;
359
+
360
+ function all(answers: Iterable<Answer>): Answer {
361
+ let unknown = false;
362
+ for (const answer of answers) {
363
+ if (answer === false) return false;
364
+ if (answer === null) unknown = true;
365
+ }
366
+ return unknown ? null : true;
367
+ }
368
+
369
+ function any(answers: Iterable<Answer>): Answer {
370
+ let unknown = false;
371
+ for (const answer of answers) {
372
+ if (answer === true) return true;
373
+ if (answer === null) unknown = true;
374
+ }
375
+ return unknown ? null : false;
376
+ }
377
+
378
+ interface Source {
379
+ views: Map<string, ExportArtifact>;
380
+ withheld: number;
381
+ review: (id: string) => { reviewedBy: ListRow["reviewedBy"]; reviewedAt: ListRow["reviewedAt"] };
382
+ registries: Registries | null;
383
+ }
384
+
385
+ function isGraph(source: Graph | ExportEnvelope): source is Graph {
386
+ return source.artifacts instanceof Map;
387
+ }
388
+
389
+ function readSource(source: Graph | ExportEnvelope, options: SelectOptions): Source {
390
+ if (isGraph(source)) {
391
+ const projected = projectArtifacts(source, options.includeRestricted === true);
392
+ return {
393
+ views: new Map(projected.artifacts.map((artifact) => [artifact.id, artifact])),
394
+ withheld: projected.withheld,
395
+ review: (id) => {
396
+ const meta = source.artifacts.get(id)?.meta;
397
+ return { reviewedBy: meta?.reviewedBy ?? null, reviewedAt: meta?.reviewedAt ?? null };
398
+ },
399
+ registries: options.registries ?? null,
400
+ };
401
+ }
402
+ return {
403
+ views: new Map(source.artifacts.map((artifact) => [artifact.id, artifact])),
404
+ withheld: source.withholding.artifacts,
405
+ // Export 0.4 does not carry Core §4's review fields.
406
+ review: () => ({ reviewedBy: "not-supplied", reviewedAt: "not-supplied" }),
407
+ registries: source.registries,
408
+ };
409
+ }
410
+
411
+ function viewIndex(source: Source): LinkIndex {
412
+ const hiddenByType = new Map<ArtifactType, string[]>();
413
+ for (const view of [...source.views.values()].sort((a, b) => compareStrings(a.id, b.id))) {
414
+ if (view.withheldLinks === 0) continue;
415
+ const list = hiddenByType.get(view.type);
416
+ if (list === undefined) hiddenByType.set(view.type, [view.id]);
417
+ else list.push(view.id);
418
+ }
419
+ return {
420
+ has: (id) => source.views.has(id),
421
+ type: (id) => source.views.get(id)?.type,
422
+ targets: (id, kind) => ends(source.views.get(id), "out", kind),
423
+ sources: (id, kind) => ends(source.views.get(id), "in", kind),
424
+ hidden: (id) => (source.views.get(id)?.withheldLinks ?? 0) > 0,
425
+ hiddenOfType: (type) => hiddenByType.get(type) ?? [],
426
+ withholds: source.withheld > 0,
427
+ };
428
+ }
429
+
430
+ /** The visible ends of a record's `kind` links in one direction, as the export projection carries them. */
431
+ function ends(view: ExportArtifact | undefined, direction: "out" | "in", kind: LinkKind): string[] {
432
+ if (view === undefined) return [];
433
+ if (direction === "in") return view.derived[kind] ?? [];
434
+ if (kind === "parent") return view.parent === null ? [] : [view.parent];
435
+ return view.links[kind] ?? [];
436
+ }
437
+
438
+ /**
439
+ * spec/list.md: the records of `source` the selector picks. A graph is read through the export's projection, so
440
+ * restricted artifacts are withheld unless `includeRestricted`; an export is read as it is, and should come from
441
+ * `readExport`. Throws on a selector `readSelector` would reject.
442
+ */
443
+ export function select(source: Graph | ExportEnvelope, selector: Selector, options: SelectOptions = {}): Selection {
444
+ const read = readSelector(selector);
445
+ if (!read.ok) {
446
+ throw new TypeError(`select: ${read.problems.map((p) => `${p.pointer || "/"} ${p.message}`).join("; ")}`);
447
+ }
448
+ const normalized = read.selector;
449
+ const src = readSource(source, options);
450
+ const index = viewIndex(src);
451
+ // A record that is not a slice has no describing records (Core §10), so naming one matches nothing.
452
+ const describingSets = (normalized.describing ?? []).map((slice) =>
453
+ src.views.has(slice) && src.views.get(slice)?.type !== "slice"
454
+ ? { exact: new Set<string>(), loose: new Set<string>() }
455
+ : { exact: describing(index, slice, false).ids, loose: describing(index, slice, true).ids },
456
+ );
457
+
458
+ const rows: ListRow[] = [];
459
+ const undetermined: string[] = [];
460
+ for (const id of [...src.views.keys()].sort(compareStrings)) {
461
+ const view = src.views.get(id) as ExportArtifact;
462
+ const answer = all([
463
+ matchEnd(view, normalized, src),
464
+ ...(normalized.links ?? []).map((predicate) => matchLink(view, predicate, src)),
465
+ normalized.describing === undefined
466
+ ? true
467
+ : any(describingSets.map((sets) => (sets.exact.has(id) ? true : sets.loose.has(id) ? null : false))),
468
+ normalized.claims === undefined ? true : claims(view, normalized.claims),
469
+ ]);
470
+ if (answer === true) rows.push(toRow(view, src));
471
+ else if (answer === null) undetermined.push(id);
472
+ }
473
+ return {
474
+ selector: normalized,
475
+ counts: {
476
+ considered: src.views.size,
477
+ withheld: src.withheld,
478
+ matched: rows.length,
479
+ undetermined: undetermined.length,
480
+ },
481
+ rows,
482
+ undetermined,
483
+ notes: notesFor(normalized, src),
484
+ };
485
+ }
486
+
487
+ function toRow(view: ExportArtifact, source: Source): ListRow {
488
+ return {
489
+ id: view.id,
490
+ type: view.type,
491
+ title: view.title,
492
+ status: view.status,
493
+ owner: view.owner,
494
+ visibility: view.visibility,
495
+ audiences: view.audiences,
496
+ path: view.path,
497
+ parent: view.parent,
498
+ availability: view.availability,
499
+ ...source.review(view.id),
500
+ };
501
+ }
502
+
503
+ function matchEnd(view: ExportArtifact, selector: EndSelector, source: Source): Answer {
504
+ const has = <T>(list: readonly T[] | undefined, value: T) => list === undefined || list.includes(value);
505
+ if (selector.ids !== undefined && !selector.ids.some((pattern) => idMatches(pattern, view.id))) return false;
506
+ if (!has(selector.types, view.type) || !has(selector.statuses, view.status)) return false;
507
+ if (!has(selector.deliveries, deliveryOf(view) ?? "unstated")) return false;
508
+ if (!has(selector.owners, view.owner) || !has(selector.visibilities, view.visibility)) return false;
509
+ if (selector.audiences !== undefined && !view.audiences.some((a) => selector.audiences?.includes(a))) return false;
510
+ if (selector.hasExtension !== undefined) {
511
+ const keys = Object.keys(view.extensions ?? {});
512
+ if (!keys.some((key) => selector.hasExtension?.includes(key))) return false;
513
+ }
514
+ if (selector.availability !== undefined && !availabilityMatches(view.availability, selector.availability)) {
515
+ return false;
516
+ }
517
+ return selector.under === undefined ? true : under(view, selector.under, source);
518
+ }
519
+
520
+ function idMatches(pattern: string, id: string): boolean {
521
+ return pattern.endsWith("*") ? id.startsWith(pattern.slice(0, -1)) : id === pattern;
522
+ }
523
+
524
+ /**
525
+ * Exact matching in each dimension the selector names (Core §7): a record has a value the list names. A record with
526
+ * no availability has no value in any dimension, so it never matches a positive dimension and always matches
527
+ * `notReleases`.
528
+ */
529
+ function availabilityMatches(availability: Availability | null, selector: AvailabilitySelector): boolean {
530
+ const values = (name: keyof Availability) => availability?.[name] ?? [];
531
+ for (const name of ["products", "releases", "roles", "editions", "flags"] as const) {
532
+ const wanted = selector[name];
533
+ if (wanted !== undefined && !values(name).some((value) => wanted.includes(value))) return false;
534
+ }
535
+ const excluded = selector.notReleases;
536
+ return excluded === undefined || !values("releases").some((value) => excluded.includes(value));
537
+ }
538
+
539
+ /**
540
+ * True when one of `ancestors` is on the record's navigation chain (Core §5, the ancestors impact reports), the
541
+ * record itself not included. Undetermined when the chain stops at a record whose parent may be withheld.
542
+ */
543
+ function under(view: ExportArtifact, ancestors: readonly string[], source: Source): Answer {
544
+ const visited = new Set<string>([view.id]);
545
+ let current = view;
546
+ while (true) {
547
+ const parent = current.parent;
548
+ if (parent === null) {
549
+ const cut = current.withheldLinks > 0 && PARENT_TARGETS[current.type] !== undefined;
550
+ return cut ? null : false;
551
+ }
552
+ if (visited.has(parent)) return false;
553
+ const next = source.views.get(parent);
554
+ if (next === undefined) return false;
555
+ if (ancestors.includes(parent)) return true;
556
+ visited.add(parent);
557
+ current = next;
558
+ }
559
+ }
560
+
561
+ /**
562
+ * One link predicate (spec/list.md §1.3): the visible ends of the record's `kind` links in the direction, each
563
+ * matched on its own fields, and the quantifier over them. A withheld end of a kind the record can have leaves a
564
+ * definite answer only where the visible ends already decide it.
565
+ */
566
+ function matchLink(view: ExportArtifact, predicate: LinkPredicate, source: Source): Answer {
567
+ const answers = ends(view, predicate.direction, predicate.kind).map((id): Answer => {
568
+ if (predicate.match === undefined) return true;
569
+ const far = source.views.get(id);
570
+ return far === undefined ? false : matchEnd(far, predicate.match, source);
571
+ });
572
+ const hidden = view.withheldLinks > 0 && linkPossible(view.type, predicate.direction, predicate.kind);
573
+ const unknowns = hidden ? [null] : [];
574
+ if (predicate.quantifier === "some") return any([...answers, ...unknowns]);
575
+ if (predicate.quantifier === "all") return all([...answers, ...unknowns]);
576
+ const some = any([...answers, ...unknowns]);
577
+ return some === null ? null : !some;
578
+ }
579
+
580
+ function claims(view: ExportArtifact, paths: readonly string[]): boolean {
581
+ if (view.type !== "slice" || view.status === "retired" || view.slice === null) return false;
582
+ const claimed = view.slice.claims;
583
+ return paths.some((path) => claimed.some((claim) => matchGlob(claim.path, path)));
584
+ }
585
+
586
+ const REGISTRY_CHECKS: Array<{
587
+ name: string;
588
+ registry: keyof Registries;
589
+ values: (selector: EndSelector) => string[] | undefined;
590
+ }> = [
591
+ { name: "owners", registry: "owners", values: (s) => s.owners },
592
+ { name: "audiences", registry: "audiences", values: (s) => s.audiences },
593
+ { name: "availability.releases", registry: "releases", values: (s) => s.availability?.releases },
594
+ { name: "availability.notReleases", registry: "releases", values: (s) => s.availability?.notReleases },
595
+ { name: "availability.roles", registry: "roles", values: (s) => s.availability?.roles },
596
+ { name: "availability.editions", registry: "editions", values: (s) => s.availability?.editions },
597
+ { name: "availability.flags", registry: "flags", values: (s) => s.availability?.flags },
598
+ ];
599
+
600
+ /** spec/list.md §4: what a reader needs to read the answer right. */
601
+ function notesFor(selector: Selector, source: Source): string[] {
602
+ const notes: string[] = [];
603
+ const ends: EndSelector[] = [selector, ...(selector.links ?? []).flatMap((link) => link.match ?? [])];
604
+ if (ends.some((end) => end.availability !== undefined)) {
605
+ notes.push("Status and availability are authored claims, not a reviewed release snapshot (Core §7).");
606
+ }
607
+ if (source.registries !== null) {
608
+ const registries = source.registries;
609
+ const unknown = new Set<string>();
610
+ for (const end of ends) {
611
+ for (const check of REGISTRY_CHECKS) {
612
+ const declared = registries[check.registry] as readonly unknown[];
613
+ for (const value of check.values(end) ?? []) {
614
+ if (!declared.includes(value)) {
615
+ const effect = check.name.endsWith("notReleases") ? "excludes" : "matches";
616
+ unknown.add(`${check.name}: ${value} is not in the ${check.registry} registry, so it ${effect} no record.`);
617
+ }
618
+ }
619
+ }
620
+ }
621
+ notes.push(...[...unknown].sort(compareStrings));
622
+ }
623
+ for (const slice of selector.describing ?? []) {
624
+ const view = source.views.get(slice);
625
+ if (view?.type === "slice") continue;
626
+ notes.push(
627
+ view !== undefined
628
+ ? `describing: ${slice} is ${/^[aeiou]/.test(view.type) ? "an" : "a"} ${view.type}, not a slice.`
629
+ : source.withheld > 0
630
+ ? `describing: ${slice} is not a visible slice; it may be withheld, so what describes it is undetermined.`
631
+ : `describing: ${slice} is not a slice in this source, so nothing describes it.`,
632
+ );
633
+ }
634
+ return notes;
635
+ }
636
+
637
+ /** The fields `--count-by` groups by: a record's own, and each availability dimension. */
638
+ export const COUNT_FIELDS = [
639
+ "type",
640
+ "status",
641
+ "owner",
642
+ "visibility",
643
+ "audience",
644
+ "product",
645
+ "release",
646
+ "role",
647
+ "edition",
648
+ "flag",
649
+ ] as const;
650
+ export type CountField = (typeof COUNT_FIELDS)[number];
651
+
652
+ const MULTI_VALUED: Partial<Record<CountField, (row: ListRow) => string[]>> = {
653
+ audience: (row) => row.audiences,
654
+ product: (row) => row.availability?.products ?? [],
655
+ release: (row) => row.availability?.releases ?? [],
656
+ role: (row) => row.availability?.roles ?? [],
657
+ edition: (row) => row.availability?.editions ?? [],
658
+ flag: (row) => row.availability?.flags ?? [],
659
+ };
660
+
661
+ export interface CountBy {
662
+ field: CountField;
663
+ /** True when a record can hold several values, so it counts once under each and the counts can sum past `matched`. */
664
+ multiValued: boolean;
665
+ /** Sorted by value, the group of records with no value (null) last. */
666
+ groups: Array<{ value: string | null; count: number }>;
667
+ }
668
+
669
+ /** spec/list.md §5.2: the matched records counted by one field. Counts only: never a percentage. */
670
+ export function countBy(selection: Selection, field: CountField): CountBy {
671
+ const counts = new Map<string | null, number>();
672
+ const bump = (value: string | null) => counts.set(value, (counts.get(value) ?? 0) + 1);
673
+ const multi = MULTI_VALUED[field];
674
+ for (const row of selection.rows) {
675
+ if (multi === undefined) bump(row[field as "type" | "status" | "owner" | "visibility"]);
676
+ else {
677
+ const values = [...new Set(multi(row))];
678
+ if (values.length === 0) bump(null);
679
+ for (const value of values) bump(value);
680
+ }
681
+ }
682
+ const groups = [...counts.entries()]
683
+ .map(([value, count]) => ({ value, count }))
684
+ .sort((a, b) => (a.value === null ? 1 : b.value === null ? -1 : compareStrings(a.value, b.value)));
685
+ return { field, multiValued: multi !== undefined, groups };
686
+ }
687
+
688
+ /** The snapshot a list names (spec/list.md §5): the source's commit and graph hash. */
689
+ export interface ListSnapshot {
690
+ repository: string;
691
+ commit: string | null;
692
+ commitUnavailable?: string;
693
+ uncommitted: boolean;
694
+ graphHash: string;
695
+ /** An export source only: when it was generated. */
696
+ generatedAt?: string;
697
+ }
698
+
699
+ export interface ListValidation {
700
+ level: Level;
701
+ status: "pass" | "fail";
702
+ errors: number;
703
+ warnings: number;
704
+ }
705
+
706
+ /** The `intentset/list/0.1` result (spec/list.md §5). */
707
+ export interface ListOutput {
708
+ contract: typeof LIST_CONTRACT;
709
+ /** `tree` when the records were read from the repository, `export` when from an export envelope. */
710
+ source: "tree" | "export";
711
+ snapshot: ListSnapshot;
712
+ validation: ListValidation;
713
+ selector: Selector;
714
+ counts: ListCounts;
715
+ rows: ListRow[];
716
+ undetermined: string[];
717
+ countBy: CountBy | null;
718
+ notes: string[];
719
+ }
720
+
721
+ /** The snapshot and validation an export names, for a list read from it. */
722
+ export function exportListMeta(envelope: ExportEnvelope): { snapshot: ListSnapshot; validation: ListValidation } {
723
+ const snapshot: ListSnapshot = {
724
+ repository: envelope.repository,
725
+ commit: envelope.source.commit,
726
+ uncommitted: envelope.source.uncommitted,
727
+ graphHash: envelope.graphHash,
728
+ generatedAt: envelope.generatedAt,
729
+ };
730
+ if (envelope.source.commitUnavailable !== undefined) snapshot.commitUnavailable = envelope.source.commitUnavailable;
731
+ const { level, status, errors, warnings } = envelope.validation;
732
+ return { snapshot, validation: { level, status, errors, warnings } };
733
+ }
734
+
735
+ /** spec/list.md §5: a selection as the versioned result, with the snapshot and validation it was made over. */
736
+ export function listOutput(
737
+ selection: Selection,
738
+ meta: { source: "tree" | "export"; snapshot: ListSnapshot; validation: ListValidation },
739
+ countField: CountField | null = null,
740
+ ): ListOutput {
741
+ return {
742
+ contract: LIST_CONTRACT,
743
+ source: meta.source,
744
+ snapshot: meta.snapshot,
745
+ validation: meta.validation,
746
+ selector: selection.selector,
747
+ counts: selection.counts,
748
+ rows: selection.rows,
749
+ undetermined: selection.undetermined,
750
+ countBy: countField === null ? null : countBy(selection, countField),
751
+ notes: selection.notes,
752
+ };
753
+ }