agent-skillboard 0.1.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 (94) hide show
  1. package/CONTRIBUTING.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +645 -0
  4. package/bin/skillboard.mjs +4 -0
  5. package/docs/adapters.md +127 -0
  6. package/docs/capabilities.md +107 -0
  7. package/docs/install.md +346 -0
  8. package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
  9. package/docs/plans/README.md +20 -0
  10. package/docs/policy-model.md +251 -0
  11. package/docs/positioning.md +94 -0
  12. package/docs/profiles.md +166 -0
  13. package/docs/rollout-runbook.md +60 -0
  14. package/docs/user-flow.md +231 -0
  15. package/docs/versioning.md +201 -0
  16. package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
  17. package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
  18. package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
  19. package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
  20. package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
  21. package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
  22. package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
  23. package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
  24. package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
  25. package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
  26. package/examples/multi-source.config.yaml +271 -0
  27. package/examples/skillboard.config.yaml +194 -0
  28. package/examples/skills/grill-me/SKILL.md +9 -0
  29. package/examples/skills/grill-with-docs/SKILL.md +9 -0
  30. package/examples/skills/requirement-intake/SKILL.md +9 -0
  31. package/examples/skills/tdd/SKILL.md +8 -0
  32. package/package.json +58 -0
  33. package/profiles/anthropics-skills.yaml +17 -0
  34. package/profiles/mattpocock-skills.yaml +26 -0
  35. package/profiles/oh-my-openagent.yaml +32 -0
  36. package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
  37. package/profiles/wshobson-agents.yaml +19 -0
  38. package/src/advisor/action-core.mjs +74 -0
  39. package/src/advisor/actions.mjs +256 -0
  40. package/src/advisor/application-commands.mjs +59 -0
  41. package/src/advisor/apply-action.mjs +183 -0
  42. package/src/advisor/schema.mjs +112 -0
  43. package/src/advisor/setup-actions.mjs +42 -0
  44. package/src/advisor/skills.mjs +191 -0
  45. package/src/advisor/sort.mjs +15 -0
  46. package/src/advisor/sources.mjs +160 -0
  47. package/src/advisor/trust-policy.mjs +65 -0
  48. package/src/advisor/workflow.mjs +41 -0
  49. package/src/advisor.mjs +134 -0
  50. package/src/agent-inventory-platforms.mjs +100 -0
  51. package/src/agent-inventory.mjs +804 -0
  52. package/src/brief-cli.mjs +40 -0
  53. package/src/brief-renderer.mjs +362 -0
  54. package/src/change-plan.mjs +169 -0
  55. package/src/cli.mjs +1171 -0
  56. package/src/config-helpers.mjs +72 -0
  57. package/src/control/can-use-guard.mjs +91 -0
  58. package/src/control/config-write.mjs +163 -0
  59. package/src/control/skill-crud.mjs +394 -0
  60. package/src/control/source-trust.mjs +161 -0
  61. package/src/control/workflow-crud.mjs +104 -0
  62. package/src/control.mjs +197 -0
  63. package/src/doctor.mjs +299 -0
  64. package/src/domain/constants.mjs +47 -0
  65. package/src/domain/indexes.mjs +29 -0
  66. package/src/domain/rules/capabilities.mjs +33 -0
  67. package/src/domain/rules/harnesses.mjs +16 -0
  68. package/src/domain/rules/install-units.mjs +95 -0
  69. package/src/domain/rules/skills.mjs +105 -0
  70. package/src/domain/rules/workflows.mjs +105 -0
  71. package/src/domain/skill-state-matrix.mjs +79 -0
  72. package/src/domain/source-classes.mjs +99 -0
  73. package/src/hook-plan.mjs +152 -0
  74. package/src/impact.mjs +41 -0
  75. package/src/index.mjs +82 -0
  76. package/src/init.mjs +136 -0
  77. package/src/install-output-detector.mjs +337 -0
  78. package/src/install-units.mjs +62 -0
  79. package/src/inventory-refresh.mjs +45 -0
  80. package/src/lifecycle-cli.mjs +166 -0
  81. package/src/lifecycle-content.mjs +87 -0
  82. package/src/policy.mjs +42 -0
  83. package/src/reconcile.mjs +111 -0
  84. package/src/report.mjs +151 -0
  85. package/src/review.mjs +88 -0
  86. package/src/rollout.mjs +453 -0
  87. package/src/skill-paths.mjs +17 -0
  88. package/src/source-cache.mjs +178 -0
  89. package/src/source-profile-loader.mjs +160 -0
  90. package/src/source-profiles.mjs +299 -0
  91. package/src/source-verification.mjs +252 -0
  92. package/src/uninstall.mjs +258 -0
  93. package/src/workspace.mjs +219 -0
  94. package/tsconfig.lsp.json +15 -0
@@ -0,0 +1,40 @@
1
+ import { dirname, isAbsolute } from "node:path";
2
+ import { buildSkillBrief } from "./advisor.mjs";
3
+ import { renderSkillBrief } from "./brief-renderer.mjs";
4
+
5
+ export async function runBriefCommand(options, stdout, paths) {
6
+ const json = options.get("json") === "true";
7
+ const result = await buildSkillBrief({
8
+ root: briefRoot(options),
9
+ configPath: paths.configPath,
10
+ skillsRoot: paths.skillsRoot,
11
+ workflow: options.get("workflow"),
12
+ includeActions: options.get("include-actions") === "true" || !json
13
+ });
14
+ writeBriefOutput(stdout, result, options);
15
+ return briefExitCode(result);
16
+ }
17
+
18
+ function briefRoot(options) {
19
+ const dir = options.get("dir");
20
+ if (dir !== undefined) {
21
+ return dir;
22
+ }
23
+ const config = options.get("config");
24
+ return config !== undefined && isAbsolute(config) ? dirname(config) : ".";
25
+ }
26
+
27
+ function briefExitCode(result) {
28
+ if (result.ok) {
29
+ return 0;
30
+ }
31
+ return result.error?.code === "unknown-workflow" ? 2 : 1;
32
+ }
33
+
34
+ function writeBriefOutput(stdout, result, options) {
35
+ if (options.get("json") === "true") {
36
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
37
+ return;
38
+ }
39
+ stdout.write(renderSkillBrief(result, { verbose: options.get("verbose") === "true" }));
40
+ }
@@ -0,0 +1,362 @@
1
+ const SKILL_SECTIONS = [
2
+ ["What your AI can use now", "automatic_allowed"],
3
+ ["Manual only", "manual_allowed"],
4
+ ["Needs your decision", "needs_review"],
5
+ ["Blocked for safety", "blocked"]
6
+ ];
7
+
8
+ const PRIMARY_ACTION_KINDS = new Set([
9
+ "setup-guidance",
10
+ "block-install-unit",
11
+ "review-install-unit",
12
+ "trust-install-unit",
13
+ "activate-skill",
14
+ "hook-install"
15
+ ]);
16
+
17
+ const CLEANUP_ACTION_KINDS = new Set([
18
+ "remove-skill-force",
19
+ "reset-cleanup"
20
+ ]);
21
+ const COMPACT_SKILL_LIMIT = 5;
22
+ const TOP_CATEGORY_LIMIT = 5;
23
+ const MAX_ACTIONS_PER_TEXT_SECTION = 5;
24
+ const ACTION_KIND_RANK = new Map([
25
+ ["setup-guidance", 0],
26
+ ["block-install-unit", 0],
27
+ ["review-install-unit", 0],
28
+ ["trust-install-unit", 0],
29
+ ["activate-skill", 1],
30
+ ["hook-install", 2],
31
+ ["block-skill", 3],
32
+ ["remove-skill-force", 4],
33
+ ["reset-cleanup", 5]
34
+ ]);
35
+
36
+ export function renderSkillBrief(brief, options = {}) {
37
+ const verbose = options.verbose === true;
38
+ const counts = briefCounts(brief);
39
+ const lines = [
40
+ "# SkillBoard Brief",
41
+ "",
42
+ `AI can use now: ${counts.usable} (${counts.automatic} automatic, ${counts.manual} manual)`,
43
+ `Needs your decision: ${counts.needsDecision}`,
44
+ `Blocked for safety: ${counts.blocked}`,
45
+ `Workflow: ${workflowSummary(brief.workflow)}`,
46
+ ""
47
+ ];
48
+ if (brief.error !== undefined) {
49
+ lines.push(`Error: ${safeText(brief.error.message)}`, "");
50
+ }
51
+ emitCategorySummary(lines, brief);
52
+ emitNextAction(lines, brief, { verbose });
53
+ for (const [title, key] of SKILL_SECTIONS) {
54
+ emitSkillSection(lines, title, brief.skills[key], {
55
+ brief,
56
+ groupLabel: compactGroupLabel(key),
57
+ verbose
58
+ });
59
+ }
60
+ emitSkillSection(lines, "Not in this workflow", [
61
+ ...brief.skills.not_in_workflow,
62
+ ...brief.skills.installed_only
63
+ ], {
64
+ brief,
65
+ groupLabel: "inactive or undeclared skills",
66
+ verbose
67
+ });
68
+ emitActions(lines, brief, { verbose });
69
+ return `${lines.join("\n")}\n`;
70
+ }
71
+
72
+ function briefCounts(brief) {
73
+ const automatic = brief.skills.automatic_allowed.length;
74
+ const manual = brief.skills.manual_allowed.length;
75
+ return {
76
+ automatic,
77
+ manual,
78
+ usable: automatic + manual,
79
+ needsDecision: brief.skills.needs_review.length,
80
+ blocked: brief.skills.blocked.length
81
+ };
82
+ }
83
+
84
+ function emitSkillSection(lines, title, entries, options) {
85
+ lines.push(`## ${title}`, "");
86
+ if (entries.length === 0) {
87
+ lines.push("- none", "");
88
+ return;
89
+ }
90
+ const visibleEntries = options.verbose ? entries : entries.slice(0, COMPACT_SKILL_LIMIT);
91
+ for (const entry of visibleEntries) {
92
+ const path = entry.path === undefined ? "" : ` (${safeText(entry.path)})`;
93
+ const reason = entry.reason === null || entry.reason === undefined
94
+ ? ""
95
+ : ` - ${safeText(entry.reason)}`;
96
+ lines.push(`- ${code(entry.id)}${path}${reason}`);
97
+ }
98
+ const hidden = entries.length - visibleEntries.length;
99
+ if (hidden > 0) {
100
+ lines.push(`- ${hidden} more ${options.groupLabel} hidden. Run ${code(verboseCommand(options.brief))} or ${code(listCommand(options.brief))}.`);
101
+ }
102
+ lines.push("");
103
+ }
104
+
105
+ function emitCategorySummary(lines, brief) {
106
+ const categories = categoryCounts(brief);
107
+ lines.push("## Top categories", "");
108
+ if (categories.length === 0) {
109
+ lines.push("- none", "");
110
+ return;
111
+ }
112
+ for (const [category, count] of categories.slice(0, TOP_CATEGORY_LIMIT)) {
113
+ lines.push(`- ${safeText(category)}: ${count}`);
114
+ }
115
+ const hidden = categories.length - TOP_CATEGORY_LIMIT;
116
+ if (hidden > 0) {
117
+ lines.push(`- ${hidden} more categories hidden.`);
118
+ }
119
+ lines.push("");
120
+ }
121
+
122
+ function categoryCounts(brief) {
123
+ const counts = new Map();
124
+ for (const entry of allSkillEntries(brief)) {
125
+ const category = entry.advanced?.category ?? "uncategorized";
126
+ counts.set(category, (counts.get(category) ?? 0) + 1);
127
+ }
128
+ return [...counts.entries()].sort((left, right) => {
129
+ return right[1] - left[1] || left[0].localeCompare(right[0]);
130
+ });
131
+ }
132
+
133
+ function allSkillEntries(brief) {
134
+ return [
135
+ ...brief.skills.automatic_allowed,
136
+ ...brief.skills.manual_allowed,
137
+ ...brief.skills.needs_review,
138
+ ...brief.skills.blocked,
139
+ ...brief.skills.not_in_workflow,
140
+ ...brief.skills.installed_only
141
+ ];
142
+ }
143
+
144
+ function compactGroupLabel(key) {
145
+ if (key === "automatic_allowed") {
146
+ return "automatic skills";
147
+ }
148
+ if (key === "manual_allowed") {
149
+ return "manual-only skills";
150
+ }
151
+ if (key === "needs_review") {
152
+ return "decision items";
153
+ }
154
+ return "blocked skills";
155
+ }
156
+
157
+ function verboseCommand(brief) {
158
+ return `skillboard brief --verbose${workflowOption(brief)}`;
159
+ }
160
+
161
+ function listCommand(brief) {
162
+ return `skillboard list skills${workflowOption(brief)}`;
163
+ }
164
+
165
+ function workflowOption(brief) {
166
+ const selected = brief.workflow.selected;
167
+ if (selected === null || brief.workflow.unknown) {
168
+ return "";
169
+ }
170
+ return ` --workflow ${selected}`;
171
+ }
172
+
173
+ function emitActions(lines, brief, options) {
174
+ const actions = actionsForTextBrief(brief);
175
+ lines.push("## Suggested next actions", "");
176
+ if (actions.length === 0) {
177
+ lines.push("- none", "");
178
+ return;
179
+ }
180
+ const ordered = orderedActions(actions, prioritizeSourceDecisions(brief));
181
+ const primary = ordered.filter((action) => PRIMARY_ACTION_KINDS.has(action.kind));
182
+ const other = ordered.filter((action) => !PRIMARY_ACTION_KINDS.has(action.kind) && !CLEANUP_ACTION_KINDS.has(action.kind));
183
+ const cleanup = ordered.filter((action) => CLEANUP_ACTION_KINDS.has(action.kind));
184
+ if (primary.length === 0 && other.length === 0 && cleanup.length === 0) {
185
+ lines.push("- none", "");
186
+ return;
187
+ }
188
+ emitActionList(lines, primary, { ...options, brief });
189
+ if (!options.verbose) {
190
+ const hiddenSecondary = other.length + cleanup.length;
191
+ if (hiddenSecondary > 0) {
192
+ if (lines.at(-1) === "") {
193
+ lines.pop();
194
+ }
195
+ lines.push(`- ${hiddenSecondary} safety and cleanup actions hidden. Run ${code(`skillboard brief --verbose${workflowOption(brief)}`)}.`);
196
+ lines.push("");
197
+ }
198
+ return;
199
+ }
200
+ if (other.length > 0) {
201
+ lines.push("## Other safety actions", "");
202
+ emitActionList(lines, other, { ...options, brief });
203
+ }
204
+ if (cleanup.length > 0) {
205
+ lines.push("## Advanced cleanup actions", "");
206
+ emitActionList(lines, cleanup, { ...options, brief });
207
+ }
208
+ }
209
+
210
+ function emitNextAction(lines, brief, options) {
211
+ lines.push("## Next safe action", "");
212
+ const action = nextSafeAction(brief);
213
+ if (action === null) {
214
+ lines.push("- none", "");
215
+ return;
216
+ }
217
+ emitAction(lines, action, { ...options, brief, listOnly: false });
218
+ lines.push("");
219
+ }
220
+
221
+ function nextSafeAction(brief) {
222
+ const actions = actionsForTextBrief(brief);
223
+ if (actions.length === 0) {
224
+ return null;
225
+ }
226
+ const ordered = orderedActions(actions, prioritizeSourceDecisions(brief));
227
+ return ordered.find((action) => PRIMARY_ACTION_KINDS.has(action.kind) && action.blocked_reason === null)
228
+ ?? ordered.find((action) => PRIMARY_ACTION_KINDS.has(action.kind))
229
+ ?? null;
230
+ }
231
+
232
+ function actionsForTextBrief(brief) {
233
+ return brief.actions ?? [];
234
+ }
235
+
236
+ function orderedActions(actions, prioritizeDecisions) {
237
+ return [...actions].sort((left, right) => {
238
+ return actionRank(left, prioritizeDecisions) - actionRank(right, prioritizeDecisions)
239
+ || left.id.localeCompare(right.id);
240
+ });
241
+ }
242
+
243
+ function prioritizeSourceDecisions(brief) {
244
+ return brief.skills.needs_review.length > 0;
245
+ }
246
+
247
+ function actionRank(action, prioritizeDecisions) {
248
+ if (!prioritizeDecisions && isSourceDecisionAction(action)) {
249
+ return 2;
250
+ }
251
+ return ACTION_KIND_RANK.get(action.kind) ?? 10;
252
+ }
253
+
254
+ function isSourceDecisionAction(action) {
255
+ return action.kind === "block-install-unit"
256
+ || action.kind === "review-install-unit"
257
+ || action.kind === "trust-install-unit";
258
+ }
259
+
260
+ function emitActionList(lines, actions, options) {
261
+ if (actions.length === 0) {
262
+ lines.push("- none", "");
263
+ return;
264
+ }
265
+ for (const action of actions.slice(0, MAX_ACTIONS_PER_TEXT_SECTION)) {
266
+ emitAction(lines, action, { ...options, listOnly: !options.verbose });
267
+ }
268
+ const hidden = actions.length - MAX_ACTIONS_PER_TEXT_SECTION;
269
+ if (hidden > 0) {
270
+ const details = options.verbose
271
+ ? "skillboard brief --include-actions --json"
272
+ : `skillboard brief --verbose${workflowOption(options.brief)}`;
273
+ lines.push(`- ${hidden} more action cards available with ${code(details)}.`);
274
+ }
275
+ lines.push("");
276
+ }
277
+
278
+ function emitAction(lines, action, options) {
279
+ if (options.listOnly) {
280
+ emitCompactActionListItem(lines, action);
281
+ return;
282
+ }
283
+
284
+ lines.push(`- ${safeText(action.label)} (${action.risk})`);
285
+ lines.push(` - why: ${safeText(action.reason)}`);
286
+ if (action.blocked_reason !== null) {
287
+ lines.push(` - blocked: ${safeText(action.blocked_reason)}`);
288
+ }
289
+ if (!options.verbose) {
290
+ emitCompactActionCommands(lines, action, options.brief);
291
+ return;
292
+ }
293
+ if (action.application?.preview !== undefined && action.application.preview !== null) {
294
+ lines.push(` - preview action: ${code(action.application.preview.display, Number.POSITIVE_INFINITY)}`);
295
+ }
296
+ if (action.application?.apply !== undefined && action.application.apply !== null) {
297
+ lines.push(` - apply action: ${code(action.application.apply.display, Number.POSITIVE_INFINITY)}`);
298
+ }
299
+ if (action.dry_run !== null) {
300
+ lines.push(` - ${rawLabel(action, "preview")}: ${code(action.dry_run.display)}`);
301
+ }
302
+ if (action.apply !== null) {
303
+ lines.push(` - ${rawLabel(action, "apply")}: ${code(action.apply.display)}`);
304
+ }
305
+ }
306
+
307
+ function emitCompactActionListItem(lines, action) {
308
+ const blocked = action.blocked_reason === null ? "" : ` - blocked: ${safeText(action.blocked_reason)}`;
309
+ lines.push(`- ${safeText(action.label)} (${action.risk})${blocked}`);
310
+ }
311
+
312
+ function emitCompactActionCommands(lines, action, brief) {
313
+ let emittedCommand = false;
314
+ if (action.application?.preview !== undefined && action.application.preview !== null) {
315
+ lines.push(` - preview: ${code(compactApplyActionCommand(action, brief, "preview"))}`);
316
+ emittedCommand = true;
317
+ }
318
+ if (action.application?.apply !== undefined && action.application.apply !== null) {
319
+ lines.push(` - apply: ${code(compactApplyActionCommand(action, brief, "apply"))}`);
320
+ emittedCommand = true;
321
+ }
322
+ if (action.application?.blocked_reason !== undefined && action.application.blocked_reason !== null) {
323
+ lines.push(` - blocked: ${safeText(action.application.blocked_reason)}`);
324
+ }
325
+ if (!emittedCommand && action.dry_run !== null) {
326
+ lines.push(` - preview: ${code(action.dry_run.display, Number.POSITIVE_INFINITY)}`);
327
+ }
328
+ lines.push(` - details: ${code(`skillboard brief --verbose${workflowOption(brief)}`)}`);
329
+ }
330
+
331
+ function compactApplyActionCommand(action, brief, mode) {
332
+ const flag = mode === "preview" ? "--dry-run" : "--yes";
333
+ const destructive = mode === "apply" && action.kind === "reset-cleanup" ? " --allow-destructive" : "";
334
+ return `skillboard apply-action ${action.id}${workflowOption(brief)} ${flag}${destructive}`;
335
+ }
336
+
337
+ function rawLabel(action, label) {
338
+ return action.application?.preview === undefined ? label : `underlying ${label}`;
339
+ }
340
+
341
+ function workflowSummary(workflow) {
342
+ if (workflow.unknown) {
343
+ return `${workflow.selected} (unknown)`;
344
+ }
345
+ if (workflow.needs_selection) {
346
+ return `needs selection: ${workflow.candidates.join(", ")}`;
347
+ }
348
+ if (workflow.selected === null) {
349
+ return "none selected";
350
+ }
351
+ return `${workflow.selected}${workflow.defaulted ? " (defaulted)" : ""}`;
352
+ }
353
+
354
+ function safeText(value, maxLength = 180) {
355
+ const compact = String(value).replaceAll(/\s+/g, " ").trim();
356
+ const withoutTicks = compact.replaceAll("`", "'");
357
+ return withoutTicks.length > maxLength ? `${withoutTicks.slice(0, maxLength - 3)}...` : withoutTicks;
358
+ }
359
+
360
+ function code(value, maxLength = 180) {
361
+ return `\`${safeText(value, maxLength)}\``;
362
+ }
@@ -0,0 +1,169 @@
1
+ import YAML from "yaml";
2
+
3
+ const DEFAULT_SEMANTIC_LIMIT = 50;
4
+
5
+ export function textChangePlan(before, after) {
6
+ const beforeLines = before.split(/\r?\n/u);
7
+ const afterLines = after.split(/\r?\n/u);
8
+ const semantic = semanticChangePlan(before, after, { limit: DEFAULT_SEMANTIC_LIMIT });
9
+ return {
10
+ changed: before !== after,
11
+ beforeBytes: Buffer.byteLength(before),
12
+ afterBytes: Buffer.byteLength(after),
13
+ beforeLines: beforeLines.length,
14
+ afterLines: afterLines.length,
15
+ changedLineCount: countChangedLinePositions(beforeLines, afterLines),
16
+ semanticAvailable: semantic.available,
17
+ semanticError: semantic.error,
18
+ semanticChangeCount: semantic.changeCount,
19
+ semanticTruncated: semantic.truncated,
20
+ semanticChanges: semantic.changes
21
+ };
22
+ }
23
+
24
+ function countChangedLinePositions(beforeLines, afterLines) {
25
+ const length = Math.max(beforeLines.length, afterLines.length);
26
+ let count = 0;
27
+ for (let index = 0; index < length; index += 1) {
28
+ if (beforeLines[index] !== afterLines[index]) {
29
+ count += 1;
30
+ }
31
+ }
32
+ return count;
33
+ }
34
+
35
+ function semanticChangePlan(before, after, options) {
36
+ try {
37
+ const beforeValue = YAML.parse(before) ?? null;
38
+ const afterValue = YAML.parse(after) ?? null;
39
+ const state = { changes: [], total: 0, truncated: false, limit: options.limit };
40
+ collectSemanticChanges(beforeValue, afterValue, [], state);
41
+ return {
42
+ available: true,
43
+ error: null,
44
+ changeCount: state.total,
45
+ truncated: state.truncated,
46
+ changes: state.changes
47
+ };
48
+ } catch (error) {
49
+ return {
50
+ available: false,
51
+ error: error instanceof Error ? error.message : String(error),
52
+ changeCount: 0,
53
+ truncated: false,
54
+ changes: []
55
+ };
56
+ }
57
+ }
58
+
59
+ function collectSemanticChanges(beforeValue, afterValue, path, state) {
60
+ if (jsonEqual(beforeValue, afterValue)) {
61
+ return;
62
+ }
63
+ if (beforeValue === undefined) {
64
+ addSemanticChange(state, "added", path, "absent", valueSummary(afterValue));
65
+ return;
66
+ }
67
+ if (afterValue === undefined) {
68
+ addSemanticChange(state, "removed", path, valueSummary(beforeValue), "absent");
69
+ return;
70
+ }
71
+ if (isRecord(beforeValue) && isRecord(afterValue)) {
72
+ const keys = [...new Set([...Object.keys(beforeValue), ...Object.keys(afterValue)])].sort((left, right) => left.localeCompare(right));
73
+ for (const key of keys) {
74
+ collectSemanticChanges(beforeValue[key], afterValue[key], [...path, key], state);
75
+ }
76
+ return;
77
+ }
78
+ if (Array.isArray(beforeValue) && Array.isArray(afterValue)) {
79
+ collectArrayChanges(beforeValue, afterValue, path, state);
80
+ return;
81
+ }
82
+ addSemanticChange(state, "changed", path, valueSummary(beforeValue), valueSummary(afterValue));
83
+ }
84
+
85
+ function collectArrayChanges(beforeValue, afterValue, path, state) {
86
+ if (beforeValue.every(isScalar) && afterValue.every(isScalar)) {
87
+ const beforeCounts = countedScalars(beforeValue);
88
+ const afterCounts = countedScalars(afterValue);
89
+ const keys = [...new Set([...beforeCounts.keys(), ...afterCounts.keys()])].sort((left, right) => left.localeCompare(right));
90
+ let emitted = false;
91
+ for (const key of keys) {
92
+ const beforeCount = beforeCounts.get(key) ?? 0;
93
+ const afterCount = afterCounts.get(key) ?? 0;
94
+ const scalar = JSON.parse(key);
95
+ if (afterCount > beforeCount) {
96
+ addSemanticChange(state, "added", [...path, scalar], "absent", `scalar x${afterCount - beforeCount}`);
97
+ emitted = true;
98
+ } else if (beforeCount > afterCount) {
99
+ addSemanticChange(state, "removed", [...path, scalar], `scalar x${beforeCount - afterCount}`, "absent");
100
+ emitted = true;
101
+ }
102
+ }
103
+ if (emitted) {
104
+ return;
105
+ }
106
+ }
107
+ addSemanticChange(state, "changed", path, valueSummary(beforeValue), valueSummary(afterValue));
108
+ }
109
+
110
+ function addSemanticChange(state, type, path, before, after) {
111
+ state.total += 1;
112
+ if (state.changes.length >= state.limit) {
113
+ state.truncated = true;
114
+ return;
115
+ }
116
+ state.changes.push({
117
+ type,
118
+ path: jsonPointer(path),
119
+ before,
120
+ after
121
+ });
122
+ }
123
+
124
+ function countedScalars(values) {
125
+ const counts = new Map();
126
+ for (const value of values) {
127
+ const key = JSON.stringify(value);
128
+ counts.set(key, (counts.get(key) ?? 0) + 1);
129
+ }
130
+ return counts;
131
+ }
132
+
133
+ function valueSummary(value) {
134
+ if (value === undefined) {
135
+ return "absent";
136
+ }
137
+ if (value === null) {
138
+ return "null";
139
+ }
140
+ if (Array.isArray(value)) {
141
+ return `sequence(${value.length})`;
142
+ }
143
+ if (isRecord(value)) {
144
+ return `mapping(${Object.keys(value).length})`;
145
+ }
146
+ if (typeof value === "string") {
147
+ return value.length > 80 ? `string(${value.length})` : JSON.stringify(value);
148
+ }
149
+ return String(value);
150
+ }
151
+
152
+ function jsonPointer(path) {
153
+ if (path.length === 0) {
154
+ return "/";
155
+ }
156
+ return `/${path.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
157
+ }
158
+
159
+ function jsonEqual(left, right) {
160
+ return JSON.stringify(left) === JSON.stringify(right);
161
+ }
162
+
163
+ function isRecord(value) {
164
+ return value !== null && typeof value === "object" && !Array.isArray(value);
165
+ }
166
+
167
+ function isScalar(value) {
168
+ return value === null || ["string", "number", "boolean"].includes(typeof value);
169
+ }