@beignet/cli 0.0.1 → 0.0.3

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/inspect.ts CHANGED
@@ -24,6 +24,9 @@ type HttpMethod =
24
24
  | "HEAD"
25
25
  | "OPTIONS";
26
26
 
27
+ /**
28
+ * Contract discovered by app inspection.
29
+ */
27
30
  export type InspectedContract = {
28
31
  exportName: string;
29
32
  file: string;
@@ -31,12 +34,18 @@ export type InspectedContract = {
31
34
  path: string;
32
35
  };
33
36
 
37
+ /**
38
+ * Route handler mapping discovered by app inspection.
39
+ */
34
40
  export type InspectedRoute = InspectedContract & {
35
41
  handlerFile?: string;
36
42
  handlerExport?: HttpMethod;
37
43
  handlerSource: "next-route" | "route-local" | "missing";
38
44
  };
39
45
 
46
+ /**
47
+ * Doctor diagnostic produced by app inspection.
48
+ */
40
49
  export type InspectDiagnostic = {
41
50
  severity: "error" | "warning";
42
51
  code: string;
@@ -45,12 +54,18 @@ export type InspectDiagnostic = {
45
54
  contract?: string;
46
55
  };
47
56
 
57
+ /**
58
+ * Automatic doctor fix that was applied.
59
+ */
48
60
  export type InspectFix = {
49
61
  code: string;
50
62
  message: string;
51
63
  file: string;
52
64
  };
53
65
 
66
+ /**
67
+ * Detected app convention and missing convention files.
68
+ */
54
69
  export type InspectConvention = {
55
70
  kind: "standard" | "next" | "custom";
56
71
  nextLayout: boolean;
@@ -60,6 +75,9 @@ export type InspectConvention = {
60
75
  missingResourceGenerator: string[];
61
76
  };
62
77
 
78
+ /**
79
+ * Full app inspection result.
80
+ */
63
81
  export type InspectAppResult = {
64
82
  targetDir: string;
65
83
  config: ResolvedBeignetConfig;
@@ -176,6 +194,9 @@ export async function applyDoctorFixes(
176
194
  return fixes;
177
195
  }
178
196
 
197
+ /**
198
+ * Format inspected routes as a CLI table.
199
+ */
179
200
  export function formatRoutes(result: InspectAppResult): string {
180
201
  if (result.routes.length === 0) {
181
202
  return `No Beignet routes found in ${result.targetDir}`;
@@ -197,6 +218,9 @@ export function formatRoutes(result: InspectAppResult): string {
197
218
  ]);
198
219
  }
199
220
 
221
+ /**
222
+ * Format doctor diagnostics and applied fixes for CLI output.
223
+ */
200
224
  export function formatDoctor(result: InspectAppResult): string {
201
225
  const fixLines =
202
226
  result.fixes.length === 0
@@ -797,6 +821,20 @@ async function inspectDiagnostics(
797
821
  contracts,
798
822
  )),
799
823
  ...(await inspectPortProviderDrift(targetDir, files, config, convention)),
824
+ ...(await inspectDatabaseLifecycleDrift(
825
+ targetDir,
826
+ files,
827
+ config,
828
+ convention,
829
+ strict,
830
+ )),
831
+ ...(await inspectServerlessFootguns(targetDir, files)),
832
+ ...(await inspectFeatureArtifactDrift(
833
+ targetDir,
834
+ files,
835
+ config,
836
+ convention,
837
+ )),
800
838
  ...(await inspectResourceSlices(
801
839
  targetDir,
802
840
  files,
@@ -809,6 +847,284 @@ async function inspectDiagnostics(
809
847
  return dedupeDiagnostics(diagnostics);
810
848
  }
811
849
 
850
+ async function inspectFeatureArtifactDrift(
851
+ targetDir: string,
852
+ files: string[],
853
+ config: ResolvedBeignetConfig,
854
+ convention: InspectConvention,
855
+ ): Promise<InspectDiagnostic[]> {
856
+ if (!convention.resourceGenerator) return [];
857
+
858
+ const diagnostics: InspectDiagnostic[] = [];
859
+ const featuresPath = directoryPath(config.paths.features);
860
+ const uploadDefinitions = files.filter((file) =>
861
+ isFeatureArtifactFile(file, featuresPath, "uploads"),
862
+ );
863
+
864
+ if (
865
+ uploadDefinitions.length > 0 &&
866
+ !(await appHasUploadRoute(targetDir, files, config))
867
+ ) {
868
+ diagnostics.push({
869
+ severity: "warning",
870
+ code: "CK_UPLOAD_ROUTE_MISSING",
871
+ file: uploadDefinitions[0],
872
+ message:
873
+ "This app defines feature uploads but no known upload route. Register the upload registry with createUploadRouter(...) and expose it through createUploadRoute(...) so browser uploads can reach the workflow.",
874
+ });
875
+ }
876
+
877
+ for (const file of files) {
878
+ if (
879
+ !file.endsWith(".ts") ||
880
+ file.endsWith(".test.ts") ||
881
+ file.endsWith(".d.ts")
882
+ ) {
883
+ continue;
884
+ }
885
+
886
+ const misplaced = misplacedFeatureArtifactFolder(file, featuresPath);
887
+ if (misplaced) {
888
+ diagnostics.push({
889
+ severity: "warning",
890
+ code: "CK_FEATURE_ARTIFACT_FOLDER",
891
+ file,
892
+ message: `${file} uses ${misplaced.actual}, but Beignet expects ${misplaced.expected}. Keep feature-owned workflow artifacts in the canonical feature folders so CLI generators, docs, and architecture lint stay aligned.`,
893
+ });
894
+ }
895
+
896
+ if (
897
+ isNotificationDefinitionFile(file, featuresPath) ||
898
+ isInfraOrServerFile(file, config)
899
+ ) {
900
+ continue;
901
+ }
902
+ const source = await readFile(path.join(targetDir, file), "utf8");
903
+ if (containsCallExpression(source, "createInlineNotificationDispatcher")) {
904
+ diagnostics.push({
905
+ severity: "warning",
906
+ code: "CK_NOTIFICATION_PORT_BYPASS",
907
+ file,
908
+ message: `${file} creates a notification dispatcher directly. Application workflows should send notifications through ctx.ports.notifications so delivery can be swapped, tested, instrumented, and run through jobs/outbox consistently.`,
909
+ });
910
+ }
911
+ }
912
+
913
+ return diagnostics;
914
+ }
915
+
916
+ function isFeatureArtifactFile(
917
+ file: string,
918
+ featuresPath: string,
919
+ folder: "uploads" | "notifications",
920
+ ): boolean {
921
+ const match = new RegExp(
922
+ `^${escapeRegExp(featuresPath)}/[^/]+/${folder}/([^/]+)\\.ts$`,
923
+ ).exec(file);
924
+ return Boolean(match && match[1] !== "index");
925
+ }
926
+
927
+ async function appHasUploadRoute(
928
+ targetDir: string,
929
+ files: string[],
930
+ config: ResolvedBeignetConfig,
931
+ ): Promise<boolean> {
932
+ for (const file of files) {
933
+ if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
934
+ if (!isRouteServerOrInfraFile(file, config)) continue;
935
+
936
+ const source = await readFile(path.join(targetDir, file), "utf8");
937
+ if (
938
+ containsCallExpression(source, "createUploadRoute") ||
939
+ containsCallExpression(source, "createUploadRouter")
940
+ ) {
941
+ return true;
942
+ }
943
+ }
944
+
945
+ return false;
946
+ }
947
+
948
+ function misplacedFeatureArtifactFolder(
949
+ file: string,
950
+ featuresPath: string,
951
+ ): { actual: string; expected: string } | undefined {
952
+ const match = new RegExp(
953
+ `^${escapeRegExp(featuresPath)}/([^/]+)/([^/]+)/`,
954
+ ).exec(file);
955
+ if (!match) return undefined;
956
+
957
+ const folder = match[2];
958
+ const mappings: Record<string, string> = {
959
+ event: "domain/events/",
960
+ events: "domain/events/",
961
+ job: "jobs/",
962
+ listener: "listeners/",
963
+ notification: "notifications/",
964
+ schedule: "schedules/",
965
+ scheduled: "schedules/",
966
+ upload: "uploads/",
967
+ };
968
+
969
+ const expected = mappings[folder];
970
+ if (!expected) return undefined;
971
+
972
+ return { actual: `${folder}/`, expected };
973
+ }
974
+
975
+ function isNotificationDefinitionFile(
976
+ file: string,
977
+ featuresPath: string,
978
+ ): boolean {
979
+ return isFeatureArtifactFile(file, featuresPath, "notifications");
980
+ }
981
+
982
+ function isRouteServerOrInfraFile(
983
+ file: string,
984
+ config: ResolvedBeignetConfig,
985
+ ): boolean {
986
+ return (
987
+ file.startsWith(`${directoryPath(config.paths.routes)}/`) ||
988
+ isInfraOrServerFile(file, config)
989
+ );
990
+ }
991
+
992
+ function isInfraOrServerFile(
993
+ file: string,
994
+ config: ResolvedBeignetConfig,
995
+ ): boolean {
996
+ const serverDir = directoryPath(path.dirname(config.paths.server));
997
+ const infrastructureDir = directoryPath(
998
+ path.dirname(config.paths.infrastructurePorts),
999
+ );
1000
+
1001
+ return (
1002
+ file === config.paths.server ||
1003
+ file.startsWith(`${serverDir}/`) ||
1004
+ file === config.paths.infrastructurePorts ||
1005
+ file.startsWith(`${infrastructureDir}/`)
1006
+ );
1007
+ }
1008
+
1009
+ function containsCallExpression(source: string, name: string): boolean {
1010
+ return new RegExp(
1011
+ `\\b${escapeRegExp(name)}\\s*(?:<[^;=(){}]*>\\s*)?\\(`,
1012
+ ).test(source);
1013
+ }
1014
+
1015
+ async function inspectServerlessFootguns(
1016
+ targetDir: string,
1017
+ files: string[],
1018
+ ): Promise<InspectDiagnostic[]> {
1019
+ const diagnostics: InspectDiagnostic[] = [];
1020
+
1021
+ for (const file of files) {
1022
+ if (
1023
+ !file.endsWith(".ts") ||
1024
+ file.endsWith(".test.ts") ||
1025
+ file.endsWith(".d.ts")
1026
+ ) {
1027
+ continue;
1028
+ }
1029
+
1030
+ const isInfraOrServerFile =
1031
+ file.startsWith("infra/") || file.startsWith("server/");
1032
+ const isProviderFile =
1033
+ /(^|\/)provider(s)?\.ts$/.test(file) ||
1034
+ /(^|\/)[^/]*provider[^/]*\.ts$/.test(file);
1035
+ const source = await readFile(path.join(targetDir, file), "utf8");
1036
+
1037
+ if (
1038
+ isInfraOrServerFile &&
1039
+ isProviderFile &&
1040
+ /\bset(?:Interval|Timeout)\s*\(/.test(source)
1041
+ ) {
1042
+ diagnostics.push({
1043
+ severity: "warning",
1044
+ code: "CK_PROVIDER_BACKGROUND_WORK",
1045
+ file,
1046
+ message: `${file} starts timer-based work from provider/server setup. Serverless apps should expose bounded runtime entrypoints such as cron routes, scheduled handlers, job functions, or worker processes instead of provider polling loops.`,
1047
+ });
1048
+ }
1049
+
1050
+ if (
1051
+ isInfraOrServerFile &&
1052
+ isProviderFile &&
1053
+ /\bcreateProvider\s*\(/.test(source) &&
1054
+ /\bdrainOutbox\s*\(/.test(source)
1055
+ ) {
1056
+ diagnostics.push({
1057
+ severity: "warning",
1058
+ code: "CK_PROVIDER_OUTBOX_DRAIN",
1059
+ file,
1060
+ message: `${file} drains the outbox from provider lifecycle code. Move outbox delivery to createOutboxDrainRoute(...) or another bounded runtime entrypoint so imports and serverless cold starts do not run background work.`,
1061
+ });
1062
+ }
1063
+
1064
+ if (
1065
+ file.startsWith("app/api/cron/") &&
1066
+ file.endsWith("/route.ts") &&
1067
+ !/\bauthorization\b/i.test(source) &&
1068
+ !/\bcreateOutboxDrainRoute\s*\(/.test(source)
1069
+ ) {
1070
+ diagnostics.push({
1071
+ severity: "warning",
1072
+ code: "CK_CRON_ROUTE_AUTH_MISSING",
1073
+ file,
1074
+ message: `${file} looks like a cron route without an authorization check. Require a shared secret or use a framework route helper such as createOutboxDrainRoute(...).`,
1075
+ });
1076
+ }
1077
+ }
1078
+
1079
+ if (
1080
+ hasOutboxRegistry(files) &&
1081
+ !(await appHasOutboxDrainEntrypoint(targetDir, files))
1082
+ ) {
1083
+ diagnostics.push({
1084
+ severity: "warning",
1085
+ code: "CK_OUTBOX_DRAIN_MISSING",
1086
+ message:
1087
+ "This app defines an outbox registry but no known drain entrypoint. Add a cron/schedule/job route with createOutboxDrainRoute(...) so durable events and jobs are delivered in serverless deployments.",
1088
+ });
1089
+ }
1090
+
1091
+ return diagnostics;
1092
+ }
1093
+
1094
+ function hasOutboxRegistry(files: string[]): boolean {
1095
+ return files.some(isOutboxRegistryFile);
1096
+ }
1097
+
1098
+ function isOutboxRegistryFile(file: string): boolean {
1099
+ return file === "server/outbox.ts";
1100
+ }
1101
+
1102
+ async function appHasOutboxDrainEntrypoint(
1103
+ targetDir: string,
1104
+ files: string[],
1105
+ ): Promise<boolean> {
1106
+ for (const file of files) {
1107
+ if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
1108
+ if (
1109
+ !file.startsWith("app/api/") &&
1110
+ !file.startsWith("server/") &&
1111
+ !file.startsWith("infra/")
1112
+ ) {
1113
+ continue;
1114
+ }
1115
+
1116
+ const source = await readFile(path.join(targetDir, file), "utf8");
1117
+ if (
1118
+ /\bcreateOutboxDrainRoute\s*\(/.test(source) ||
1119
+ /\bdrainOutbox\s*\(/.test(source)
1120
+ ) {
1121
+ return true;
1122
+ }
1123
+ }
1124
+
1125
+ return false;
1126
+ }
1127
+
812
1128
  async function inspectPortProviderDrift(
813
1129
  targetDir: string,
814
1130
  files: string[],
@@ -875,6 +1191,178 @@ async function inspectPortProviderDrift(
875
1191
  return diagnostics;
876
1192
  }
877
1193
 
1194
+ async function inspectDatabaseLifecycleDrift(
1195
+ targetDir: string,
1196
+ files: string[],
1197
+ config: ResolvedBeignetConfig,
1198
+ convention: InspectConvention,
1199
+ strict: boolean,
1200
+ ): Promise<InspectDiagnostic[]> {
1201
+ if (!convention.resourceGenerator) return [];
1202
+
1203
+ const diagnostics: InspectDiagnostic[] = [];
1204
+ const resources = await collectResourceNames(targetDir, files, config);
1205
+ const infrastructurePath = directoryPath(
1206
+ path.dirname(config.paths.infrastructurePorts),
1207
+ );
1208
+ const featuresPath = directoryPath(config.paths.features);
1209
+ const repositoriesPath = path.join(
1210
+ infrastructurePath,
1211
+ "db",
1212
+ "repositories.ts",
1213
+ );
1214
+ const repositoriesSource = files.includes(repositoriesPath)
1215
+ ? await readFile(path.join(targetDir, repositoriesPath), "utf8")
1216
+ : "";
1217
+
1218
+ for (const resource of resources) {
1219
+ const singular = singularize(resource);
1220
+ const pascal = pascalCase(singular);
1221
+ const pluralCamel = camelCase(resource);
1222
+ const infrastructureDir = `${infrastructurePath}/${resource}/`;
1223
+ const hasRepositoryAdapter = hasRepositoryAdapterFile(
1224
+ files,
1225
+ infrastructureDir,
1226
+ );
1227
+ const drizzleRepositoryAdapter = drizzleRepositoryAdapterFile(
1228
+ files,
1229
+ infrastructureDir,
1230
+ singular,
1231
+ );
1232
+
1233
+ const portFile = resourcePortFile(resource, singular, config);
1234
+
1235
+ if (files.includes(portFile) && !hasRepositoryAdapter) {
1236
+ diagnostics.push({
1237
+ severity: "warning",
1238
+ code: "CK_REPOSITORY_ADAPTER_MISSING",
1239
+ file: portFile,
1240
+ message: `${portFile} declares a repository port, but ${infrastructureDir} does not contain a repository adapter. Add an infra adapter such as drizzle-${singular}-repository.ts and wire it through app ports.`,
1241
+ });
1242
+ }
1243
+
1244
+ if (drizzleRepositoryAdapter) {
1245
+ const factoryName = `createDrizzle${pascal}Repository`;
1246
+ if (
1247
+ repositoriesSource &&
1248
+ (!repositoriesSource.includes(factoryName) ||
1249
+ !new RegExp(`\\b${pluralCamel}\\s*:`).test(repositoriesSource))
1250
+ ) {
1251
+ diagnostics.push({
1252
+ severity: "warning",
1253
+ code: "CK_DRIZZLE_REPOSITORY_UNWIRED",
1254
+ file: repositoriesPath,
1255
+ message: `${drizzleRepositoryAdapter} exists, but ${repositoriesPath} does not appear to import and return ${factoryName}(db). Wire the repository through createRepositories(...) so normal and transaction-scoped ports stay aligned.`,
1256
+ });
1257
+ }
1258
+ }
1259
+
1260
+ if (
1261
+ strict &&
1262
+ hasFeatureSeeds(files, featuresPath, resource) &&
1263
+ !hasFeatureFactories(files, featuresPath, resource)
1264
+ ) {
1265
+ diagnostics.push({
1266
+ severity: "warning",
1267
+ code: "CK_FACTORY_MISSING",
1268
+ file: `${featuresPath}/${resource}/seeds/`,
1269
+ message: `${resource} defines feature seeds but no feature factory. Add a factory under ${featuresPath}/${resource}/tests/factories/ so seeds and tests share the same app-owned data setup path.`,
1270
+ });
1271
+ }
1272
+ }
1273
+
1274
+ if (
1275
+ hasAnyFeatureSeeds(files, featuresPath) &&
1276
+ !(await packageScriptExists(targetDir, files, "db:seed"))
1277
+ ) {
1278
+ diagnostics.push({
1279
+ severity: "warning",
1280
+ code: "CK_DB_SEED_SCRIPT_MISSING",
1281
+ file: "package.json",
1282
+ message:
1283
+ 'This app defines feature seeds, but package.json does not define "db:seed". Add an app-owned db:seed script so beignet db seed can run the seed entrypoint.',
1284
+ });
1285
+ }
1286
+
1287
+ return diagnostics;
1288
+ }
1289
+
1290
+ function hasRepositoryAdapterFile(
1291
+ files: string[],
1292
+ infrastructureDir: string,
1293
+ ): boolean {
1294
+ return files.some(
1295
+ (file) =>
1296
+ file.startsWith(infrastructureDir) &&
1297
+ file.endsWith("-repository.ts") &&
1298
+ !file.endsWith(".test.ts"),
1299
+ );
1300
+ }
1301
+
1302
+ function drizzleRepositoryAdapterFile(
1303
+ files: string[],
1304
+ infrastructureDir: string,
1305
+ singular: string,
1306
+ ): string | undefined {
1307
+ return files.find(
1308
+ (file) =>
1309
+ file.startsWith(infrastructureDir) &&
1310
+ file.endsWith(`/drizzle-${singular}-repository.ts`),
1311
+ );
1312
+ }
1313
+
1314
+ function hasFeatureSeeds(
1315
+ files: string[],
1316
+ featuresPath: string,
1317
+ resource: string,
1318
+ ): boolean {
1319
+ return files.some(
1320
+ (file) =>
1321
+ file.startsWith(`${featuresPath}/${resource}/seeds/`) &&
1322
+ file.endsWith(".ts") &&
1323
+ !file.endsWith("/index.ts"),
1324
+ );
1325
+ }
1326
+
1327
+ function hasAnyFeatureSeeds(files: string[], featuresPath: string): boolean {
1328
+ return files.some(
1329
+ (file) =>
1330
+ new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/seeds/`).test(file) &&
1331
+ file.endsWith(".ts") &&
1332
+ !file.endsWith("/index.ts"),
1333
+ );
1334
+ }
1335
+
1336
+ function hasFeatureFactories(
1337
+ files: string[],
1338
+ featuresPath: string,
1339
+ resource: string,
1340
+ ): boolean {
1341
+ return files.some(
1342
+ (file) =>
1343
+ file === `${featuresPath}/${resource}/tests/factories.ts` ||
1344
+ (file.startsWith(`${featuresPath}/${resource}/tests/factories/`) &&
1345
+ file.endsWith(".ts") &&
1346
+ !file.endsWith("/index.ts")),
1347
+ );
1348
+ }
1349
+
1350
+ async function packageScriptExists(
1351
+ targetDir: string,
1352
+ files: string[],
1353
+ script: string,
1354
+ ): Promise<boolean> {
1355
+ if (!files.includes("package.json")) return false;
1356
+
1357
+ const packageJson = JSON.parse(
1358
+ await readFile(path.join(targetDir, "package.json"), "utf8"),
1359
+ ) as {
1360
+ scripts?: Record<string, string>;
1361
+ };
1362
+
1363
+ return Boolean(packageJson.scripts?.[script]);
1364
+ }
1365
+
878
1366
  const canonicalProviderPorts = [
879
1367
  {
880
1368
  packageName: "@beignet/provider-auth-better-auth",
@@ -1128,7 +1616,7 @@ function parseFeatureRouteGroups(
1128
1616
  ): FeatureRouteGroup[] {
1129
1617
  const routeGroups: FeatureRouteGroup[] = [];
1130
1618
  const exportRegex =
1131
- /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(\s*(?:\{[\s\S]*?routes:\s*)?\[([\s\S]*?)\]\s*(?:,?\s*\})?\s*\)\s*;?/g;
1619
+ /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?(?:\{[\s\S]*?routes:\s*)?\[([\s\S]*?)\]\s*(?:,?\s*\})?\s*\)\s*;?/g;
1132
1620
 
1133
1621
  for (const match of source.matchAll(exportRegex)) {
1134
1622
  const contracts = [...match[2].matchAll(/contract:\s*([^,\n}]+)/g)]
@@ -1745,9 +2233,10 @@ async function isGeneratedResourcePort(
1745
2233
 
1746
2234
  return (
1747
2235
  source.includes(`Create${singularPascal}Input`) &&
1748
- source.includes(`List${pluralPascal}Input`) &&
1749
2236
  source.includes(`List${pluralPascal}Result`) &&
2237
+ source.includes("OffsetPage") &&
1750
2238
  source.includes(`interface ${singularPascal}Repository`) &&
2239
+ source.includes("list(page: OffsetPage)") &&
1751
2240
  source.includes(`create(input: Create${singularPascal}Input)`)
1752
2241
  );
1753
2242
  }
@@ -1853,7 +2342,7 @@ function resourceDiagnostic(
1853
2342
  severity: "error",
1854
2343
  code,
1855
2344
  file,
1856
- message: `${resource} appears to be a generated resource slice, but ${file} is missing. Re-run make resource ${resource}, restore the missing file, or remove the partial resource wiring.`,
2345
+ message: `${resource} appears to be a generated feature slice, but ${file} is missing. Re-run make feature ${resource}, restore the missing file, or remove the partial feature wiring.`,
1857
2346
  };
1858
2347
  }
1859
2348
 
@@ -1893,6 +2382,11 @@ function pascalCase(value: string): string {
1893
2382
  .join("");
1894
2383
  }
1895
2384
 
2385
+ function camelCase(value: string): string {
2386
+ const pascal = pascalCase(value);
2387
+ return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`;
2388
+ }
2389
+
1896
2390
  function singularize(value: string): string {
1897
2391
  if (value.endsWith("ies") && value.length > 3) {
1898
2392
  return `${value.slice(0, -3)}y`;