@oddkin/analyzer 0.0.0-stage → 1.0.0-beta.1

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -2
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +65 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/contracts.d.ts +29 -0
  8. package/dist/contracts.d.ts.map +1 -0
  9. package/dist/contracts.js +57 -0
  10. package/dist/contracts.js.map +1 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.d.ts.map +1 -0
  13. package/dist/docs.js +63 -0
  14. package/dist/docs.js.map +1 -0
  15. package/dist/emit.d.ts +38 -0
  16. package/dist/emit.d.ts.map +1 -0
  17. package/dist/emit.js +406 -0
  18. package/dist/emit.js.map +1 -0
  19. package/dist/examples.d.ts +35 -0
  20. package/dist/examples.d.ts.map +1 -0
  21. package/dist/examples.js +345 -0
  22. package/dist/examples.js.map +1 -0
  23. package/dist/extract.d.ts +208 -0
  24. package/dist/extract.d.ts.map +1 -0
  25. package/dist/extract.js +1570 -0
  26. package/dist/extract.js.map +1 -0
  27. package/dist/foundations.d.ts +36 -0
  28. package/dist/foundations.d.ts.map +1 -0
  29. package/dist/foundations.js +108 -0
  30. package/dist/foundations.js.map +1 -0
  31. package/dist/history.d.ts +10 -0
  32. package/dist/history.d.ts.map +1 -0
  33. package/dist/history.js +62 -0
  34. package/dist/history.js.map +1 -0
  35. package/dist/impact-bin.d.ts +3 -0
  36. package/dist/impact-bin.d.ts.map +1 -0
  37. package/dist/impact-bin.js +7 -0
  38. package/dist/impact-bin.js.map +1 -0
  39. package/dist/impact-cli.d.ts +8 -0
  40. package/dist/impact-cli.d.ts.map +1 -0
  41. package/dist/impact-cli.js +121 -0
  42. package/dist/impact-cli.js.map +1 -0
  43. package/dist/impact.d.ts +282 -0
  44. package/dist/impact.d.ts.map +1 -0
  45. package/dist/impact.js +744 -0
  46. package/dist/impact.js.map +1 -0
  47. package/dist/index.d.ts +82 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +228 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/kit/agents-md.d.ts +10 -0
  52. package/dist/kit/agents-md.d.ts.map +1 -0
  53. package/dist/kit/agents-md.js +64 -0
  54. package/dist/kit/agents-md.js.map +1 -0
  55. package/dist/kit/cli.d.ts +8 -0
  56. package/dist/kit/cli.d.ts.map +1 -0
  57. package/dist/kit/cli.js +214 -0
  58. package/dist/kit/cli.js.map +1 -0
  59. package/dist/kit/context.d.ts +104 -0
  60. package/dist/kit/context.d.ts.map +1 -0
  61. package/dist/kit/context.js +323 -0
  62. package/dist/kit/context.js.map +1 -0
  63. package/dist/kit/index.d.ts +12 -0
  64. package/dist/kit/index.d.ts.map +1 -0
  65. package/dist/kit/index.js +12 -0
  66. package/dist/kit/index.js.map +1 -0
  67. package/dist/kit/kit.d.ts +62 -0
  68. package/dist/kit/kit.d.ts.map +1 -0
  69. package/dist/kit/kit.js +626 -0
  70. package/dist/kit/kit.js.map +1 -0
  71. package/dist/kit/llms.d.ts +22 -0
  72. package/dist/kit/llms.d.ts.map +1 -0
  73. package/dist/kit/llms.js +58 -0
  74. package/dist/kit/llms.js.map +1 -0
  75. package/dist/kit/package-kit.d.ts +18 -0
  76. package/dist/kit/package-kit.d.ts.map +1 -0
  77. package/dist/kit/package-kit.js +224 -0
  78. package/dist/kit/package-kit.js.map +1 -0
  79. package/dist/kit-bin.d.ts +3 -0
  80. package/dist/kit-bin.d.ts.map +1 -0
  81. package/dist/kit-bin.js +7 -0
  82. package/dist/kit-bin.js.map +1 -0
  83. package/dist/published.d.ts +43 -0
  84. package/dist/published.d.ts.map +1 -0
  85. package/dist/published.js +62 -0
  86. package/dist/published.js.map +1 -0
  87. package/dist/references.d.ts +70 -0
  88. package/dist/references.d.ts.map +1 -0
  89. package/dist/references.js +255 -0
  90. package/dist/references.js.map +1 -0
  91. package/dist/style-mappings.d.ts +46 -0
  92. package/dist/style-mappings.d.ts.map +1 -0
  93. package/dist/style-mappings.js +246 -0
  94. package/dist/style-mappings.js.map +1 -0
  95. package/dist/stylesheets.d.ts +56 -0
  96. package/dist/stylesheets.d.ts.map +1 -0
  97. package/dist/stylesheets.js +83 -0
  98. package/dist/stylesheets.js.map +1 -0
  99. package/dist/verification.d.ts +62 -0
  100. package/dist/verification.d.ts.map +1 -0
  101. package/dist/verification.js +115 -0
  102. package/dist/verification.js.map +1 -0
  103. package/package.json +45 -4
package/dist/impact.js ADDED
@@ -0,0 +1,744 @@
1
+ /**
2
+ * Change impact (change-impact-and-verification.md §2). A comparison snapshot records, by stable
3
+ * id, the hash of every public contract item, definition source, token, product, example, and
4
+ * suite, plus the extracted and declared edges between them. Comparing two snapshots classifies
5
+ * each change, follows edges back to the components, examples, and products that depend on it,
6
+ * and selects the checks to run. Renames are matched only through authoritative records.
7
+ *
8
+ * Inputs are existing artifacts (CEM, `oddkin.metadata.json`, token inspection JSON, product
9
+ * manifests, suite versions); module sources are hashed, never stored. This entry is plain Node:
10
+ * it never loads TypeScript and never executes the analyzed code.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ export const SNAPSHOT_SCHEMA = 'https://oddkin.dev/schemas/comparison-snapshot/v1.json';
16
+ export const IMPACT_SCHEMA = 'https://oddkin.dev/schemas/impact-report/v1.json';
17
+ const sha256 = (text) => `sha256-${createHash('sha256').update(text).digest('hex')}`;
18
+ /** JSON with sorted object keys, so equal facts hash equally. */
19
+ function canonical(value) {
20
+ if (Array.isArray(value))
21
+ return `[${value.map(canonical).join(',')}]`;
22
+ if (value && typeof value === 'object')
23
+ return `{${Object.keys(value)
24
+ .sort()
25
+ .filter((k) => value[k] !== undefined)
26
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
27
+ .join(',')}}`;
28
+ return JSON.stringify(value ?? null);
29
+ }
30
+ const hashOf = (value) => sha256(canonical(value));
31
+ const sortBy = (list, key) => list.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
32
+ const DIMENSION = /(-?\d*\.?\d+)([a-z%]+)$/i;
33
+ function unitsOf(value) {
34
+ const values = typeof value === 'string'
35
+ ? [value]
36
+ : value && typeof value === 'object'
37
+ ? Object.values(value).filter((v) => typeof v === 'string')
38
+ : [];
39
+ const units = [...new Set(values.map((v) => DIMENSION.exec(v.trim())?.[2]).filter(Boolean))];
40
+ return units.length ? units.sort().join(' ') : undefined;
41
+ }
42
+ const refsIn = (definition) => [...JSON.stringify(definition ?? '').matchAll(/\{([^{}"]+)\}/g)].map((m) => m[1]);
43
+ /** Build a snapshot from existing artifacts. Never executes the analyzed code. */
44
+ export function createSnapshot(inputs) {
45
+ const items = {};
46
+ const edges = [];
47
+ const unresolved = [];
48
+ const item = (id, value) => {
49
+ items[id] = value;
50
+ };
51
+ const edge = (from, to, kind, provenance) => edges.push({ from, to, kind, provenance });
52
+ // Tokens first: style reads resolve against them.
53
+ const inspection = inputs.theme?.inspection;
54
+ const byVariable = new Map((inspection?.tokens ?? []).map((t) => [t.cssVariable ?? '', t.name]));
55
+ const tokenNames = new Set((inspection?.tokens ?? []).map((t) => t.name));
56
+ for (const token of inspection?.tokens ?? []) {
57
+ const unit = unitsOf(token.value);
58
+ item(`token:${token.name}`, {
59
+ kind: 'token',
60
+ hash: hashOf({ type: token.type, definition: token.definition, value: token.value }),
61
+ docs: hashOf({ description: token.description, provenance: token.provenance }),
62
+ ...(token.type ? { type: token.type } : {}),
63
+ ...(unit ? { unit } : {}),
64
+ });
65
+ for (const ref of new Set(refsIn(token.definition)))
66
+ if (tokenNames.has(ref))
67
+ edge(`token:${token.name}`, `token:${ref}`, 'alias', 'declared');
68
+ }
69
+ const config = inputs.theme?.config;
70
+ if (config) {
71
+ const { products: _products, ...base } = config;
72
+ item('theme:base', { kind: 'theme', hash: hashOf(base) });
73
+ }
74
+ const packages = [];
75
+ const allTags = [];
76
+ const read = new Set(inputs.packages.map((p) => p.name));
77
+ const variables = new Set(inputs.packages.flatMap((p) => p.manifest.modules.flatMap((m) => (m.declarations ?? []).flatMap((d) => (d.cssProperties ?? []).map((c) => c.name)))));
78
+ for (const pkg of inputs.packages) {
79
+ const at = (ref) => `${ref.package ?? pkg.name}/${ref.module}#${ref.name}`;
80
+ /** The publisher's item wins: only it can hash the source. */
81
+ const source = (id, kind, ref, facts) => {
82
+ const owner = ref.package ?? pkg.name;
83
+ if (owner !== pkg.name) {
84
+ if (!read.has(owner))
85
+ unresolved.push({
86
+ item: id,
87
+ package: pkg.name,
88
+ reason: `${owner} was not read; edits to ${ref.module} are not detected`,
89
+ });
90
+ if (!items[id])
91
+ item(id, { kind, hash: hashOf({ facts, source: null }), package: owner });
92
+ return;
93
+ }
94
+ const text = pkg.modules?.[ref.module];
95
+ if (text === undefined)
96
+ unresolved.push({
97
+ item: id,
98
+ package: pkg.name,
99
+ reason: `source of ${ref.module} not supplied; edits inside it are not detected`,
100
+ });
101
+ item(id, {
102
+ kind,
103
+ hash: hashOf({ facts, source: text === undefined ? null : sha256(text) }),
104
+ package: owner,
105
+ });
106
+ };
107
+ packages.push({
108
+ name: pkg.name,
109
+ version: pkg.version,
110
+ artifacts: {
111
+ 'custom-elements.json': hashOf(pkg.manifest),
112
+ 'oddkin.metadata.json': hashOf(pkg.metadata),
113
+ ...(pkg.exports ? { 'package.json#exports': hashOf(pkg.exports) } : {}),
114
+ },
115
+ });
116
+ for (const key of Object.keys(pkg.exports ?? {}))
117
+ item(`export:${pkg.name}/${key}`, {
118
+ kind: 'export',
119
+ hash: hashOf(pkg.exports?.[key]),
120
+ package: pkg.name,
121
+ });
122
+ const declarations = new Map(pkg.manifest.modules.flatMap((m) => (m.declarations ?? [])
123
+ .filter((d) => d.tagName)
124
+ .map((d) => [d.tagName, { d, path: m.path }])));
125
+ for (const [tag, { d, path }] of declarations) {
126
+ allTags.push(tag);
127
+ const component = `component:${tag}`;
128
+ // The class owns behavior and the public API; a derived definition depends on it, not on
129
+ // the foundation's template or skin, which it names itself.
130
+ const owner = `class:${tag}`;
131
+ const text = path ? pkg.modules?.[path] : undefined;
132
+ if (text === undefined)
133
+ unresolved.push({
134
+ item: owner,
135
+ package: pkg.name,
136
+ reason: `source of ${path ?? d.name} not supplied; edits inside it are not detected`,
137
+ });
138
+ item(owner, {
139
+ kind: 'class',
140
+ hash: hashOf({ name: d.name, source: text === undefined ? null : sha256(text) }),
141
+ docs: hashOf(d.description),
142
+ package: pkg.name,
143
+ });
144
+ edge(component, owner, 'class', 'extracted');
145
+ const member = (id, kind, facts, docs, type) => {
146
+ item(id, {
147
+ kind,
148
+ hash: hashOf(facts),
149
+ ...(docs ? { docs: hashOf(docs) } : {}),
150
+ package: pkg.name,
151
+ ...(type ? { type } : {}),
152
+ });
153
+ edge(owner, id, 'member', 'extracted');
154
+ };
155
+ for (const m of d.members ?? []) {
156
+ if (m.static || m.privacy === 'private' || m.privacy === 'protected')
157
+ continue;
158
+ const type = m.type?.text;
159
+ if (m.kind === 'method')
160
+ member(`method:${tag}#${m.name}`, 'method', { type }, m.description);
161
+ else
162
+ member(`property:${tag}#${m.name}`, 'property', {
163
+ type,
164
+ default: m.default,
165
+ attribute: m.attribute,
166
+ reflects: m.reflects,
167
+ readonly: m.readonly,
168
+ }, m.description, type);
169
+ }
170
+ for (const a of d.attributes ?? [])
171
+ member(`attribute:${tag}#${a.name}`, 'attribute', { type: a.type?.text, field: a.fieldName, default: a.default }, a.description, a.type?.text);
172
+ for (const e of d.events ?? [])
173
+ member(`event:${tag}#${e.name}`, 'event', { type: e.type?.text }, e.description);
174
+ for (const s of d.slots ?? [])
175
+ member(`slot:${tag}#${s.name || 'default'}`, 'slot', {}, s.description);
176
+ for (const p of d.cssParts ?? [])
177
+ member(`part:${tag}#${p.name}`, 'part', {}, p.description);
178
+ for (const c of d.cssProperties ?? [])
179
+ member(`cssprop:${tag}#${c.name}`, 'cssprop', { default: c.default, syntax: c.syntax }, c.description);
180
+ for (const s of d.cssStates ?? [])
181
+ member(`state:${tag}#${s.name}`, 'state', {}, s.description);
182
+ item(component, {
183
+ kind: 'component',
184
+ hash: hashOf({ tag }),
185
+ package: pkg.name,
186
+ });
187
+ }
188
+ for (const [id, c] of Object.entries(pkg.metadata.components ?? {})) {
189
+ const tag = c.tag;
190
+ if (c.registration) {
191
+ item(`registration:${tag}`, {
192
+ kind: 'registration',
193
+ hash: hashOf(c.registration),
194
+ package: pkg.name,
195
+ });
196
+ edge(id, `registration:${tag}`, 'member', 'extracted');
197
+ }
198
+ const def = c.definition;
199
+ if (!def)
200
+ continue;
201
+ const definition = `definition:${tag}`;
202
+ item(definition, {
203
+ kind: 'definition',
204
+ hash: hashOf({
205
+ extends: def.extends,
206
+ template: def.template && at(def.template),
207
+ styles: def.styles?.map(at),
208
+ dependencies: def.dependencies,
209
+ }),
210
+ package: pkg.name,
211
+ });
212
+ edge(id, definition, 'definition', 'extracted');
213
+ if (def.template) {
214
+ const template = `template:${at(def.template)}`;
215
+ source(template, 'template', def.template, pkg.metadata.templates?.[`${def.template.module}#${def.template.name}`] ?? null);
216
+ edge(definition, template, 'template', 'extracted');
217
+ }
218
+ for (const ref of def.styles ?? []) {
219
+ const style = `style:${at(ref)}`;
220
+ const facts = pkg.metadata.styles?.[`${ref.module}#${ref.name}`];
221
+ source(style, 'style', ref, facts ?? null);
222
+ edge(definition, style, 'style', 'extracted');
223
+ for (const read of facts?.tokens ?? []) {
224
+ const name = read.startsWith('--') ? byVariable.get(read) : read;
225
+ if (name && tokenNames.has(name))
226
+ edge(style, `token:${name}`, 'reads', 'extracted');
227
+ // A declared component variable is the component's own API (its cssprop items).
228
+ else if (!variables.has(read))
229
+ unresolved.push({
230
+ item: style,
231
+ package: pkg.name,
232
+ reason: read.startsWith('--')
233
+ ? `reads ${read}, which is neither a token nor a declared component variable`
234
+ : `reads ${read}, which no token defines`,
235
+ });
236
+ }
237
+ }
238
+ for (const dependency of Object.values(def.dependencies ?? {}))
239
+ edge(definition, dependency, 'dependency', 'extracted');
240
+ if (def.extends)
241
+ edge(definition, `class:${def.extends.replace(/^component:/, '')}`, 'foundation', 'extracted');
242
+ }
243
+ for (const [id, e] of Object.entries(pkg.metadata.examples ?? {})) {
244
+ // Explained mistakes are never checks to run.
245
+ if (e.incorrect)
246
+ continue;
247
+ const file = e.file ? pkg.modules?.[e.file] : undefined;
248
+ item(id, {
249
+ kind: 'example',
250
+ hash: hashOf({ entry: e, file: file === undefined ? null : sha256(file) }),
251
+ package: pkg.name,
252
+ });
253
+ edge(id, e.component, 'example', 'authored');
254
+ // Fixtures that render other elements depend on them too.
255
+ for (const used of e.uses ?? [])
256
+ edge(id, used, 'uses', 'extracted');
257
+ }
258
+ for (const d of pkg.metadata.diagnostics ?? [])
259
+ if (d.code.startsWith('analyzer.dynamic.') || d.code === 'analyzer.published.missing')
260
+ unresolved.push({
261
+ item: `package:${pkg.name}`,
262
+ package: pkg.name,
263
+ reason: `${d.code}${d.source?.file ? ` at ${d.source.file}:${d.source.line ?? 0}` : ''}`,
264
+ });
265
+ }
266
+ const productScopes = new Map((inspection?.scopes ?? []).flatMap((s) => {
267
+ const name = /^\[data-odd-product="([^"]+)"\]$/.exec(s.selector)?.[1];
268
+ return name ? [[name, s.changed]] : [];
269
+ }));
270
+ const themeProducts = (config?.['products'] ?? {});
271
+ const products = [];
272
+ for (const product of inputs.products ?? []) {
273
+ const id = `product:${product.id}`;
274
+ const themeProduct = product.theme?.product ?? product.id;
275
+ item(id, { kind: 'product', hash: hashOf(product) });
276
+ if (themeProduct in themeProducts) {
277
+ item(`theme:product/${themeProduct}`, {
278
+ kind: 'theme',
279
+ hash: hashOf(themeProducts[themeProduct]),
280
+ });
281
+ edge(id, `theme:product/${themeProduct}`, 'theme', 'configuration');
282
+ }
283
+ const sets = [
284
+ ...(productScopes.get(themeProduct) ?? []),
285
+ ...Object.keys(product.theme?.overrides ?? {}),
286
+ ];
287
+ for (const token of new Set(sets))
288
+ edge(id, `token:${token}`, 'sets', 'configuration');
289
+ for (const tag of product.components ?? allTags)
290
+ edge(id, `component:${tag}`, 'applies', 'configuration');
291
+ products.push({
292
+ id: product.id,
293
+ sets: [...new Set(sets)].sort(),
294
+ ...(product.components ? { components: [...product.components].sort() } : {}),
295
+ });
296
+ }
297
+ for (const suite of inputs.suites ?? []) {
298
+ item(`suite:${suite.id}`, {
299
+ kind: 'suite',
300
+ hash: hashOf({ version: suite.version, targets: suite.targets }),
301
+ });
302
+ for (const tag of suite.targets)
303
+ edge(`suite:${suite.id}`, `component:${tag}`, 'suite', 'authored');
304
+ }
305
+ return {
306
+ schema: SNAPSHOT_SCHEMA,
307
+ versions: { ...(inputs.versions ?? {}) },
308
+ packages,
309
+ items: Object.fromEntries(sortBy(Object.entries(items), ([id]) => id)),
310
+ edges: sortBy([...new Map(edges.map((e) => [`${e.from} ${e.to} ${e.kind}`, e])).values()], (e) => `${e.from} ${e.to} ${e.kind}`),
311
+ unresolved: sortBy(unresolved, (u) => `${u.item} ${u.reason}`),
312
+ products: sortBy(products, (p) => p.id),
313
+ migrations: [...(inputs.migrations ?? [])],
314
+ };
315
+ }
316
+ const PUBLIC = new Set([
317
+ 'component',
318
+ 'property',
319
+ 'attribute',
320
+ 'event',
321
+ 'method',
322
+ 'slot',
323
+ 'part',
324
+ 'cssprop',
325
+ 'state',
326
+ 'registration',
327
+ 'export',
328
+ 'token',
329
+ ]);
330
+ const unionOf = (type) => type && /^"[^"]*"( \| "[^"]*")*$/.test(type)
331
+ ? [...type.matchAll(/"([^"]*)"/g)].map((m) => m[1])
332
+ : undefined;
333
+ function classify(id, base, head) {
334
+ if (base.hash === head.hash)
335
+ return { classification: 'informational', reason: 'documentation only' };
336
+ if (head.kind === 'token') {
337
+ if (base.type !== head.type)
338
+ return {
339
+ classification: 'incompatible',
340
+ reason: `type changed from ${base.type} to ${head.type}`,
341
+ };
342
+ if (base.unit !== head.unit)
343
+ return {
344
+ classification: 'incompatible',
345
+ reason: `unit changed from ${base.unit ?? 'none'} to ${head.unit ?? 'none'}`,
346
+ };
347
+ return { classification: 'review', reason: 'value or definition changed' };
348
+ }
349
+ if ((head.kind === 'property' || head.kind === 'attribute') && base.type !== head.type) {
350
+ const before = unionOf(base.type);
351
+ const after = unionOf(head.type);
352
+ if (before && after)
353
+ return before.every((v) => after.includes(v))
354
+ ? { classification: 'review', reason: `type widened to ${head.type}` }
355
+ : {
356
+ classification: 'incompatible',
357
+ reason: `type narrowed from ${base.type} to ${head.type}`,
358
+ };
359
+ return { classification: 'review', reason: `type changed from ${base.type} to ${head.type}` };
360
+ }
361
+ return { classification: 'review', reason: `${id.split(':')[0]} changed` };
362
+ }
363
+ /** Compare two snapshots: classified changes, affected items with reasons, and selected checks. */
364
+ export function compareSnapshots(base, head, options = {}) {
365
+ const fallback = [];
366
+ const major = (schema) => /\/v(\d+)\.json$/.exec(schema)?.[1];
367
+ if (major(base.schema) !== major(head.schema))
368
+ fallback.push(`snapshot schemas differ (${base.schema} → ${head.schema}); items are not compared`);
369
+ for (const key of new Set([...Object.keys(base.versions), ...Object.keys(head.versions)]))
370
+ if (base.versions[key] !== head.versions[key])
371
+ fallback.push(`${key} version changed (${base.versions[key] ?? 'none'} → ${head.versions[key] ?? 'none'})`);
372
+ // Changes, with renames only through migration records.
373
+ const changes = [];
374
+ const renamedFrom = new Map(head.migrations.map((m) => [m.from, m]));
375
+ const added = Object.keys(head.items).filter((id) => !(id in base.items));
376
+ const removed = Object.keys(base.items).filter((id) => !(id in head.items));
377
+ const renamedTo = new Set();
378
+ for (const id of removed) {
379
+ const was = base.items[id];
380
+ const migration = renamedFrom.get(id);
381
+ if (migration && head.items[migration.to]) {
382
+ renamedTo.add(migration.to);
383
+ changes.push({
384
+ id: migration.to,
385
+ kind: was.kind,
386
+ change: 'renamed',
387
+ from: id,
388
+ migration: migration.source,
389
+ classification: PUBLIC.has(was.kind) ? 'incompatible' : 'review',
390
+ reason: `renamed from ${id}${head.items[migration.to]?.hash === was.hash ? '' : ', and changed'}`,
391
+ });
392
+ continue;
393
+ }
394
+ const lookalike = added.find((a) => head.items[a]?.kind === was.kind && head.items[a]?.hash === was.hash);
395
+ changes.push({
396
+ id,
397
+ kind: was.kind,
398
+ change: 'removed',
399
+ classification: PUBLIC.has(was.kind) ? 'incompatible' : 'review',
400
+ reason: 'removed',
401
+ ...(lookalike ? { possibleRename: lookalike } : {}),
402
+ });
403
+ }
404
+ for (const id of added)
405
+ if (!renamedTo.has(id))
406
+ changes.push({
407
+ id,
408
+ kind: head.items[id].kind,
409
+ change: 'added',
410
+ classification: 'informational',
411
+ reason: 'added',
412
+ });
413
+ for (const [id, after] of Object.entries(head.items)) {
414
+ const before = base.items[id];
415
+ if (!before || (before.hash === after.hash && before.docs === after.docs))
416
+ continue;
417
+ changes.push({ id, kind: after.kind, change: 'changed', ...classify(id, before, after) });
418
+ }
419
+ sortBy(changes, (c) => c.id);
420
+ // Dependents: reverse edges of both snapshots, so removed items still reach their users.
421
+ const dependents = new Map();
422
+ for (const e of [...base.edges, ...head.edges]) {
423
+ if (e.kind === 'sets' || e.kind === 'applies')
424
+ continue;
425
+ if (!dependents.has(e.to))
426
+ dependents.set(e.to, new Set());
427
+ dependents.get(e.to).add(e.from);
428
+ }
429
+ const contexts = head.products.length
430
+ ? head.products.map((p) => ({
431
+ id: p.id,
432
+ sets: new Set(p.sets.map((t) => `token:${t}`)),
433
+ components: p.components,
434
+ }))
435
+ : [{ id: undefined, sets: new Set(), components: undefined }];
436
+ const productOf = new Map(head.products.map((p) => [p.id, p]));
437
+ // Roots: every changed item, documentation-only changes excepted.
438
+ const roots = changes
439
+ .filter((c) => c.change !== 'added' && !(c.change === 'changed' && c.reason === 'documentation only'))
440
+ .map((c) => (c.change === 'renamed' ? c.from : c.id));
441
+ const affectedComponents = [];
442
+ const affectedProducts = new Map();
443
+ const selected = new Map();
444
+ const examplesOf = (snapshot, tag) => snapshot.edges
445
+ .filter((e) => e.kind === 'example' && e.to === `component:${tag}`)
446
+ .map((e) => e.from);
447
+ const applies = (context, tag) => !context.components || context.components.includes(tag);
448
+ const select = (check) => {
449
+ const key = `${check.id}@${check.product ?? ''}`;
450
+ if (!selected.has(key))
451
+ selected.set(key, check);
452
+ };
453
+ for (const context of contexts) {
454
+ // Breadth-first from the roots; a token the product sets itself is its own value.
455
+ const via = new Map();
456
+ const queue = [];
457
+ const reach = (id, path) => {
458
+ if (via.has(id) || context.sets.has(id))
459
+ return;
460
+ via.set(id, path);
461
+ queue.push(id);
462
+ };
463
+ for (const root of roots)
464
+ reach(root, [root]);
465
+ const product = context.id ? productOf.get(context.id) : undefined;
466
+ if (product) {
467
+ const changedOwn = roots.includes(`product:${product.id}`) ||
468
+ head.edges.some((e) => e.from === `product:${product.id}` && e.kind === 'theme' && roots.includes(e.to));
469
+ // A product whose own theme changed: every token its scope sets is a root for it.
470
+ if (changedOwn)
471
+ for (const token of product.sets) {
472
+ const path = [`product:${product.id}`, `token:${token}`];
473
+ if (!via.has(`token:${token}`)) {
474
+ via.set(`token:${token}`, path);
475
+ queue.push(`token:${token}`);
476
+ }
477
+ }
478
+ }
479
+ while (queue.length) {
480
+ const id = queue.shift();
481
+ for (const next of dependents.get(id) ?? [])
482
+ reach(next, [...via.get(id), next]);
483
+ }
484
+ const components = [...via.keys()].filter((id) => id.startsWith('component:'));
485
+ for (const component of components) {
486
+ const tag = component.slice('component:'.length);
487
+ if (!applies(context, tag))
488
+ continue;
489
+ affectedComponents.push({
490
+ id: component,
491
+ ...(context.id ? { product: context.id } : {}),
492
+ via: via.get(component),
493
+ });
494
+ for (const example of examplesOf(head, tag))
495
+ select({
496
+ id: example,
497
+ ...(context.id ? { product: context.id } : {}),
498
+ state: 'selected',
499
+ reason: 'an input on its path changed',
500
+ via: [...via.get(component), example],
501
+ });
502
+ for (const e of head.edges.filter((e) => e.kind === 'suite' && e.to === component))
503
+ select({
504
+ id: `${e.from}#${tag}`,
505
+ ...(context.id ? { product: context.id } : {}),
506
+ state: 'selected',
507
+ reason: 'its target is affected',
508
+ via: [...via.get(component), e.from],
509
+ });
510
+ }
511
+ // A changed suite or example is selected for its own sake.
512
+ for (const id of via.keys()) {
513
+ if (id.startsWith('suite:') && roots.includes(id))
514
+ for (const e of head.edges.filter((e) => e.kind === 'suite' && e.from === id)) {
515
+ const tag = e.to.slice('component:'.length);
516
+ if (applies(context, tag))
517
+ select({
518
+ id: `${id}#${tag}`,
519
+ ...(context.id ? { product: context.id } : {}),
520
+ state: 'selected',
521
+ reason: 'the suite changed',
522
+ via: [id],
523
+ });
524
+ }
525
+ if (id.startsWith('example:') && head.items[id]) {
526
+ const tag = head.edges
527
+ .find((e) => e.kind === 'example' && e.from === id)
528
+ ?.to.slice('component:'.length);
529
+ if (tag && applies(context, tag))
530
+ select({
531
+ id,
532
+ ...(context.id ? { product: context.id } : {}),
533
+ state: 'selected',
534
+ reason: roots.includes(id)
535
+ ? 'the example changed'
536
+ : 'its fixture renders an affected element',
537
+ via: via.get(id),
538
+ });
539
+ }
540
+ }
541
+ if (context.id && [...selected.values()].some((c) => c.product === context.id))
542
+ affectedProducts.set(context.id, [
543
+ ...([...selected.values()].find((c) => c.product === context.id)?.via ?? []),
544
+ ]);
545
+ }
546
+ // Every check: examples (explained mistakes excluded at snapshot time) and suite targets.
547
+ const universe = (context) => [
548
+ ...Object.entries(head.items)
549
+ .filter(([, entry]) => entry.kind === 'example')
550
+ .flatMap(([id, entry]) => {
551
+ const tag = head.edges
552
+ .find((e) => e.kind === 'example' && e.from === id)
553
+ ?.to.slice('component:'.length);
554
+ return tag && applies(context, tag) ? [{ id, package: entry.package }] : [];
555
+ }),
556
+ ...head.edges
557
+ .filter((e) => e.kind === 'suite' && applies(context, e.to.slice('component:'.length)))
558
+ .map((e) => ({ id: `${e.from}#${e.to.slice('component:'.length)}`, package: undefined })),
559
+ ];
560
+ // A changed product manifest (components, defaults, examples) selects all of its checks.
561
+ for (const context of contexts)
562
+ if (context.id && roots.includes(`product:${context.id}`))
563
+ for (const check of universe(context))
564
+ select({
565
+ id: check.id,
566
+ product: context.id,
567
+ state: 'selected',
568
+ reason: 'the product changed',
569
+ via: [`product:${context.id}`],
570
+ });
571
+ // Unresolved dependencies widen selection to every example of an affected package.
572
+ const coverageGaps = head.unresolved.map((u) => ({ item: u.item, reason: u.reason }));
573
+ const affectedPackages = new Set([...roots, ...affectedComponents.map((a) => a.id)]
574
+ .map((id) => head.items[id]?.package ?? base.items[id]?.package)
575
+ .filter((p) => !!p));
576
+ for (const pkg of affectedPackages) {
577
+ const gaps = head.unresolved.filter((u) => u.package === pkg);
578
+ if (!gaps.length)
579
+ continue;
580
+ for (const context of contexts)
581
+ for (const check of universe(context))
582
+ if (check.package === pkg)
583
+ select({
584
+ id: check.id,
585
+ ...(context.id ? { product: context.id } : {}),
586
+ state: 'selected',
587
+ reason: `unresolved dependency in ${pkg}: ${gaps[0]?.reason}`,
588
+ });
589
+ }
590
+ if (fallback.length)
591
+ for (const context of contexts)
592
+ for (const check of universe(context))
593
+ select({
594
+ id: check.id,
595
+ ...(context.id ? { product: context.id } : {}),
596
+ state: 'selected',
597
+ reason: `full verification: ${fallback[0]}`,
598
+ });
599
+ // With a baseline, every other check is reused only from a passing result, whose conditions
600
+ // are listed; a missing or failed result is executed. Without one, unaffected checks are not
601
+ // part of this run.
602
+ const checks = [...selected.values()];
603
+ if (options.baseline) {
604
+ const results = new Map(options.baseline.map((r) => [`${r.check}@${r.product ?? ''}`, r]));
605
+ for (const context of contexts)
606
+ for (const check of universe(context)) {
607
+ const key = `${check.id}@${context.id ?? ''}`;
608
+ if (selected.has(key))
609
+ continue;
610
+ const result = results.get(key);
611
+ checks.push(result?.state === 'passed'
612
+ ? {
613
+ id: check.id,
614
+ ...(context.id ? { product: context.id } : {}),
615
+ state: 'reused',
616
+ reason: 'no input on its path changed',
617
+ conditions: result.conditions,
618
+ }
619
+ : {
620
+ id: check.id,
621
+ ...(context.id ? { product: context.id } : {}),
622
+ state: 'selected',
623
+ reason: result ? 'the baseline result failed' : 'no baseline result',
624
+ });
625
+ }
626
+ }
627
+ for (const context of contexts) {
628
+ const first = [...selected.values()].find((c) => c.product === context.id);
629
+ if (context.id && !affectedProducts.has(context.id) && first)
630
+ affectedProducts.set(context.id, [...(first.via ?? [])]);
631
+ }
632
+ return {
633
+ schema: IMPACT_SCHEMA,
634
+ base: { versions: base.versions },
635
+ head: { versions: head.versions },
636
+ ...(fallback.length ? { fallback: { kind: 'full', reasons: fallback } } : {}),
637
+ changes,
638
+ affected: {
639
+ products: sortBy([...affectedProducts].map(([id, path]) => ({ id: `product:${id}`, via: path })), (a) => a.id),
640
+ components: sortBy(affectedComponents, (a) => `${a.id}@${a.product ?? ''}`),
641
+ },
642
+ coverageGaps,
643
+ checks: sortBy(checks, (c) => `${c.id}@${c.product ?? ''}`),
644
+ };
645
+ }
646
+ /**
647
+ * Merge executed results into the report. A selected check passes only when every run of it
648
+ * passed; any failure fails it; with no run it is `not-executed`. Conditions of every run are
649
+ * listed. Reused checks keep their baseline state and conditions.
650
+ */
651
+ export function mergeResults(report, results) {
652
+ const runs = new Map();
653
+ for (const result of results) {
654
+ const key = `${result.check}@${result.product ?? ''}`;
655
+ runs.set(key, [...(runs.get(key) ?? []), result]);
656
+ }
657
+ return {
658
+ ...report,
659
+ checks: report.checks.map((check) => {
660
+ if (check.state !== 'selected')
661
+ return check;
662
+ const ran = (runs.get(`${check.id}@${check.product ?? ''}`) ?? []).filter((r) => r.state !== 'not-executed');
663
+ if (!ran.length)
664
+ return { ...check, state: 'not-executed', reason: `${check.reason}; no run recorded` };
665
+ const failed = ran.filter((r) => r.state === 'failed');
666
+ return {
667
+ ...check,
668
+ state: failed.length ? 'failed' : 'passed',
669
+ reason: failed.length
670
+ ? `${check.reason}; failed: ${failed.map((r) => r.message ?? 'no message').join('; ')}`
671
+ : check.reason,
672
+ conditions: { runs: ran.map((r) => r.conditions) },
673
+ };
674
+ }),
675
+ };
676
+ }
677
+ // ---- reading a package from disk ----
678
+ /**
679
+ * The snapshot inputs of a built package directory: `package.json` (name, version, exports),
680
+ * its `customElements` manifest, `oddkin.metadata.json`, and the sources of every module its
681
+ * elements, definitions, and examples name. Missing sources stay missing (coverage gaps).
682
+ */
683
+ export function readPackage(dir) {
684
+ const json = (path) => JSON.parse(readFileSync(join(dir, path), 'utf8'));
685
+ const pkg = json('package.json');
686
+ const manifest = json(pkg.customElements ?? 'custom-elements.json');
687
+ const metadata = existsSync(join(dir, 'oddkin.metadata.json'))
688
+ ? json('oddkin.metadata.json')
689
+ : {};
690
+ const paths = new Set([
691
+ ...manifest.modules
692
+ .filter((m) => m.declarations?.some((d) => d.tagName))
693
+ .flatMap((m) => (m.path ? [m.path] : [])),
694
+ ...Object.values(metadata.components ?? {}).flatMap((c) => [
695
+ ...(c.definition?.template && !c.definition.template.package
696
+ ? [c.definition.template.module]
697
+ : []),
698
+ ...(c.definition?.styles ?? []).filter((s) => !s.package).map((s) => s.module),
699
+ ]),
700
+ ...Object.values(metadata.examples ?? {}).flatMap((e) => (e.file ? [e.file] : [])),
701
+ ]);
702
+ const modules = {};
703
+ for (const path of [...paths].sort())
704
+ if (existsSync(join(dir, path)))
705
+ modules[path] = readFileSync(join(dir, path), 'utf8');
706
+ return {
707
+ name: pkg.name,
708
+ version: pkg.version,
709
+ manifest,
710
+ metadata,
711
+ ...(pkg.exports ? { exports: pkg.exports } : {}),
712
+ modules,
713
+ };
714
+ }
715
+ // ---- Markdown ----
716
+ const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
717
+ /** A readable rendering of the report for terminals, pull requests, and Workbench. */
718
+ export function renderImpactMarkdown(report) {
719
+ const lines = ['# Change impact', ''];
720
+ const count = (state) => report.checks.filter((c) => c.state === state).length;
721
+ const incompatible = report.changes.filter((c) => c.classification === 'incompatible').length;
722
+ const states = ['selected', 'passed', 'failed', 'reused', 'not-executed', 'unresolved']
723
+ .filter((state) => count(state))
724
+ .map((state) => `${count(state)} ${state}`);
725
+ lines.push(`${report.changes.length} changes (${incompatible} incompatible) · ${report.affected.products.length} products affected · ${report.checks.length} checks${states.length ? ` (${states.join(', ')})` : ''}`, '');
726
+ if (report.fallback)
727
+ lines.push('## Full verification', '', ...report.fallback.reasons.map((r) => `- ${cell(r)}`), '');
728
+ if (report.changes.length) {
729
+ lines.push('## Changes', '', '| Classification | Change | Item | Reason |', '| --- | --- | --- | --- |');
730
+ for (const c of report.changes)
731
+ lines.push(`| ${c.classification} | ${c.change} | \`${cell(c.id)}\` | ${cell(c.reason)}${c.migration ? ` (${cell(c.migration)})` : ''}${c.possibleRename ? `; possible rename to \`${cell(c.possibleRename)}\` (unconfirmed)` : ''} |`);
732
+ lines.push('');
733
+ }
734
+ if (report.coverageGaps.length)
735
+ lines.push('## Coverage gaps', '', ...report.coverageGaps.map((g) => `- \`${cell(g.item)}\`: ${cell(g.reason)}`), '');
736
+ if (report.checks.length) {
737
+ lines.push('## Checks', '', '| State | Check | Product | Reason |', '| --- | --- | --- | --- |');
738
+ for (const c of report.checks)
739
+ lines.push(`| ${c.state} | \`${cell(c.id)}\` | ${c.product ?? ''} | ${cell(c.reason)}${c.via?.length ? ` (via ${c.via.map((v) => `\`${cell(v)}\``).join(' → ')})` : ''} |`);
740
+ lines.push('');
741
+ }
742
+ return `${lines.join('\n')}\n`;
743
+ }
744
+ //# sourceMappingURL=impact.js.map