@beignet/cli 0.0.52 → 0.0.53

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +131 -1
  3. package/dist/analysis/layers.d.ts +2 -0
  4. package/dist/analysis/layers.d.ts.map +1 -1
  5. package/dist/analysis/layers.js +5 -0
  6. package/dist/analysis/layers.js.map +1 -1
  7. package/dist/analysis/port-wiring.d.ts +4 -2
  8. package/dist/analysis/port-wiring.d.ts.map +1 -1
  9. package/dist/analysis/port-wiring.js +39 -10
  10. package/dist/analysis/port-wiring.js.map +1 -1
  11. package/dist/analysis/source-index.d.ts +2 -1
  12. package/dist/analysis/source-index.d.ts.map +1 -1
  13. package/dist/analysis/source-index.js +34 -5
  14. package/dist/analysis/source-index.js.map +1 -1
  15. package/dist/analysis/workspace-routes.d.ts +8 -0
  16. package/dist/analysis/workspace-routes.d.ts.map +1 -0
  17. package/dist/analysis/workspace-routes.js +291 -0
  18. package/dist/analysis/workspace-routes.js.map +1 -0
  19. package/dist/analysis/workspace.d.ts +24 -3
  20. package/dist/analysis/workspace.d.ts.map +1 -1
  21. package/dist/analysis/workspace.js +166 -21
  22. package/dist/analysis/workspace.js.map +1 -1
  23. package/dist/app-map-changes.d.ts.map +1 -1
  24. package/dist/app-map-changes.js +19 -7
  25. package/dist/app-map-changes.js.map +1 -1
  26. package/dist/app-map.d.ts.map +1 -1
  27. package/dist/app-map.js +58 -55
  28. package/dist/app-map.js.map +1 -1
  29. package/dist/config.d.ts +7 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +19 -0
  32. package/dist/config.js.map +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +20 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/inspect.d.ts.map +1 -1
  37. package/dist/inspect.js +89 -21
  38. package/dist/inspect.js.map +1 -1
  39. package/dist/lint.d.ts.map +1 -1
  40. package/dist/lint.js +106 -38
  41. package/dist/lint.js.map +1 -1
  42. package/dist/make/shared.js +1 -1
  43. package/dist/make.js +18 -1
  44. package/dist/make.js.map +1 -1
  45. package/dist/provider-add.js +2 -2
  46. package/dist/provider-add.js.map +1 -1
  47. package/dist/provider-audit.d.ts.map +1 -1
  48. package/dist/provider-audit.js +22 -6
  49. package/dist/provider-audit.js.map +1 -1
  50. package/dist/templates/shared.js +3 -3
  51. package/package.json +3 -2
  52. package/skills/app-structure/SKILL.md +42 -0
  53. package/src/analysis/layers.ts +9 -0
  54. package/src/analysis/port-wiring.ts +66 -10
  55. package/src/analysis/source-index.ts +56 -4
  56. package/src/analysis/workspace-routes.ts +385 -0
  57. package/src/analysis/workspace.ts +281 -34
  58. package/src/app-map-changes.ts +31 -5
  59. package/src/app-map.ts +75 -75
  60. package/src/config.ts +33 -0
  61. package/src/index.ts +25 -0
  62. package/src/inspect.ts +123 -16
  63. package/src/lint.ts +146 -43
  64. package/src/make/shared.ts +1 -1
  65. package/src/make.ts +31 -1
  66. package/src/provider-add.ts +2 -2
  67. package/src/provider-audit.ts +30 -10
  68. package/src/templates/shared.ts +3 -3
@@ -1,4 +1,4 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import ts from "typescript";
4
4
  import {
@@ -9,6 +9,7 @@ import {
9
9
  resolveConfig,
10
10
  } from "../config.js";
11
11
  import {
12
+ isAnalysisSourceFile,
12
13
  isAnalysisSourceFileExtension,
13
14
  sourceFileScriptKind,
14
15
  } from "./source-files.js";
@@ -22,8 +23,23 @@ const ignoredDirectoryNames = new Set([
22
23
  "node_modules",
23
24
  ]);
24
25
 
26
+ export type AnalysisProject = {
27
+ targetDir: string;
28
+ canonicalDir: string;
29
+ prefix: string;
30
+ name?: string;
31
+ dependencies: Set<string>;
32
+ localDependencies: Set<string>;
33
+ config: ResolvedBeignetConfig;
34
+ files: string[];
35
+ compilerOptions: ts.CompilerOptions;
36
+ compilerConfigFiles: string[];
37
+ moduleResolutionCache: ts.ModuleResolutionCache;
38
+ };
39
+
25
40
  export type AnalysisWorkspace = {
26
41
  targetDir: string;
42
+ projects: AnalysisProject[];
27
43
  files: string[];
28
44
  fileSet: Set<string>;
29
45
  config: ResolvedBeignetConfig;
@@ -44,24 +60,70 @@ export async function createAnalysisWorkspace(
44
60
  ): Promise<AnalysisWorkspace> {
45
61
  const targetDir = path.resolve(cwd);
46
62
  await assertDirectory(targetDir);
47
- const files = await listProjectFiles(targetDir);
48
- const fileSet = new Set(files);
63
+ const appFiles = await listProjectFiles(targetDir);
49
64
  const config = options.config
50
65
  ? resolveConfig(options.config)
51
- : await loadBeignetConfig(targetDir, files);
52
- const { compilerConfigFiles, compilerOptions } =
53
- resolveCompilerConfiguration(targetDir);
54
- const moduleResolutionCache = ts.createModuleResolutionCache(
55
- targetDir,
56
- ts.sys.useCaseSensitiveFileNames
57
- ? (fileName) => fileName
58
- : (fileName) => fileName.toLowerCase(),
59
- compilerOptions,
60
- );
66
+ : await loadBeignetConfig(targetDir, appFiles);
67
+ const app = await createProject(targetDir, targetDir, appFiles, config);
68
+ const projects = [app];
69
+ for (const directory of config.workspace.packages) {
70
+ const packageDir = path.resolve(targetDir, directory);
71
+ await assertDirectory(packageDir);
72
+ const canonical = await realpath(packageDir);
73
+ for (const project of projects) {
74
+ const existing = project.canonicalDir;
75
+ if (within(canonical, existing) || within(existing, canonical)) {
76
+ throw new Error(
77
+ `Workspace package ${directory} overlaps ${project.prefix || "the app"}. Package roots must be separate directories.`,
78
+ );
79
+ }
80
+ }
81
+ const packageFiles = await listProjectFiles(packageDir);
82
+ const packageConfig = await loadBeignetConfig(packageDir, packageFiles);
83
+ const project = await createProject(
84
+ targetDir,
85
+ packageDir,
86
+ packageFiles,
87
+ packageConfig,
88
+ );
89
+ if (!project.name)
90
+ throw new Error(
91
+ `Workspace package ${directory} must have a name in package.json.`,
92
+ );
93
+ if (projects.some((candidate) => candidate.name === project.name)) {
94
+ throw new Error(`Duplicate workspace package name ${project.name}.`);
95
+ }
96
+ projects.push(project);
97
+ }
98
+ const reachable = new Set([app]);
99
+ for (const project of reachable) {
100
+ for (const candidate of projects.slice(1)) {
101
+ if (candidate.name && project.dependencies.has(candidate.name))
102
+ reachable.add(candidate);
103
+ }
104
+ }
105
+ for (const project of projects.slice(1)) {
106
+ if (!reachable.has(project))
107
+ throw new Error(
108
+ `Workspace package ${project.name} is not a declared dependency of the app or its configured source packages.`,
109
+ );
110
+ }
111
+ const files = projects
112
+ .flatMap((project) =>
113
+ project.files.map((file) => projectPath(project, file)),
114
+ )
115
+ .sort();
116
+ const fileSet = new Set(files);
117
+ const { compilerOptions, moduleResolutionCache } = app;
118
+ const compilerConfigFiles = [
119
+ ...new Set(projects.flatMap((project) => project.compilerConfigFiles)),
120
+ ].sort();
61
121
  const sources = new Map<string, string>();
62
122
  const sourceFiles = new Map<string, ts.SourceFile>();
63
123
 
64
124
  const readSource = async (file: string): Promise<string> => {
125
+ if (!fileSet.has(file))
126
+ throw new Error(`Source file is outside the analysis workspace: ${file}`);
65
127
  const cached = sources.get(file);
66
128
  if (cached !== undefined) return cached;
67
129
 
@@ -72,6 +134,7 @@ export async function createAnalysisWorkspace(
72
134
 
73
135
  return {
74
136
  targetDir,
137
+ projects,
75
138
  files,
76
139
  fileSet,
77
140
  config,
@@ -136,7 +199,11 @@ export async function listProjectFiles(targetDir: string): Promise<string[]> {
136
199
  export function resolveLocalSourceFile(
137
200
  workspace: Pick<
138
201
  AnalysisWorkspace,
139
- "compilerOptions" | "fileSet" | "moduleResolutionCache" | "targetDir"
202
+ | "compilerOptions"
203
+ | "fileSet"
204
+ | "moduleResolutionCache"
205
+ | "targetDir"
206
+ | "projects"
140
207
  >,
141
208
  importerFile: string,
142
209
  importPath: string,
@@ -147,14 +214,36 @@ export function resolveLocalSourceFile(
147
214
  const file = normalizePath(
148
215
  path.relative(workspace.targetDir, resolved.resolvedFileName),
149
216
  );
150
- if (file === ".." || file.startsWith("../")) return undefined;
151
- return workspace.fileSet.has(file) ? file : undefined;
217
+ if (
218
+ workspace.fileSet.has(file) &&
219
+ (!projectForFile(workspace, file).prefix || isAnalysisSourceFile(file))
220
+ )
221
+ return file;
222
+ const canonicalFile =
223
+ ts.sys.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName;
224
+ for (const project of workspace.projects) {
225
+ if (!within(canonicalFile, project.canonicalDir)) continue;
226
+ const candidate = projectPath(
227
+ project,
228
+ path.relative(project.canonicalDir, canonicalFile),
229
+ );
230
+ if (
231
+ workspace.fileSet.has(candidate) &&
232
+ (!project.prefix || isAnalysisSourceFile(candidate))
233
+ )
234
+ return candidate;
235
+ }
236
+ return undefined;
152
237
  }
153
238
 
154
239
  export function resolveLocalModulePath(
155
240
  workspace: Pick<
156
241
  AnalysisWorkspace,
157
- "compilerOptions" | "fileSet" | "moduleResolutionCache" | "targetDir"
242
+ | "compilerOptions"
243
+ | "fileSet"
244
+ | "moduleResolutionCache"
245
+ | "targetDir"
246
+ | "projects"
158
247
  >,
159
248
  importerFile: string,
160
249
  importPath: string,
@@ -170,16 +259,34 @@ export function resolveLocalModulePath(
170
259
  return undefined;
171
260
  }
172
261
 
262
+ const project = projectForFile(workspace, importerFile);
263
+ if (
264
+ workspace.projects.length > 1 &&
265
+ (importPath.startsWith(".") ||
266
+ importPath.startsWith("#") ||
267
+ Object.keys(project.compilerOptions.paths ?? {}).some(
268
+ (pattern) =>
269
+ pattern !== "*" && modulePatternMatches(pattern, importPath),
270
+ ))
271
+ ) {
272
+ const resolved = resolveModule(workspace, importerFile, importPath);
273
+ if (resolved)
274
+ return normalizePath(
275
+ path.relative(
276
+ workspace.targetDir,
277
+ ts.sys.realpath?.(resolved.resolvedFileName) ??
278
+ resolved.resolvedFileName,
279
+ ),
280
+ );
281
+ }
282
+
173
283
  if (importPath.startsWith(".")) {
174
284
  return normalizePath(path.join(path.dirname(importerFile), importPath));
175
285
  }
176
286
 
177
- const baseUrl = compilerPathsBase(
178
- workspace.compilerOptions,
179
- workspace.targetDir,
180
- );
287
+ const baseUrl = compilerPathsBase(project.compilerOptions, project.targetDir);
181
288
  for (const [pattern, targets] of Object.entries(
182
- workspace.compilerOptions.paths ?? {},
289
+ project.compilerOptions.paths ?? {},
183
290
  )) {
184
291
  if (pattern === "*") continue;
185
292
  const wildcardValue = modulePatternWildcard(pattern, importPath);
@@ -191,14 +298,22 @@ export function resolveLocalModulePath(
191
298
  const relative = normalizePath(
192
299
  path.relative(workspace.targetDir, absolute),
193
300
  );
194
- if (relative !== ".." && !relative.startsWith("../")) return relative;
301
+ if (
302
+ workspace.projects.length > 1 ||
303
+ workspace.projects.some((candidate) =>
304
+ within(absolute, candidate.targetDir),
305
+ )
306
+ )
307
+ return relative;
195
308
  }
196
309
  }
197
310
 
198
311
  const relative = normalizePath(
199
312
  path.relative(workspace.targetDir, path.resolve(baseUrl, importPath)),
200
313
  );
201
- return relative !== ".." && !relative.startsWith("../")
314
+ return workspace.projects.some((candidate) =>
315
+ within(path.resolve(workspace.targetDir, relative), candidate.targetDir),
316
+ )
202
317
  ? relative
203
318
  : undefined;
204
319
  }
@@ -206,13 +321,21 @@ export function resolveLocalModulePath(
206
321
  export function isLocalModuleSpecifier(
207
322
  workspace: Pick<
208
323
  AnalysisWorkspace,
209
- "compilerOptions" | "fileSet" | "moduleResolutionCache" | "targetDir"
324
+ | "compilerOptions"
325
+ | "fileSet"
326
+ | "moduleResolutionCache"
327
+ | "targetDir"
328
+ | "projects"
210
329
  >,
211
330
  importerFile: string,
212
331
  importPath: string,
213
332
  ): boolean {
214
333
  const extension = path.posix.extname(importPath);
215
- if (extension && !isAnalysisSourceFileExtension(extension)) {
334
+ if (
335
+ !importPath.startsWith("#") &&
336
+ extension &&
337
+ !isAnalysisSourceFileExtension(extension)
338
+ ) {
216
339
  return false;
217
340
  }
218
341
  if (importPath.startsWith(".")) return true;
@@ -222,12 +345,33 @@ export function isLocalModuleSpecifier(
222
345
  const file = normalizePath(
223
346
  path.relative(workspace.targetDir, resolved.resolvedFileName),
224
347
  );
225
- return (
226
- file !== ".." && !file.startsWith("../") && workspace.fileSet.has(file)
227
- );
348
+ if (
349
+ workspace.fileSet.has(file) ||
350
+ resolveLocalSourceFile(workspace, importerFile, importPath)
351
+ )
352
+ return true;
353
+ // Package-import aliases can resolve to an omitted workspace through an
354
+ // install symlink. Keep normal dependencies inside node_modules external.
355
+ const canonical =
356
+ ts.sys.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName;
357
+ if (
358
+ importPath.startsWith("#") &&
359
+ !normalizePath(canonical).split("/").includes("node_modules")
360
+ )
361
+ return true;
228
362
  }
229
363
 
230
- const pathPatterns = Object.keys(workspace.compilerOptions.paths ?? {});
364
+ const project = projectForFile(workspace, importerFile);
365
+ if (
366
+ workspace.projects.some(
367
+ (candidate) =>
368
+ candidate.name &&
369
+ (importPath === candidate.name ||
370
+ importPath.startsWith(`${candidate.name}/`)),
371
+ )
372
+ )
373
+ return true;
374
+ const pathPatterns = Object.keys(project.compilerOptions.paths ?? {});
231
375
  if (
232
376
  pathPatterns.some(
233
377
  (pattern) =>
@@ -239,7 +383,7 @@ export function isLocalModuleSpecifier(
239
383
  return true;
240
384
  }
241
385
 
242
- const baseUrl = workspace.compilerOptions.baseUrl;
386
+ const baseUrl = project.compilerOptions.baseUrl;
243
387
  if (!baseUrl) return false;
244
388
  const firstSegment = importPath.split("/")[0];
245
389
  const prefix = normalizePath(
@@ -254,17 +398,18 @@ export function isLocalModuleSpecifier(
254
398
  function resolveModule(
255
399
  workspace: Pick<
256
400
  AnalysisWorkspace,
257
- "compilerOptions" | "moduleResolutionCache" | "targetDir"
401
+ "compilerOptions" | "moduleResolutionCache" | "targetDir" | "projects"
258
402
  >,
259
403
  importerFile: string,
260
404
  importPath: string,
261
405
  ): ts.ResolvedModuleFull | undefined {
406
+ const project = projectForFile(workspace, importerFile);
262
407
  return ts.resolveModuleName(
263
408
  importPath,
264
409
  path.join(workspace.targetDir, importerFile),
265
- workspace.compilerOptions,
410
+ project.compilerOptions,
266
411
  ts.sys,
267
- workspace.moduleResolutionCache,
412
+ project.moduleResolutionCache,
268
413
  ).resolvedModule;
269
414
  }
270
415
 
@@ -341,3 +486,105 @@ function compilerPathsBase(
341
486
  compilerOptions.baseUrl ?? optionsWithPathBase.pathsBasePath ?? targetDir
342
487
  );
343
488
  }
489
+
490
+ async function createProject(
491
+ appDir: string,
492
+ targetDir: string,
493
+ files: string[],
494
+ config: ResolvedBeignetConfig,
495
+ ): Promise<AnalysisProject> {
496
+ const manifest: unknown = files.includes("package.json")
497
+ ? JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8"))
498
+ : {};
499
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
500
+ throw new Error(`Invalid package.json in ${targetDir}.`);
501
+ }
502
+ const data = manifest as Record<string, unknown>;
503
+ const dependencies = new Set<string>();
504
+ const localDependencies = new Set<string>();
505
+ for (const key of [
506
+ "dependencies",
507
+ "devDependencies",
508
+ "peerDependencies",
509
+ "optionalDependencies",
510
+ ]) {
511
+ const value = data[key];
512
+ if (value && typeof value === "object" && !Array.isArray(value)) {
513
+ for (const [name, specifier] of Object.entries(value)) {
514
+ dependencies.add(name);
515
+ if (
516
+ typeof specifier === "string" &&
517
+ /^(workspace|link|file):/.test(specifier) &&
518
+ !specifier.endsWith(".tgz")
519
+ )
520
+ localDependencies.add(name);
521
+ }
522
+ }
523
+ }
524
+ const { compilerConfigFiles, compilerOptions } =
525
+ resolveCompilerConfiguration(targetDir);
526
+ return {
527
+ targetDir,
528
+ canonicalDir: await realpath(targetDir),
529
+ prefix: normalizePath(path.relative(appDir, targetDir)),
530
+ ...(typeof data.name === "string" && data.name.trim()
531
+ ? { name: data.name }
532
+ : {}),
533
+ dependencies,
534
+ localDependencies,
535
+ config,
536
+ files,
537
+ compilerConfigFiles,
538
+ compilerOptions,
539
+ moduleResolutionCache: ts.createModuleResolutionCache(
540
+ targetDir,
541
+ ts.sys.useCaseSensitiveFileNames
542
+ ? (file) => file
543
+ : (file) => file.toLowerCase(),
544
+ compilerOptions,
545
+ ),
546
+ };
547
+ }
548
+
549
+ export function projectForFile(
550
+ workspace: Pick<AnalysisWorkspace, "projects" | "targetDir">,
551
+ file: string,
552
+ ): AnalysisProject {
553
+ return findProjectForFile(workspace, file) ?? workspace.projects[0];
554
+ }
555
+
556
+ export function findProjectForFile(
557
+ workspace: Pick<AnalysisWorkspace, "projects" | "targetDir">,
558
+ file: string,
559
+ ): AnalysisProject | undefined {
560
+ const absolute = path.resolve(workspace.targetDir, file);
561
+ return workspace.projects.find((project) =>
562
+ within(absolute, project.targetDir),
563
+ );
564
+ }
565
+
566
+ export function projectPath(project: AnalysisProject, file: string): string {
567
+ return normalizePath(path.join(project.prefix, file));
568
+ }
569
+
570
+ export function projectSource(
571
+ workspace: Pick<AnalysisWorkspace, "projects" | "targetDir">,
572
+ file: string,
573
+ ): { project: AnalysisProject; file: string } {
574
+ const project = projectForFile(workspace, file);
575
+ return {
576
+ project,
577
+ file: normalizePath(
578
+ path.relative(project.targetDir, path.resolve(workspace.targetDir, file)),
579
+ ),
580
+ };
581
+ }
582
+
583
+ function within(file: string, directory: string): boolean {
584
+ const relative = path.relative(directory, file);
585
+ return (
586
+ relative !== ".." &&
587
+ !relative.startsWith(`..${path.sep}`) &&
588
+ !path.isAbsolute(relative)
589
+ );
590
+ }
@@ -216,8 +216,22 @@ export async function mapAppChanges(
216
216
  createAnalysisWorkspace(targetDir),
217
217
  resolveWorkspaceScope(git.repositoryRoot, canonicalTargetDir),
218
218
  ]);
219
+ for (const project of workspace.projects.slice(1)) {
220
+ if (isAbsoluteWithin(project.canonicalDir, git.repositoryRoot)) {
221
+ workspaceScope.dependencyDirectories.add(
222
+ normalizePath(path.relative(git.repositoryRoot, project.canonicalDir)),
223
+ );
224
+ }
225
+ }
219
226
  const governingFiles = new Set(
220
- workspace.compilerConfigFiles
227
+ [
228
+ ...workspace.compilerConfigFiles,
229
+ ...workspace.projects.flatMap((project) =>
230
+ project.config.configFile
231
+ ? [path.join(project.targetDir, project.config.configFile)]
232
+ : [],
233
+ ),
234
+ ]
221
235
  .map((file) =>
222
236
  path.resolve(
223
237
  canonicalTargetDir,
@@ -285,6 +299,11 @@ export async function mapAppChanges(
285
299
  changedFiles,
286
300
  git.repositoryRoot,
287
301
  canonicalTargetDir,
302
+ workspace.projects
303
+ .slice(1)
304
+ .map((project) =>
305
+ normalizePath(path.relative(git.repositoryRoot, project.canonicalDir)),
306
+ ),
288
307
  );
289
308
  for (const change of appSourceChanges) {
290
309
  const directNodes = nodesByFile.get(change.file) ?? [];
@@ -750,6 +769,7 @@ function appRelativeSourceChanges(
750
769
  files: AppChangedFile[],
751
770
  repositoryRoot: string,
752
771
  canonicalTargetDir: string,
772
+ sourcePackages: string[],
753
773
  ): Array<{
754
774
  file: string;
755
775
  repositoryPath: string;
@@ -764,9 +784,15 @@ function appRelativeSourceChanges(
764
784
  status: GitChangedFileStatus;
765
785
  }> = [];
766
786
  for (const changed of files) {
767
- if (changed.scope !== "app") continue;
787
+ if (changed.scope !== "app" && changed.scope !== "workspace-dependency")
788
+ continue;
768
789
  for (const repositoryPath of changedFilePaths(changed)) {
769
- if (!isWithin(repositoryPath, targetPrefix)) continue;
790
+ if (
791
+ ![targetPrefix, ...sourcePackages].some((prefix) =>
792
+ isWithin(repositoryPath, prefix),
793
+ )
794
+ )
795
+ continue;
770
796
  const file = targetPrefix
771
797
  ? normalizePath(path.posix.relative(targetPrefix, repositoryPath))
772
798
  : repositoryPath;
@@ -1378,8 +1404,8 @@ function appWideChangeReasons(
1378
1404
  const message =
1379
1405
  scope === "workspace-dependency"
1380
1406
  ? scoped.length === 1
1381
- ? `Direct local dependency ${changedFile} changed.`
1382
- : `${scoped.length} direct local dependency files changed, including ${changedFile}.`
1407
+ ? `Local source dependency ${changedFile} changed.`
1408
+ : `${scoped.length} local source dependency files changed, including ${changedFile}.`
1383
1409
  : scoped.length === 1
1384
1410
  ? `Governing file ${changedFile} changed.`
1385
1411
  : `${scoped.length} governing files changed, including ${changedFile}.`;