@crvouga/atlas 0.14.0 → 0.15.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 (39) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/reports/src/artifact-tag-store.d.ts +20 -0
  3. package/dist/reports/src/artifact-tag-store.js +70 -0
  4. package/dist/reports/src/artifact-tags.d.ts +96 -0
  5. package/dist/reports/src/artifact-tags.js +96 -0
  6. package/dist/reports/src/evidence-detail.d.ts +1 -1
  7. package/dist/reports/src/evidence-detail.js +6 -2
  8. package/dist/reports/src/evidence-types.d.ts +2 -0
  9. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  10. package/dist/reports/src/reports-route.d.ts +8 -0
  11. package/dist/reports/src/test-catalog.d.ts +11 -23
  12. package/dist/reports/src/test-catalog.js +42 -4
  13. package/dist/schema/src/api.d.ts +79 -0
  14. package/dist/schema/src/api.js +31 -1
  15. package/dist/schema/src/layer.d.ts +33 -0
  16. package/dist/schema/src/layer.js +11 -1
  17. package/dist/server/src/app.js +32 -1
  18. package/dist/server/src/openapi.d.ts +1 -1
  19. package/dist/server/src/openapi.js +18 -2
  20. package/docs/extending.md +3 -0
  21. package/docs/reference/http-api.md +48 -0
  22. package/docs/reference/reports.md +217 -2
  23. package/docs/reference/schema.md +32 -0
  24. package/docs/rich-evidence.md +28 -0
  25. package/docs/server.md +24 -0
  26. package/llms-full.txt +103 -0
  27. package/package.json +1 -1
  28. package/packages/schema/schemas/check-detail.schema.json +28 -0
  29. package/packages/schema/schemas/effective-delta.schema.json +14 -0
  30. package/packages/schema/schemas/effective-evidence.schema.json +14 -0
  31. package/packages/schema/schemas/evidence-page.schema.json +7 -0
  32. package/packages/schema/schemas/layer-rows.schema.json +7 -0
  33. package/packages/schema/schemas/layer.schema.json +70 -0
  34. package/packages/schema/schemas/publish-checks.schema.json +35 -0
  35. package/packages/schema/schemas/test-catalog.schema.json +42 -0
  36. package/packages/schema/schemas/test-history.schema.json +28 -0
  37. package/packages/server/openapi.json +732 -1
  38. package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
  39. package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.15.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - tag artifacts and demos with visible, searchable metadata ([833114a](https://github.com/crvouga/atlas/commit/833114ae543fa9a1bfe796b5991611efa02bd098))
8
+
3
9
  ## 0.14.0 (2026-10-28)
4
10
 
5
11
  ### Features
@@ -0,0 +1,20 @@
1
+ import type { ObjectStore } from './object-store.js';
2
+ import { type ArtifactTagEntry } from './artifact-tags.js';
3
+ /** The prefix of every recorded edit in a store. */
4
+ export declare const ARTIFACT_TAGS_PREFIX = "curation/artifact-tags/";
5
+ /** The edit was not acceptable (`status` is the HTTP status to answer). */
6
+ export declare class ArtifactTagError extends Error {
7
+ readonly status: 422;
8
+ constructor(status: 422, message: string);
9
+ }
10
+ /**
11
+ * Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
12
+ * `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
13
+ * over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
14
+ */
15
+ export declare function recordArtifactTags(store: ObjectStore, input: unknown, by: string, atMs: number): Promise<ArtifactTagEntry>;
16
+ /**
17
+ * Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
18
+ * guessed at). A store with none gives an empty list. Transport errors throw.
19
+ */
20
+ export declare function readArtifactTagEntries(store: ObjectStore): Promise<ArtifactTagEntry[]>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @module Reviewer tags in a store
3
+ *
4
+ * Where a reviewer's tags for attachments are kept: one small immutable object per edit under
5
+ * `curation/artifact-tags/`, named by time and a random id so editors never overwrite one another, and never changed
6
+ * or deleted afterwards. The layers they are about are not touched. The list of edits is the audit trail: who changed
7
+ * which tags of which attachment, and when. `foldArtifactTags` turns it into the tags that stand now.
8
+ *
9
+ * @see docs/server.md#tagging-artifacts
10
+ */
11
+ import { ArtifactTagEntrySchema, ArtifactTagEditSchema } from '../../schema/src/api.js';
12
+ import { readUtf8, writeUtf8 } from './object-io.js';
13
+ import { normalizeTag, normalizeTags } from './artifact-tags.js';
14
+ /** The prefix of every recorded edit in a store. */
15
+ export const ARTIFACT_TAGS_PREFIX = 'curation/artifact-tags/';
16
+ /** The edit was not acceptable (`status` is the HTTP status to answer). */
17
+ export class ArtifactTagError extends Error {
18
+ status;
19
+ constructor(status, message) {
20
+ super(message);
21
+ this.status = status;
22
+ }
23
+ }
24
+ /**
25
+ * Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
26
+ * `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
27
+ * over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
28
+ */
29
+ export async function recordArtifactTags(store, input, by, atMs) {
30
+ const parsed = ArtifactTagEditSchema.safeParse(input);
31
+ if (!parsed.success)
32
+ throw new ArtifactTagError(422, `Not a tag edit: ${parsed.error.issues[0]?.path.join('.') || '(body)'}: ${parsed.error.issues[0]?.message ?? 'invalid'}`);
33
+ const { target, add = [], remove = [] } = parsed.data;
34
+ if ([...add, ...remove].some(tag => normalizeTag(tag) === null))
35
+ throw new ArtifactTagError(422, 'A tag is empty or longer than 64 characters.');
36
+ const added = normalizeTags(add);
37
+ const removed = normalizeTags(remove);
38
+ const entryAdd = added.filter(tag => !removed.includes(tag));
39
+ const entryRemove = removed.filter(tag => !added.includes(tag));
40
+ if (!entryAdd.length && !entryRemove.length)
41
+ throw new ArtifactTagError(422, 'Name at least one tag to add or remove.');
42
+ const id = `${String(atMs).padStart(16, '0')}-${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
43
+ const entry = {
44
+ id, by, atMs,
45
+ target: { layer: target.layer, check: target.check, ...(target.kind === undefined ? {} : { kind: target.kind }), artifact: target.artifact },
46
+ add: entryAdd, remove: entryRemove
47
+ };
48
+ await writeUtf8(store, `${ARTIFACT_TAGS_PREFIX}${id}.json`, JSON.stringify({ schemaVersion: 1, ...entry }), 'application/json');
49
+ return entry;
50
+ }
51
+ /**
52
+ * Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
53
+ * guessed at). A store with none gives an empty list. Transport errors throw.
54
+ */
55
+ export async function readArtifactTagEntries(store) {
56
+ const keys = (await store.listKeys(ARTIFACT_TAGS_PREFIX)).filter(key => key.endsWith('.json'));
57
+ const read = await Promise.all(keys.map(async (key) => {
58
+ const text = await readUtf8(store, key);
59
+ if (text === null)
60
+ return [];
61
+ try {
62
+ const parsed = ArtifactTagEntrySchema.safeParse(JSON.parse(text));
63
+ return parsed.success ? [parsed.data] : [];
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }));
69
+ return read.flat().sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
70
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module Artifact tags
3
+ *
4
+ * The tags of one attachment (a recording, a screenshot, a trace): how a tag is normalized so `demo` and `#demo`
5
+ * are the same tag, which tags an attachment shows (its producer's, its reviewers', and those it inherits from its
6
+ * check and run), and how a reviewer's edits fold into the tags that stand now. Pure functions, safe in a browser.
7
+ *
8
+ * Tags describe evidence; they never decide a status, a slot or a winner. A producer writes `tags` on the attachment
9
+ * inside an immutable layer. A reviewer's tags live apart, as an append-only list of edits that the server records
10
+ * with who made each and when (`artifact-tag-store.ts`); the layer is never rewritten.
11
+ *
12
+ * @see docs/rich-evidence.md#tagging-artifacts-and-demos
13
+ */
14
+ import type { Attachment } from '../../schema/src/layer.js';
15
+ /** The longest a normalized tag may be, in characters. */
16
+ export declare const MAX_TAG_LENGTH = 64;
17
+ /**
18
+ * Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
19
+ * trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
20
+ * so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
21
+ * `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * normalizeTag('#Demo'); // 'demo'
26
+ * normalizeTag('release candidate'); // 'release-candidate'
27
+ * normalizeTag('#'); // null
28
+ * ```
29
+ */
30
+ export declare function normalizeTag(raw: string): string | null;
31
+ /** Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place. */
32
+ export declare function normalizeTags(raw: Iterable<string> | undefined): string[];
33
+ /** The tag as a person sees it: `#demo`. */
34
+ export declare const displayTag: (tag: string) => string;
35
+ /**
36
+ * How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
37
+ * `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
38
+ * no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
39
+ */
40
+ export declare function artifactRef(attachment: Pick<Attachment, 'id' | 'bundleKey' | 'name'>): string;
41
+ /** Where a reviewer's tags are about: one attachment of one check of one archived layer. */
42
+ export type ArtifactTarget = {
43
+ readonly layer: string;
44
+ readonly check: string;
45
+ readonly kind?: string | undefined;
46
+ readonly artifact: string;
47
+ };
48
+ /** One recorded edit. Entries are never changed or removed. */
49
+ export type ArtifactTagEntry = {
50
+ readonly id: string;
51
+ readonly target: ArtifactTarget;
52
+ readonly add: readonly string[];
53
+ readonly remove: readonly string[];
54
+ readonly by: string;
55
+ readonly atMs: number;
56
+ };
57
+ /** A string that is equal for equal targets (the part `kind` is optional and then does not matter). */
58
+ export declare const targetKey: (target: ArtifactTarget) => string;
59
+ /** A tag a reviewer added: who added it last, and when. */
60
+ export type ReviewerTag = {
61
+ readonly tag: string;
62
+ readonly by: string;
63
+ readonly atMs: number;
64
+ };
65
+ /**
66
+ * Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
67
+ * Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
68
+ * the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
69
+ */
70
+ export declare function foldArtifactTags(entries: readonly ArtifactTagEntry[]): Map<string, ReviewerTag[]>;
71
+ /** Where a shown tag comes from: the attachment's own producer (`artifact`), a reviewer after the upload (`reviewer`), or the check or run around it (`inherited`). */
72
+ export type TagSource = 'artifact' | 'reviewer' | 'inherited';
73
+ /** One tag an attachment shows, with its source and, for a reviewer's tag, who added it and when. */
74
+ export type ArtifactTag = {
75
+ readonly tag: string;
76
+ readonly source: TagSource;
77
+ readonly by?: string;
78
+ readonly atMs?: number;
79
+ };
80
+ /**
81
+ * The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
82
+ * reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
83
+ * more than once is shown once, by the first source above.
84
+ */
85
+ export declare function artifactTagsOf(attachment: Pick<Attachment, 'tags'>, options?: {
86
+ readonly reviewer?: readonly ReviewerTag[];
87
+ readonly inherited?: Iterable<string>;
88
+ }): ArtifactTag[];
89
+ /**
90
+ * Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
91
+ * tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
92
+ * inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
93
+ */
94
+ export declare function hasArtifactTag(tags: readonly ArtifactTag[], wanted: string, options?: {
95
+ readonly inherited?: boolean;
96
+ }): boolean;
@@ -0,0 +1,96 @@
1
+ /** The longest a normalized tag may be, in characters. */
2
+ export const MAX_TAG_LENGTH = 64;
3
+ /**
4
+ * Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
5
+ * trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
6
+ * so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
7
+ * `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * normalizeTag('#Demo'); // 'demo'
12
+ * normalizeTag('release candidate'); // 'release-candidate'
13
+ * normalizeTag('#'); // null
14
+ * ```
15
+ */
16
+ export function normalizeTag(raw) {
17
+ if (typeof raw !== 'string')
18
+ return null;
19
+ const tag = raw.normalize('NFC').trim().replace(/^#+/, '').trim().toLowerCase().replace(/\s+/g, '-');
20
+ return tag.length > 0 && tag.length <= MAX_TAG_LENGTH ? tag : null;
21
+ }
22
+ /** Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place. */
23
+ export function normalizeTags(raw) {
24
+ const seen = new Set();
25
+ for (const value of raw ?? []) {
26
+ const tag = normalizeTag(value);
27
+ if (tag !== null)
28
+ seen.add(tag);
29
+ }
30
+ return [...seen];
31
+ }
32
+ /** The tag as a person sees it: `#demo`. */
33
+ export const displayTag = (tag) => `#${tag}`;
34
+ /**
35
+ * How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
36
+ * `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
37
+ * no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
38
+ */
39
+ export function artifactRef(attachment) {
40
+ if (attachment.id)
41
+ return `id:${attachment.id}`;
42
+ if (attachment.bundleKey)
43
+ return `key:${attachment.bundleKey}`;
44
+ return `name:${attachment.name}`;
45
+ }
46
+ /** A string that is equal for equal targets (the part `kind` is optional and then does not matter). */
47
+ export const targetKey = (target) => JSON.stringify([target.layer, target.check, target.kind ?? '', target.artifact]);
48
+ /**
49
+ * Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
50
+ * Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
51
+ * the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
52
+ */
53
+ export function foldArtifactTags(entries) {
54
+ const ordered = [...entries].sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
55
+ const standing = new Map();
56
+ for (const entry of ordered) {
57
+ const key = targetKey(entry.target);
58
+ const tags = standing.get(key) ?? new Map();
59
+ for (const tag of entry.remove)
60
+ tags.delete(tag);
61
+ for (const tag of entry.add)
62
+ tags.set(tag, { tag, by: entry.by, atMs: entry.atMs });
63
+ standing.set(key, tags);
64
+ }
65
+ const out = new Map();
66
+ for (const [key, tags] of standing)
67
+ if (tags.size)
68
+ out.set(key, [...tags.values()]);
69
+ return out;
70
+ }
71
+ /**
72
+ * The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
73
+ * reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
74
+ * more than once is shown once, by the first source above.
75
+ */
76
+ export function artifactTagsOf(attachment, options = {}) {
77
+ const out = new Map();
78
+ for (const tag of normalizeTags(attachment.tags))
79
+ out.set(tag, { tag, source: 'artifact' });
80
+ for (const found of options.reviewer ?? [])
81
+ if (!out.has(found.tag))
82
+ out.set(found.tag, { tag: found.tag, source: 'reviewer', by: found.by, atMs: found.atMs });
83
+ for (const tag of normalizeTags(options.inherited))
84
+ if (!out.has(tag))
85
+ out.set(tag, { tag, source: 'inherited' });
86
+ return [...out.values()];
87
+ }
88
+ /**
89
+ * Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
90
+ * tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
91
+ * inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
92
+ */
93
+ export function hasArtifactTag(tags, wanted, options = {}) {
94
+ const tag = normalizeTag(wanted);
95
+ return tag !== null && tags.some(item => item.tag === tag && (options.inherited === true || item.source !== 'inherited'));
96
+ }
@@ -33,7 +33,7 @@ export type EvidenceHolder = {
33
33
  *
34
34
  * Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
35
35
  * attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
36
- * label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
36
+ * label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
37
37
  * name implies), sorted and distinct.
38
38
  */
39
39
  export declare function summarizeEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
@@ -1,3 +1,4 @@
1
+ import { normalizeTags } from './artifact-tags.js';
1
2
  import { contentTypeOf } from './media-types.js';
2
3
  function count(holder) {
3
4
  return (holder.attachments?.length ?? 0) + (holder.steps?.length ?? 0) + (holder.annotations?.length ?? 0) + (holder.attempts?.length ?? 0) + (holder.tags?.length ?? 0) + (holder.links?.length ?? 0) + Object.keys(holder.labels ?? {}).length + Object.keys(holder.extensions ?? {}).length;
@@ -9,7 +10,7 @@ function count(holder) {
9
10
  *
10
11
  * Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
11
12
  * attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
12
- * label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
13
+ * label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
13
14
  * name implies), sorted and distinct.
14
15
  */
15
16
  export function summarizeEvidence(...holders) {
@@ -17,10 +18,13 @@ export function summarizeEvidence(...holders) {
17
18
  return undefined;
18
19
  const roles = new Set();
19
20
  const tags = new Set();
21
+ const artifactTags = new Set();
20
22
  const labels = {};
21
23
  let attachments = 0, steps = 0, annotations = 0, attempts = 0, links = 0;
22
24
  const attachmentsOf = (list) => { for (const item of list ?? []) {
23
25
  attachments += 1;
26
+ for (const tag of normalizeTags(item.tags))
27
+ artifactTags.add(tag);
24
28
  roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
25
29
  } };
26
30
  for (const holder of holders) {
@@ -41,7 +45,7 @@ export function summarizeEvidence(...holders) {
41
45
  tags.add(tag);
42
46
  Object.assign(labels, holder.labels);
43
47
  }
44
- return { attachments, steps, annotations, attempts, links, tags: [...tags], labels, roles: [...roles].sort() };
48
+ return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort() };
45
49
  }
46
50
  /** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
47
51
  export function roleOfType(contentType) {
@@ -50,6 +50,8 @@ export type EvidenceSummary = {
50
50
  readonly attempts: number;
51
51
  readonly links: number;
52
52
  readonly tags: readonly string[];
53
+ /** The normalized tags of the attachments themselves (`#demo` is `demo`), sorted and distinct. Absent when no attachment is tagged. Unlike `tags` they are never inherited from a check or run. */
54
+ readonly artifactTags?: readonly string[];
53
55
  readonly labels: Readonly<Record<string, string>>;
54
56
  /** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
55
57
  readonly roles: readonly string[];
@@ -82,6 +82,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
82
82
  height?: number | undefined;
83
83
  durationMs?: number | undefined;
84
84
  description?: string | undefined;
85
+ tags?: string[] | undefined;
85
86
  at?: {
86
87
  [x: string]: import("hono/utils/types").JSONValue;
87
88
  step?: string | undefined;
@@ -125,6 +126,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
125
126
  height?: number | undefined;
126
127
  durationMs?: number | undefined;
127
128
  description?: string | undefined;
129
+ tags?: string[] | undefined;
128
130
  at?: {
129
131
  [x: string]: import("hono/utils/types").JSONValue;
130
132
  step?: string | undefined;
@@ -181,6 +183,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
181
183
  height?: number | undefined;
182
184
  durationMs?: number | undefined;
183
185
  description?: string | undefined;
186
+ tags?: string[] | undefined;
184
187
  at?: {
185
188
  [x: string]: import("hono/utils/types").JSONValue;
186
189
  step?: string | undefined;
@@ -247,6 +250,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
247
250
  height?: number | undefined;
248
251
  durationMs?: number | undefined;
249
252
  description?: string | undefined;
253
+ tags?: string[] | undefined;
250
254
  at?: {
251
255
  [x: string]: import("hono/utils/types").JSONValue;
252
256
  step?: string | undefined;
@@ -352,6 +356,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
352
356
  height?: number | undefined;
353
357
  durationMs?: number | undefined;
354
358
  description?: string | undefined;
359
+ tags?: string[] | undefined;
355
360
  at?: {
356
361
  [x: string]: import("hono/utils/types").JSONValue;
357
362
  step?: string | undefined;
@@ -395,6 +400,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
395
400
  height?: number | undefined;
396
401
  durationMs?: number | undefined;
397
402
  description?: string | undefined;
403
+ tags?: string[] | undefined;
398
404
  at?: {
399
405
  [x: string]: import("hono/utils/types").JSONValue;
400
406
  step?: string | undefined;
@@ -451,6 +457,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
451
457
  height?: number | undefined;
452
458
  durationMs?: number | undefined;
453
459
  description?: string | undefined;
460
+ tags?: string[] | undefined;
454
461
  at?: {
455
462
  [x: string]: import("hono/utils/types").JSONValue;
456
463
  step?: string | undefined;
@@ -517,6 +524,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
517
524
  height?: number | undefined;
518
525
  durationMs?: number | undefined;
519
526
  description?: string | undefined;
527
+ tags?: string[] | undefined;
520
528
  at?: {
521
529
  [x: string]: import("hono/utils/types").JSONValue;
522
530
  step?: string | undefined;
@@ -120,6 +120,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
120
120
  height?: number | undefined;
121
121
  durationMs?: number | undefined;
122
122
  description?: string | undefined;
123
+ tags?: string[] | undefined;
123
124
  at?: {
124
125
  [x: string]: import("hono/utils/types").JSONValue;
125
126
  step?: string | undefined;
@@ -163,6 +164,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
163
164
  height?: number | undefined;
164
165
  durationMs?: number | undefined;
165
166
  description?: string | undefined;
167
+ tags?: string[] | undefined;
166
168
  at?: {
167
169
  [x: string]: import("hono/utils/types").JSONValue;
168
170
  step?: string | undefined;
@@ -219,6 +221,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
219
221
  height?: number | undefined;
220
222
  durationMs?: number | undefined;
221
223
  description?: string | undefined;
224
+ tags?: string[] | undefined;
222
225
  at?: {
223
226
  [x: string]: import("hono/utils/types").JSONValue;
224
227
  step?: string | undefined;
@@ -285,6 +288,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
285
288
  height?: number | undefined;
286
289
  durationMs?: number | undefined;
287
290
  description?: string | undefined;
291
+ tags?: string[] | undefined;
288
292
  at?: {
289
293
  [x: string]: import("hono/utils/types").JSONValue;
290
294
  step?: string | undefined;
@@ -390,6 +394,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
390
394
  height?: number | undefined;
391
395
  durationMs?: number | undefined;
392
396
  description?: string | undefined;
397
+ tags?: string[] | undefined;
393
398
  at?: {
394
399
  [x: string]: import("hono/utils/types").JSONValue;
395
400
  step?: string | undefined;
@@ -433,6 +438,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
433
438
  height?: number | undefined;
434
439
  durationMs?: number | undefined;
435
440
  description?: string | undefined;
441
+ tags?: string[] | undefined;
436
442
  at?: {
437
443
  [x: string]: import("hono/utils/types").JSONValue;
438
444
  step?: string | undefined;
@@ -489,6 +495,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
489
495
  height?: number | undefined;
490
496
  durationMs?: number | undefined;
491
497
  description?: string | undefined;
498
+ tags?: string[] | undefined;
492
499
  at?: {
493
500
  [x: string]: import("hono/utils/types").JSONValue;
494
501
  step?: string | undefined;
@@ -555,6 +562,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
555
562
  height?: number | undefined;
556
563
  durationMs?: number | undefined;
557
564
  description?: string | undefined;
565
+ tags?: string[] | undefined;
558
566
  at?: {
559
567
  [x: string]: import("hono/utils/types").JSONValue;
560
568
  step?: string | undefined;
@@ -1,25 +1,3 @@
1
- /**
2
- * @module Test catalog, history and runs
3
- *
4
- * What a person or an agent asks of a report once it holds more than a handful of checks: every test with where it ran,
5
- * how it went over time and how long it takes; the specs, domains, environments and producers they fall under; the CI
6
- * runs that produced them and which tests each one ran.
7
- *
8
- * - `TestHistory` keeps every recorded result of every test (bounded per test) and every run's results, folded in one
9
- * layer at a time, in any order, with the same outcome. It never decides a status: the current status of a test is
10
- * always the report's winner (`combineEvidence`), and history only describes how it got there.
11
- * - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
12
- * insights (slowest, flaky, failing), facets to filter by, and the tests that match a `TestQuery`.
13
- * - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run.
14
- * - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
15
- * terminal.
16
- *
17
- * A test is a check identity (its spec path and id) regardless of producer, environment and parameters; each of those
18
- * combinations is one of its results. A result that is not `passed` is never counted as a pass, and a test with no
19
- * completed result has no pass rate.
20
- *
21
- * @see docs/server.md#reading-the-report-as-an-agent
22
- */
23
1
  import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
24
2
  import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts } from './evidence-types.js';
25
3
  import { RecordedSpecs } from './recorded-specs.js';
@@ -116,6 +94,10 @@ export type TestSummary = {
116
94
  readonly outcomes: readonly TestOutcome[];
117
95
  readonly stats: TestStats;
118
96
  readonly tags: readonly string[];
97
+ /** The normalized tags of the attachments of its current results (`demo`, shown as `#demo`). Never inherited from a check or run; the `tags` above are. Absent in documents of older servers. */
98
+ readonly artifactTags?: readonly string[];
99
+ /** The normalized tags its attachments inherit from their check and run (those of the results that have attachments), apart from `artifactTags`. What an inherited artifact query also matches. */
100
+ readonly inheritedArtifactTags?: readonly string[];
119
101
  readonly labels: Readonly<Record<string, string>>;
120
102
  /** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
121
103
  readonly evidence: {
@@ -187,6 +169,8 @@ export type CatalogFacets = {
187
169
  readonly counts: StatusCounts;
188
170
  })[];
189
171
  readonly tags: readonly FacetValue[];
172
+ /** The tags of attachments, normalized. Absent in documents of older servers. */
173
+ readonly artifactTags?: readonly FacetValue[];
190
174
  readonly branches: readonly FacetValue[];
191
175
  readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
192
176
  };
@@ -206,6 +190,10 @@ export type TestQuery = {
206
190
  /** Spec paths (`specs/...`). */
207
191
  readonly spec?: readonly string[];
208
192
  readonly tag?: readonly string[];
193
+ /** Only tests with an attachment carrying this tag (`demo` and `#demo` are the same), not counting tags inherited from a check or run: the exact artifact query. `tag` is the inherited one. */
194
+ readonly artifactTag?: readonly string[];
195
+ /** With `artifactTag`: also match the tags an attachment inherits from its check and run (a recording without a tag of its own under a run tagged `demo`). Default false: the exact query. */
196
+ readonly inherited?: boolean;
209
197
  /** Only tests recorded in this run (a key from `runList`). */
210
198
  readonly run?: string;
211
199
  readonly sort?: TestSort;
@@ -396,7 +384,7 @@ export declare function testHistory(model: CatalogModel, key: string): TestHisto
396
384
  export declare function runList(history: TestHistory, version: string, limit?: number): RunList;
397
385
  /** One run with every result it recorded, or null when no run has that key. */
398
386
  export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
399
- /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
387
+ /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
400
388
  export declare function parseTestQuery(params: URLSearchParams): TestQuery;
401
389
  /** A duration for a person: `850 ms`, `12.3 s`, `4m 05s`. */
402
390
  export declare function formatMs(ms: number | null | undefined): string;