@beignet/cli 0.0.1

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/src/lint.ts ADDED
@@ -0,0 +1,785 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ type BeignetConfig,
5
+ directoryPath,
6
+ loadBeignetConfig,
7
+ normalizePath,
8
+ type ResolvedBeignetConfig,
9
+ resolveConfig,
10
+ } from "./config.js";
11
+
12
+ type LintAppOptions = {
13
+ cwd?: string;
14
+ config?: BeignetConfig;
15
+ };
16
+
17
+ type SourceLayer =
18
+ | "app"
19
+ | "app-context"
20
+ | "client"
21
+ | "component"
22
+ | "contract"
23
+ | "domain"
24
+ | "feature-port"
25
+ | "infra"
26
+ | "lib"
27
+ | "policy"
28
+ | "port"
29
+ | "route"
30
+ | "server"
31
+ | "test"
32
+ | "use-case"
33
+ | "unknown";
34
+
35
+ type ImportReference = {
36
+ importPath: string;
37
+ resolvedPath?: string;
38
+ packageName?: string;
39
+ };
40
+
41
+ export type LintDiagnostic = {
42
+ severity: "error";
43
+ code: string;
44
+ message: string;
45
+ file: string;
46
+ importPath: string;
47
+ };
48
+
49
+ export type LintAppResult = {
50
+ targetDir: string;
51
+ config: ResolvedBeignetConfig;
52
+ diagnostics: LintDiagnostic[];
53
+ };
54
+
55
+ export async function lintApp(
56
+ options: LintAppOptions = {},
57
+ ): Promise<LintAppResult> {
58
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
59
+ await assertDirectory(targetDir);
60
+
61
+ const files = await listFiles(targetDir);
62
+ const config = options.config
63
+ ? resolveConfig(options.config)
64
+ : await loadBeignetConfig(targetDir, files);
65
+ const diagnostics: LintDiagnostic[] = [];
66
+
67
+ for (const file of lintSourceFiles(files)) {
68
+ const sourceLayer = classifyPath(file, config);
69
+ if (sourceLayer === "test" || sourceLayer === "unknown") continue;
70
+
71
+ const source = await readFile(path.join(targetDir, file), "utf8");
72
+ for (const reference of parseImports(source, file)) {
73
+ const diagnostic = lintImport(file, sourceLayer, reference, config);
74
+ if (diagnostic) diagnostics.push(diagnostic);
75
+ }
76
+ }
77
+
78
+ return {
79
+ targetDir,
80
+ config,
81
+ diagnostics: dedupeDiagnostics(diagnostics),
82
+ };
83
+ }
84
+
85
+ export function formatLint(result: LintAppResult): string {
86
+ if (result.diagnostics.length === 0) {
87
+ return `No Beignet lint issues found in ${result.targetDir}.`;
88
+ }
89
+
90
+ return [
91
+ `Found ${result.diagnostics.length} Beignet lint issue${
92
+ result.diagnostics.length === 1 ? "" : "s"
93
+ } in ${result.targetDir}:`,
94
+ "",
95
+ ...result.diagnostics.map(
96
+ (diagnostic) => `ERROR ${diagnostic.code} ${diagnostic.file}
97
+ ${diagnostic.message}`,
98
+ ),
99
+ ].join("\n");
100
+ }
101
+
102
+ async function assertDirectory(targetDir: string): Promise<void> {
103
+ try {
104
+ const stats = await stat(targetDir);
105
+ if (!stats.isDirectory()) {
106
+ throw new Error(`${targetDir} is not a directory.`);
107
+ }
108
+ } catch (error) {
109
+ if (error instanceof Error && error.message.includes("not a directory")) {
110
+ throw error;
111
+ }
112
+ throw new Error(`Directory ${targetDir} does not exist.`);
113
+ }
114
+ }
115
+
116
+ async function listFiles(targetDir: string): Promise<string[]> {
117
+ const files: string[] = [];
118
+
119
+ async function visit(relativeDir: string): Promise<void> {
120
+ const absoluteDir = path.join(targetDir, relativeDir);
121
+ const entries = await readdir(absoluteDir, { withFileTypes: true });
122
+
123
+ for (const entry of entries) {
124
+ if (ignoredDirectoryNames.has(entry.name)) continue;
125
+
126
+ const relativePath = normalizePath(path.join(relativeDir, entry.name));
127
+ if (entry.isDirectory()) {
128
+ await visit(relativePath);
129
+ } else if (entry.isFile()) {
130
+ files.push(relativePath);
131
+ }
132
+ }
133
+ }
134
+
135
+ await visit("");
136
+ return files.sort();
137
+ }
138
+
139
+ const ignoredDirectoryNames = new Set([
140
+ ".git",
141
+ ".next",
142
+ ".turbo",
143
+ "coverage",
144
+ "dist",
145
+ "node_modules",
146
+ ]);
147
+
148
+ function lintSourceFiles(files: string[]): string[] {
149
+ return files.filter((file) => {
150
+ if (file.endsWith(".d.ts")) return false;
151
+ return /\.(c|m)?tsx?$/.test(file);
152
+ });
153
+ }
154
+
155
+ function parseImports(source: string, importerFile: string): ImportReference[] {
156
+ const references = parseImportSpecifiers(source);
157
+
158
+ return references.map((importPath) => ({
159
+ importPath,
160
+ ...resolveImport(importPath, importerFile),
161
+ }));
162
+ }
163
+
164
+ function parseImportSpecifiers(source: string): string[] {
165
+ const specifiers: string[] = [];
166
+ let index = 0;
167
+
168
+ while (index < source.length) {
169
+ index = skipNonCode(source, index);
170
+
171
+ if (isKeywordAt(source, index, "import")) {
172
+ const parsed = parseImportSpecifierAt(source, index + "import".length);
173
+ if (parsed?.specifier) specifiers.push(parsed.specifier);
174
+ index = parsed?.end ?? index + "import".length;
175
+ continue;
176
+ }
177
+
178
+ if (isKeywordAt(source, index, "export")) {
179
+ const parsed = parseExportSpecifierAt(source, index + "export".length);
180
+ if (parsed?.specifier) specifiers.push(parsed.specifier);
181
+ index = parsed?.end ?? index + "export".length;
182
+ continue;
183
+ }
184
+
185
+ index++;
186
+ }
187
+
188
+ return specifiers;
189
+ }
190
+
191
+ function parseImportSpecifierAt(
192
+ source: string,
193
+ index: number,
194
+ ): { specifier?: string; end: number } | undefined {
195
+ let cursor = skipWhitespaceAndComments(source, index);
196
+ if (source[cursor] === "(") {
197
+ cursor = skipWhitespaceAndComments(source, cursor + 1);
198
+ const stringLiteral = readStringLiteral(source, cursor);
199
+ if (!stringLiteral) return { end: cursor };
200
+ return {
201
+ specifier: stringLiteral.value,
202
+ end: stringLiteral.end,
203
+ };
204
+ }
205
+
206
+ const sideEffectImport = readStringLiteral(source, cursor);
207
+ if (sideEffectImport) {
208
+ return {
209
+ specifier: sideEffectImport.value,
210
+ end: sideEffectImport.end,
211
+ };
212
+ }
213
+
214
+ while (cursor < source.length) {
215
+ cursor = skipWhitespaceAndComments(source, cursor);
216
+
217
+ if (source[cursor] === ";" || source[cursor] === "\n") {
218
+ return { end: cursor + 1 };
219
+ }
220
+
221
+ if (isKeywordAt(source, cursor, "from")) {
222
+ const fromCursor = skipWhitespaceAndComments(
223
+ source,
224
+ cursorAfterKeyword("from", cursor),
225
+ );
226
+ const stringLiteral = readStringLiteral(source, fromCursor);
227
+ if (!stringLiteral) return { end: fromCursor };
228
+ return {
229
+ specifier: stringLiteral.value,
230
+ end: stringLiteral.end,
231
+ };
232
+ }
233
+
234
+ cursor = skipToken(source, cursor);
235
+ }
236
+
237
+ return undefined;
238
+ }
239
+
240
+ function parseExportSpecifierAt(
241
+ source: string,
242
+ index: number,
243
+ ): { specifier?: string; end: number } | undefined {
244
+ let cursor = index;
245
+
246
+ while (cursor < source.length) {
247
+ cursor = skipWhitespaceAndComments(source, cursor);
248
+
249
+ if (source[cursor] === ";" || source[cursor] === "\n") {
250
+ return { end: cursor + 1 };
251
+ }
252
+
253
+ if (isKeywordAt(source, cursor, "from")) {
254
+ const fromCursor = skipWhitespaceAndComments(
255
+ source,
256
+ cursorAfterKeyword("from", cursor),
257
+ );
258
+ const stringLiteral = readStringLiteral(source, fromCursor);
259
+ if (!stringLiteral) return { end: fromCursor };
260
+ return {
261
+ specifier: stringLiteral.value,
262
+ end: stringLiteral.end,
263
+ };
264
+ }
265
+
266
+ cursor = skipToken(source, cursor);
267
+ }
268
+
269
+ return undefined;
270
+ }
271
+
272
+ function cursorAfterKeyword(keyword: string, index: number): number {
273
+ return index + keyword.length;
274
+ }
275
+
276
+ function skipNonCode(source: string, index: number): number {
277
+ let cursor = index;
278
+
279
+ while (cursor < source.length) {
280
+ if (source.startsWith("//", cursor)) {
281
+ cursor = skipLineComment(source, cursor);
282
+ continue;
283
+ }
284
+ if (source.startsWith("/*", cursor)) {
285
+ cursor = skipBlockComment(source, cursor);
286
+ continue;
287
+ }
288
+ if (source[cursor] === '"' || source[cursor] === "'") {
289
+ cursor = skipStringLiteral(source, cursor);
290
+ continue;
291
+ }
292
+ if (source[cursor] === "`") {
293
+ cursor = skipTemplateLiteral(source, cursor);
294
+ continue;
295
+ }
296
+ break;
297
+ }
298
+
299
+ return cursor;
300
+ }
301
+
302
+ function skipWhitespaceAndComments(source: string, index: number): number {
303
+ let cursor = index;
304
+
305
+ while (cursor < source.length) {
306
+ if (/\s/.test(source[cursor])) {
307
+ cursor++;
308
+ continue;
309
+ }
310
+ if (source.startsWith("//", cursor)) {
311
+ cursor = skipLineComment(source, cursor);
312
+ continue;
313
+ }
314
+ if (source.startsWith("/*", cursor)) {
315
+ cursor = skipBlockComment(source, cursor);
316
+ continue;
317
+ }
318
+ break;
319
+ }
320
+
321
+ return cursor;
322
+ }
323
+
324
+ function skipToken(source: string, index: number): number {
325
+ if (source[index] === '"' || source[index] === "'") {
326
+ return skipStringLiteral(source, index);
327
+ }
328
+ if (source[index] === "`") {
329
+ return skipTemplateLiteral(source, index);
330
+ }
331
+
332
+ return index + 1;
333
+ }
334
+
335
+ function readStringLiteral(
336
+ source: string,
337
+ index: number,
338
+ ): { value: string; end: number } | undefined {
339
+ const quote = source[index];
340
+ if (quote !== '"' && quote !== "'") return undefined;
341
+
342
+ let value = "";
343
+ let escaped = false;
344
+
345
+ for (let cursor = index + 1; cursor < source.length; cursor++) {
346
+ const char = source[cursor];
347
+ if (escaped) {
348
+ value += char;
349
+ escaped = false;
350
+ continue;
351
+ }
352
+ if (char === "\\") {
353
+ escaped = true;
354
+ continue;
355
+ }
356
+ if (char === quote) {
357
+ return { value, end: cursor + 1 };
358
+ }
359
+ value += char;
360
+ }
361
+
362
+ return undefined;
363
+ }
364
+
365
+ function skipStringLiteral(source: string, index: number): number {
366
+ return readStringLiteral(source, index)?.end ?? source.length;
367
+ }
368
+
369
+ function skipTemplateLiteral(source: string, index: number): number {
370
+ let escaped = false;
371
+
372
+ for (let cursor = index + 1; cursor < source.length; cursor++) {
373
+ const char = source[cursor];
374
+ if (escaped) {
375
+ escaped = false;
376
+ continue;
377
+ }
378
+ if (char === "\\") {
379
+ escaped = true;
380
+ continue;
381
+ }
382
+ if (char === "`") {
383
+ return cursor + 1;
384
+ }
385
+ }
386
+
387
+ return source.length;
388
+ }
389
+
390
+ function skipLineComment(source: string, index: number): number {
391
+ const end = source.indexOf("\n", index + 2);
392
+ return end === -1 ? source.length : end + 1;
393
+ }
394
+
395
+ function skipBlockComment(source: string, index: number): number {
396
+ const end = source.indexOf("*/", index + 2);
397
+ return end === -1 ? source.length : end + 2;
398
+ }
399
+
400
+ function isKeywordAt(source: string, index: number, keyword: string): boolean {
401
+ if (!source.startsWith(keyword, index)) return false;
402
+
403
+ const before = source[index - 1];
404
+ const after = source[index + keyword.length];
405
+ return !isIdentifierChar(before) && !isIdentifierChar(after);
406
+ }
407
+
408
+ function isIdentifierChar(char: string | undefined): boolean {
409
+ return Boolean(char && /[A-Za-z0-9_$]/.test(char));
410
+ }
411
+
412
+ function resolveImport(
413
+ importPath: string,
414
+ importerFile: string,
415
+ ): Pick<ImportReference, "packageName" | "resolvedPath"> {
416
+ if (importPath.startsWith("@/")) {
417
+ return {
418
+ resolvedPath: stripKnownExtension(importPath.slice(2)),
419
+ };
420
+ }
421
+
422
+ if (importPath.startsWith(".")) {
423
+ return {
424
+ resolvedPath: stripKnownExtension(
425
+ normalizePath(path.join(path.dirname(importerFile), importPath)),
426
+ ),
427
+ };
428
+ }
429
+
430
+ return {
431
+ packageName: packageName(importPath),
432
+ };
433
+ }
434
+
435
+ function packageName(importPath: string): string {
436
+ if (!importPath.startsWith("@")) return importPath.split("/")[0];
437
+
438
+ const [scope, name] = importPath.split("/");
439
+ return `${scope}/${name}`;
440
+ }
441
+
442
+ function stripKnownExtension(filePath: string): string {
443
+ return filePath.replace(/\.(c|m)?tsx?$/, "");
444
+ }
445
+
446
+ function lintImport(
447
+ file: string,
448
+ sourceLayer: SourceLayer,
449
+ reference: ImportReference,
450
+ config: ResolvedBeignetConfig,
451
+ ): LintDiagnostic | undefined {
452
+ const targetLayer = reference.resolvedPath
453
+ ? classifyPath(reference.resolvedPath, config)
454
+ : undefined;
455
+ const appImport = reference.resolvedPath
456
+ ? appImportViolation(sourceLayer, targetLayer)
457
+ : undefined;
458
+
459
+ if (appImport) {
460
+ return {
461
+ severity: "error",
462
+ code: "CK_IMPORT_DIRECTION",
463
+ file,
464
+ importPath: reference.importPath,
465
+ message: appImportMessage(sourceLayer, reference.importPath, appImport),
466
+ };
467
+ }
468
+
469
+ if (
470
+ reference.packageName &&
471
+ packageImportViolation(sourceLayer, reference.packageName)
472
+ ) {
473
+ return {
474
+ severity: "error",
475
+ code: "CK_IMPORT_DIRECTION",
476
+ file,
477
+ importPath: reference.importPath,
478
+ message: packageImportMessage(sourceLayer, reference.packageName),
479
+ };
480
+ }
481
+
482
+ return undefined;
483
+ }
484
+
485
+ function appImportViolation(
486
+ sourceLayer: SourceLayer,
487
+ targetLayer: SourceLayer | undefined,
488
+ ): SourceLayer | undefined {
489
+ if (!targetLayer) return undefined;
490
+
491
+ if (
492
+ sourceLayer === "domain" &&
493
+ [
494
+ "app",
495
+ "app-context",
496
+ "client",
497
+ "component",
498
+ "contract",
499
+ "feature-port",
500
+ "infra",
501
+ "lib",
502
+ "policy",
503
+ "port",
504
+ "route",
505
+ "server",
506
+ "use-case",
507
+ ].includes(targetLayer)
508
+ ) {
509
+ return targetLayer;
510
+ }
511
+
512
+ if (
513
+ sourceLayer === "use-case" &&
514
+ ["app", "client", "component", "infra", "route", "server"].includes(
515
+ targetLayer,
516
+ )
517
+ ) {
518
+ return targetLayer;
519
+ }
520
+
521
+ if (
522
+ ["feature-port", "policy", "port"].includes(sourceLayer) &&
523
+ ["app", "client", "component", "infra", "route", "server"].includes(
524
+ targetLayer,
525
+ )
526
+ ) {
527
+ return targetLayer;
528
+ }
529
+
530
+ if (
531
+ sourceLayer === "contract" &&
532
+ ["app", "client", "component", "infra", "route"].includes(targetLayer)
533
+ ) {
534
+ return targetLayer;
535
+ }
536
+
537
+ if (
538
+ sourceLayer === "route" &&
539
+ ["app", "client", "component", "infra"].includes(targetLayer)
540
+ ) {
541
+ return targetLayer;
542
+ }
543
+
544
+ return undefined;
545
+ }
546
+
547
+ function packageImportViolation(
548
+ sourceLayer: SourceLayer,
549
+ packageName: string,
550
+ ): boolean {
551
+ if (sourceLayer === "domain") {
552
+ return (
553
+ domainBannedPackages.has(packageName) ||
554
+ packageName.startsWith("@beignet/provider-")
555
+ );
556
+ }
557
+
558
+ if (
559
+ ["contract", "feature-port", "policy", "port", "use-case"].includes(
560
+ sourceLayer,
561
+ )
562
+ ) {
563
+ return (
564
+ coreBannedPackages.has(packageName) ||
565
+ serverRuntimePackages.has(packageName) ||
566
+ packageName.startsWith("@beignet/provider-")
567
+ );
568
+ }
569
+
570
+ if (sourceLayer === "route") {
571
+ return (
572
+ coreBannedPackages.has(packageName) ||
573
+ packageName.startsWith("@beignet/provider-")
574
+ );
575
+ }
576
+
577
+ return false;
578
+ }
579
+
580
+ const domainBannedPackages = new Set([
581
+ "@aws-sdk/client-s3",
582
+ "@aws-sdk/lib-storage",
583
+ "@aws-sdk/s3-request-presigner",
584
+ "@beignet/core/application",
585
+ "@beignet/core/client",
586
+ "@beignet/core/errors",
587
+ "@beignet/next",
588
+ "@beignet/core/ports",
589
+ "@beignet/react-hook-form",
590
+ "@beignet/react-query",
591
+ "@hookform/resolvers",
592
+ "@libsql/client",
593
+ "@neondatabase/serverless",
594
+ "@planetscale/database",
595
+ "@tanstack/react-query",
596
+ "@upstash/ratelimit",
597
+ "@upstash/redis",
598
+ "better-auth",
599
+ "drizzle-orm",
600
+ "inngest",
601
+ "mongodb",
602
+ "mongoose",
603
+ "mysql2",
604
+ "next",
605
+ "nodemailer",
606
+ "pg",
607
+ "pino",
608
+ "postgres",
609
+ "react",
610
+ "react-dom",
611
+ "react-hook-form",
612
+ "redis",
613
+ "resend",
614
+ ]);
615
+
616
+ const coreBannedPackages = new Set([
617
+ "@aws-sdk/client-s3",
618
+ "@aws-sdk/lib-storage",
619
+ "@aws-sdk/s3-request-presigner",
620
+ "@beignet/core/client",
621
+ "@beignet/react-hook-form",
622
+ "@beignet/react-query",
623
+ "@hookform/resolvers",
624
+ "@libsql/client",
625
+ "@neondatabase/serverless",
626
+ "@planetscale/database",
627
+ "@tanstack/react-query",
628
+ "@upstash/ratelimit",
629
+ "@upstash/redis",
630
+ "better-auth",
631
+ "drizzle-orm",
632
+ "inngest",
633
+ "mongodb",
634
+ "mongoose",
635
+ "mysql2",
636
+ "next",
637
+ "nodemailer",
638
+ "pg",
639
+ "pino",
640
+ "postgres",
641
+ "react",
642
+ "react-dom",
643
+ "react-hook-form",
644
+ "redis",
645
+ "resend",
646
+ ]);
647
+
648
+ const serverRuntimePackages = new Set([
649
+ "@beignet/devtools",
650
+ "@beignet/next",
651
+ "@beignet/core/openapi",
652
+ "@beignet/core/server",
653
+ ]);
654
+
655
+ function appImportMessage(
656
+ sourceLayer: SourceLayer,
657
+ importPath: string,
658
+ targetLayer: SourceLayer,
659
+ ): string {
660
+ return `${layerLabel(sourceLayer)} files must not import ${layerLabel(
661
+ targetLayer,
662
+ )} modules (${importPath}). Keep framework, UI, and infrastructure dependencies outside the core application boundary.`;
663
+ }
664
+
665
+ function packageImportMessage(
666
+ sourceLayer: SourceLayer,
667
+ packageName: string,
668
+ ): string {
669
+ return `${layerLabel(
670
+ sourceLayer,
671
+ )} files must not import runtime or provider package ${packageName}. Depend on Beignet ports or app-owned ports instead.`;
672
+ }
673
+
674
+ function classifyPath(
675
+ filePath: string,
676
+ config: ResolvedBeignetConfig,
677
+ ): SourceLayer {
678
+ const normalizedPath = stripKnownExtension(normalizePath(filePath));
679
+ const featuresPath = directoryPath(config.paths.features);
680
+ const portsDir = directoryPath(path.dirname(config.paths.ports));
681
+
682
+ if (isTestPath(normalizedPath)) return "test";
683
+ if (normalizedPath === stripKnownExtension(config.paths.appContext)) {
684
+ return "app-context";
685
+ }
686
+ if (
687
+ isUnder(normalizedPath, directoryPath(path.dirname(config.paths.server)))
688
+ ) {
689
+ return "server";
690
+ }
691
+ if (
692
+ isUnder(normalizedPath, directoryPath(path.dirname(config.paths.routes)))
693
+ ) {
694
+ return "app";
695
+ }
696
+ if (
697
+ isUnder(
698
+ normalizedPath,
699
+ directoryPath(path.dirname(config.paths.infrastructurePorts)),
700
+ )
701
+ ) {
702
+ return "infra";
703
+ }
704
+ if (isUnder(normalizedPath, portsDir)) return "port";
705
+ if (isUnder(normalizedPath, "client")) return "client";
706
+ if (isUnder(normalizedPath, "components")) return "component";
707
+ if (isUnder(normalizedPath, sharedDomainPath(config))) return "domain";
708
+ if (isUnder(normalizedPath, "lib")) return "lib";
709
+
710
+ const featurePath = featureRelativePath(normalizedPath, featuresPath);
711
+ if (featurePath) {
712
+ const layerSegment = featurePath.split("/")[1];
713
+ if (layerSegment === "domain") return "domain";
714
+ if (layerSegment === "contracts") return "contract";
715
+ if (layerSegment === "routes") return "route";
716
+ if (layerSegment === "ports") return "feature-port";
717
+ if (layerSegment === "policy") return "policy";
718
+ if (layerSegment === "use-cases") return "use-case";
719
+ if (layerSegment === "components") return "component";
720
+ }
721
+
722
+ if (isUnder(normalizedPath, directoryPath(config.paths.contracts))) {
723
+ return "contract";
724
+ }
725
+ if (isUnder(normalizedPath, directoryPath(config.paths.useCases))) {
726
+ return "use-case";
727
+ }
728
+ if (isUnder(normalizedPath, directoryPath(config.paths.policies))) {
729
+ return "policy";
730
+ }
731
+
732
+ return "unknown";
733
+ }
734
+
735
+ function isTestPath(filePath: string): boolean {
736
+ return (
737
+ filePath.includes("/tests/") ||
738
+ filePath.endsWith(".test") ||
739
+ filePath.endsWith(".spec")
740
+ );
741
+ }
742
+
743
+ function isUnder(filePath: string, directory: string): boolean {
744
+ const normalizedDir = directoryPath(directory);
745
+ if (!normalizedDir || normalizedDir === ".") return false;
746
+ return filePath === normalizedDir || filePath.startsWith(`${normalizedDir}/`);
747
+ }
748
+
749
+ function featureRelativePath(
750
+ filePath: string,
751
+ featuresPath: string,
752
+ ): string | undefined {
753
+ if (!isUnder(filePath, featuresPath)) return undefined;
754
+
755
+ const prefix = `${directoryPath(featuresPath)}/`;
756
+ return filePath.slice(prefix.length);
757
+ }
758
+
759
+ function sharedDomainPath(config: ResolvedBeignetConfig): string {
760
+ const featuresDir = directoryPath(config.paths.features);
761
+ const featuresParent = directoryPath(path.dirname(featuresDir));
762
+ if (!featuresParent || featuresParent === ".") return "domain";
763
+
764
+ return path.join(featuresParent, "domain");
765
+ }
766
+
767
+ function layerLabel(layer: SourceLayer): string {
768
+ return layer.replaceAll("-", " ");
769
+ }
770
+
771
+ function dedupeDiagnostics(diagnostics: LintDiagnostic[]): LintDiagnostic[] {
772
+ const seen = new Set<string>();
773
+ const deduped: LintDiagnostic[] = [];
774
+
775
+ for (const diagnostic of diagnostics) {
776
+ const key = [diagnostic.code, diagnostic.file, diagnostic.importPath].join(
777
+ ":",
778
+ );
779
+ if (seen.has(key)) continue;
780
+ seen.add(key);
781
+ deduped.push(diagnostic);
782
+ }
783
+
784
+ return deduped;
785
+ }