@intentset/core 0.1.0 → 0.2.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.
package/src/export.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  /**
2
- * The export envelope (Core §12; the integration contract; spec/export.schema.json).
2
+ * The export envelope (spec/export.md; spec/export.schema.json; Core §12).
3
3
  * Artifacts are sorted by ID and every list inside is sorted, so the same
4
4
  * validation result with the same `generatedAt` is the same bytes. Bodies
5
5
  * are included only when asked for.
6
+ *
7
+ * Restricted artifacts are withheld unless asked for, as they are from agent
8
+ * context: absent from `artifacts`, `products` and every report, their IDs
9
+ * removed from the links of what is exported and redacted from diagnostic
10
+ * text, and every omission counted where it happened, so a short export never
11
+ * reads as a complete one. The graph hash still covers them: it names the
12
+ * snapshot, which evidence is bound to, not the projection.
6
13
  */
7
14
  import { graphHash, sortKeysDeep } from "./hash.ts";
8
15
  import { compareStrings } from "./report.ts";
@@ -11,6 +18,8 @@ import {
11
18
  EXPORT_CONTRACT,
12
19
  type ExportArtifact,
13
20
  type ExportEnvelope,
21
+ type ExportImpactHit,
22
+ type ExportReports,
14
23
  LINK_KINDS,
15
24
  type Level,
16
25
  type LinkKind,
@@ -18,7 +27,9 @@ import {
18
27
  SPEC_VERSION,
19
28
  type SliceMeta,
20
29
  type ValidationResult,
30
+ type Visibility,
21
31
  } from "./types.ts";
32
+ import type { Diagnostic } from "./diagnostics.ts";
22
33
 
23
34
  /** What the caller knows and the validation result does not: identity, commit, scope, level, release. */
24
35
  export interface ExportMeta {
@@ -27,6 +38,8 @@ export interface ExportMeta {
27
38
  /** The commit the export describes, or null with `commitUnavailable` saying why (a default reason is written when none is given). */
28
39
  commit: string | null;
29
40
  commitUnavailable?: string;
41
+ /** True when tracked files differed from the commit. Ignored when the commit is null. */
42
+ uncommitted?: boolean;
30
43
  /** The scope globs validated; defaults to every Markdown file. */
31
44
  scope?: string[];
32
45
  /** The level the validation ran at; defaults to L1. */
@@ -35,17 +48,29 @@ export interface ExportMeta {
35
48
  /** ISO 8601; defaults to now. Fix it to make two exports comparable byte for byte. */
36
49
  generatedAt?: string;
37
50
  includeBodies?: boolean;
51
+ /** Export restricted artifacts too. Default false: they are withheld and counted (spec/export.md §3). */
52
+ includeRestricted?: boolean;
53
+ /**
54
+ * Report sections, each computed over the whole graph by the package that
55
+ * owns it. The export withholds inside them exactly as it does for artifacts.
56
+ */
57
+ reports?: ExportReports;
38
58
  }
39
59
 
40
- /** Core §12 and the integration contract: the `intentset/export/0.1` envelope over a validation result. */
60
+ /** spec/export.md: the `intentset/export/0.2` envelope over a validation result. */
41
61
  export function exportGraph(result: ValidationResult, registries: Registries, meta: ExportMeta): ExportEnvelope {
42
62
  const { graph } = result;
43
- const ids = [...graph.artifacts.keys()].sort(compareStrings);
63
+ const withheld = withheldIds(result, meta.includeRestricted === true);
64
+ const ids = [...graph.artifacts.keys()].filter((id) => !withheld.ids.has(id)).sort(compareStrings);
44
65
  const errors = result.diagnostics.filter((d) => d.severity === "error").length;
45
66
  const warnings = result.diagnostics.length - errors;
46
- const source: ExportEnvelope["source"] = { commit: meta.commit };
47
- if (meta.commit === null)
67
+ const diagnostics = withholdDiagnostics(result.diagnostics, withheld);
68
+ const source: ExportEnvelope["source"] = { commit: meta.commit, uncommitted: false };
69
+ if (meta.commit === null) {
48
70
  source.commitUnavailable = meta.commitUnavailable ?? "No commit was supplied to the export.";
71
+ } else {
72
+ source.uncommitted = meta.uncommitted === true;
73
+ }
49
74
 
50
75
  return {
51
76
  contract: EXPORT_CONTRACT,
@@ -62,24 +87,92 @@ export function exportGraph(result: ValidationResult, registries: Registries, me
62
87
  status: result.ok ? "pass" : "fail",
63
88
  errors,
64
89
  warnings,
65
- diagnostics: [...result.diagnostics],
90
+ diagnostics,
91
+ },
92
+ withholding: {
93
+ visibilities: meta.includeRestricted === true ? [] : ["restricted"],
94
+ artifacts: withheld.ids.size,
95
+ diagnostics: result.diagnostics.length - diagnostics.length,
66
96
  },
67
- registries: exportRegistries(registries),
68
- artifacts: ids.map((id) => exportArtifact(result, id, meta.includeBodies === true)),
97
+ registries: exportRegistries(registries, withheld.ids),
98
+ artifacts: ids.map((id) => exportArtifact(result, id, meta.includeBodies === true, withheld.ids)),
99
+ reports: withholdReports(meta.reports ?? {}, withheld.ids),
69
100
  };
70
101
  }
71
102
 
72
- function exportArtifact(result: ValidationResult, id: string, includeBody: boolean): ExportArtifact {
103
+ interface Withheld {
104
+ ids: Set<string>;
105
+ /** Source paths of the withheld artifacts, for diagnostics that name a file but no artifact. */
106
+ paths: Set<string>;
107
+ /** Matches any withheld ID as a whole token, or null when nothing is withheld. */
108
+ pattern: RegExp | null;
109
+ }
110
+
111
+ const WITHHELD_VISIBILITIES: readonly Visibility[] = ["restricted"];
112
+
113
+ function withheldIds(result: ValidationResult, includeRestricted: boolean): Withheld {
114
+ const ids = new Set<string>();
115
+ const paths = new Set<string>();
116
+ if (!includeRestricted) {
117
+ for (const [id, artifact] of result.graph.artifacts) {
118
+ if (WITHHELD_VISIBILITIES.includes(artifact.meta.visibility)) {
119
+ ids.add(id);
120
+ paths.add(artifact.path);
121
+ }
122
+ }
123
+ }
124
+ const alternatives = [...ids].sort((a, b) => b.length - a.length || compareStrings(a, b)).map(escapeRegExp);
125
+ const pattern =
126
+ alternatives.length === 0 ? null : new RegExp(`(?<![A-Z0-9-])(?:${alternatives.join("|")})(?![A-Z0-9-])`, "g");
127
+ return { ids, paths, pattern };
128
+ }
129
+
130
+ function escapeRegExp(text: string): string {
131
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
132
+ }
133
+
134
+ /** Diagnostics about a withheld artifact are left out; any other that names one has the ID redacted. */
135
+ function withholdDiagnostics(diagnostics: readonly Diagnostic[], withheld: Withheld): Diagnostic[] {
136
+ const out: Diagnostic[] = [];
137
+ for (const d of diagnostics) {
138
+ if (d.artifact !== null && withheld.ids.has(d.artifact)) continue;
139
+ if (d.path !== null && withheld.paths.has(d.path)) continue;
140
+ const pattern = withheld.pattern;
141
+ if (pattern === null) {
142
+ out.push(d);
143
+ continue;
144
+ }
145
+ const redact = (text: string) => text.replace(pattern, "a withheld artifact");
146
+ out.push({ ...d, message: redact(d.message), remediation: redact(d.remediation) });
147
+ }
148
+ return out;
149
+ }
150
+
151
+ function exportArtifact(
152
+ result: ValidationResult,
153
+ id: string,
154
+ includeBody: boolean,
155
+ withheld: ReadonlySet<string>,
156
+ ): ExportArtifact {
73
157
  const { graph } = result;
74
158
  const artifact = graph.artifacts.get(id);
75
159
  if (artifact === undefined) throw new Error(`graph has no artifact ${id}`);
76
160
  const { meta } = artifact;
161
+ let withheldLinks = 0;
162
+ const keep = (list: Iterable<string>): string[] => {
163
+ const out: string[] = [];
164
+ for (const target of list) {
165
+ if (withheld.has(target)) withheldLinks++;
166
+ else out.push(target);
167
+ }
168
+ return out.sort(compareStrings);
169
+ };
77
170
 
78
171
  const links: ExportArtifact["links"] = {};
79
172
  for (const kind of LINK_KINDS) {
80
173
  if (kind === "parent") continue;
81
174
  const list = meta.links[kind];
82
- if (list !== undefined) links[kind] = [...list].sort(compareStrings);
175
+ if (list !== undefined) links[kind] = keep(list);
83
176
  }
84
177
  const derived: ExportArtifact["derived"] = {};
85
178
  const byKind = new Map<LinkKind, string[]>();
@@ -90,7 +183,15 @@ function exportArtifact(result: ValidationResult, id: string, includeBody: boole
90
183
  }
91
184
  for (const kind of LINK_KINDS) {
92
185
  const list = byKind.get(kind);
93
- if (list !== undefined) derived[kind] = [...new Set(list)].sort(compareStrings);
186
+ if (list !== undefined) {
187
+ const kept = keep(new Set(list));
188
+ if (kept.length > 0) derived[kind] = kept;
189
+ }
190
+ }
191
+ let parent = meta.parent ?? null;
192
+ if (parent !== null && withheld.has(parent)) {
193
+ parent = null;
194
+ withheldLinks++;
94
195
  }
95
196
 
96
197
  const exported: ExportArtifact = {
@@ -105,9 +206,10 @@ function exportArtifact(result: ValidationResult, id: string, includeBody: boole
105
206
  revision: meta.revision ?? null,
106
207
  path: artifact.path,
107
208
  sourceHash: artifact.sourceHash,
108
- parent: meta.parent ?? null,
209
+ parent,
109
210
  links,
110
211
  derived,
212
+ withheldLinks,
111
213
  availability: meta.availability === undefined ? null : sortedAvailability(meta.availability),
112
214
  slice: meta.slice === undefined ? null : sortedSlice(meta.slice),
113
215
  verification:
@@ -124,6 +226,72 @@ function exportArtifact(result: ValidationResult, id: string, includeBody: boole
124
226
  return exported;
125
227
  }
126
228
 
229
+ /**
230
+ * Each report as its producer computed it, less what is withheld: entries
231
+ * about a withheld artifact go, lists inside an entry lose withheld IDs, and
232
+ * an impact hit whose path passes through a withheld artifact goes too, each
233
+ * counted in the `withheld` beside it.
234
+ */
235
+ function withholdReports(reports: ExportReports, withheld: ReadonlySet<string>): ExportReports {
236
+ const out: ExportReports = {};
237
+ const split = (list: readonly string[]): [string[], number] => {
238
+ const kept = list.filter((id) => !withheld.has(id));
239
+ return [kept, list.length - kept.length];
240
+ };
241
+ if (reports.evidence !== undefined) {
242
+ const evidence = reports.evidence;
243
+ out.evidence = {
244
+ ...evidence,
245
+ verifications: evidence.verifications.filter((v) => !withheld.has(v.id)),
246
+ claims: evidence.claims
247
+ .filter((claim) => !withheld.has(claim.id))
248
+ .map((claim) => {
249
+ const [verifications, removed] = split(claim.verifications);
250
+ return { ...claim, verifications, withheld: claim.withheld + removed };
251
+ }),
252
+ };
253
+ }
254
+ if (reports.knowledge !== undefined) {
255
+ out.knowledge = {
256
+ ...reports.knowledge,
257
+ artifacts: reports.knowledge.artifacts
258
+ .filter((entry) => !withheld.has(entry.id))
259
+ .map((entry) => {
260
+ const [sources, a] = split(entry.sources);
261
+ const [changed, b] = split(entry.changed);
262
+ const [missing, c] = split(entry.missing);
263
+ return { ...entry, sources, changed, missing, withheld: entry.withheld + a + b + c };
264
+ }),
265
+ };
266
+ }
267
+ if (reports.impact !== undefined) {
268
+ const visible = (hit: ExportImpactHit) => !withheld.has(hit.id) && hit.path.every((step) => !withheld.has(step.id));
269
+ out.impact = {
270
+ ...reports.impact,
271
+ starts: reports.impact.starts
272
+ .filter((entry) => !withheld.has(entry.start))
273
+ .map((entry) => {
274
+ const direct = entry.direct.filter(visible);
275
+ const candidates = entry.candidates.filter(visible);
276
+ const context = entry.context.filter(visible);
277
+ const [ancestors, removed] = split(entry.ancestors);
278
+ const hits = entry.direct.length + entry.candidates.length + entry.context.length;
279
+ const kept = direct.length + candidates.length + context.length;
280
+ return { ...entry, direct, candidates, context, ancestors, withheld: entry.withheld + hits - kept + removed };
281
+ }),
282
+ };
283
+ }
284
+ if (reports.ownership !== undefined) {
285
+ const files = reports.ownership.files.filter((file) => file.owner === null || !withheld.has(file.owner));
286
+ out.ownership = {
287
+ ...reports.ownership,
288
+ files,
289
+ withheld: reports.ownership.withheld + reports.ownership.files.length - files.length,
290
+ };
291
+ }
292
+ return out;
293
+ }
294
+
127
295
  function sortedAvailability(availability: Availability): Availability {
128
296
  return {
129
297
  products: [...availability.products].sort(compareStrings),
@@ -153,7 +321,7 @@ function sortedSlice(slice: SliceMeta): SliceMeta {
153
321
  return out;
154
322
  }
155
323
 
156
- function exportRegistries(registries: Registries): Registries {
324
+ function exportRegistries(registries: Registries, withheld: ReadonlySet<string>): Registries {
157
325
  return {
158
326
  owners: [...registries.owners].sort(compareStrings),
159
327
  audiences: [...registries.audiences].sort(compareStrings),
@@ -166,7 +334,7 @@ function exportRegistries(registries: Registries): Registries {
166
334
  id: resource.id,
167
335
  path: resource.path,
168
336
  owner: resource.owner,
169
- consumers: [...resource.consumers].sort(compareStrings),
337
+ consumers: resource.consumers.filter((id) => !withheld.has(id)).sort(compareStrings),
170
338
  }))
171
339
  .sort((a, b) => compareStrings(a.id, b.id)),
172
340
  };
package/src/impact.ts CHANGED
@@ -7,8 +7,22 @@
7
7
  * reason sentence per step. Reachability is recorded, never asserted as
8
8
  * proof that runtime behavior changed.
9
9
  */
10
+ import { graphHash } from "./hash.ts";
10
11
  import { compareStrings } from "./report.ts";
11
- import type { Edge, Graph, ImpactHit, ImpactReport, ImpactStep, LinkKind } from "./types.ts";
12
+ import type {
13
+ Edge,
14
+ ExportImpactHit,
15
+ Graph,
16
+ ImpactHit,
17
+ ImpactReport,
18
+ ImpactReportSection,
19
+ ImpactStep,
20
+ LinkKind,
21
+ } from "./types.ts";
22
+
23
+ /** Said beside every impact report, because reachability is easy to read as more than it is (Core §10). */
24
+ export const IMPACT_NOTE =
25
+ "Reachability through authored links marks an artifact for review; it is not proof that runtime behavior changed (Core §10).";
12
26
 
13
27
  const PHRASES: Record<LinkKind, string> = {
14
28
  parent: "has {to} as its navigation parent",
@@ -88,3 +102,30 @@ function toHit(graph: Graph, id: string, path: ImpactStep[]): ImpactHit | null {
88
102
  if (artifact === undefined) return null;
89
103
  return { id, type: artifact.meta.type, title: artifact.meta.title, path };
90
104
  }
105
+
106
+ /**
107
+ * `reports.impact` for the export (spec/export.md §4.3): the impact report from
108
+ * every artifact in the graph, or from `starts` when given, with each hit
109
+ * reduced to its ID and path. A consumer joins hits to `artifacts` by ID.
110
+ */
111
+ export function impactReport(graph: Graph, starts?: Iterable<string>): ImpactReportSection {
112
+ const ids = [...new Set(starts ?? graph.artifacts.keys())]
113
+ .filter((id) => graph.artifacts.has(id))
114
+ .sort(compareStrings);
115
+ const hit = (h: ImpactHit): ExportImpactHit => ({ id: h.id, path: h.path });
116
+ return {
117
+ graphHash: graphHash(graph),
118
+ note: IMPACT_NOTE,
119
+ starts: ids.map((id) => {
120
+ const report = impact(graph, id);
121
+ return {
122
+ start: id,
123
+ direct: report.direct.map(hit),
124
+ candidates: report.candidates.map(hit),
125
+ context: report.context.map(hit),
126
+ ancestors: report.ancestors,
127
+ withheld: 0,
128
+ };
129
+ }),
130
+ };
131
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./export.ts";
7
7
  export * from "./graph.ts";
8
8
  export * from "./hash.ts";
9
9
  export * from "./impact.ts";
10
+ export * from "./read-export.ts";
10
11
  export * from "./registries.ts";
11
12
  export * from "./types.ts";
12
13
  export * from "./validate.ts";