@akanjs/devkit 2.3.9-rc.6 → 2.3.9-rc.8

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.
@@ -0,0 +1,504 @@
1
+ import { access } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import type { AkanModuleContext } from "../akanContext";
5
+ import type { Workspace } from "../commandDecorators";
6
+ import { moduleComponentName, moduleSourcePaths } from "./source";
7
+ import type {
8
+ AkanConstantIndex,
9
+ AkanDictionaryIndex,
10
+ AkanFieldOutline,
11
+ AkanFieldPresence,
12
+ AkanIndexedFile,
13
+ AkanIndexedFileKind,
14
+ AkanModuleContextIndex,
15
+ AkanProjectionOutline,
16
+ AkanSourceSpan,
17
+ WorkflowDiagnostic,
18
+ } from "./types";
19
+
20
+ export interface BuildAkanModuleContextIndexOptions {
21
+ field?: string;
22
+ }
23
+
24
+ const indexFileKinds = [
25
+ "abstract",
26
+ "constant",
27
+ "dictionary",
28
+ "service",
29
+ "signal",
30
+ "store",
31
+ "template",
32
+ "unit",
33
+ "util",
34
+ "view",
35
+ "zone",
36
+ ] as const;
37
+
38
+ const sourceText = async (filePath: string) => {
39
+ try {
40
+ return await Bun.file(filePath).text();
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const fileExists = async (filePath: string) => {
47
+ try {
48
+ await access(filePath);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+
55
+ const sourceFileFor = (filePath: string, content: string) =>
56
+ ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
57
+
58
+ const parseDiagnosticsFor = (source: ts.SourceFile, filePath: string): WorkflowDiagnostic[] => {
59
+ const parseDiagnostics =
60
+ (source as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }).parseDiagnostics ?? [];
61
+ return parseDiagnostics.map((diagnostic) => ({
62
+ severity: "error",
63
+ code: "module-index-typescript-parse-error",
64
+ message: `TypeScript parse diagnostic TS${diagnostic.code} in ${filePath}: ${ts.flattenDiagnosticMessageText(
65
+ diagnostic.messageText,
66
+ "\n",
67
+ )}`,
68
+ context: { target: filePath, paths: [filePath] },
69
+ }));
70
+ };
71
+
72
+ const spanFor = (source: ts.SourceFile, file: string, node: ts.Node): AkanSourceSpan => {
73
+ const startOffset = node.getStart(source);
74
+ const endOffset = node.getEnd();
75
+ const start = source.getLineAndCharacterOfPosition(startOffset);
76
+ const end = source.getLineAndCharacterOfPosition(endOffset);
77
+ return {
78
+ file,
79
+ startLine: start.line + 1,
80
+ endLine: end.line + 1,
81
+ startOffset,
82
+ endOffset,
83
+ };
84
+ };
85
+
86
+ const nodeName = (node: ts.PropertyName | ts.BindingName | undefined) => {
87
+ if (!node) return null;
88
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text;
89
+ return null;
90
+ };
91
+
92
+ const propertyName = (node: ts.ObjectLiteralElementLike) =>
93
+ ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node) || ts.isMethodDeclaration(node)
94
+ ? nodeName(node.name)
95
+ : null;
96
+
97
+ const expressionName = (expression: ts.Expression): string | null => {
98
+ if (ts.isIdentifier(expression)) return expression.text;
99
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
100
+ if (ts.isCallExpression(expression)) return expressionName(expression.expression);
101
+ if (ts.isAsExpression(expression)) return expressionName(expression.expression);
102
+ return null;
103
+ };
104
+
105
+ const expressionSummary = (expression: ts.Expression): string => {
106
+ if (ts.isAsExpression(expression)) return expressionSummary(expression.expression);
107
+ if (ts.isIdentifier(expression) || ts.isPropertyAccessExpression(expression))
108
+ return expressionName(expression) ?? "expression";
109
+ if (ts.isArrayLiteralExpression(expression)) return "array-literal";
110
+ if (ts.isObjectLiteralExpression(expression)) return "object-literal";
111
+ if (ts.isStringLiteralLike(expression)) return "string-literal";
112
+ if (ts.isNumericLiteral(expression)) return "numeric-literal";
113
+ if (expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) {
114
+ return "boolean-literal";
115
+ }
116
+ if (ts.isCallExpression(expression)) {
117
+ const callee = expressionName(expression.expression);
118
+ return callee ? `${callee}(...)` : "call-expression";
119
+ }
120
+ return ts.SyntaxKind[expression.kind] ?? "expression";
121
+ };
122
+
123
+ const typeSummaryForInitializer = (initializer: ts.Expression | undefined) => {
124
+ if (!initializer) return undefined;
125
+ const expression = ts.isAsExpression(initializer) ? initializer.expression : initializer;
126
+ if (!ts.isCallExpression(expression)) return expressionSummary(expression);
127
+ const callee = expressionName(expression.expression);
128
+ const firstArg = expression.arguments[0];
129
+ const argSummary = firstArg ? expressionSummary(firstArg) : "";
130
+ return [callee, argSummary ? `(${argSummary})` : ""].filter(Boolean).join("");
131
+ };
132
+
133
+ const fieldsFromObject = (
134
+ source: ts.SourceFile,
135
+ file: string,
136
+ objectLiteral: ts.ObjectLiteralExpression,
137
+ kind: AkanFieldOutline["kind"],
138
+ ) =>
139
+ objectLiteral.properties
140
+ .map((property, index): AkanFieldOutline | null => {
141
+ const name = propertyName(property);
142
+ if (!name) return null;
143
+ const initializer = ts.isPropertyAssignment(property) ? property.initializer : undefined;
144
+ return {
145
+ name,
146
+ kind,
147
+ order: index,
148
+ ...(initializer ? { typeSummary: typeSummaryForInitializer(initializer) } : {}),
149
+ sourceSpan: spanFor(source, file, property),
150
+ };
151
+ })
152
+ .filter((field): field is AkanFieldOutline => field !== null);
153
+
154
+ const firstObjectReturnedByArrow = (node: ts.Node): ts.ObjectLiteralExpression | null => {
155
+ if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return null;
156
+ if (ts.isObjectLiteralExpression(node.body)) return node.body;
157
+ if (ts.isParenthesizedExpression(node.body) && ts.isObjectLiteralExpression(node.body.expression)) {
158
+ return node.body.expression;
159
+ }
160
+ if (!ts.isBlock(node.body)) return null;
161
+ for (const statement of node.body.statements) {
162
+ if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(statement.expression)) {
163
+ return statement.expression;
164
+ }
165
+ }
166
+ return null;
167
+ };
168
+
169
+ const isViaCall = (expression: ts.Expression) =>
170
+ ts.isCallExpression(expression) && expressionName(expression.expression) === "via";
171
+
172
+ const heritageCall = (node: ts.ClassDeclaration) => {
173
+ const heritage = node.heritageClauses?.flatMap((clause) => [...clause.types]) ?? [];
174
+ const expression = heritage.find((clause) => isViaCall(clause.expression))?.expression;
175
+ return expression && ts.isCallExpression(expression) ? expression : null;
176
+ };
177
+
178
+ interface ParsedConstantIndex {
179
+ index: AkanConstantIndex;
180
+ diagnostics: WorkflowDiagnostic[];
181
+ }
182
+
183
+ interface ParsedDictionaryIndex {
184
+ index?: AkanDictionaryIndex;
185
+ diagnostics: WorkflowDiagnostic[];
186
+ modelFound: boolean;
187
+ }
188
+
189
+ const parseConstantIndex = (filePath: string, content: string, moduleClassName: string): ParsedConstantIndex => {
190
+ const source = sourceFileFor(filePath, content);
191
+ const diagnostics = parseDiagnosticsFor(source, filePath);
192
+ const inputClassName = `${moduleClassName}Input`;
193
+ let inputClass: ts.ClassDeclaration | null = null;
194
+ let inputViaFound = false;
195
+ let inputBuilderFound = false;
196
+ let inputBuilderObjectFound = false;
197
+ let builderName: string | null = null;
198
+ let fields: AkanFieldOutline[] = [];
199
+ let lightProjection: AkanProjectionOutline | undefined;
200
+
201
+ const visit = (node: ts.Node) => {
202
+ if (!ts.isClassDeclaration(node) || !node.name) {
203
+ ts.forEachChild(node, visit);
204
+ return;
205
+ }
206
+ const className = node.name.text;
207
+ const viaCall = heritageCall(node);
208
+ if (className === inputClassName) {
209
+ inputClass = node;
210
+ if (!viaCall) return;
211
+ inputViaFound = true;
212
+ const callback = viaCall.arguments.find((arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg));
213
+ if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
214
+ inputBuilderFound = true;
215
+ builderName = nodeName(callback.parameters[0]?.name) ?? null;
216
+ const objectLiteral = firstObjectReturnedByArrow(callback);
217
+ if (objectLiteral) {
218
+ inputBuilderObjectFound = true;
219
+ fields = fieldsFromObject(source, filePath, objectLiteral, "constant");
220
+ }
221
+ }
222
+ }
223
+ if (!viaCall) return;
224
+ if (className === `Light${moduleClassName}`) {
225
+ const projectionArg = viaCall.arguments.find((arg) => {
226
+ const expression = ts.isAsExpression(arg) ? arg.expression : arg;
227
+ return ts.isArrayLiteralExpression(expression);
228
+ });
229
+ const arrayLiteral = projectionArg
230
+ ? ts.isAsExpression(projectionArg)
231
+ ? projectionArg.expression
232
+ : projectionArg
233
+ : null;
234
+ if (arrayLiteral && ts.isArrayLiteralExpression(arrayLiteral)) {
235
+ lightProjection = {
236
+ className,
237
+ sourceSpan: spanFor(source, filePath, arrayLiteral),
238
+ fields: arrayLiteral.elements
239
+ .map((element, order): AkanFieldOutline | null =>
240
+ ts.isStringLiteralLike(element)
241
+ ? {
242
+ name: element.text,
243
+ kind: "lightProjection",
244
+ order,
245
+ typeSummary: "string-literal",
246
+ sourceSpan: spanFor(source, filePath, element),
247
+ }
248
+ : null,
249
+ )
250
+ .filter((field): field is AkanFieldOutline => field !== null),
251
+ };
252
+ }
253
+ }
254
+ };
255
+ ts.forEachChild(source, visit);
256
+
257
+ if (!inputClass) {
258
+ diagnostics.push({
259
+ severity: "error",
260
+ code: "module-index-constant-input-missing",
261
+ message: `Constant input class ${inputClassName} was not found in ${filePath}.`,
262
+ context: { target: inputClassName, paths: [filePath] },
263
+ });
264
+ } else if (!inputViaFound) {
265
+ diagnostics.push({
266
+ severity: "error",
267
+ code: "module-index-constant-via-missing",
268
+ message: `Constant input class ${inputClassName} does not extend via(...) in ${filePath}.`,
269
+ context: { target: inputClassName, paths: [filePath] },
270
+ });
271
+ } else if (!inputBuilderFound) {
272
+ diagnostics.push({
273
+ severity: "error",
274
+ code: "module-index-constant-builder-missing",
275
+ message: `Constant input class ${inputClassName} does not provide a via(...) builder callback in ${filePath}.`,
276
+ context: { target: inputClassName, paths: [filePath] },
277
+ });
278
+ } else if (!inputBuilderObjectFound) {
279
+ diagnostics.push({
280
+ severity: "error",
281
+ code: "module-index-constant-builder-object-missing",
282
+ message: `Constant input class ${inputClassName} builder does not return an object literal in ${filePath}.`,
283
+ context: { target: inputClassName, paths: [filePath] },
284
+ });
285
+ }
286
+
287
+ return {
288
+ index: {
289
+ path: filePath,
290
+ inputClassName,
291
+ builderName,
292
+ fields,
293
+ ...(lightProjection ? { lightProjection } : {}),
294
+ ...(inputClass ? { sourceSpan: spanFor(source, filePath, inputClass) } : {}),
295
+ },
296
+ diagnostics,
297
+ };
298
+ };
299
+
300
+ const callExpressionName = (node: ts.CallExpression) =>
301
+ ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : expressionName(node.expression);
302
+
303
+ const parseDictionaryIndex = (filePath: string, content: string, moduleClassName: string): ParsedDictionaryIndex => {
304
+ const source = sourceFileFor(filePath, content);
305
+ const diagnostics = parseDiagnosticsFor(source, filePath);
306
+ let index: AkanDictionaryIndex | undefined;
307
+ let modelFound = false;
308
+
309
+ const visit = (node: ts.Node) => {
310
+ if (modelFound || !ts.isCallExpression(node)) {
311
+ ts.forEachChild(node, visit);
312
+ return;
313
+ }
314
+ if (callExpressionName(node) !== "model") {
315
+ ts.forEachChild(node, visit);
316
+ return;
317
+ }
318
+ const typeArgument = node.typeArguments?.[0];
319
+ if (!typeArgument || typeArgument.getText(source) !== moduleClassName) {
320
+ ts.forEachChild(node, visit);
321
+ return;
322
+ }
323
+ modelFound = true;
324
+ const callback = node.arguments.find((arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg));
325
+ if (!callback || (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))) {
326
+ diagnostics.push({
327
+ severity: "error",
328
+ code: "module-index-dictionary-builder-missing",
329
+ message: `Dictionary .model<${moduleClassName}> branch does not provide a builder callback in ${filePath}.`,
330
+ context: { target: moduleClassName, paths: [filePath] },
331
+ });
332
+ return;
333
+ }
334
+ const objectLiteral = firstObjectReturnedByArrow(callback);
335
+ if (!objectLiteral) {
336
+ diagnostics.push({
337
+ severity: "error",
338
+ code: "module-index-dictionary-builder-object-missing",
339
+ message: `Dictionary .model<${moduleClassName}> builder does not return an object literal in ${filePath}.`,
340
+ context: { target: moduleClassName, paths: [filePath] },
341
+ });
342
+ }
343
+ index = {
344
+ path: filePath,
345
+ modelClassName: moduleClassName,
346
+ translatorName: nodeName(callback.parameters[0]?.name),
347
+ fields: objectLiteral ? fieldsFromObject(source, filePath, objectLiteral, "dictionary") : [],
348
+ sourceSpan: spanFor(source, filePath, node),
349
+ };
350
+ };
351
+ ts.forEachChild(source, visit);
352
+ return { index, diagnostics, modelFound };
353
+ };
354
+
355
+ const expectedFilesFor = (module: AkanModuleContext): AkanIndexedFile[] => {
356
+ const paths = moduleSourcePaths(module.name);
357
+ return indexFileKinds.map((kind) => {
358
+ const expectedModulePath = paths[kind];
359
+ const expectedFilename = path.basename(expectedModulePath);
360
+ const actualFilename =
361
+ module.files.find((file) => file === expectedFilename) ??
362
+ module.files.find((file) => file.toLowerCase() === expectedFilename.toLowerCase());
363
+ const present = actualFilename !== undefined;
364
+ const casing = !present ? "missing" : actualFilename === expectedFilename ? "match" : "mismatch";
365
+ return {
366
+ kind: kind as AkanIndexedFileKind,
367
+ path: `${module.path}/${actualFilename ?? expectedFilename}`,
368
+ expectedPath: `${module.path}/${expectedFilename}`,
369
+ filename: actualFilename ?? expectedFilename,
370
+ expectedFilename,
371
+ present,
372
+ casing,
373
+ };
374
+ });
375
+ };
376
+
377
+ const diagnosticsForFiles = (files: AkanIndexedFile[]): WorkflowDiagnostic[] =>
378
+ files.flatMap((file) => {
379
+ if (file.kind !== "constant" && file.kind !== "dictionary") return [];
380
+ if (file.casing === "match") return [];
381
+ return [
382
+ {
383
+ severity: "error",
384
+ code: file.casing === "missing" ? "module-index-file-missing" : "module-index-file-casing-mismatch",
385
+ message:
386
+ file.casing === "missing"
387
+ ? `Expected ${file.kind} file is missing: ${file.expectedPath}.`
388
+ : `Expected ${file.expectedPath}, but found ${file.path}.`,
389
+ context: { target: file.expectedPath, paths: [file.path, file.expectedPath] },
390
+ },
391
+ ];
392
+ });
393
+
394
+ const fieldPresence = (
395
+ requestedField: string | undefined,
396
+ constantFields: AkanFieldOutline[],
397
+ dictionaryFields: AkanFieldOutline[],
398
+ lightFields: AkanFieldOutline[],
399
+ ) => {
400
+ const names = new Set([
401
+ ...constantFields.map((field) => field.name),
402
+ ...dictionaryFields.map((field) => field.name),
403
+ ...lightFields.map((field) => field.name),
404
+ ...(requestedField ? [requestedField] : []),
405
+ ]);
406
+ return [...names].sort().map(
407
+ (name): AkanFieldPresence => ({
408
+ name,
409
+ requested: requestedField === name,
410
+ constant: constantFields.some((field) => field.name === name),
411
+ dictionary: dictionaryFields.some((field) => field.name === name),
412
+ lightProjection: lightFields.some((field) => field.name === name),
413
+ }),
414
+ );
415
+ };
416
+
417
+ const partialPresenceDiagnostics = (presence: AkanFieldPresence[], module: AkanModuleContext): WorkflowDiagnostic[] =>
418
+ presence
419
+ .filter(
420
+ (field) =>
421
+ field.constant !== field.dictionary || (field.lightProjection && (!field.constant || !field.dictionary)),
422
+ )
423
+ .map((field) => ({
424
+ severity: "warning",
425
+ code: "module-index-field-presence-partial",
426
+ message: `${module.sysName}:${module.name}.${field.name} presence differs across constant, dictionary, and lightProjection.`,
427
+ context: {
428
+ target: `${module.sysName}:${module.name}.${field.name}`,
429
+ paths: [`${module.path}/${module.name}.constant.ts`, `${module.path}/${module.name}.dictionary.ts`],
430
+ },
431
+ }));
432
+
433
+ export const buildAkanModuleContextIndex = async (
434
+ workspace: Workspace,
435
+ module: AkanModuleContext,
436
+ options: BuildAkanModuleContextIndexOptions = {},
437
+ ): Promise<AkanModuleContextIndex> => {
438
+ const files = expectedFilesFor(module);
439
+ const diagnostics = diagnosticsForFiles(files);
440
+ const moduleClassName = moduleComponentName(module.name);
441
+ const constantFile = files.find((file) => file.kind === "constant");
442
+ const dictionaryFile = files.find((file) => file.kind === "dictionary");
443
+ const constantContent =
444
+ constantFile?.present && constantFile.casing === "match"
445
+ ? await sourceText(path.join(workspace.workspaceRoot, constantFile.path))
446
+ : null;
447
+ const dictionaryContent =
448
+ dictionaryFile?.present && dictionaryFile.casing === "match"
449
+ ? await sourceText(path.join(workspace.workspaceRoot, dictionaryFile.path))
450
+ : null;
451
+ const parsedConstant =
452
+ constantFile && constantContent
453
+ ? parseConstantIndex(constantFile.path, constantContent, moduleClassName)
454
+ : undefined;
455
+ const constant = parsedConstant?.index;
456
+ const parsedDictionary =
457
+ dictionaryFile && dictionaryContent
458
+ ? parseDictionaryIndex(dictionaryFile.path, dictionaryContent, moduleClassName)
459
+ : undefined;
460
+ const dictionary = parsedDictionary?.index;
461
+ const presence = fieldPresence(
462
+ options.field,
463
+ constant?.fields ?? [],
464
+ dictionary?.fields ?? [],
465
+ constant?.lightProjection?.fields ?? [],
466
+ );
467
+
468
+ if (
469
+ constantFile?.present &&
470
+ constantFile.casing === "match" &&
471
+ !(await fileExists(path.join(workspace.workspaceRoot, constantFile.path)))
472
+ ) {
473
+ diagnostics.push({
474
+ severity: "error",
475
+ code: "module-index-file-unreadable",
476
+ message: `Expected constant file could not be read: ${constantFile.path}.`,
477
+ context: { paths: [constantFile.path] },
478
+ });
479
+ }
480
+ if (parsedConstant) diagnostics.push(...parsedConstant.diagnostics);
481
+ if (parsedDictionary) diagnostics.push(...parsedDictionary.diagnostics);
482
+ if (dictionaryFile?.present && dictionaryFile.casing === "match" && !parsedDictionary?.modelFound) {
483
+ diagnostics.push({
484
+ severity: "error",
485
+ code: "module-index-dictionary-model-missing",
486
+ message: `Dictionary .model<${moduleClassName}> branch was not found in ${dictionaryFile.path}.`,
487
+ context: { paths: [dictionaryFile.path] },
488
+ });
489
+ }
490
+
491
+ diagnostics.push(...partialPresenceDiagnostics(presence, module));
492
+
493
+ return {
494
+ schemaVersion: 1,
495
+ app: module.sysName,
496
+ module: module.name,
497
+ moduleClassName,
498
+ files,
499
+ fieldPresence: presence,
500
+ ...(constant ? { constant } : {}),
501
+ ...(dictionary ? { dictionary } : {}),
502
+ diagnostics,
503
+ };
504
+ };
@@ -93,6 +93,9 @@ const renderRecommendation = (recommendation: WorkflowApplyReport["recommendatio
93
93
  const renderDiagnostic = (diagnostic: WorkflowApplyReport["diagnostics"][number]) =>
94
94
  `- [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`;
95
95
 
96
+ const renderNextAction = (action: WorkflowApplyReport["nextActions"][number]) =>
97
+ `- \`${action.command}\`: ${action.reason}`;
98
+
96
99
  const applySourceStatus = (report: WorkflowApplyReport) =>
97
100
  report.diagnostics.some(
98
101
  (diagnostic) =>
@@ -181,9 +184,7 @@ export const renderWorkflowApplyReport = (report: WorkflowApplyReport) => {
181
184
  ...(report.recommendations.length ? report.recommendations.slice(0, 3).map(renderRecommendation) : ["- none"]),
182
185
  "",
183
186
  "## Next Actions",
184
- ...(report.nextActions.length
185
- ? report.nextActions.map((action) => `- \`${action.command}\`: ${action.reason}`)
186
- : ["- none"]),
187
+ ...(report.nextActions.length ? report.nextActions.slice(0, 3).map(renderNextAction) : ["- none"]),
187
188
  "",
188
189
  ].join("\n");
189
190
  };
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import type { PackageJson } from "../types";
4
+ import { findToolingRolloutViolations } from "./rolloutGate";
5
+
6
+ const manifest = (overrides: Partial<PackageJson>): PackageJson => ({
7
+ name: "fixture",
8
+ version: "0.0.0",
9
+ description: "fixture",
10
+ ...overrides,
11
+ });
12
+
13
+ const workspaceRoot = path.join(import.meta.dir, "../../../..");
14
+
15
+ const readWorkspaceManifest = (relativePath: string) =>
16
+ Bun.file(path.join(workspaceRoot, relativePath)).json() as Promise<PackageJson>;
17
+
18
+ const workspacePackageManifestPaths = async () => {
19
+ const rootManifest = await readWorkspaceManifest("package.json");
20
+ const workspaces = Array.isArray(rootManifest.workspaces)
21
+ ? rootManifest.workspaces.filter((workspace): workspace is string => typeof workspace === "string")
22
+ : [];
23
+ const paths = new Set(["package.json"]);
24
+ for (const workspace of workspaces) {
25
+ const glob = new Bun.Glob(`${workspace}/package.json`);
26
+ for await (const relativePath of glob.scan({ cwd: workspaceRoot, onlyFiles: true })) {
27
+ paths.add(relativePath);
28
+ }
29
+ }
30
+ return [...paths].sort();
31
+ };
32
+
33
+ describe("tooling rollout gate", () => {
34
+ test("allows the stable TypeScript Compiler API baseline", () => {
35
+ expect(
36
+ findToolingRolloutViolations(
37
+ manifest({
38
+ dependencies: { typescript: "^6.0.3" },
39
+ }),
40
+ ),
41
+ ).toEqual([]);
42
+ });
43
+
44
+ test("rejects reference-only and experiment-only AST dependencies in package manifests", () => {
45
+ const violations = findToolingRolloutViolations(
46
+ manifest({
47
+ dependencies: {
48
+ "@ttsc/graph": "0.1.0",
49
+ "ast-grep": "0.39.0",
50
+ recast: "0.23.11",
51
+ "ts-morph": "27.0.2",
52
+ },
53
+ optionalDependencies: {
54
+ "typescript-go": "0.0.0",
55
+ },
56
+ }),
57
+ );
58
+
59
+ expect(violations.map((violation) => `${violation.packageName}:${violation.status}`).sort()).toEqual([
60
+ "@ttsc/graph:reference-only",
61
+ "ast-grep:experiment-only",
62
+ "recast:experiment-only",
63
+ "ts-morph:experiment-only",
64
+ "typescript-go:blocked",
65
+ ]);
66
+ });
67
+
68
+ test("rejects TypeScript rc or native-preview toolchain transitions", () => {
69
+ const violations = findToolingRolloutViolations(
70
+ manifest({
71
+ devDependencies: {
72
+ "@typescript/native-preview": "0.0.1",
73
+ typescript: "npm:typescript@rc",
74
+ },
75
+ }),
76
+ );
77
+
78
+ expect(violations).toContainEqual(
79
+ expect.objectContaining({
80
+ packageName: "typescript",
81
+ section: "devDependencies",
82
+ status: "blocked",
83
+ }),
84
+ );
85
+ expect(violations).toContainEqual(
86
+ expect.objectContaining({
87
+ packageName: "@typescript/native-preview",
88
+ section: "devDependencies",
89
+ status: "blocked",
90
+ }),
91
+ );
92
+ });
93
+
94
+ test("keeps workspace package manifests free of blocked AST and graph dependencies", async () => {
95
+ const manifestPaths = await workspacePackageManifestPaths();
96
+ expect(manifestPaths).toEqual(
97
+ expect.arrayContaining([
98
+ "package.json",
99
+ "pkgs/@akanjs/devkit/package.json",
100
+ "pkgs/@akanjs/cli/package.json",
101
+ "pkgs/akanjs/package.json",
102
+ "pkgs/create-akan-workspace/package.json",
103
+ ]),
104
+ );
105
+ const manifests = await Promise.all(manifestPaths.map(readWorkspaceManifest));
106
+
107
+ expect(manifests.flatMap(findToolingRolloutViolations)).toEqual([]);
108
+ });
109
+ });