@docsxai/engine 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.
Files changed (129) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +130 -0
  3. package/dist/auth/api-login.d.ts +69 -0
  4. package/dist/auth/api-login.js +95 -0
  5. package/dist/auth/browser-session.d.ts +28 -0
  6. package/dist/auth/browser-session.js +43 -0
  7. package/dist/auth/cookie-jar.d.ts +58 -0
  8. package/dist/auth/cookie-jar.js +212 -0
  9. package/dist/auth/email-otp.d.ts +210 -0
  10. package/dist/auth/email-otp.js +166 -0
  11. package/dist/auth/http-basic.d.ts +5 -0
  12. package/dist/auth/http-basic.js +17 -0
  13. package/dist/auth/index.d.ts +47 -0
  14. package/dist/auth/index.js +137 -0
  15. package/dist/auth/jwt-injection.d.ts +153 -0
  16. package/dist/auth/jwt-injection.js +136 -0
  17. package/dist/auth/manual-capture.d.ts +35 -0
  18. package/dist/auth/manual-capture.js +30 -0
  19. package/dist/auth/mtls.d.ts +15 -0
  20. package/dist/auth/mtls.js +53 -0
  21. package/dist/auth/pat-header.d.ts +19 -0
  22. package/dist/auth/pat-header.js +34 -0
  23. package/dist/auth/storage-state-cache.d.ts +38 -0
  24. package/dist/auth/storage-state-cache.js +143 -0
  25. package/dist/auth/test-backdoor.d.ts +25 -0
  26. package/dist/auth/test-backdoor.js +51 -0
  27. package/dist/auth/totp.d.ts +39 -0
  28. package/dist/auth/totp.js +108 -0
  29. package/dist/auth/types.d.ts +86 -0
  30. package/dist/auth/types.js +57 -0
  31. package/dist/auth/ui-form.d.ts +204 -0
  32. package/dist/auth/ui-form.js +153 -0
  33. package/dist/auth/webauthn.d.ts +88 -0
  34. package/dist/auth/webauthn.js +67 -0
  35. package/dist/auth.d.ts +1 -0
  36. package/dist/auth.js +3 -0
  37. package/dist/backend-client-contracts.d.ts +88 -0
  38. package/dist/backend-client-contracts.js +19 -0
  39. package/dist/backend-client-oauth-login.d.ts +7 -0
  40. package/dist/backend-client-oauth-login.js +90 -0
  41. package/dist/backend-client-state-cache.d.ts +73 -0
  42. package/dist/backend-client-state-cache.js +185 -0
  43. package/dist/backend-client-token.d.ts +18 -0
  44. package/dist/backend-client-token.js +94 -0
  45. package/dist/backend-client-transport.d.ts +66 -0
  46. package/dist/backend-client-transport.js +181 -0
  47. package/dist/backend-client.d.ts +5 -0
  48. package/dist/backend-client.js +18 -0
  49. package/dist/calibrate.d.ts +31 -0
  50. package/dist/calibrate.js +68 -0
  51. package/dist/cli-commands-authoring.d.ts +5 -0
  52. package/dist/cli-commands-authoring.js +403 -0
  53. package/dist/cli-commands-backend.d.ts +5 -0
  54. package/dist/cli-commands-backend.js +211 -0
  55. package/dist/cli-commands-docpack.d.ts +5 -0
  56. package/dist/cli-commands-docpack.js +280 -0
  57. package/dist/cli-commands-session.d.ts +4 -0
  58. package/dist/cli-commands-session.js +398 -0
  59. package/dist/cli-shared.d.ts +5 -0
  60. package/dist/cli-shared.js +45 -0
  61. package/dist/cli-usage.d.ts +1 -0
  62. package/dist/cli-usage.js +137 -0
  63. package/dist/cli.d.ts +2 -0
  64. package/dist/cli.js +77 -0
  65. package/dist/diagnose.d.ts +50 -0
  66. package/dist/diagnose.js +168 -0
  67. package/dist/diff-compute.d.ts +13 -0
  68. package/dist/diff-compute.js +378 -0
  69. package/dist/diff-report.d.ts +7 -0
  70. package/dist/diff-report.js +125 -0
  71. package/dist/diff-types.d.ts +125 -0
  72. package/dist/diff-types.js +15 -0
  73. package/dist/diff.d.ts +3 -0
  74. package/dist/diff.js +16 -0
  75. package/dist/doc-pack-io.d.ts +30 -0
  76. package/dist/doc-pack-io.js +182 -0
  77. package/dist/doc-pack.d.ts +1814 -0
  78. package/dist/doc-pack.js +328 -0
  79. package/dist/doctor-checks-plugins.d.ts +2 -0
  80. package/dist/doctor-checks-plugins.js +136 -0
  81. package/dist/doctor-checks.d.ts +56 -0
  82. package/dist/doctor-checks.js +367 -0
  83. package/dist/doctor.d.ts +7 -0
  84. package/dist/doctor.js +62 -0
  85. package/dist/export/adf.d.ts +57 -0
  86. package/dist/export/adf.js +323 -0
  87. package/dist/export/playwright-test.d.ts +26 -0
  88. package/dist/export/playwright-test.js +221 -0
  89. package/dist/flow-file.d.ts +21 -0
  90. package/dist/flow-file.js +180 -0
  91. package/dist/flow-lint.d.ts +24 -0
  92. package/dist/flow-lint.js +203 -0
  93. package/dist/flow-runtime.d.ts +113 -0
  94. package/dist/flow-runtime.js +273 -0
  95. package/dist/flow-tree.d.ts +19 -0
  96. package/dist/flow-tree.js +104 -0
  97. package/dist/index.d.ts +27 -0
  98. package/dist/index.js +31 -0
  99. package/dist/playwright-driver.d.ts +105 -0
  100. package/dist/playwright-driver.js +363 -0
  101. package/dist/playwright-instrumented-browser.d.ts +51 -0
  102. package/dist/playwright-instrumented-browser.js +189 -0
  103. package/dist/plugins/load.d.ts +22 -0
  104. package/dist/plugins/load.js +99 -0
  105. package/dist/plugins/lock.d.ts +40 -0
  106. package/dist/plugins/lock.js +122 -0
  107. package/dist/plugins/manifest.d.ts +70 -0
  108. package/dist/plugins/manifest.js +115 -0
  109. package/dist/plugins/plan.d.ts +51 -0
  110. package/dist/plugins/plan.js +279 -0
  111. package/dist/plugins/registry.d.ts +59 -0
  112. package/dist/plugins/registry.js +71 -0
  113. package/dist/plugins/runtime.d.ts +7 -0
  114. package/dist/plugins/runtime.js +27 -0
  115. package/dist/plugins/types.d.ts +58 -0
  116. package/dist/plugins/types.js +4 -0
  117. package/dist/plugins-cli.d.ts +1 -0
  118. package/dist/plugins-cli.js +191 -0
  119. package/dist/redact.d.ts +16 -0
  120. package/dist/redact.js +72 -0
  121. package/dist/style.d.ts +46 -0
  122. package/dist/style.js +151 -0
  123. package/dist/viewer-bin.d.ts +20 -0
  124. package/dist/viewer-bin.js +97 -0
  125. package/dist/workspace.d.ts +60 -0
  126. package/dist/workspace.js +172 -0
  127. package/dist/zip.d.ts +17 -0
  128. package/dist/zip.js +113 -0
  129. package/package.json +64 -0
@@ -0,0 +1,378 @@
1
+ // Doc-pack drift detection — the deterministic computation half.
2
+ //
3
+ // The engine DETECTS drift and reports it; proposing flow-file patches is the host agent's
4
+ // calibration-time job (`diagnose` feeds that loop). `docsxai baseline` snapshots a doc pack;
5
+ // `docsxai diff` compares the live workspace against it and emits this module's DriftReport —
6
+ // per flow: step deltas (id-keyed field changes), annotation moves (bounding-box delta beyond a
7
+ // pixel tolerance), screenshot pixel diffs (pngjs, exact RGBA compare, ignore-region aware),
8
+ // prose line-change counts, and locator changes. Reports carry no timestamps: same two doc packs
9
+ // → byte-identical report, which is what makes the report PR-comment- and CI-gate-safe.
10
+ import { promises as fs } from "node:fs";
11
+ import { PNG } from "pngjs";
12
+ import { AnnotationsFile } from "./doc-pack.js";
13
+ import { DriftError, maxSeverity, } from "./diff-types.js";
14
+ import { parseFlowFile } from "./flow-file.js";
15
+ import { resolveWorkspacePath } from "./workspace.js";
16
+ // ---------------------------------------------------------------------------
17
+ // PNG pixel diff
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Exact-RGBA pixel diff between two PNG buffers. Dimension mismatch is reported distinctly (no
21
+ * pixel comparison is meaningful across sizes). `ignoreRegions` rectangles are excluded from the
22
+ * comparison; `pct` is changed pixels over the FULL image area, rounded to 4 decimals.
23
+ */
24
+ export function diffPngBuffers(aPng, bPng, ignoreRegions = []) {
25
+ const a = PNG.sync.read(aPng);
26
+ const b = PNG.sync.read(bPng);
27
+ if (a.width !== b.width || a.height !== b.height) {
28
+ return {
29
+ kind: "dimension-change",
30
+ a: { width: a.width, height: a.height },
31
+ b: { width: b.width, height: b.height },
32
+ };
33
+ }
34
+ const ignored = (x, y) => ignoreRegions.some((r) => x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height);
35
+ let count = 0;
36
+ let minX = Infinity;
37
+ let minY = Infinity;
38
+ let maxX = -Infinity;
39
+ let maxY = -Infinity;
40
+ for (let y = 0; y < a.height; y++) {
41
+ for (let x = 0; x < a.width; x++) {
42
+ if (ignoreRegions.length > 0 && ignored(x, y))
43
+ continue;
44
+ const i = (y * a.width + x) * 4;
45
+ if (a.data[i] !== b.data[i] ||
46
+ a.data[i + 1] !== b.data[i + 1] ||
47
+ a.data[i + 2] !== b.data[i + 2] ||
48
+ a.data[i + 3] !== b.data[i + 3]) {
49
+ count++;
50
+ if (x < minX)
51
+ minX = x;
52
+ if (x > maxX)
53
+ maxX = x;
54
+ if (y < minY)
55
+ minY = y;
56
+ if (y > maxY)
57
+ maxY = y;
58
+ }
59
+ }
60
+ }
61
+ const pct = Math.round((count / (a.width * a.height)) * 100 * 10000) / 10000;
62
+ const region = count > 0 ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } : null;
63
+ return { kind: "pixels", changed_pixel_count: count, pct, region };
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Prose line diff (LCS — step write-ups are small)
67
+ // ---------------------------------------------------------------------------
68
+ function lineDiffCounts(aText, bText) {
69
+ const a = aText.split("\n");
70
+ const b = bText.split("\n");
71
+ const m = a.length;
72
+ const n = b.length;
73
+ const w = n + 1;
74
+ const dp = new Uint32Array((m + 1) * w);
75
+ for (let i = 1; i <= m; i++) {
76
+ for (let j = 1; j <= n; j++) {
77
+ dp[i * w + j] =
78
+ a[i - 1] === b[j - 1]
79
+ ? dp[(i - 1) * w + (j - 1)] + 1
80
+ : Math.max(dp[(i - 1) * w + j], dp[i * w + (j - 1)]);
81
+ }
82
+ }
83
+ const lcs = dp[m * w + n];
84
+ return { added: n - lcs, removed: m - lcs };
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // Pack reading
88
+ // ---------------------------------------------------------------------------
89
+ async function readIfExists(p) {
90
+ try {
91
+ return await fs.readFile(p);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ async function listDir(p) {
98
+ try {
99
+ return (await fs.readdir(p)).sort();
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ }
105
+ async function flowNames(dir) {
106
+ const entries = await listDir(resolveWorkspacePath(dir, "flows"));
107
+ return entries
108
+ .filter((e) => e.endsWith(".flow.yaml"))
109
+ .map((e) => e.slice(0, -".flow.yaml".length));
110
+ }
111
+ async function loadFlow(dir, name) {
112
+ const p = resolveWorkspacePath(dir, "flows", `${name}.flow.yaml`);
113
+ return parseFlowFile(await fs.readFile(p, "utf8"), p);
114
+ }
115
+ async function loadAnnotations(dir, flow) {
116
+ const raw = await readIfExists(resolveWorkspacePath(dir, "docs", flow, "annotations.json"));
117
+ if (raw === null)
118
+ return [];
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw.toString("utf8"));
122
+ }
123
+ catch (e) {
124
+ throw new DriftError(`docs/${flow}/annotations.json: not valid JSON — ${e.message}`);
125
+ }
126
+ const result = AnnotationsFile.safeParse(parsed);
127
+ if (!result.success) {
128
+ throw new DriftError(`docs/${flow}/annotations.json: invalid annotations file`);
129
+ }
130
+ return result.data.annotations;
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // Per-flow comparison
134
+ // ---------------------------------------------------------------------------
135
+ const STEP_FIELDS = [
136
+ "action",
137
+ "optional",
138
+ "target",
139
+ "value",
140
+ "wait_for",
141
+ "success",
142
+ "annotation",
143
+ "annotations",
144
+ "redactions",
145
+ ];
146
+ function jsonEqual(a, b) {
147
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
148
+ }
149
+ function diffSteps(a, b) {
150
+ const aById = new Map(a.steps.map((s) => [s.id, s]));
151
+ const bById = new Map(b.steps.map((s) => [s.id, s]));
152
+ const added = [...bById.keys()].filter((id) => !aById.has(id)).sort();
153
+ const removed = [...aById.keys()].filter((id) => !bById.has(id)).sort();
154
+ const changed = [];
155
+ // Walk in b's step order (the current pack) for a stable, reader-meaningful sequence.
156
+ for (const step of b.steps) {
157
+ const prior = aById.get(step.id);
158
+ if (!prior)
159
+ continue;
160
+ const fields = [];
161
+ for (const field of STEP_FIELDS) {
162
+ const av = prior[field];
163
+ const bv = step[field];
164
+ if (!jsonEqual(av, bv))
165
+ fields.push({ field, a: av ?? null, b: bv ?? null });
166
+ }
167
+ if (fields.length > 0)
168
+ changed.push({ id: step.id, fields });
169
+ }
170
+ return { added, removed, changed };
171
+ }
172
+ function diffLocators(a, b) {
173
+ const aLoc = a.locators;
174
+ const bLoc = b.locators;
175
+ const added = Object.keys(bLoc)
176
+ .filter((k) => !(k in aLoc))
177
+ .sort();
178
+ const removed = Object.keys(aLoc)
179
+ .filter((k) => !(k in bLoc))
180
+ .sort();
181
+ const changed = Object.keys(aLoc)
182
+ .filter((k) => k in bLoc && aLoc[k] !== bLoc[k])
183
+ .sort()
184
+ .map((name) => ({ name, a: aLoc[name], b: bLoc[name] }));
185
+ return { added, removed, changed };
186
+ }
187
+ function annotationKey(r) {
188
+ return `${r.step} ${r.index ?? 0}`;
189
+ }
190
+ function diffAnnotationPositions(a, b, tolerancePx) {
191
+ const aByKey = new Map(a.map((r) => [annotationKey(r), r]));
192
+ const moves = [];
193
+ for (const rec of b) {
194
+ const prior = aByKey.get(annotationKey(rec));
195
+ if (!prior?.bounding_box || !rec.bounding_box)
196
+ continue;
197
+ const pa = prior.bounding_box;
198
+ const pb = rec.bounding_box;
199
+ const delta = Math.max(Math.abs(pa.x - pb.x), Math.abs(pa.y - pb.y), Math.abs(pa.width - pb.width), Math.abs(pa.height - pb.height));
200
+ if (delta > tolerancePx) {
201
+ moves.push({ step: rec.step, copy: rec.copy, a: pa, b: pb, delta_px: delta });
202
+ }
203
+ }
204
+ return moves;
205
+ }
206
+ async function diffScreenshots(aDir, bDir, flow, policy, ignoreRegions) {
207
+ const aShots = (await listDir(resolveWorkspacePath(aDir, "docs", flow, "screenshots"))).filter((f) => f.endsWith(".png"));
208
+ const bShots = (await listDir(resolveWorkspacePath(bDir, "docs", flow, "screenshots"))).filter((f) => f.endsWith(".png"));
209
+ const names = [...new Set([...aShots, ...bShots])].sort();
210
+ const out = [];
211
+ for (const name of names) {
212
+ const step = name.slice(0, -".png".length);
213
+ const inA = aShots.includes(name);
214
+ const inB = bShots.includes(name);
215
+ if (!inA || !inB) {
216
+ out.push({ step, status: inB ? "added" : "removed", severity: "warn" });
217
+ continue;
218
+ }
219
+ const aPng = await fs.readFile(resolveWorkspacePath(aDir, "docs", flow, "screenshots", name));
220
+ const bPng = await fs.readFile(resolveWorkspacePath(bDir, "docs", flow, "screenshots", name));
221
+ const regions = ignoreRegions
222
+ .filter((r) => r.flow === flow && r.step === step)
223
+ .map((r) => r.region);
224
+ const diff = diffPngBuffers(aPng, bPng, regions);
225
+ if (diff.kind === "dimension-change") {
226
+ out.push({
227
+ step,
228
+ status: "changed",
229
+ dimension_change: { a: diff.a, b: diff.b },
230
+ severity: "fail",
231
+ });
232
+ continue;
233
+ }
234
+ if (diff.changed_pixel_count === 0)
235
+ continue;
236
+ const severity = diff.pct >= policy.screenshot_pct_fail
237
+ ? "fail"
238
+ : diff.pct >= policy.screenshot_pct_warn
239
+ ? "warn"
240
+ : "info";
241
+ out.push({
242
+ step,
243
+ status: "changed",
244
+ changed_pixel_count: diff.changed_pixel_count,
245
+ pct: diff.pct,
246
+ ...(diff.region ? { region: diff.region } : {}),
247
+ severity,
248
+ });
249
+ }
250
+ return out;
251
+ }
252
+ async function diffProse(aDir, bDir, flow) {
253
+ const isStepMd = (f) => f.endsWith(".md");
254
+ const aMds = (await listDir(resolveWorkspacePath(aDir, "docs", flow))).filter(isStepMd);
255
+ const bMds = (await listDir(resolveWorkspacePath(bDir, "docs", flow))).filter(isStepMd);
256
+ const names = [...new Set([...aMds, ...bMds])].sort();
257
+ const out = [];
258
+ for (const name of names) {
259
+ const step = name.slice(0, -".md".length);
260
+ const aText = await readIfExists(resolveWorkspacePath(aDir, "docs", flow, name));
261
+ const bText = await readIfExists(resolveWorkspacePath(bDir, "docs", flow, name));
262
+ if (aText === null || bText === null) {
263
+ const text = (aText ?? bText).toString("utf8");
264
+ const lines = text === "" ? 0 : text.split("\n").length;
265
+ out.push({
266
+ step,
267
+ status: bText !== null ? "added" : "removed",
268
+ lines_added: bText !== null ? lines : 0,
269
+ lines_removed: aText !== null ? lines : 0,
270
+ });
271
+ continue;
272
+ }
273
+ if (aText.equals(bText))
274
+ continue;
275
+ const { added, removed } = lineDiffCounts(aText.toString("utf8"), bText.toString("utf8"));
276
+ out.push({ step, status: "changed", lines_added: added, lines_removed: removed });
277
+ }
278
+ return out;
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // diffDocPacks
282
+ // ---------------------------------------------------------------------------
283
+ /**
284
+ * Diff two doc-pack directories (each a workspace-shaped tree: `flows/` + `docs/`). `aDir` is the
285
+ * baseline ("before"), `bDir` the candidate ("after"). Pure file → JSON transform; deterministic
286
+ * (no timestamps); never writes.
287
+ */
288
+ export async function diffDocPacks(aDir, bDir, options = {}) {
289
+ const policy = {
290
+ screenshot_pct_warn: options.screenshot_pct_warn ?? 1,
291
+ screenshot_pct_fail: options.screenshot_pct_fail ?? 5,
292
+ };
293
+ const tolerance = options.annotation_move_tolerance_px ?? 2;
294
+ const ignoreRegions = options.ignore_regions ?? [];
295
+ const aFlows = await flowNames(aDir);
296
+ const bFlows = await flowNames(bDir);
297
+ const names = [...new Set([...aFlows, ...bFlows])].sort();
298
+ const flows = [];
299
+ for (const name of names) {
300
+ const inA = aFlows.includes(name);
301
+ const inB = bFlows.includes(name);
302
+ const empty = {
303
+ steps_added: [],
304
+ steps_removed: [],
305
+ steps_changed: [],
306
+ annotations_moved: [],
307
+ screenshots: [],
308
+ prose: [],
309
+ locators_added: [],
310
+ locators_removed: [],
311
+ locators_changed: [],
312
+ };
313
+ if (!inA || !inB) {
314
+ flows.push({ flow: name, status: inB ? "added" : "removed", ...empty, severity: "warn" });
315
+ continue;
316
+ }
317
+ const aFlow = await loadFlow(aDir, name);
318
+ const bFlow = await loadFlow(bDir, name);
319
+ const steps = diffSteps(aFlow, bFlow);
320
+ const locators = diffLocators(aFlow, bFlow);
321
+ const annotationsMoved = diffAnnotationPositions(await loadAnnotations(aDir, name), await loadAnnotations(bDir, name), tolerance);
322
+ const screenshots = await diffScreenshots(aDir, bDir, name, policy, ignoreRegions);
323
+ const prose = await diffProse(aDir, bDir, name);
324
+ const structural = steps.added.length +
325
+ steps.removed.length +
326
+ steps.changed.length +
327
+ locators.added.length +
328
+ locators.removed.length +
329
+ locators.changed.length +
330
+ annotationsMoved.length +
331
+ prose.length >
332
+ 0;
333
+ let severity = structural ? "warn" : "none";
334
+ for (const s of screenshots)
335
+ severity = maxSeverity(severity, s.severity);
336
+ if (severity === "none")
337
+ continue; // no drift in this flow
338
+ flows.push({
339
+ flow: name,
340
+ status: "changed",
341
+ steps_added: steps.added,
342
+ steps_removed: steps.removed,
343
+ steps_changed: steps.changed,
344
+ annotations_moved: annotationsMoved,
345
+ screenshots,
346
+ prose,
347
+ locators_added: locators.added,
348
+ locators_removed: locators.removed,
349
+ locators_changed: locators.changed,
350
+ severity: severity,
351
+ });
352
+ }
353
+ let summarySeverity = "none";
354
+ let stepsChanged = 0;
355
+ let screenshotsChanged = 0;
356
+ let maxPct = 0;
357
+ for (const f of flows) {
358
+ summarySeverity = maxSeverity(summarySeverity, f.severity);
359
+ stepsChanged += f.steps_added.length + f.steps_removed.length + f.steps_changed.length;
360
+ screenshotsChanged += f.screenshots.length;
361
+ for (const s of f.screenshots)
362
+ if (s.pct !== undefined && s.pct > maxPct)
363
+ maxPct = s.pct;
364
+ }
365
+ return {
366
+ schema: "docsxai/drift@1",
367
+ a: aDir,
368
+ b: bDir,
369
+ flows,
370
+ summary: {
371
+ flows_changed: flows.length,
372
+ steps_changed: stepsChanged,
373
+ screenshots_changed: screenshotsChanged,
374
+ max_pixel_change_pct: maxPct,
375
+ severity: summarySeverity,
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,7 @@
1
+ import { type DriftReport, type DriftSeverity } from "./diff-types.js";
2
+ /** True when `severity` is at or above `threshold` (the `--fail-on` gate). */
3
+ export declare function severityAtLeast(severity: DriftSeverity, threshold: DriftSeverity): boolean;
4
+ /** PR-comment-ready markdown rendering of a {@link DriftReport}. */
5
+ export declare function formatDriftReportMarkdown(report: DriftReport): string;
6
+ /** Plain-text rendering of a {@link DriftReport} (the CLI's default `--format text`). */
7
+ export declare function formatDriftReportText(report: DriftReport): string;
@@ -0,0 +1,125 @@
1
+ // Doc-pack drift detection — severity ranking and the report formatters.
2
+ //
3
+ // These render the deterministic DriftReport produced by `diffDocPacks` into the surfaces the host
4
+ // surfaces it on: markdown for PR comments, plain text for the CLI. Like the report itself, the
5
+ // formatters carry no timestamps — same report → byte-identical output — so the renderings stay
6
+ // PR-comment- and CI-gate-safe.
7
+ import { SEVERITY_RANK, } from "./diff-types.js";
8
+ /** True when `severity` is at or above `threshold` (the `--fail-on` gate). */
9
+ export function severityAtLeast(severity, threshold) {
10
+ return SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
11
+ }
12
+ // ---------------------------------------------------------------------------
13
+ // Formatters
14
+ // ---------------------------------------------------------------------------
15
+ const MARKER = {
16
+ none: "OK",
17
+ info: "[INFO]",
18
+ warn: "[WARN]",
19
+ fail: "[FAIL]",
20
+ };
21
+ function fmtRegion(r) {
22
+ return `(x ${r.x}, y ${r.y}, ${r.width}×${r.height})`;
23
+ }
24
+ function fmtValue(v) {
25
+ return v === null || v === undefined ? "∅" : JSON.stringify(v);
26
+ }
27
+ function flowDetailLines(f) {
28
+ const lines = [];
29
+ if (f.steps_added.length > 0)
30
+ lines.push(`- steps added: ${f.steps_added.map((s) => `\`${s}\``).join(", ")}`);
31
+ if (f.steps_removed.length > 0)
32
+ lines.push(`- steps removed: ${f.steps_removed.map((s) => `\`${s}\``).join(", ")}`);
33
+ for (const c of f.steps_changed) {
34
+ const deltas = c.fields
35
+ .map((d) => `${d.field}: ${fmtValue(d.a)} → ${fmtValue(d.b)}`)
36
+ .join("; ");
37
+ lines.push(`- step \`${c.id}\` changed: ${deltas}`);
38
+ }
39
+ for (const m of f.annotations_moved) {
40
+ lines.push(`- annotation moved on \`${m.step}\` (Δ ${m.delta_px}px): ` +
41
+ `(${m.a.x},${m.a.y} ${m.a.width}×${m.a.height}) → ` +
42
+ `(${m.b.x},${m.b.y} ${m.b.width}×${m.b.height})`);
43
+ }
44
+ for (const s of f.screenshots) {
45
+ if (s.status !== "changed") {
46
+ lines.push(`- screenshot \`${s.step}.png\` ${MARKER[s.severity]}: ${s.status}`);
47
+ }
48
+ else if (s.dimension_change) {
49
+ const d = s.dimension_change;
50
+ lines.push(`- screenshot \`${s.step}.png\` ${MARKER[s.severity]}: dimensions ` +
51
+ `${d.a.width}×${d.a.height} → ${d.b.width}×${d.b.height}`);
52
+ }
53
+ else {
54
+ lines.push(`- screenshot \`${s.step}.png\` ${MARKER[s.severity]}: ${s.changed_pixel_count} px ` +
55
+ `(${s.pct}%) changed${s.region ? ` in region ${fmtRegion(s.region)}` : ""}`);
56
+ }
57
+ }
58
+ if (f.locators_added.length > 0)
59
+ lines.push(`- locators added: ${f.locators_added.map((l) => `\`${l}\``).join(", ")}`);
60
+ if (f.locators_removed.length > 0)
61
+ lines.push(`- locators removed: ${f.locators_removed.map((l) => `\`${l}\``).join(", ")}`);
62
+ for (const l of f.locators_changed) {
63
+ lines.push(`- locator \`${l.name}\` changed: \`${l.a}\` → \`${l.b}\``);
64
+ }
65
+ for (const p of f.prose) {
66
+ lines.push(p.status === "changed"
67
+ ? `- prose \`${p.step}.md\`: +${p.lines_added} / -${p.lines_removed} lines`
68
+ : `- prose \`${p.step}.md\`: ${p.status}`);
69
+ }
70
+ return lines;
71
+ }
72
+ /** PR-comment-ready markdown rendering of a {@link DriftReport}. */
73
+ export function formatDriftReportMarkdown(report) {
74
+ const lines = ["# docsxai drift report", ""];
75
+ lines.push(`\`${report.a}\` → \`${report.b}\``, "");
76
+ if (report.flows.length === 0) {
77
+ lines.push("No drift detected.", "");
78
+ return lines.join("\n");
79
+ }
80
+ lines.push("| Flow | Severity | Steps Δ | Annotations | Screenshots Δ | Locators Δ | Prose Δ |", "| --- | --- | --- | --- | --- | --- | --- |");
81
+ for (const f of report.flows) {
82
+ if (f.status !== "changed") {
83
+ lines.push(`| \`${f.flow}\` | ${MARKER[f.severity]} | flow ${f.status} | — | — | — | — |`);
84
+ continue;
85
+ }
86
+ const locatorCount = f.locators_added.length + f.locators_removed.length + f.locators_changed.length;
87
+ lines.push(`| \`${f.flow}\` | ${MARKER[f.severity]} ` +
88
+ `| +${f.steps_added.length} / -${f.steps_removed.length} / ~${f.steps_changed.length} ` +
89
+ `| ${f.annotations_moved.length} moved | ${f.screenshots.length} | ${locatorCount} | ${f.prose.length} |`);
90
+ }
91
+ lines.push("");
92
+ for (const f of report.flows) {
93
+ lines.push(`## \`${f.flow}\` ${MARKER[f.severity]}`, "");
94
+ if (f.status !== "changed") {
95
+ lines.push(`- flow ${f.status}`, "");
96
+ continue;
97
+ }
98
+ lines.push(...flowDetailLines(f), "");
99
+ }
100
+ const s = report.summary;
101
+ lines.push(`**Totals:** ${s.flows_changed} flow${s.flows_changed === 1 ? "" : "s"} changed · ` +
102
+ `${s.steps_changed} step${s.steps_changed === 1 ? "" : "s"} · ` +
103
+ `${s.screenshots_changed} screenshot${s.screenshots_changed === 1 ? "" : "s"} · ` +
104
+ `max pixel change ${s.max_pixel_change_pct}% · severity ${s.severity}`, "");
105
+ return lines.join("\n");
106
+ }
107
+ /** Plain-text rendering of a {@link DriftReport} (the CLI's default `--format text`). */
108
+ export function formatDriftReportText(report) {
109
+ const lines = [`drift: ${report.a} → ${report.b}`];
110
+ if (report.flows.length === 0) {
111
+ lines.push("no drift detected");
112
+ return lines.join("\n") + "\n";
113
+ }
114
+ for (const f of report.flows) {
115
+ lines.push(`flow ${f.flow} ${MARKER[f.severity]}${f.status !== "changed" ? ` (${f.status})` : ""}`);
116
+ if (f.status === "changed") {
117
+ for (const l of flowDetailLines(f))
118
+ lines.push(` ${l.replace(/`/g, "")}`);
119
+ }
120
+ }
121
+ const s = report.summary;
122
+ lines.push(`totals: ${s.flows_changed} flows changed, ${s.steps_changed} steps, ` +
123
+ `${s.screenshots_changed} screenshots, max pixel change ${s.max_pixel_change_pct}%, severity ${s.severity}`);
124
+ return lines.join("\n") + "\n";
125
+ }
@@ -0,0 +1,125 @@
1
+ import type { BoundingBox } from "./doc-pack.js";
2
+ export type DriftSeverity = "none" | "info" | "warn" | "fail";
3
+ export declare const SEVERITY_RANK: Record<DriftSeverity, number>;
4
+ export declare function maxSeverity(a: DriftSeverity, b: DriftSeverity): DriftSeverity;
5
+ export interface DriftRegion {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+ /** A rectangle excluded from a specific screenshot's pixel diff (a clock widget, an ad slot, …). */
12
+ export interface IgnoreRegion {
13
+ flow: string;
14
+ step: string;
15
+ region: DriftRegion;
16
+ }
17
+ export interface DriftPolicy {
18
+ /** Pixel-change percentage at/above which a screenshot diff is `warn`. Default 1. */
19
+ screenshot_pct_warn?: number;
20
+ /** Pixel-change percentage at/above which a screenshot diff is `fail`. Default 5. */
21
+ screenshot_pct_fail?: number;
22
+ /** Annotation bounding-box delta (px, max over x/y/width/height) tolerated before "moved". Default 2. */
23
+ annotation_move_tolerance_px?: number;
24
+ /** Regions excluded from the pixel diff of the named flow/step screenshots. */
25
+ ignore_regions?: IgnoreRegion[];
26
+ }
27
+ export interface FieldDelta {
28
+ field: string;
29
+ a: unknown;
30
+ b: unknown;
31
+ }
32
+ export interface StepChange {
33
+ id: string;
34
+ fields: FieldDelta[];
35
+ }
36
+ export interface AnnotationMove {
37
+ step: string;
38
+ copy: string;
39
+ a: BoundingBox;
40
+ b: BoundingBox;
41
+ /** Max absolute delta across x / y / width / height, in image pixels. */
42
+ delta_px: number;
43
+ }
44
+ export interface DimensionChange {
45
+ a: {
46
+ width: number;
47
+ height: number;
48
+ };
49
+ b: {
50
+ width: number;
51
+ height: number;
52
+ };
53
+ }
54
+ export interface ScreenshotDiff {
55
+ step: string;
56
+ status: "added" | "removed" | "changed";
57
+ /** Set (instead of pixel counts) when the two PNGs have different dimensions. */
58
+ dimension_change?: DimensionChange;
59
+ changed_pixel_count?: number;
60
+ /** Changed pixels as a percentage of the full image, rounded to 4 decimals. */
61
+ pct?: number;
62
+ /** Bounding box of all changed pixels; absent when nothing comparable changed. */
63
+ region?: DriftRegion;
64
+ severity: Exclude<DriftSeverity, "none">;
65
+ }
66
+ export interface ProseDiff {
67
+ step: string;
68
+ status: "added" | "removed" | "changed";
69
+ lines_added: number;
70
+ lines_removed: number;
71
+ }
72
+ export interface LocatorChange {
73
+ name: string;
74
+ a: string;
75
+ b: string;
76
+ }
77
+ export interface FlowDrift {
78
+ flow: string;
79
+ status: "added" | "removed" | "changed";
80
+ steps_added: string[];
81
+ steps_removed: string[];
82
+ steps_changed: StepChange[];
83
+ annotations_moved: AnnotationMove[];
84
+ screenshots: ScreenshotDiff[];
85
+ prose: ProseDiff[];
86
+ locators_added: string[];
87
+ locators_removed: string[];
88
+ locators_changed: LocatorChange[];
89
+ severity: Exclude<DriftSeverity, "none">;
90
+ }
91
+ export interface DriftSummary {
92
+ flows_changed: number;
93
+ /** Steps added + removed + field-changed, across all flows. */
94
+ steps_changed: number;
95
+ screenshots_changed: number;
96
+ max_pixel_change_pct: number;
97
+ severity: DriftSeverity;
98
+ }
99
+ export interface DriftReport {
100
+ schema: "docsxai/drift@1";
101
+ a: string;
102
+ b: string;
103
+ /** Only flows with drift appear; sorted by flow name. */
104
+ flows: FlowDrift[];
105
+ summary: DriftSummary;
106
+ }
107
+ export declare class DriftError extends Error {
108
+ constructor(message: string);
109
+ }
110
+ export type PngDiffResult = {
111
+ kind: "dimension-change";
112
+ a: {
113
+ width: number;
114
+ height: number;
115
+ };
116
+ b: {
117
+ width: number;
118
+ height: number;
119
+ };
120
+ } | {
121
+ kind: "pixels";
122
+ changed_pixel_count: number;
123
+ pct: number;
124
+ region: DriftRegion | null;
125
+ };
@@ -0,0 +1,15 @@
1
+ // Doc-pack drift report shapes — the result interfaces, severity enum, and the severity-rank
2
+ // leaf both the computation and the formatters lean on.
3
+ //
4
+ // Reports carry no timestamps: same two doc packs → byte-identical report, which is what makes
5
+ // the report PR-comment- and CI-gate-safe. These shapes are the wire contract for that report.
6
+ export const SEVERITY_RANK = { none: 0, info: 1, warn: 2, fail: 3 };
7
+ export function maxSeverity(a, b) {
8
+ return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
9
+ }
10
+ export class DriftError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "DriftError";
14
+ }
15
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { type AnnotationMove, type DimensionChange, DriftError, type DriftPolicy, type DriftRegion, type DriftReport, type DriftSeverity, type DriftSummary, type FieldDelta, type FlowDrift, type IgnoreRegion, type LocatorChange, type PngDiffResult, type ProseDiff, type ScreenshotDiff, type StepChange, } from "./diff-types.js";
2
+ export { diffDocPacks, diffPngBuffers } from "./diff-compute.js";
3
+ export { formatDriftReportMarkdown, formatDriftReportText, severityAtLeast, } from "./diff-report.js";
package/dist/diff.js ADDED
@@ -0,0 +1,16 @@
1
+ // Doc-pack drift detection — deterministic diff between two doc-pack directories.
2
+ //
3
+ // The engine DETECTS drift and reports it; proposing flow-file patches is the host agent's
4
+ // calibration-time job (`diagnose` feeds that loop). `docsxai baseline` snapshots a doc pack;
5
+ // `docsxai diff` compares the live workspace against it and emits this module's DriftReport —
6
+ // per flow: step deltas (id-keyed field changes), annotation moves (bounding-box delta beyond a
7
+ // pixel tolerance), screenshot pixel diffs (pngjs, exact RGBA compare, ignore-region aware),
8
+ // prose line-change counts, and locator changes. Reports carry no timestamps: same two doc packs
9
+ // → byte-identical report, which is what makes the report PR-comment- and CI-gate-safe.
10
+ //
11
+ // This module is a barrel: the report shapes live in `diff-types.ts`, the deterministic
12
+ // computation in `diff-compute.ts`, the severity gate + formatters in `diff-report.ts`. Importers
13
+ // keep using `./diff.js` — the public surface is re-exported here verbatim.
14
+ export { DriftError, } from "./diff-types.js";
15
+ export { diffDocPacks, diffPngBuffers } from "./diff-compute.js";
16
+ export { formatDriftReportMarkdown, formatDriftReportText, severityAtLeast, } from "./diff-report.js";