@cosmicdrift/kumiko-framework 0.215.4 → 0.215.6

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.
@@ -30,6 +30,7 @@
30
30
  // roadmap C-Notes for the canonical-comment-attach Pattern that would
31
31
  // preserve prefixed `// kumiko-comment:` markers across roundtrips.
32
32
 
33
+ import type { ObjectLiteralExpression } from "ts-morph";
33
34
  import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
34
35
  import { readNameLiteral, readNameOrRef } from "./extractors/shared";
35
36
  import type { FeaturePattern, FeaturePatternKind } from "./patterns";
@@ -72,6 +73,9 @@ export type PatternId =
72
73
  | { readonly kind: "multiStreamProjection"; readonly name: string }
73
74
  | { readonly kind: "defineEvent"; readonly eventName: string }
74
75
  | { readonly kind: "extendsRegistrar"; readonly extensionName: string }
76
+ | { readonly kind: "ai.generate"; readonly stepKey: string }
77
+ | { readonly kind: "ai.extract"; readonly stepKey: string }
78
+ | { readonly kind: "ai.classify"; readonly stepKey: string }
75
79
  // Singleton patterns — only one per feature, kind alone identifies them.
76
80
  | { readonly kind: "requires" }
77
81
  | { readonly kind: "optionalRequires" }
@@ -188,10 +192,10 @@ export function replacePattern(
188
192
  // Whole call-statement spans from the CallExpression's start through
189
193
  // its enclosing ExpressionStatement (which carries the trailing `;`).
190
194
  const enclosingStatement = call.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
191
- const startNode = enclosingStatement ?? call;
195
+ const startNode = isAiStepId(id) ? call : (enclosingStatement ?? call);
192
196
 
193
197
  const startPos = startNode.getStart();
194
- const endPos = startNode.getEnd();
198
+ const endPos = isAiStepId(id) ? aiStepPatchSpan(sourceFile, call).end : startNode.getEnd();
195
199
 
196
200
  // Detect column of the original call's first non-whitespace character;
197
201
  // the rendered pattern starts at column 0 and gets indented to match.
@@ -219,26 +223,51 @@ export function removePattern(sourceFile: SourceFile, id: PatternId): void {
219
223
  if (!call) {
220
224
  throw new Error(`removePattern: no call found for ${describeId(id)}`);
221
225
  }
222
- const enclosingStatement = call.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
223
- const target = enclosingStatement ?? call;
224
-
225
- // Erase from the start of the line containing the statement (so leading
226
- // indentation goes with it) through the trailing newline, including the
227
- // *leading* blank line that addPattern emits — keeps blank-line counts
228
- // stable under add → remove cycles. We don't touch leading comments.
229
- const startPos = lineStart(sourceFile, target.getStart());
230
- const endPos = lineEnd(sourceFile, target.getEnd());
231
-
232
- // Collapse a preceding blank line if there is one (avoids a double
233
- // blank line between the now-adjacent statements).
234
- const collapseStart = collapsePrecedingBlankLine(sourceFile, startPos);
235
- sourceFile.replaceText([collapseStart, endPos + 1], "");
226
+ if (isAiStepId(id)) {
227
+ const { start, end } = aiStepPatchSpan(sourceFile, call);
228
+ sourceFile.replaceText([start, end], "");
229
+ } else {
230
+ const enclosingStatement = call.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
231
+ const target = enclosingStatement ?? call;
232
+
233
+ // Erase from the start of the line containing the statement (so leading
234
+ // indentation goes with it) through the trailing newline, including the
235
+ // *leading* blank line that addPattern emits — keeps blank-line counts
236
+ // stable under add remove cycles. We don't touch leading comments.
237
+ const startPos = lineStart(sourceFile, target.getStart());
238
+ const endPos = lineEnd(sourceFile, target.getEnd());
239
+
240
+ // Collapse a preceding blank line if there is one (avoids a double
241
+ // blank line between the now-adjacent statements).
242
+ const collapseStart = collapsePrecedingBlankLine(sourceFile, startPos);
243
+ sourceFile.replaceText([collapseStart, endPos + 1], "");
244
+ }
236
245
  }
237
246
 
238
247
  // =============================================================================
239
248
  // Lookup
240
249
  // =============================================================================
241
250
 
251
+ function isAiStepId(
252
+ id: PatternId,
253
+ ): id is Extract<PatternId, { kind: "ai.generate" | "ai.extract" | "ai.classify" }> {
254
+ return id.kind === "ai.generate" || id.kind === "ai.extract" || id.kind === "ai.classify";
255
+ }
256
+
257
+ function aiStepPatchSpan(
258
+ sourceFile: SourceFile,
259
+ call: CallExpression,
260
+ ): { start: number; end: number } {
261
+ const start = call.getStart();
262
+ let end = call.getEnd();
263
+ const text = sourceFile.getFullText();
264
+ while (end < text.length && (text[end] === " " || text[end] === " ")) end++;
265
+ if (text[end] === ",") end++;
266
+ while (end < text.length && (text[end] === " " || text[end] === " ")) end++;
267
+ if (end < text.length && text[end] === "\n") end++;
268
+ return { start, end };
269
+ }
270
+
242
271
  function findSetupCallback(
243
272
  sourceFile: SourceFile,
244
273
  ): { call: CallExpression; body: Node } | undefined {
@@ -293,6 +322,9 @@ export const SINGLETON_KINDS: ReadonlySet<PatternId["kind"]> = new Set([
293
322
  * feature can fix it explicitly.
294
323
  */
295
324
  function findCallForId(sourceFile: SourceFile, id: PatternId): CallExpression | undefined {
325
+ if (id.kind === "ai.generate" || id.kind === "ai.extract" || id.kind === "ai.classify") {
326
+ return findAiStepCall(sourceFile, id);
327
+ }
296
328
  const setup = findSetupCallback(sourceFile);
297
329
  if (!setup) return undefined;
298
330
  const registrarParam = setup.call
@@ -319,6 +351,53 @@ function findCallForId(sourceFile: SourceFile, id: PatternId): CallExpression |
319
351
  return matches[0];
320
352
  }
321
353
 
354
+ const AI_STEP_FACTORY: Readonly<Record<"ai.generate" | "ai.extract" | "ai.classify", string>> = {
355
+ "ai.generate": "aiGenerateStep",
356
+ "ai.extract": "aiExtractStep",
357
+ "ai.classify": "aiClassifyStep",
358
+ };
359
+
360
+ function resolveSameFileObjectLiteral(
361
+ node: import("ts-morph").Node,
362
+ ): ObjectLiteralExpression | undefined {
363
+ const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
364
+ if (direct) return direct;
365
+ const identifier = node.asKind(SyntaxKind.Identifier);
366
+ if (!identifier) return undefined;
367
+ const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
368
+ return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
369
+ }
370
+
371
+ function readAiStepKey(call: CallExpression): string | undefined {
372
+ const arg = call.getArguments()[0];
373
+ if (!arg) return undefined;
374
+ const obj = resolveSameFileObjectLiteral(arg);
375
+ if (!obj) return undefined;
376
+ const prop = obj.getProperty("stepKey");
377
+ if (!prop) return undefined;
378
+ const assign = prop.asKind(SyntaxKind.PropertyAssignment);
379
+ if (assign) {
380
+ const init = assign.getInitializer();
381
+ if (!init) return undefined;
382
+ return readNameLiteral(init);
383
+ }
384
+ const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
385
+ if (shorthand) return readNameLiteral(shorthand.getNameNode());
386
+ return undefined;
387
+ }
388
+
389
+ function findAiStepCall(sourceFile: SourceFile, id: PatternId): CallExpression | undefined {
390
+ if (id.kind !== "ai.generate" && id.kind !== "ai.extract" && id.kind !== "ai.classify") {
391
+ return undefined;
392
+ }
393
+ const factory = AI_STEP_FACTORY[id.kind];
394
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
395
+ if (call.getExpression().getText() !== factory) continue;
396
+ if (callMatchesId(call, id)) return call;
397
+ }
398
+ return undefined;
399
+ }
400
+
322
401
  function callMatchesId(call: CallExpression, id: PatternId): boolean {
323
402
  switch (id.kind) {
324
403
  // Singletons: kind alone identifies the call.
@@ -421,6 +500,10 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
421
500
  );
422
501
  case "extendsRegistrar":
423
502
  return matchFirstArgString(call, id.extensionName);
503
+ case "ai.generate":
504
+ case "ai.extract":
505
+ case "ai.classify":
506
+ return readAiStepKey(call) === id.stepKey;
424
507
  default: {
425
508
  const _exhaustive: never = id;
426
509
  return _exhaustive;
@@ -582,6 +582,51 @@ export type EnvSchemaPattern = {
582
582
  readonly schemaBody: SourceLocation;
583
583
  };
584
584
 
585
+ /** Live-editable policy defaults for an AI pipeline step instance (#256). */
586
+ export type AiStepPolicy = {
587
+ readonly enabled: boolean;
588
+ readonly providerId?: string;
589
+ readonly model?: string;
590
+ readonly params: Record<string, unknown>;
591
+ };
592
+
593
+ /** Preserves an unresolvable argument reference across parse → render (#998). */
594
+ export type AiStepOpaqueArgs = {
595
+ readonly __raw: string;
596
+ };
597
+
598
+ type AiStepCommonPatternFields = {
599
+ readonly source: SourceLocation;
600
+ /** Whole-args reference when the spec is not an inline/same-file object. */
601
+ readonly argsSource?: AiStepOpaqueArgs;
602
+ readonly stepKey?: string | AiStepOpaqueArgs;
603
+ readonly promptKey?: string | AiStepOpaqueArgs;
604
+ readonly promptFallback?: string | AiStepOpaqueArgs;
605
+ readonly defaults?: AiStepPolicy | AiStepOpaqueArgs;
606
+ readonly paramsSchemaSource?: SourceLocation;
607
+ };
608
+
609
+ // `aiGenerateStep({...})` inside `stepsPipeline` — Tier-3 freeform generation.
610
+ export type AiGeneratePattern = AiStepCommonPatternFields & {
611
+ readonly kind: "ai.generate";
612
+ readonly inputBody?: SourceLocation;
613
+ };
614
+
615
+ // `aiExtractStep({...})` — schema-driven structured extraction.
616
+ export type AiExtractPattern = AiStepCommonPatternFields & {
617
+ readonly kind: "ai.extract";
618
+ readonly outputSchemaSource?: SourceLocation;
619
+ readonly instructionsBody?: SourceLocation;
620
+ readonly documentBody?: SourceLocation;
621
+ };
622
+
623
+ // `aiClassifyStep({...})` — action-catalogue classification.
624
+ export type AiClassifyPattern = AiStepCommonPatternFields & {
625
+ readonly kind: "ai.classify";
626
+ readonly actions?: readonly { readonly type: string; readonly description: string }[];
627
+ readonly inputBody?: SourceLocation;
628
+ };
629
+
585
630
  // Catch-all — r.* calls the visitor doesn't recognise. Designer renders
586
631
  // "unknown call (cannot edit)", AI patcher leaves them unchanged. When
587
632
  // an UnknownPattern shows up in the wild it's a signal that a new r.*
@@ -635,6 +680,9 @@ export type FeaturePattern =
635
680
  | DefineEventPattern
636
681
  | ExtendsRegistrarPattern
637
682
  | EnvSchemaPattern
683
+ | AiGeneratePattern
684
+ | AiExtractPattern
685
+ | AiClassifyPattern
638
686
  // Catch-all
639
687
  | UnknownPattern;
640
688
 
@@ -688,6 +736,9 @@ export function getEditability(pattern: FeaturePattern): Editability {
688
736
  case "projection":
689
737
  case "multiStreamProjection":
690
738
  case "defineEvent":
739
+ case "ai.generate":
740
+ case "ai.extract":
741
+ case "ai.classify":
691
742
  return "mixed";
692
743
  case "authClaims":
693
744
  case "extendsRegistrar":
@@ -23,6 +23,11 @@
23
23
 
24
24
  import { isRawRefSentinel } from "./extractors/shared";
25
25
  import type {
26
+ AiClassifyPattern,
27
+ AiExtractPattern,
28
+ AiGeneratePattern,
29
+ AiStepOpaqueArgs,
30
+ AiStepPolicy,
26
31
  AuthClaimsPattern,
27
32
  ClaimKeyPattern,
28
33
  ConfigPattern,
@@ -143,6 +148,12 @@ export function renderPattern(pattern: FeaturePattern): string {
143
148
  return renderTreeActions(pattern);
144
149
  case "envSchema":
145
150
  return renderEnvSchema(pattern);
151
+ case "ai.generate":
152
+ return renderAiGenerate(pattern);
153
+ case "ai.extract":
154
+ return renderAiExtract(pattern);
155
+ case "ai.classify":
156
+ return renderAiClassify(pattern);
146
157
  case "unknown":
147
158
  return renderUnknown(pattern);
148
159
  default: {
@@ -439,6 +450,66 @@ function renderHook(p: HookPattern): string {
439
450
  return lines.join("\n");
440
451
  }
441
452
 
453
+ function renderEditableString(value: string | AiStepOpaqueArgs): string {
454
+ return typeof value === "string" ? JSON.stringify(value) : value.__raw;
455
+ }
456
+
457
+ function renderEditableDefaults(value: AiStepPolicy | AiStepOpaqueArgs): string {
458
+ if (isRawRefSentinel(value)) return value.__raw;
459
+ return renderValue(value);
460
+ }
461
+
462
+ function renderAiStepCommonFields(
463
+ lines: string[],
464
+ p: AiGeneratePattern | AiExtractPattern | AiClassifyPattern,
465
+ ): void {
466
+ if (p.stepKey !== undefined) lines.push(` stepKey: ${renderEditableString(p.stepKey)},`);
467
+ if (p.promptKey !== undefined) lines.push(` promptKey: ${renderEditableString(p.promptKey)},`);
468
+ if (p.promptFallback !== undefined) {
469
+ lines.push(` promptFallback: ${renderEditableString(p.promptFallback)},`);
470
+ }
471
+ if (p.paramsSchemaSource !== undefined) {
472
+ lines.push(` paramsSchema: ${p.paramsSchemaSource.raw},`);
473
+ }
474
+ if (p.defaults !== undefined) {
475
+ lines.push(` defaults: ${renderEditableDefaults(p.defaults)},`);
476
+ }
477
+ }
478
+
479
+ function renderAiGenerate(p: AiGeneratePattern): string {
480
+ if (p.argsSource) return `aiGenerateStep(${p.argsSource.__raw})`;
481
+ const lines: string[] = ["aiGenerateStep({"];
482
+ renderAiStepCommonFields(lines, p);
483
+ if (p.inputBody !== undefined) lines.push(` input: ${p.inputBody.raw},`);
484
+ lines.push("});");
485
+ return lines.join("\n");
486
+ }
487
+
488
+ function renderAiExtract(p: AiExtractPattern): string {
489
+ if (p.argsSource) return `aiExtractStep(${p.argsSource.__raw})`;
490
+ const lines: string[] = ["aiExtractStep({"];
491
+ renderAiStepCommonFields(lines, p);
492
+ if (p.outputSchemaSource !== undefined) {
493
+ lines.push(` outputSchema: ${p.outputSchemaSource.raw},`);
494
+ }
495
+ if (p.instructionsBody !== undefined) {
496
+ lines.push(` instructions: ${p.instructionsBody.raw},`);
497
+ }
498
+ if (p.documentBody !== undefined) lines.push(` document: ${p.documentBody.raw},`);
499
+ lines.push("});");
500
+ return lines.join("\n");
501
+ }
502
+
503
+ function renderAiClassify(p: AiClassifyPattern): string {
504
+ if (p.argsSource) return `aiClassifyStep(${p.argsSource.__raw})`;
505
+ const lines: string[] = ["aiClassifyStep({"];
506
+ renderAiStepCommonFields(lines, p);
507
+ if (p.actions !== undefined) lines.push(` actions: ${renderValue(p.actions)},`);
508
+ if (p.inputBody !== undefined) lines.push(` input: ${p.inputBody.raw},`);
509
+ lines.push("});");
510
+ return lines.join("\n");
511
+ }
512
+
442
513
  function renderJob(p: JobPattern): string {
443
514
  const lines: string[] = ["r.job({"];
444
515
  lines.push(` name: ${JSON.stringify(p.jobName)},`);
@@ -60,6 +60,9 @@ const ALL_KINDS: FeaturePatternKind[] = [
60
60
  "exposesApi",
61
61
  "treeActions",
62
62
  "envSchema",
63
+ "ai.generate",
64
+ "ai.extract",
65
+ "ai.classify",
63
66
  "unknown",
64
67
  ];
65
68
 
@@ -342,6 +345,42 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
342
345
  return { kind, source: PLACEHOLDER_LOC, definitions: {} };
343
346
  case "envSchema":
344
347
  return { kind, source: PLACEHOLDER_LOC, schemaBody: PLACEHOLDER_BODY_LOC };
348
+
349
+ case "ai.generate":
350
+ return {
351
+ kind,
352
+ source: PLACEHOLDER_LOC,
353
+ stepKey: "generate",
354
+ promptKey: "demo:generate",
355
+ promptFallback: "Write a summary.",
356
+ defaults: { enabled: true, params: {} },
357
+ paramsSchemaSource: PLACEHOLDER_BODY_LOC,
358
+ inputBody: PLACEHOLDER_BODY_LOC,
359
+ };
360
+ case "ai.extract":
361
+ return {
362
+ kind,
363
+ source: PLACEHOLDER_LOC,
364
+ stepKey: "extract",
365
+ promptKey: "demo:extract",
366
+ promptFallback: "Extract fields.",
367
+ defaults: { enabled: true, params: {} },
368
+ paramsSchemaSource: PLACEHOLDER_BODY_LOC,
369
+ outputSchemaSource: PLACEHOLDER_BODY_LOC,
370
+ instructionsBody: PLACEHOLDER_BODY_LOC,
371
+ };
372
+ case "ai.classify":
373
+ return {
374
+ kind,
375
+ source: PLACEHOLDER_LOC,
376
+ stepKey: "classify",
377
+ promptKey: "demo:classify",
378
+ promptFallback: "Classify the input.",
379
+ defaults: { enabled: true, params: {} },
380
+ paramsSchemaSource: PLACEHOLDER_BODY_LOC,
381
+ actions: [{ type: "approve", description: "Approve" }],
382
+ inputBody: PLACEHOLDER_BODY_LOC,
383
+ };
345
384
  case "unknown":
346
385
  return { kind, source: PLACEHOLDER_LOC, methodName: "x" };
347
386
  case "usesApi":
@@ -15,6 +15,9 @@
15
15
 
16
16
  import type { FeaturePatternKind } from "../feature-ast/patterns";
17
17
  import {
18
+ aiClassifySchema,
19
+ aiExtractSchema,
20
+ aiGenerateSchema,
18
21
  authClaimsSchema,
19
22
  defineEventSchema,
20
23
  hookSchema,
@@ -94,6 +97,9 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
94
97
  exposesApi: exposesApiSchema,
95
98
  treeActions: treeActionsSchema,
96
99
  envSchema: envSchemaSchema,
100
+ "ai.generate": aiGenerateSchema,
101
+ "ai.extract": aiExtractSchema,
102
+ "ai.classify": aiClassifySchema,
97
103
  unknown: unknownSchema,
98
104
  } satisfies Readonly<Record<FeaturePatternKind, PatternFormSchema>>;
99
105
 
@@ -1,7 +1,7 @@
1
1
  // Mixed pattern schemas (header form + opaque body source).
2
2
 
3
3
  import { accessRuleField, HOOK_TYPE_OPTIONS, HTTP_METHOD_OPTIONS } from "./shared-fields";
4
- import type { PatternFormSchema } from "./types";
4
+ import type { FormFieldSpec, PatternFormSchema } from "./types";
5
5
 
6
6
  // --- Mixed patterns (header form + opaque body source) --------------------
7
7
 
@@ -230,6 +230,108 @@ export const jobSchema: PatternFormSchema = {
230
230
  ],
231
231
  };
232
232
 
233
+ const aiStepCommonFields = [
234
+ {
235
+ path: "stepKey",
236
+ label: { en: "Step key", de: "Step-Key" },
237
+ input: "text",
238
+ required: true,
239
+ },
240
+ {
241
+ path: "promptKey",
242
+ label: { en: "Prompt key", de: "Prompt-Key" },
243
+ input: "text",
244
+ required: true,
245
+ },
246
+ {
247
+ path: "defaults",
248
+ label: { en: "Defaults", de: "Defaults" },
249
+ hint: { en: "enabled, providerId, model, params" },
250
+ input: "json-readonly",
251
+ },
252
+ {
253
+ path: "paramsSchemaSource",
254
+ label: { en: "Params schema (source)", de: "Params-Schema (Source)" },
255
+ input: "code-block",
256
+ language: "typescript",
257
+ readOnly: true,
258
+ },
259
+ ] as const satisfies readonly FormFieldSpec[];
260
+
261
+ export const aiGenerateSchema: PatternFormSchema = {
262
+ kind: "ai.generate",
263
+ label: { en: "AI generate step", de: "AI-Generate-Step" },
264
+ summary: { en: "Tier-3 workflow step for freeform text generation." },
265
+ category: "behaviour",
266
+ editability: "mixed",
267
+ fields: [
268
+ ...aiStepCommonFields,
269
+ {
270
+ path: "inputBody",
271
+ label: { en: "Input resolver (source)", de: "Input-Resolver (Source)" },
272
+ input: "code-block",
273
+ language: "typescript",
274
+ readOnly: true,
275
+ },
276
+ ],
277
+ };
278
+
279
+ export const aiExtractSchema: PatternFormSchema = {
280
+ kind: "ai.extract",
281
+ label: { en: "AI extract step", de: "AI-Extract-Step" },
282
+ summary: { en: "Schema-driven structured extraction step." },
283
+ category: "behaviour",
284
+ editability: "mixed",
285
+ fields: [
286
+ ...aiStepCommonFields,
287
+ {
288
+ path: "outputSchemaSource",
289
+ label: { en: "Output schema (source)", de: "Output-Schema (Source)" },
290
+ input: "code-block",
291
+ language: "typescript",
292
+ readOnly: true,
293
+ },
294
+ {
295
+ path: "instructionsBody",
296
+ label: { en: "Instructions (source)", de: "Instructions (Source)" },
297
+ input: "code-block",
298
+ language: "typescript",
299
+ readOnly: true,
300
+ },
301
+ {
302
+ path: "documentBody",
303
+ label: { en: "Document resolver (source)", de: "Document-Resolver (Source)" },
304
+ input: "code-block",
305
+ language: "typescript",
306
+ readOnly: true,
307
+ },
308
+ ],
309
+ };
310
+
311
+ export const aiClassifySchema: PatternFormSchema = {
312
+ kind: "ai.classify",
313
+ label: { en: "AI classify step", de: "AI-Classify-Step" },
314
+ summary: { en: "Action-catalogue classification step." },
315
+ category: "behaviour",
316
+ editability: "mixed",
317
+ fields: [
318
+ ...aiStepCommonFields,
319
+ {
320
+ path: "actions",
321
+ label: { en: "Actions", de: "Aktionen" },
322
+ input: "json-readonly",
323
+ required: true,
324
+ },
325
+ {
326
+ path: "inputBody",
327
+ label: { en: "Input resolver (source)", de: "Input-Resolver (Source)" },
328
+ input: "code-block",
329
+ language: "typescript",
330
+ readOnly: true,
331
+ },
332
+ ],
333
+ };
334
+
233
335
  export const notificationSchema: PatternFormSchema = {
234
336
  kind: "notification",
235
337
  label: { en: "Notification", de: "Benachrichtigung" },
@@ -180,7 +180,6 @@ describe("tenant-storage-usage MSP", () => {
180
180
  const [first] = await selectMany(stack.db, tenantStorageUsageTable, {
181
181
  tenantId: admin.tenantId,
182
182
  });
183
- expect(first?.["lastUpdatedAt"]).toBeInstanceOf(Temporal.Instant);
184
183
 
185
184
  // Postgres NOW() resolution is microseconds; a second upload a beat
186
185
  // later must produce a strictly later timestamp (or at least not an
@@ -193,7 +192,6 @@ describe("tenant-storage-usage MSP", () => {
193
192
  const [second] = await selectMany(stack.db, tenantStorageUsageTable, {
194
193
  tenantId: admin.tenantId,
195
194
  });
196
- expect(second?.["lastUpdatedAt"]).toBeInstanceOf(Temporal.Instant);
197
195
  if (!first?.["lastUpdatedAt"] || !second?.["lastUpdatedAt"]) throw new Error("missing rows");
198
196
  expect(
199
197
  Temporal.Instant.compare(second["lastUpdatedAt"], first["lastUpdatedAt"]),
@@ -47,14 +47,14 @@ export function attachSignalHandlers(
47
47
  .then(() => exitFn(0))
48
48
  .catch(() => exitFn(1));
49
49
  };
50
- process.on(sig, handler);
50
+ (process as NodeJS.EventEmitter).on(sig, handler);
51
51
  listeners.set(sig, handler);
52
52
  }
53
53
 
54
54
  return {
55
55
  detach: () => {
56
56
  for (const [sig, handler] of listeners) {
57
- process.off(sig, handler);
57
+ (process as NodeJS.EventEmitter).off(sig, handler);
58
58
  }
59
59
  listeners.clear();
60
60
  },
@@ -1,81 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import {
3
- formatReport,
4
- isRawSqlAllowed,
5
- joinPath,
6
- scanRepo,
7
- toBaselineJson,
8
- } from "../sql-inventory";
9
-
10
- const cleanups: string[] = [];
11
-
12
- afterEach(async () => {
13
- for (const dir of cleanups) {
14
- await Bun.spawn(["rm", "-rf", dir]).exited;
15
- }
16
- cleanups.length = 0;
17
- });
18
-
19
- async function tempRepo(files: Record<string, string>): Promise<string> {
20
- const root = joinPath(import.meta.dir, `.tmp-sql-inv-${crypto.randomUUID()}`);
21
- cleanups.push(root);
22
- await Promise.all(
23
- Object.entries(files).map(([rel, content]) => Bun.write(joinPath(root, rel), content)),
24
- );
25
- return root;
26
- }
27
-
28
- describe("sql-inventory", () => {
29
- test("isRawSqlAllowed permits db/queries and bun-db/query", () => {
30
- expect(isRawSqlAllowed("/repo/packages/framework/src/db/queries/event-store.ts")).toBe(true);
31
- expect(isRawSqlAllowed("/repo/packages/framework/src/bun-db/query.ts")).toBe(true);
32
- expect(
33
- isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/db/queries/cleanup.ts"),
34
- ).toBe(true);
35
- expect(isRawSqlAllowed("/repo/samples/apps/marketing-demo/src/db/queries/seed-counts.ts")).toBe(
36
- true,
37
- );
38
- expect(isRawSqlAllowed("/repo/bin/commands/schema.ts")).toBe(true);
39
- expect(isRawSqlAllowed("/repo/scripts/codemod-bun-db-swap.ts")).toBe(true);
40
- expect(
41
- isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/handlers/cleanup.job.ts"),
42
- ).toBe(false);
43
- });
44
-
45
- test("scanRepo classifies production vs test hits", async () => {
46
- const root = await tempRepo({
47
- "packages/framework/src/db/queries/demo.ts": `export async function x(db: unknown) {
48
- return asRawClient(db).unsafe("SELECT 1");
49
- }`,
50
- "packages/framework/src/handlers/bad.ts": `export async function y(db: unknown) {
51
- return asRawClient(db).unsafe("DELETE FROM read_users");
52
- }`,
53
- "packages/framework/src/__tests__/ok.integration.ts": `await asRawClient(db).unsafe("DELETE FROM read_users");`,
54
- });
55
-
56
- const report = await scanRepo(root);
57
- expect(report.summary.byBucket.allowed).toBeGreaterThanOrEqual(1);
58
- expect(report.summary.byBucket.tests).toBeGreaterThanOrEqual(1);
59
- expect(report.summary.byBucket.disallowed).toBeGreaterThanOrEqual(1);
60
- expect(formatReport(report)).toContain("sql inventory");
61
- });
62
-
63
- test("toBaselineJson normalizes machine-specific root + scannedAt, keeps the rest", async () => {
64
- const root = await tempRepo({
65
- "packages/framework/src/handlers/bad.ts": `export async function y(db: unknown) {
66
- return asRawClient(db).unsafe("DELETE FROM read_users");
67
- }`,
68
- });
69
-
70
- const report = await scanRepo(root);
71
- expect(report.root).toBe(root);
72
- expect(report.scannedAt).not.toBe("");
73
-
74
- const parsed = JSON.parse(toBaselineJson(report));
75
- expect(parsed.root).toBe(".");
76
- expect(parsed.scannedAt).toBe("");
77
- expect(parsed.root).not.toContain(root);
78
- expect(parsed.summary.disallowed).toBe(report.summary.disallowed);
79
- expect(parsed.hits).toHaveLength(report.hits.length);
80
- });
81
- });