@ankhorage/devtools 1.11.10 → 1.12.0

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/README.md CHANGED
@@ -245,7 +245,7 @@ The React profile adds React and React Hooks correctness rules. The React Native
245
245
 
246
246
  ### Managed ESLint setup and local overrides
247
247
 
248
- `ankh devtools eslint sync` centrally owns `eslint.config.mjs` and creates `eslint.local.config.mjs` once.
248
+ `ankh devtools eslint sync` centrally owns `eslint.config.mjs` and creates `eslint.local.config.mjs` once. When the repository has a root `examples/` directory, synchronization also owns `eslint.examples.config.mjs`; repositories without public examples do not receive that file, and synchronization removes the managed wrapper when the directory is removed.
249
249
 
250
250
  The canonical wrapper uses automatic profile detection and appends repository-owned flat-config entries:
251
251
 
@@ -267,6 +267,16 @@ export default [
267
267
 
268
268
  Use `eslint.local.config.mjs` for narrow repository-specific flat-config overrides, including temporary file-specific migration overrides. On first synchronization, an existing non-canonical `eslint.config.mjs` is preserved as the initial local config before the canonical wrapper is installed. Synchronization never overwrites that local file afterward.
269
269
 
270
+ Each public example lives in a named directory, such as `examples/basic-usage/*.ts`; example source files do not live directly under `examples/`. The examples wrapper uses root `tsconfig.eslint.json` and `tsconfig.json` when present and discovers TypeScript projects below `examples/`. This covers example directories included only by the root ESLint project as well as standalone applications with their own tsconfig. It applies the same shared policy and appends the same repository-owned local entries.
271
+
272
+ An existing consumer-owned `eslint.examples.config.mjs` requires explicit adoption before synchronization can replace it. Status, dry-run, and sync report an actionable error instead of overwriting or deleting it. Move its repository-specific overrides into `eslint.local.config.mjs`, preserving existing entries and the examples-only file scope; retain custom parser options there when needed. Remove the old examples config only after that transfer, then rerun sync and the examples lint. The generated wrapper carries a Devtools ownership marker and subsequent synchronization updates it normally. Do not mark an old consumer config as managed to bypass this transfer.
273
+
274
+ Repositories can lint their independently runnable examples explicitly:
275
+
276
+ ```bash
277
+ ankhorage-eslint examples --config eslint.examples.config.mjs --max-warnings=0
278
+ ```
279
+
270
280
  ## Prettier
271
281
 
272
282
  `ankh devtools prettier sync` owns `.prettierrc.js`, emits the correct ESM or CommonJS wrapper based on the repository's `package.json` module type, and creates `prettier.local.config.js` once for narrow repository-specific options.
@@ -5,6 +5,12 @@ export declare const eslintManagedFiles: readonly [{
5
5
  }, {
6
6
  readonly relativePath: "eslint.config.mjs";
7
7
  readonly contents: "import { createConfig } from '@ankhorage/devtools/eslint';\nimport localConfig from './eslint.local.config.mjs';\n\nconst localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];\n\nexport default [\n ...createConfig({\n files: ['src/**/*.{ts,tsx}'],\n project: ['./tsconfig.json'],\n tsconfigRootDir: import.meta.dirname,\n }),\n ...localEntries,\n];\n";
8
+ }, {
9
+ readonly relativePath: "eslint.examples.config.mjs";
10
+ readonly contents: "// This file is managed by @ankhorage/devtools.\nimport { existsSync } from 'node:fs';\n\nimport { createConfig } from '@ankhorage/devtools/eslint';\nimport localConfig from './eslint.local.config.mjs';\n\nconst exampleFiles = ['examples/**/*.{ts,tsx}'];\nconst localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];\nconst rootProjects = ['./tsconfig.eslint.json', './tsconfig.json'].filter((project) =>\n existsSync(new URL(project, import.meta.url)),\n);\n\nexport default [\n ...createConfig({\n files: exampleFiles,\n project: [...rootProjects, './examples/**/tsconfig.json'],\n tsconfigRootDir: import.meta.dirname,\n }),\n ...localEntries,\n];\n";
11
+ readonly isApplicable: typeof hasExamplesDirectory;
8
12
  }];
9
13
  declare function renderInitialLocalConfig(targetDirectory: string): Promise<string>;
14
+ /*** Report whether the target repository owns public examples at its root. */
15
+ declare function hasExamplesDirectory(targetDirectory: string): Promise<boolean>;
10
16
  export {};
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  const ESLINT_CONFIG = `import { createConfig } from '@ankhorage/devtools/eslint';
4
4
  import localConfig from './eslint.local.config.mjs';
@@ -16,6 +16,27 @@ export default [
16
16
  `;
17
17
  const EMPTY_LOCAL_CONFIG = `export default [];
18
18
  `;
19
+ const EXAMPLES_OWNERSHIP_MARKER = '// This file is managed by @ankhorage/devtools.\n';
20
+ const ESLINT_EXAMPLES_CONFIG = `${EXAMPLES_OWNERSHIP_MARKER}import { existsSync } from 'node:fs';
21
+
22
+ import { createConfig } from '@ankhorage/devtools/eslint';
23
+ import localConfig from './eslint.local.config.mjs';
24
+
25
+ const exampleFiles = ['examples/**/*.{ts,tsx}'];
26
+ const localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];
27
+ const rootProjects = ['./tsconfig.eslint.json', './tsconfig.json'].filter((project) =>
28
+ existsSync(new URL(project, import.meta.url)),
29
+ );
30
+
31
+ export default [
32
+ ...createConfig({
33
+ files: exampleFiles,
34
+ project: [...rootProjects, './examples/**/tsconfig.json'],
35
+ tsconfigRootDir: import.meta.dirname,
36
+ }),
37
+ ...localEntries,
38
+ ];
39
+ `;
19
40
  export const eslintManagedFiles = [
20
41
  {
21
42
  relativePath: 'eslint.local.config.mjs',
@@ -26,6 +47,11 @@ export const eslintManagedFiles = [
26
47
  relativePath: 'eslint.config.mjs',
27
48
  contents: ESLINT_CONFIG,
28
49
  },
50
+ {
51
+ relativePath: 'eslint.examples.config.mjs',
52
+ contents: ESLINT_EXAMPLES_CONFIG,
53
+ isApplicable: hasExamplesDirectory,
54
+ },
29
55
  ];
30
56
  async function renderInitialLocalConfig(targetDirectory) {
31
57
  try {
@@ -42,3 +68,34 @@ async function renderInitialLocalConfig(targetDirectory) {
42
68
  function isNodeError(error) {
43
69
  return error instanceof Error && 'code' in error;
44
70
  }
71
+ /*** Report whether the target repository owns public examples at its root. */
72
+ async function hasExamplesDirectory(targetDirectory) {
73
+ await assertExamplesConfigOwnershipAsync(targetDirectory);
74
+ try {
75
+ return (await stat(resolve(targetDirectory, 'examples'))).isDirectory();
76
+ }
77
+ catch (error) {
78
+ if (isNodeError(error) && error.code === 'ENOENT') {
79
+ return false;
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+ /*** Require explicit adoption of consumer overrides before managing an existing examples config. */
85
+ async function assertExamplesConfigOwnershipAsync(targetDirectory) {
86
+ let contents;
87
+ try {
88
+ contents = await readFile(resolve(targetDirectory, 'eslint.examples.config.mjs'), 'utf8');
89
+ }
90
+ catch (error) {
91
+ if (isNodeError(error) && error.code === 'ENOENT')
92
+ return;
93
+ throw error;
94
+ }
95
+ if (!contents.startsWith(EXAMPLES_OWNERSHIP_MARKER)) {
96
+ throw new Error('Cannot replace consumer-owned eslint.examples.config.mjs. ' +
97
+ 'Move its repository-specific overrides into eslint.local.config.mjs, keeping their ' +
98
+ 'examples file scope and preserving existing local entries. Then remove the old examples ' +
99
+ 'config and rerun sync to create the Devtools-managed wrapper.');
100
+ }
101
+ }
@@ -6,6 +6,7 @@ export interface ManagedFileDefinition {
6
6
  readonly contents?: string;
7
7
  readonly render?: ManagedFileRenderer;
8
8
  readonly mode?: ManagedFileMode;
9
+ readonly isApplicable?: (targetDirectory: string) => Promise<boolean> | boolean;
9
10
  }
10
11
  type ManagedFileState = 'current' | 'missing' | 'obsolete' | 'outdated';
11
12
  export type ManagedFileSyncAction = 'unchanged' | 'created' | 'removed' | 'updated' | 'would-create' | 'would-remove' | 'would-update';
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  export async function resolveManagedTargetDirectory(cwd, requestedPath) {
4
4
  const targetDirectory = resolve(cwd, requestedPath ?? '.');
@@ -15,10 +15,14 @@ export async function resolveManagedTargetDirectory(cwd, requestedPath) {
15
15
  return targetDirectory;
16
16
  }
17
17
  export async function inspectManagedFiles(targetDirectory, definitions) {
18
- return await Promise.all(definitions.map(async (definition) => {
18
+ const statuses = await Promise.all(definitions.map(async (definition) => {
19
19
  const targetPath = resolve(targetDirectory, definition.relativePath);
20
+ const isApplicable = await (definition.isApplicable?.(targetDirectory) ?? true);
20
21
  try {
21
22
  const targetContents = await readFile(targetPath, 'utf8');
23
+ if (!isApplicable) {
24
+ return { relativePath: definition.relativePath, state: 'obsolete' };
25
+ }
22
26
  if ((definition.mode ?? 'replace') === 'create-only') {
23
27
  return { relativePath: definition.relativePath, state: 'current' };
24
28
  }
@@ -30,11 +34,14 @@ export async function inspectManagedFiles(targetDirectory, definitions) {
30
34
  }
31
35
  catch (error) {
32
36
  if (isMissingFileError(error)) {
33
- return { relativePath: definition.relativePath, state: 'missing' };
37
+ return isApplicable
38
+ ? { relativePath: definition.relativePath, state: 'missing' }
39
+ : undefined;
34
40
  }
35
41
  throw new Error(`Failed to inspect managed file: ${targetPath}`, { cause: error });
36
42
  }
37
43
  }));
44
+ return statuses.filter((status) => status !== undefined);
38
45
  }
39
46
  export async function syncManagedFiles(targetDirectory, definitions, options) {
40
47
  const statuses = await inspectManagedFiles(targetDirectory, definitions);
@@ -54,6 +61,15 @@ async function syncManagedFile(targetDirectory, status, definitionsByPath, optio
54
61
  if (definition === undefined) {
55
62
  throw new Error(`Missing managed file definition for ${status.relativePath}.`);
56
63
  }
64
+ if (status.state === 'obsolete') {
65
+ if (!options.dryRun) {
66
+ await rm(resolve(targetDirectory, definition.relativePath));
67
+ }
68
+ return {
69
+ relativePath: status.relativePath,
70
+ action: options.dryRun ? 'would-remove' : 'removed',
71
+ };
72
+ }
57
73
  if (options.dryRun) {
58
74
  return {
59
75
  relativePath: status.relativePath,
@@ -2,8 +2,8 @@
2
2
  name: ankhorage-project-structure
3
3
  description: >
4
4
  Define, review, or implement the standard source structure of Ankhorage repositories. Use for
5
- feature ownership, CLI layout, hexagonal boundaries, source-module naming, utilities, or package
6
- entrypoints.
5
+ feature ownership, CLI layout, hexagonal boundaries, source-module naming, type ownership,
6
+ utilities, or package entrypoints.
7
7
  ---
8
8
 
9
9
  # Ankhorage Project Structure
@@ -31,6 +31,18 @@ If `hexagonal-architecture` is missing or unreadable, stop immediately and repor
31
31
  Cannot continue: the required repository skill `hexagonal-architecture` is missing or unreadable at `.agents/skills/hexagonal-architecture/SKILL.md`. Synchronize the repository skills from `@ankhorage/devtools` and retry.
32
32
  ```
33
33
 
34
+ ## Repository-root examples
35
+
36
+ `examples/` is a generally valid repository-root folder in every repository covered by this skill.
37
+ Use it for complete, intentional, user-facing examples that people can inspect, copy, install, and
38
+ run independently of a monorepo or internal fixture layout.
39
+
40
+ Each example lives in a named subdirectory, such as `examples/basic-usage/*.ts`. Do not put example
41
+ source files directly under `examples/`.
42
+
43
+ Test-only fixtures remain owned by the applicable test structure. Do not relabel fixtures as public
44
+ examples merely to bypass repository structure rules.
45
+
34
46
  ## Required source layout
35
47
 
36
48
  Every repository provides `src/features/`. It lists the repository's actual product capabilities;
@@ -62,6 +74,8 @@ src/
62
74
  outbound/
63
75
  composition/
64
76
  utils/
77
+ types/
78
+ <topic>.ts
65
79
  utils/
66
80
  ```
67
81
 
@@ -96,26 +110,69 @@ colors/
96
110
  Resolve the ownership of `otherFolder` and move it to the appropriate taxonomy. Use domain names for
97
111
  features, not framework, transport, database, or generic technical names.
98
112
 
99
- ## One export per production module
113
+ ## Implementation modules
100
114
 
101
- Each production source file has exactly one export. Its exported declaration is the first declaration
102
- after imports and module documentation, and its name matches the filename exactly.
115
+ Each production implementation module has exactly one exported runtime declaration. It is the first
116
+ declaration after imports and module documentation, and its name matches the filename exactly.
117
+ This rule does not split types into one-file-per-type modules. Type ownership follows the separate
118
+ rules below. Deliberate public facades may group explicit named exports; they are not internal
119
+ convenience barrels and must not expose private implementation details.
103
120
 
104
121
  - `myFunction.ts` exports `myFunction`.
105
122
  - `myFunctionAsync.ts` exports `myFunctionAsync`.
106
123
  - A public operation that is asynchronous or returns a `Promise` uses the `Async` suffix in both its
107
124
  filename and exported name.
108
125
 
109
- Keep private helpers below that exported declaration when they are used only by that module. Move a
110
- helper used by multiple modules to `utils/` at the owning layer. Put a repository-wide utility in
111
- `src/utils/`. Put a generally reusable cross-package utility in the correct `@ankhorage/utility`
112
- location.
126
+ Keep private helpers below that exported declaration when they are used only by that module.
127
+ Decide the owner of a reused function using the utility rules below, before creating another file.
128
+
129
+ ## Type ownership
130
+
131
+ Choose type ownership by its production consumers, not by the number of textual references or
132
+ whether a barrel happens to re-export it:
133
+
134
+ 1. **Used by one implementation module:** keep the type directly below the function that owns it,
135
+ without `export`. Its private helpers can use the same local type. A test does not justify
136
+ exporting an implementation-private type; test through the function boundary.
137
+ 2. **Reused within the repository:** put related types together in `src/types/<topic>.ts` and use
138
+ type-only imports. Name the file for a cohesive topic, not for each individual type. Such a file
139
+ may export multiple related types/interfaces and contains no runtime implementation. Do not mix
140
+ type-only files among feature functions or `utils/`, and do not create one global catch-all file.
141
+ 3. **Shared across repositories:** the canonical declaration belongs in `@ankhorage/contracts` at
142
+ the owning topic's public subpath. Consumers import that contract through a declared dependency,
143
+ not another repository's source or a duplicated local declaration. Keep framework-specific
144
+ adapters separate from the portable shared contract.
145
+
146
+ Inspect published API declarations and real consumer imports before privatizing or relocating a
147
+ type. A public boundary type is not private just because only one implementation uses it locally.
148
+ Coordinate its Contracts change and consumer migration; do not silently remove a public type,
149
+ invent an unreleased dependency version, or retain a compatibility re-export as the final design.
150
+ When the required package change or release is outside the approved scope, state the dependency
151
+ explicitly instead of claiming the migration is complete.
152
+
153
+ For example, `selectRoute.ts` can own a non-exported `SelectRouteInput` directly below `selectRoute`.
154
+ Types used by several local navigation operations belong together in `src/types/navigation.ts`.
155
+ A navigation binding exchanged by Studio and Navigator belongs in `@ankhorage/contracts/navigator`.
113
156
 
114
157
  ## Utilities
115
158
 
116
159
  `utils/` is the only utility directory name. Do not create `shared/`, `helper/`, `helpers/`,
117
- `common/`, or equivalent catch-all folders. Feature-local utilities live in that feature's `utils/`;
118
- utilities shared by repository features live in `src/utils/`.
160
+ `common/`, or equivalent catch-all folders. It is not a destination for every pure function or type.
161
+
162
+ - Used by one module: keep the helper private below its owning function.
163
+ - Reused only inside a feature: keep it in that feature's `utils/`.
164
+ - Shared across features but tied to this package's capability or policy: use `src/utils/`.
165
+ Navigator topology traversal or Expo Router-specific validation does not become a general utility
166
+ merely because several navigator features use it.
167
+ - Generally reusable without the owning product, manifest, or framework policy: inspect the
168
+ published `@ankhorage/utility` API first, reuse it where semantics match, and put missing general
169
+ helpers in that package's owning topic. Examples include generic string escaping or source-literal
170
+ serialization. Do not copy a utility locally, create a forwarding wrapper, or change semantics
171
+ just to reuse a similarly named function.
172
+
173
+ Separate the decisions for functions and types: reusable functions belong to Utility when general;
174
+ repo-local type groups belong to `src/types/`; repo-crossing types belong to Contracts. Respect
175
+ release boundaries and obtain approval for additional package changes when they exceed the task.
119
176
 
120
177
  This skill defines the target architecture. Schedule repository migrations separately and in this
121
178
  order: Studio, Deploy, Infra, Repository, Navigator.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/devtools",
3
- "version": "1.11.10",
3
+ "version": "1.12.0",
4
4
  "description": "Shared development tools and repository standards for Ankhorage",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ankhorage/devtools#readme",