@akanjs/devkit 2.3.9-rc.9 → 2.3.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 2.3.9
4
+
5
+ ### Patch Changes
6
+
7
+ - f518afd: Improve dictionary type inference and lint coverage for generated workspaces.
8
+ - 6bc2209: auto-select app when the workspace has only one app
9
+ - Updated dependencies [f518afd]
10
+ - Updated dependencies [f518afd]
11
+ - akanjs@2.3.9
12
+
3
13
  ## 2.3.6
4
14
 
5
15
  ### Patch Changes
@@ -1,4 +1,5 @@
1
1
  import { cp, mkdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import type { App } from "./commandDecorators";
3
4
  import { FileSys } from "./fileSys";
4
5
  import { uploadRelease } from "./uploadRelease";
@@ -61,13 +62,14 @@ export class ApplicationReleasePackager {
61
62
  await cp(this.#app.dist.cwdPath, buildRoot, { recursive: true });
62
63
  await rm(`${buildRoot}/frontend/.next`, { recursive: true, force: true });
63
64
 
64
- await this.#app.workspace.spawn("tar", [
65
- "-zcf",
66
- `${this.#app.workspace.workspaceRoot}/releases/builds/${this.#app.name}-release.tar.gz`,
67
- "-C",
68
- buildRoot,
69
- "./",
70
- ]);
65
+ const releaseRoot = this.#app.workspace.workspaceRoot;
66
+ // Pass a path relative to cwd so tar never sees a Windows drive letter (e.g. "C:\...")
67
+ // which GNU tar would interpret as a remote "host:file" spec.
68
+ const releaseArchivePath = path
69
+ .relative(releaseRoot, `${releaseRoot}/releases/builds/${this.#app.name}-release.tar.gz`)
70
+ .split(path.sep)
71
+ .join("/");
72
+ await this.#app.workspace.spawn("tar", ["-zcf", releaseArchivePath, "-C", buildRoot, "./"], { cwd: releaseRoot });
71
73
  await this.#writeCsrZipIfPresent();
72
74
  return { platformVersion };
73
75
  }
@@ -109,13 +111,14 @@ export class ApplicationReleasePackager {
109
111
  );
110
112
  await this.#writeSourceTsconfig(sourceRoot, libDeps);
111
113
  await Bun.write(`${sourceRoot}/README.md`, readme);
112
- await this.#app.workspace.spawn("tar", [
113
- "-zcf",
114
- `${this.#app.workspace.cwdPath}/releases/sources/${this.#app.name}-source.tar.gz`,
115
- "-C",
116
- sourceRoot,
117
- "./",
118
- ]);
114
+ const sourceCwd = this.#app.workspace.cwdPath;
115
+ // Pass a path relative to cwd so tar never sees a Windows drive letter (e.g. "C:\...")
116
+ // which GNU tar would interpret as a remote "host:file" spec.
117
+ const sourceArchivePath = path
118
+ .relative(sourceCwd, `${sourceCwd}/releases/sources/${this.#app.name}-source.tar.gz`)
119
+ .split(path.sep)
120
+ .join("/");
121
+ await this.#app.workspace.spawn("tar", ["-zcf", sourceArchivePath, "-C", sourceRoot, "./"], { cwd: sourceCwd });
119
122
  }
120
123
 
121
124
  async #resetSourceRoot(sourceRoot: string): Promise<void> {
package/index.ts CHANGED
@@ -27,6 +27,7 @@ export * from "./guideline";
27
27
  export * from "./incrementalBuilder";
28
28
  export * from "./mobile";
29
29
  export * from "./prompter";
30
+ export * from "./qualityScanner";
30
31
  export * from "./scanInfo";
31
32
  export * from "./selectModel";
32
33
  export * from "./spinner";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9-rc.9",
3
+ "version": "2.3.9",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.9-rc.9",
35
+ "akanjs": "2.3.9",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
@@ -0,0 +1,687 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import ignore from "ignore";
5
+ import ts from "typescript";
6
+
7
+ type QualitySeverity = "warning";
8
+ type QualityScope = "global" | "file" | "convention" | "layout";
9
+
10
+ export interface QualityWarning {
11
+ rule: string;
12
+ scope: QualityScope;
13
+ severity: QualitySeverity;
14
+ message: string;
15
+ file?: string;
16
+ line?: number;
17
+ locations?: Array<{ file: string; line: number }>;
18
+ }
19
+
20
+ export interface QualityScanResult {
21
+ workspaceRoot: string;
22
+ scannedFiles: number;
23
+ warnings: QualityWarning[];
24
+ suggestedRules: string[];
25
+ }
26
+
27
+ interface SourceFileInfo {
28
+ file: string;
29
+ absolutePath: string;
30
+ content: string;
31
+ sourceFile: ts.SourceFile;
32
+ }
33
+
34
+ interface ExportedFunctionLike {
35
+ name: string;
36
+ kind: "class" | "function" | "function-variable";
37
+ file: string;
38
+ line: number;
39
+ bodyFingerprint?: string;
40
+ }
41
+
42
+ interface TopLevelDeclaration {
43
+ name: string;
44
+ kind: string;
45
+ line: number;
46
+ exported: boolean;
47
+ node: ts.Statement;
48
+ }
49
+
50
+ const MAX_FILE_LINES = 2000;
51
+ const PLACEHOLDER_EXPORT_NAMES = new Set([
52
+ "aa",
53
+ "dumb",
54
+ "dumb2",
55
+ "someBaseLogic",
56
+ "someCommonLogic",
57
+ "someFrontendLogic",
58
+ "someBackendLogic",
59
+ ]);
60
+
61
+ const SUGGESTED_RULES = [
62
+ "Keep generated scanSync index files out of hand-written changes; generated indexes should only contain one-depth export statements.",
63
+ "Generated Akan index files should not contain placeholder exports such as aa, dumb, dumb2, or some*Logic.",
64
+ "Dictionary text should not contain scaffold wording, misspellings, or stale copied domain nouns.",
65
+ "Warn earlier on very long Akan files: 500 lines for services, 800 lines for Template/Zone files, and 1000 lines for Util files.",
66
+ "Global declarations, Window augmentation, and prototype mutation should stay in explicitly approved low-level integration files.",
67
+ "Keep app root folders small and conventional: application code belongs under common, env, lib, page, private, public, script, srvkit, ui, or webkit.",
68
+ "Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
69
+ "Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
70
+ "Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
71
+ "Move shared app utilities to apps/*/common instead of creating apps/*/base.",
72
+ "Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
73
+ ];
74
+
75
+ const APP_ROOT_FILES = new Set([
76
+ "akan.app.json",
77
+ "akan.config.ts",
78
+ "capacitor.config.ts",
79
+ "client.ts",
80
+ "main.ts",
81
+ "package.json",
82
+ "server.ts",
83
+ "tsconfig.json",
84
+ ]);
85
+
86
+ const LIB_ROOT_FILES = new Set([
87
+ "cnst.ts",
88
+ "db.ts",
89
+ "dict.ts",
90
+ "option.ts",
91
+ "sig.ts",
92
+ "srv.ts",
93
+ "st.ts",
94
+ "useClient.ts",
95
+ "useServer.ts",
96
+ ]);
97
+
98
+ const CONVENTION_SUFFIXES = [
99
+ ".constant.ts",
100
+ ".dictionary.ts",
101
+ ".document.ts",
102
+ ".service.ts",
103
+ ".signal.ts",
104
+ ".store.ts",
105
+ ] as const;
106
+
107
+ export class AkanQualityScanner {
108
+ async scan(workspaceRoot: string): Promise<QualityScanResult> {
109
+ const targetFiles = await this.#collectTargetFiles(workspaceRoot);
110
+ const sourceFiles = await Promise.all(targetFiles.map((file) => this.#readSourceFile(workspaceRoot, file)));
111
+ const warnings = [
112
+ ...this.#scanGlobalQuality(sourceFiles),
113
+ ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
114
+ ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
115
+ ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
116
+ ];
117
+
118
+ return {
119
+ workspaceRoot,
120
+ scannedFiles: sourceFiles.length,
121
+ warnings: warnings.sort(compareWarnings),
122
+ suggestedRules: SUGGESTED_RULES,
123
+ };
124
+ }
125
+
126
+ async #collectTargetFiles(workspaceRoot: string) {
127
+ const ignoreFilter = ignore().add(await this.#readGitIgnore(workspaceRoot));
128
+ const files: string[] = [];
129
+ for (const targetRoot of ["apps", "libs"]) {
130
+ const absoluteTargetRoot = path.join(workspaceRoot, targetRoot);
131
+ if (!(await isDirectory(absoluteTargetRoot))) continue;
132
+ await this.#walkTargetFiles(workspaceRoot, absoluteTargetRoot, ignoreFilter, files);
133
+ }
134
+ return files.sort();
135
+ }
136
+
137
+ async #readGitIgnore(workspaceRoot: string) {
138
+ const gitIgnorePath = path.join(workspaceRoot, ".gitignore");
139
+ if (!(await Bun.file(gitIgnorePath).exists())) return [];
140
+ return (await readFile(gitIgnorePath, "utf8")).split(/\r?\n/);
141
+ }
142
+
143
+ async #walkTargetFiles(
144
+ workspaceRoot: string,
145
+ currentPath: string,
146
+ ignoreFilter: ReturnType<typeof ignore>,
147
+ files: string[],
148
+ ) {
149
+ const entries = await readdir(currentPath, { withFileTypes: true });
150
+ for (const entry of entries) {
151
+ const absolutePath = path.join(currentPath, entry.name);
152
+ const relativePath = toPosix(path.relative(workspaceRoot, absolutePath));
153
+ if (shouldSkipPath(relativePath, entry.isDirectory(), ignoreFilter)) continue;
154
+
155
+ if (entry.isDirectory()) {
156
+ await this.#walkTargetFiles(workspaceRoot, absolutePath, ignoreFilter, files);
157
+ continue;
158
+ }
159
+ if ((relativePath.endsWith(".ts") || relativePath.endsWith(".tsx")) && !relativePath.endsWith(".d.ts")) {
160
+ files.push(relativePath);
161
+ }
162
+ }
163
+ }
164
+
165
+ async #readSourceFile(workspaceRoot: string, file: string): Promise<SourceFileInfo> {
166
+ const absolutePath = path.join(workspaceRoot, file);
167
+ const content = await readFile(absolutePath, "utf8");
168
+ return {
169
+ file,
170
+ absolutePath,
171
+ content,
172
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, getScriptKind(file)),
173
+ };
174
+ }
175
+
176
+ #scanGlobalQuality(sourceFiles: SourceFileInfo[]): QualityWarning[] {
177
+ const exportedFunctionLikes = sourceFiles.flatMap((sourceFile) => getExportedFunctionLikes(sourceFile));
178
+ const warnings: QualityWarning[] = [];
179
+
180
+ for (const [name, declarations] of groupBy(exportedFunctionLikes, (declaration) => declaration.name)) {
181
+ if (declarations.length < 2) continue;
182
+ warnings.push({
183
+ rule: "akan.global.duplicate-exported-function-name",
184
+ scope: "global",
185
+ severity: "warning",
186
+ message: `Exported function or class name "${name}" is declared in ${declarations.length} files.`,
187
+ locations: declarations.map(({ file, line }) => ({ file, line })),
188
+ });
189
+ }
190
+
191
+ const declarationsWithBody = exportedFunctionLikes.filter((declaration) => declaration.bodyFingerprint);
192
+ for (const [fingerprint, declarations] of groupBy(
193
+ declarationsWithBody,
194
+ (declaration) => declaration.bodyFingerprint ?? "",
195
+ )) {
196
+ const uniqueNames = new Set(declarations.map((declaration) => declaration.name));
197
+ if (declarations.length < 2 || uniqueNames.size < 2 || fingerprint === "") continue;
198
+ warnings.push({
199
+ rule: "akan.global.duplicate-exported-function-body",
200
+ scope: "global",
201
+ severity: "warning",
202
+ message: `Exported functions/classes share the same implementation body: ${declarations
203
+ .map((declaration) => declaration.name)
204
+ .join(", ")}.`,
205
+ locations: declarations.map(({ file, line }) => ({ file, line })),
206
+ });
207
+ }
208
+
209
+ return warnings;
210
+ }
211
+
212
+ #scanSingleFileQuality(sourceFile: SourceFileInfo): QualityWarning[] {
213
+ const warnings: QualityWarning[] = [];
214
+ const lineCount = sourceFile.content.split(/\r?\n/).length;
215
+ const recommendedLineLimit = getRecommendedLineLimit(sourceFile.file);
216
+ if (recommendedLineLimit && lineCount > recommendedLineLimit) {
217
+ warnings.push({
218
+ rule: "akan.file.recommended-max-lines",
219
+ scope: "file",
220
+ severity: "warning",
221
+ file: sourceFile.file,
222
+ message: `File has ${lineCount} lines. Recommended limit for this file type is ${recommendedLineLimit} lines.`,
223
+ });
224
+ }
225
+ if (lineCount > MAX_FILE_LINES) {
226
+ warnings.push({
227
+ rule: "akan.file.max-lines",
228
+ scope: "file",
229
+ severity: "warning",
230
+ file: sourceFile.file,
231
+ message: `File has ${lineCount} lines. Keep single files under ${MAX_FILE_LINES} lines.`,
232
+ });
233
+ }
234
+
235
+ warnings.push(...getPlaceholderExportWarnings(sourceFile));
236
+ warnings.push(...getDictionaryTextWarnings(sourceFile));
237
+ warnings.push(...getGlobalMutationWarnings(sourceFile));
238
+
239
+ const exportedClassNames = getExportedClassNames(sourceFile.sourceFile);
240
+ if (exportedClassNames.length === 0) return warnings;
241
+ const allowedInterfaceNames = new Set(exportedClassNames.map((name) => `${name}Options`));
242
+ for (const declaration of getTopLevelDeclarations(sourceFile)) {
243
+ if (declaration.kind === "class" && exportedClassNames.includes(declaration.name)) continue;
244
+ if (declaration.kind === "interface" && allowedInterfaceNames.has(declaration.name)) continue;
245
+ warnings.push({
246
+ rule: "akan.file.class-export-global-declaration",
247
+ scope: "file",
248
+ severity: "warning",
249
+ file: sourceFile.file,
250
+ line: declaration.line,
251
+ message: `Class export files should not declare top-level ${declaration.kind} "${declaration.name}". Move helpers to another file and import them.`,
252
+ });
253
+ }
254
+ return warnings;
255
+ }
256
+
257
+ #scanConventionQuality(sourceFile: SourceFileInfo): QualityWarning[] {
258
+ const suffix = CONVENTION_SUFFIXES.find((candidate) => sourceFile.file.endsWith(candidate));
259
+ if (!suffix) return [];
260
+
261
+ const modelName = toPascalCase(path.basename(sourceFile.file, suffix));
262
+ const warnings: QualityWarning[] = [];
263
+ for (const declaration of getTopLevelDeclarations(sourceFile)) {
264
+ if (isAllowedConventionDeclaration(suffix, modelName, declaration)) continue;
265
+ warnings.push({
266
+ rule: `akan.convention${suffix.replace(".ts", "")}`,
267
+ scope: "convention",
268
+ severity: "warning",
269
+ file: sourceFile.file,
270
+ line: declaration.line,
271
+ message: `${path.basename(sourceFile.file)} should not declare top-level ${declaration.kind} "${declaration.name}". Allowed declarations: ${getConventionDescription(
272
+ suffix,
273
+ modelName,
274
+ )}.`,
275
+ });
276
+ }
277
+ return warnings;
278
+ }
279
+
280
+ #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
281
+ const segments = sourceFile.file.split("/");
282
+ const warnings: QualityWarning[] = [];
283
+ if (segments[0] === "apps" && segments.length === 3 && !APP_ROOT_FILES.has(segments[2])) {
284
+ warnings.push({
285
+ rule: "akan.layout.app-root-file",
286
+ scope: "layout",
287
+ severity: "warning",
288
+ file: sourceFile.file,
289
+ message: `Unexpected app root file "${segments[2]}". Keep application code in conventional app folders.`,
290
+ });
291
+ }
292
+
293
+ const libRootFile = getLibRootFile(sourceFile.file);
294
+ if (libRootFile && !LIB_ROOT_FILES.has(libRootFile)) {
295
+ warnings.push({
296
+ rule: "akan.layout.lib-root-file",
297
+ scope: "layout",
298
+ severity: "warning",
299
+ file: sourceFile.file,
300
+ message: `Unexpected lib root file "${libRootFile}". Keep direct lib root files limited to generated support facets.`,
301
+ });
302
+ }
303
+
304
+ const moduleUiWarning = getModuleUiWarning(sourceFile.file);
305
+ if (moduleUiWarning) warnings.push(moduleUiWarning);
306
+ return warnings;
307
+ }
308
+ }
309
+
310
+ export function formatQualityScanResult(result: QualityScanResult) {
311
+ const sections = [
312
+ "Akan Code Quality Scan",
313
+ `workspace: ${result.workspaceRoot}`,
314
+ `scanned files: ${result.scannedFiles}`,
315
+ `warnings: ${result.warnings.length}`,
316
+ "",
317
+ "Warnings:",
318
+ "",
319
+ ...formatQualityWarnings(result.warnings),
320
+ "",
321
+ "Suggested quality rules:",
322
+ "",
323
+ ...result.suggestedRules.map((rule) => ` - ${rule}`),
324
+ ];
325
+ return sections.join("\n");
326
+ }
327
+
328
+ export function formatQualityWarnings(warnings: QualityWarning[]) {
329
+ if (warnings.length === 0) return ["No warnings found."];
330
+ return warnings.flatMap((warning) => {
331
+ const location = formatQualityLocation(warning.file, warning.line);
332
+ const lines = [`${location} - warning ${warning.rule}: ${warning.message}`];
333
+ if (warning.locations?.length) {
334
+ lines.push(
335
+ ...warning.locations.map(({ file, line }) => ` note: related location ${formatQualityLocation(file, line)}`),
336
+ );
337
+ }
338
+ return lines;
339
+ });
340
+ }
341
+
342
+ function formatQualityLocation(file: string | undefined, line: number | undefined) {
343
+ return `${file ?? "<global>"}:${line ?? 1}:1`;
344
+ }
345
+
346
+ function getExportedFunctionLikes(sourceFile: SourceFileInfo): ExportedFunctionLike[] {
347
+ const declarations: ExportedFunctionLike[] = [];
348
+ for (const statement of sourceFile.sourceFile.statements) {
349
+ if (ts.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
350
+ declarations.push({
351
+ name: statement.name.text,
352
+ kind: "function",
353
+ file: sourceFile.file,
354
+ line: getLine(sourceFile.sourceFile, statement),
355
+ bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement.body),
356
+ });
357
+ }
358
+ if (ts.isClassDeclaration(statement) && statement.name && isExported(statement)) {
359
+ declarations.push({
360
+ name: statement.name.text,
361
+ kind: "class",
362
+ file: sourceFile.file,
363
+ line: getLine(sourceFile.sourceFile, statement),
364
+ bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, statement),
365
+ });
366
+ }
367
+ if (ts.isVariableStatement(statement) && isExported(statement)) {
368
+ for (const declaration of statement.declarationList.declarations) {
369
+ if (!ts.isIdentifier(declaration.name) || !isFunctionLikeInitializer(declaration.initializer)) continue;
370
+ declarations.push({
371
+ name: declaration.name.text,
372
+ kind: "function-variable",
373
+ file: sourceFile.file,
374
+ line: getLine(sourceFile.sourceFile, declaration),
375
+ bodyFingerprint: getBodyFingerprint(sourceFile.sourceFile, declaration.initializer),
376
+ });
377
+ }
378
+ }
379
+ }
380
+ return declarations;
381
+ }
382
+
383
+ function getExportedClassNames(sourceFile: ts.SourceFile) {
384
+ return sourceFile.statements
385
+ .filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement) && !!statement.name)
386
+ .filter((statement) => isExported(statement))
387
+ .map((statement) => statement.name!.text);
388
+ }
389
+
390
+ function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
391
+ if (!sourceFile.file.endsWith("/index.ts") && !sourceFile.file.endsWith("/index.tsx")) return [];
392
+ return getTopLevelDeclarations(sourceFile)
393
+ .filter((declaration) => declaration.exported && PLACEHOLDER_EXPORT_NAMES.has(declaration.name))
394
+ .map((declaration) => ({
395
+ rule: "akan.file.placeholder-export",
396
+ scope: "file",
397
+ severity: "warning",
398
+ file: sourceFile.file,
399
+ line: declaration.line,
400
+ message: `Generated or barrel index file should not export placeholder "${declaration.name}".`,
401
+ }));
402
+ }
403
+
404
+ function getDictionaryTextWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
405
+ if (!sourceFile.file.endsWith(".dictionary.ts")) return [];
406
+ const stalePatterns = [
407
+ { pattern: /\b[A-Z][A-Za-z0-9]* description\b/, label: "scaffold description text" },
408
+ { pattern: /settting/, label: "misspelling: settting" },
409
+ { pattern: /배너 수/, label: "stale copied Korean domain noun: 배너 수" },
410
+ ];
411
+ return stalePatterns.flatMap(({ pattern, label }) =>
412
+ findPatternLines(sourceFile.content, pattern).map((line) => ({
413
+ rule: "akan.file.dictionary-stale-text",
414
+ scope: "file",
415
+ severity: "warning",
416
+ file: sourceFile.file,
417
+ line,
418
+ message: `Dictionary text appears to contain ${label}.`,
419
+ })),
420
+ );
421
+ }
422
+
423
+ function getGlobalMutationWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
424
+ const warnings: QualityWarning[] = [];
425
+ for (const statement of sourceFile.sourceFile.statements) {
426
+ if (ts.isModuleDeclaration(statement) && statement.name.getText(sourceFile.sourceFile) === "global") {
427
+ warnings.push({
428
+ rule: "akan.file.global-declaration",
429
+ scope: "file",
430
+ severity: "warning",
431
+ file: sourceFile.file,
432
+ line: getLine(sourceFile.sourceFile, statement),
433
+ message: "Global declarations require an explicit low-level integration allowlist.",
434
+ });
435
+ }
436
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === "Window") {
437
+ warnings.push({
438
+ rule: "akan.file.window-augmentation",
439
+ scope: "file",
440
+ severity: "warning",
441
+ file: sourceFile.file,
442
+ line: getLine(sourceFile.sourceFile, statement),
443
+ message: "Window augmentation should be isolated to approved browser integration files.",
444
+ });
445
+ }
446
+ if (
447
+ ts.isExpressionStatement(statement) &&
448
+ statement.expression.getText(sourceFile.sourceFile).includes(".prototype.")
449
+ ) {
450
+ warnings.push({
451
+ rule: "akan.file.prototype-mutation",
452
+ scope: "file",
453
+ severity: "warning",
454
+ file: sourceFile.file,
455
+ line: getLine(sourceFile.sourceFile, statement),
456
+ message: "Prototype mutation should be avoided or isolated to approved low-level integration files.",
457
+ });
458
+ }
459
+ }
460
+ return warnings;
461
+ }
462
+
463
+ function getTopLevelDeclarations(sourceFile: SourceFileInfo): TopLevelDeclaration[] {
464
+ return sourceFile.sourceFile.statements.flatMap((statement) =>
465
+ getTopLevelDeclaration(sourceFile.sourceFile, statement),
466
+ );
467
+ }
468
+
469
+ function getTopLevelDeclaration(sourceFile: ts.SourceFile, statement: ts.Statement): TopLevelDeclaration[] {
470
+ const line = getLine(sourceFile, statement);
471
+ if (ts.isClassDeclaration(statement) && statement.name) {
472
+ return [{ name: statement.name.text, kind: "class", line, exported: isExported(statement), node: statement }];
473
+ }
474
+ if (ts.isFunctionDeclaration(statement) && statement.name) {
475
+ return [{ name: statement.name.text, kind: "function", line, exported: isExported(statement), node: statement }];
476
+ }
477
+ if (ts.isInterfaceDeclaration(statement)) {
478
+ return [{ name: statement.name.text, kind: "interface", line, exported: isExported(statement), node: statement }];
479
+ }
480
+ if (ts.isTypeAliasDeclaration(statement)) {
481
+ return [{ name: statement.name.text, kind: "type", line, exported: isExported(statement), node: statement }];
482
+ }
483
+ if (ts.isEnumDeclaration(statement)) {
484
+ return [{ name: statement.name.text, kind: "enum", line, exported: isExported(statement), node: statement }];
485
+ }
486
+ if (ts.isVariableStatement(statement)) {
487
+ return statement.declarationList.declarations
488
+ .filter((declaration) => ts.isIdentifier(declaration.name))
489
+ .map((declaration) => ({
490
+ name: (declaration.name as ts.Identifier).text,
491
+ kind: "variable",
492
+ line: getLine(sourceFile, declaration),
493
+ exported: isExported(statement),
494
+ node: statement,
495
+ }));
496
+ }
497
+ if (ts.isExportDeclaration(statement)) {
498
+ return [{ name: "export declaration", kind: "export", line, exported: true, node: statement }];
499
+ }
500
+ return [];
501
+ }
502
+
503
+ function isAllowedConventionDeclaration(
504
+ suffix: (typeof CONVENTION_SUFFIXES)[number],
505
+ modelName: string,
506
+ declaration: TopLevelDeclaration,
507
+ ) {
508
+ if (suffix === ".dictionary.ts") return isExportedConst(declaration) && declaration.name === "dictionary";
509
+ if (suffix === ".constant.ts") return isAllowedConstantDeclaration(modelName, declaration);
510
+ if (suffix === ".document.ts")
511
+ return (
512
+ declaration.kind === "class" && [`${modelName}Filter`, modelName, `${modelName}Model`].includes(declaration.name)
513
+ );
514
+ if (suffix === ".service.ts") return declaration.kind === "class" && declaration.name === `${modelName}Service`;
515
+ if (suffix === ".signal.ts")
516
+ return (
517
+ declaration.kind === "class" &&
518
+ [`${modelName}Internal`, `${modelName}Slice`, `${modelName}Endpoint`].includes(declaration.name)
519
+ );
520
+ if (suffix === ".store.ts") return declaration.kind === "class" && declaration.name === `${modelName}Store`;
521
+ return false;
522
+ }
523
+
524
+ function isAllowedConstantDeclaration(modelName: string, declaration: TopLevelDeclaration) {
525
+ if (declaration.kind !== "class") return false;
526
+ if (
527
+ [`${modelName}Input`, `${modelName}Object`, modelName, `Light${modelName}`, `${modelName}Insight`].includes(
528
+ declaration.name,
529
+ )
530
+ )
531
+ return true;
532
+ if (!ts.isClassDeclaration(declaration.node)) return false;
533
+ const heritageClause = declaration.node.heritageClauses?.find(
534
+ (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
535
+ );
536
+ const expression = heritageClause?.types[0]?.expression;
537
+ return !!expression && expression.getText().startsWith("enumOf(");
538
+ }
539
+
540
+ function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number], modelName: string) {
541
+ if (suffix === ".dictionary.ts") return "export const dictionary";
542
+ if (suffix === ".constant.ts")
543
+ return `${modelName}Input, ${modelName}Object, ${modelName}, Light${modelName}, ${modelName}Insight, or enumOf classes`;
544
+ if (suffix === ".document.ts") return `${modelName}Filter, ${modelName}, or ${modelName}Model`;
545
+ if (suffix === ".service.ts") return `${modelName}Service`;
546
+ if (suffix === ".signal.ts") return `${modelName}Internal, ${modelName}Slice, or ${modelName}Endpoint`;
547
+ return `${modelName}Store`;
548
+ }
549
+
550
+ function getLibRootFile(file: string) {
551
+ const segments = file.split("/");
552
+ if (segments[0] === "libs" && segments.length === 4 && segments[2] === "lib") return segments[3];
553
+ if (segments[0] === "apps" && segments.length === 5 && segments[2] === "lib") return segments[4];
554
+ return null;
555
+ }
556
+
557
+ function getModuleUiWarning(file: string): QualityWarning | null {
558
+ if (!file.endsWith(".tsx")) return null;
559
+ const moduleInfo = getModuleInfo(file);
560
+ if (!moduleInfo) return null;
561
+ const { moduleName, fileName, kind } = moduleInfo;
562
+ const pascalName = toPascalCase(moduleName.replace(/^_+/, ""));
563
+ const allowedSuffixes =
564
+ kind === "database"
565
+ ? ["Template", "Unit", "Util", "View", "Zone"]
566
+ : kind === "service"
567
+ ? ["Util", "Zone"]
568
+ : ["Template", "Unit"];
569
+ const allowedFileNames = new Set(allowedSuffixes.map((suffix) => `${pascalName}.${suffix}.tsx`));
570
+ if (allowedFileNames.has(fileName) || fileName.endsWith(".test.tsx") || fileName.endsWith(".spec.tsx")) return null;
571
+ return {
572
+ rule: "akan.layout.module-ui-file",
573
+ scope: "layout",
574
+ severity: "warning",
575
+ file,
576
+ message: `Unexpected ${kind} module UI filename "${fileName}". Expected one of: ${[...allowedFileNames].join(", ")}.`,
577
+ };
578
+ }
579
+
580
+ function getModuleInfo(file: string) {
581
+ const segments = file.split("/");
582
+ const libIndex = segments.indexOf("lib");
583
+ if (libIndex < 0) return null;
584
+ const moduleName = segments[libIndex + 1];
585
+ if (moduleName === "__scalar") {
586
+ if (segments.length !== libIndex + 4) return null;
587
+ return { moduleName: segments[libIndex + 2], fileName: segments[libIndex + 3], kind: "scalar" as const };
588
+ }
589
+ if (segments.length !== libIndex + 3) return null;
590
+ const fileName = segments[libIndex + 2];
591
+ if (moduleName.startsWith("__")) {
592
+ const scalarName = moduleName === "__scalar" ? segments[libIndex + 2] : moduleName;
593
+ return { moduleName: scalarName, fileName, kind: "scalar" as const };
594
+ }
595
+ if (moduleName.startsWith("_")) return { moduleName, fileName, kind: "service" as const };
596
+ return { moduleName, fileName, kind: "database" as const };
597
+ }
598
+
599
+ function isExportedConst(declaration: TopLevelDeclaration) {
600
+ return (
601
+ declaration.exported &&
602
+ ts.isVariableStatement(declaration.node) &&
603
+ (declaration.node.declarationList.flags & ts.NodeFlags.Const) !== 0
604
+ );
605
+ }
606
+
607
+ function isExported(node: ts.Node) {
608
+ return (
609
+ !!ts.getCombinedModifierFlags(node as ts.Declaration) &&
610
+ !!(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export)
611
+ );
612
+ }
613
+
614
+ function isFunctionLikeInitializer(node: ts.Expression | undefined) {
615
+ return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
616
+ }
617
+
618
+ function getBodyFingerprint(sourceFile: ts.SourceFile, node: ts.Node | undefined) {
619
+ if (!node) return undefined;
620
+ const normalizedBody = node.getText(sourceFile).replace(/\s+/g, " ").trim();
621
+ if (normalizedBody.length < 80) return undefined;
622
+ return createHash("sha256").update(normalizedBody).digest("hex");
623
+ }
624
+
625
+ function shouldSkipPath(relativePath: string, isDirectory: boolean, ignoreFilter: ReturnType<typeof ignore>) {
626
+ const ignorePath = isDirectory ? `${relativePath}/` : relativePath;
627
+ return (
628
+ relativePath === ".git" ||
629
+ relativePath.includes("/.git/") ||
630
+ relativePath.includes("/node_modules/") ||
631
+ ignoreFilter.ignores(relativePath) ||
632
+ ignoreFilter.ignores(ignorePath)
633
+ );
634
+ }
635
+
636
+ async function isDirectory(absolutePath: string) {
637
+ try {
638
+ return (await stat(absolutePath)).isDirectory();
639
+ } catch {
640
+ return false;
641
+ }
642
+ }
643
+
644
+ function getScriptKind(file: string) {
645
+ return file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
646
+ }
647
+
648
+ function getLine(sourceFile: ts.SourceFile, node: ts.Node) {
649
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
650
+ }
651
+
652
+ function getRecommendedLineLimit(file: string) {
653
+ if (file.endsWith(".service.ts")) return 500;
654
+ if (file.endsWith(".Template.tsx") || file.endsWith(".Zone.tsx")) return 800;
655
+ if (file.endsWith(".Util.tsx")) return 1000;
656
+ return null;
657
+ }
658
+
659
+ function findPatternLines(content: string, pattern: RegExp) {
660
+ return content.split(/\r?\n/).flatMap((line, index) => (pattern.test(line) ? [index + 1] : []));
661
+ }
662
+
663
+ function toPascalCase(value: string) {
664
+ return value.replace(/(^|[-_./])([a-zA-Z0-9])/g, (_, __, char: string) => char.toUpperCase()).replace(/[-_./]/g, "");
665
+ }
666
+
667
+ function toPosix(value: string) {
668
+ return value.split(path.sep).join("/");
669
+ }
670
+
671
+ function groupBy<T>(items: T[], getKey: (item: T) => string) {
672
+ const grouped = new Map<string, T[]>();
673
+ for (const item of items) {
674
+ const key = getKey(item);
675
+ grouped.set(key, [...(grouped.get(key) ?? []), item]);
676
+ }
677
+ return grouped;
678
+ }
679
+
680
+ function compareWarnings(a: QualityWarning, b: QualityWarning) {
681
+ return (
682
+ a.scope.localeCompare(b.scope) ||
683
+ (a.file ?? "").localeCompare(b.file ?? "") ||
684
+ (a.line ?? 0) - (b.line ?? 0) ||
685
+ a.rule.localeCompare(b.rule)
686
+ );
687
+ }