@alloy-js/core 0.23.0-dev.15 → 0.23.0-dev.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alloy-js/core",
3
- "version": "0.23.0-dev.15",
3
+ "version": "0.23.0-dev.17",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,7 +66,7 @@
66
66
  "ws": "^8.19.0"
67
67
  },
68
68
  "devDependencies": {
69
- "@alloy-js/cli": "~0.22.0 || >= 0.23.0-dev.5",
69
+ "@alloy-js/cli": "~0.22.0 || >= 0.23.0-dev.6",
70
70
  "@alloy-js/rollup-plugin": "~0.1.0 || >= 0.1.1-dev.2",
71
71
  "@microsoft/api-extractor": "~7.52.8",
72
72
  "@rollup/plugin-typescript": "^12.1.2",
@@ -0,0 +1,98 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, expect, it } from "vitest";
5
+ import { DiagnosticsCollector } from "../diagnostics.js";
6
+ import { closeTrace, initTrace, setChangeListener } from "./trace-writer.js";
7
+
8
+ let tmpDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tmpDir = mkdtempSync(join(tmpdir(), "alloy-diag-test-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ setChangeListener(null);
16
+ closeTrace();
17
+ rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ it("does not call insertDiagnostic when tracing is disabled", () => {
21
+ let events = 0;
22
+ setChangeListener(() => {
23
+ events++;
24
+ });
25
+
26
+ const collector = new DiagnosticsCollector();
27
+ for (let i = 0; i < 100; i++) {
28
+ collector.emit({ message: `diag ${i}`, severity: "warning" });
29
+ }
30
+
31
+ // No trace DB => no change events should fire.
32
+ expect(events).toBe(0);
33
+ expect(collector.getDiagnostics()).toHaveLength(100);
34
+ });
35
+
36
+ it("emits one added event per diagnostic (linear, not quadratic)", async () => {
37
+ await initTrace(join(tmpDir, "trace.db"));
38
+ const events: Array<{ channel: string; action: string }> = [];
39
+ setChangeListener((event) => {
40
+ events.push({ channel: event.channel, action: event.action });
41
+ });
42
+
43
+ const collector = new DiagnosticsCollector();
44
+ const N = 50;
45
+ for (let i = 0; i < N; i++) {
46
+ collector.emit({ message: `diag ${i}`, severity: "warning" });
47
+ }
48
+
49
+ const added = events.filter(
50
+ (e) => e.channel === "diagnostics" && e.action === "added",
51
+ );
52
+ expect(added).toHaveLength(N); // was previously N*(N+1)/2 due to re-broadcast
53
+ });
54
+
55
+ it("emits one removed event per dismissal", async () => {
56
+ await initTrace(join(tmpDir, "trace.db"));
57
+ const events: Array<{ channel: string; action: string }> = [];
58
+ setChangeListener((event) => {
59
+ events.push({ channel: event.channel, action: event.action });
60
+ });
61
+
62
+ const collector = new DiagnosticsCollector();
63
+ const handles = [];
64
+ for (let i = 0; i < 10; i++) {
65
+ handles.push(collector.emit({ message: `diag ${i}`, severity: "warning" }));
66
+ }
67
+
68
+ for (const h of handles) h.dismiss();
69
+
70
+ const added = events.filter(
71
+ (e) => e.channel === "diagnostics" && e.action === "added",
72
+ );
73
+ const removed = events.filter(
74
+ (e) => e.channel === "diagnostics" && e.action === "removed",
75
+ );
76
+ expect(added).toHaveLength(10);
77
+ expect(removed).toHaveLength(10);
78
+ expect(collector.getDiagnostics()).toHaveLength(0);
79
+ });
80
+
81
+ it("dismiss is idempotent and only fires one removal", async () => {
82
+ await initTrace(join(tmpDir, "trace.db"));
83
+ const events: Array<{ channel: string; action: string }> = [];
84
+ setChangeListener((event) => {
85
+ events.push({ channel: event.channel, action: event.action });
86
+ });
87
+
88
+ const collector = new DiagnosticsCollector();
89
+ const h = collector.emit({ message: "once", severity: "warning" });
90
+ h.dismiss();
91
+ h.dismiss();
92
+ h.dismiss();
93
+
94
+ const removed = events.filter(
95
+ (e) => e.channel === "diagnostics" && e.action === "removed",
96
+ );
97
+ expect(removed).toHaveLength(1);
98
+ });
@@ -801,6 +801,8 @@ export function insertRenderError(
801
801
  });
802
802
  }
803
803
 
804
+ let stmtDeleteDiagnostic: StatementSync | undefined;
805
+
804
806
  export function insertDiagnostic(
805
807
  message: string,
806
808
  severity: string | undefined,
@@ -808,10 +810,10 @@ export function insertDiagnostic(
808
810
  sourceLine: number | undefined,
809
811
  sourceCol: number | undefined,
810
812
  componentStack: string | undefined,
811
- ): void {
812
- if (!db) return;
813
+ ): number | undefined {
814
+ if (!db) return undefined;
813
815
  const s = nextSeq();
814
- stmtInsertDiagnostic.run(
816
+ const info = stmtInsertDiagnostic.run(
815
817
  message,
816
818
  severity ?? null,
817
819
  sourceFile ?? null,
@@ -820,7 +822,9 @@ export function insertDiagnostic(
820
822
  componentStack ?? null,
821
823
  s,
822
824
  );
825
+ const id = Number(info.lastInsertRowid);
823
826
  notifyChange("diagnostics", "added", {
827
+ id,
824
828
  message,
825
829
  severity: severity ?? null,
826
830
  source_file: sourceFile ?? null,
@@ -829,6 +833,16 @@ export function insertDiagnostic(
829
833
  component_stack: componentStack ?? null,
830
834
  seq: s,
831
835
  });
836
+ return id;
837
+ }
838
+
839
+ export function deleteDiagnostic(id: number): void {
840
+ if (!db) return;
841
+ if (!stmtDeleteDiagnostic) {
842
+ stmtDeleteDiagnostic = db.prepare(`DELETE FROM diagnostics WHERE id = ?`);
843
+ }
844
+ stmtDeleteDiagnostic.run(id);
845
+ notifyChange("diagnostics", "removed", { id, seq: nextSeq() });
832
846
  }
833
847
 
834
848
  /**
@@ -946,6 +960,7 @@ export function commitTransaction(): void {
946
960
  export function closeTrace(): void {
947
961
  db?.close();
948
962
  db = null;
963
+ stmtDeleteDiagnostic = undefined;
949
964
  }
950
965
 
951
966
  export function resetTrace(): void {
@@ -1,5 +1,9 @@
1
1
  import { getRenderNodeId } from "./debug/index.js";
2
- import { insertDiagnostic } from "./debug/trace-writer.js";
2
+ import {
3
+ deleteDiagnostic,
4
+ insertDiagnostic,
5
+ isTraceEnabled,
6
+ } from "./debug/trace-writer.js";
3
7
  import { getContext } from "./reactivity.js";
4
8
  import { getRenderStackSnapshot } from "./render-stack.js";
5
9
  import type { SourceLocation } from "./runtime/component.js";
@@ -31,22 +35,31 @@ export interface DiagnosticHandle {
31
35
  dismiss(): void;
32
36
  }
33
37
 
38
+ function buildComponentStack(): DiagnosticStackEntry[] {
39
+ return getRenderStackSnapshot().map((entry) => ({
40
+ name: entry.displayName,
41
+ renderNodeId:
42
+ entry.context?.meta?.renderNode ?
43
+ getRenderNodeId(entry.context.meta.renderNode)
44
+ : undefined,
45
+ source: entry.source,
46
+ }));
47
+ }
48
+
34
49
  export class DiagnosticsCollector {
35
50
  private entries = new Map<string, Diagnostic>();
36
51
  private order: string[] = [];
52
+ private traceRowIds = new Map<string, number>();
37
53
 
38
54
  emit(input: DiagnosticInput): DiagnosticHandle {
55
+ const traceEnabled = isTraceEnabled();
56
+ // Component stack is only required when a trace consumer will read it,
57
+ // or when the caller explicitly provided one. Building it walks the full
58
+ // render stack, which is expensive on hot paths.
39
59
  const componentStack =
40
60
  input.componentStack ??
41
- getRenderStackSnapshot().map((entry) => ({
42
- name: entry.displayName,
43
- renderNodeId:
44
- entry.context?.meta?.renderNode ?
45
- getRenderNodeId(entry.context.meta.renderNode)
46
- : undefined,
47
- source: entry.source,
48
- }));
49
- const source = input.source ?? componentStack.at(-1)?.source ?? undefined;
61
+ (traceEnabled ? buildComponentStack() : undefined);
62
+ const source = input.source ?? componentStack?.at(-1)?.source ?? undefined;
50
63
  const id = `diag-${this.order.length + 1}-${Date.now()}`;
51
64
  const diagnostic: Diagnostic = {
52
65
  id,
@@ -58,14 +71,30 @@ export class DiagnosticsCollector {
58
71
  this.entries.set(id, diagnostic);
59
72
  this.order.push(id);
60
73
 
61
- // Broadcast updated diagnostics
62
- this.broadcast();
74
+ if (traceEnabled) {
75
+ const rowId = insertDiagnostic(
76
+ diagnostic.message,
77
+ diagnostic.severity,
78
+ diagnostic.source?.fileName,
79
+ diagnostic.source?.lineNumber,
80
+ diagnostic.source?.columnNumber,
81
+ diagnostic.componentStack ?
82
+ JSON.stringify(diagnostic.componentStack)
83
+ : undefined,
84
+ );
85
+ if (rowId !== undefined) {
86
+ this.traceRowIds.set(id, rowId);
87
+ }
88
+ }
63
89
 
64
90
  return {
65
91
  dismiss: () => {
66
- this.entries.delete(id);
67
- // Broadcast updated diagnostics after dismissal
68
- this.broadcast();
92
+ if (!this.entries.delete(id)) return;
93
+ const rowId = this.traceRowIds.get(id);
94
+ if (rowId !== undefined) {
95
+ this.traceRowIds.delete(id);
96
+ if (isTraceEnabled()) deleteDiagnostic(rowId);
97
+ }
69
98
  },
70
99
  };
71
100
  }
@@ -75,22 +104,6 @@ export class DiagnosticsCollector {
75
104
  .map((id) => this.entries.get(id))
76
105
  .filter((entry): entry is Diagnostic => Boolean(entry));
77
106
  }
78
-
79
- private broadcast() {
80
- const diagnostics = this.getDiagnostics();
81
- for (const diagnostic of diagnostics) {
82
- insertDiagnostic(
83
- diagnostic.message,
84
- diagnostic.severity,
85
- diagnostic.source?.fileName,
86
- diagnostic.source?.lineNumber,
87
- diagnostic.source?.columnNumber,
88
- diagnostic.componentStack ?
89
- JSON.stringify(diagnostic.componentStack)
90
- : undefined,
91
- );
92
- }
93
- }
94
107
  }
95
108
 
96
109
  const DIAGNOSTICS_META_KEY = "diagnostics";
@@ -1,5 +1,25 @@
1
+ /**
2
+ * Vitest matcher type augmentations for alloy testing.
3
+ *
4
+ * Consumers load these types via `"types": ["@alloy-js/core/testing/matchers"]`
5
+ * in their tsconfig.json. This file is resolved directly from the source tree
6
+ * (not from dist/), so it **must remain self-contained** — do NOT add imports
7
+ * that resolve to .ts source files (e.g. `./extend-expect.js`). Doing so pulls
8
+ * the entire src/ tree into the consumer's compilation, creating a dual-module
9
+ * identity conflict between src/ types and dist/ types that produces TS errors.
10
+ *
11
+ * If matcher signatures change, update the inlined types here to match.
12
+ */
1
13
  import "vitest";
2
- import type { ToRenderToOptions } from "./extend-expect.js";
14
+
15
+ interface ToRenderToOptions {
16
+ /** Maximum line width before the formatter wraps output. */
17
+ printWidth?: number;
18
+ /** Number of spaces per indentation level. */
19
+ tabWidth?: number;
20
+ /** Use tab characters instead of spaces. */
21
+ useTabs?: boolean;
22
+ }
3
23
 
4
24
  interface ExpectedDiagnostic {
5
25
  message: string | RegExp;