@ankhorage/devtools 1.20.0 → 1.21.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
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/devtools
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v1.20.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v1.21.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
7
7
 
8
8
  Shared tooling, repository automation, runtime policies, and agent standards for Ankhorage TypeScript projects
9
9
 
@@ -199,10 +199,12 @@ The canonical workflow, VS Code, and skill files are packaged with `@ankhorage/d
199
199
 
200
200
  ## Managed agent instructions
201
201
 
202
- `ankh devtools agents sync` owns the repository-root `AGENTS.md`. The shared instructions are intentionally small and stable. The target repository's package name and description are rendered from `package.json`; the remaining content defines the unconditional current-architecture policy, directs structural work to the managed project-structure skill, and requires the canonical build, type, lint, Knip, Changeset, and formatting commands before pull request creation.
202
+ `ankh devtools agents sync` owns the repository-root `AGENTS.md` plus `CLAUDE.md` and `GEMINI.md` as symbolic links to that canonical file. The shared instructions are intentionally small and stable. The target repository's package name and description are rendered from `package.json`; the remaining content defines the unconditional current-architecture policy, directs structural work to the managed project-structure skill, and requires the canonical build, type, lint, Knip, Changeset, and formatting commands before pull request creation.
203
203
 
204
204
  Only the current Ankhorage architecture is supported. Managed instructions reject deprecated APIs, compatibility aliases, shims, dual old/new paths, historical-state fallbacks, and migrations whose sole purpose is obsolete state. A canonical cross-package change requires affected repositories to update to the latest released public API.
205
205
 
206
+ Every Devtools-managed repository is standalone: its own checkout must be sufficient to install, build, test, and use it with declared dependencies and explicit configuration. Published packages are additionally consumer-agnostic and reusable outside Ankhorage. Sibling repositories/source imports, unpublished workspace/file/link coupling, hidden organization-local state, and hard assumptions about a consuming app, infrastructure, hosting provider, web server, container runtime, or deployment topology are architecture debt rather than supported exceptions.
207
+
206
208
  ## Managed repository skills
207
209
 
208
210
  `ankh devtools skills sync` owns the complete `.agents/skills/ankhorage-coding-rules/`, `.agents/skills/hexagonal-architecture/`, and `.agents/skills/ankhorage-project-structure/` trees from the immutable copies shipped in the Devtools release. It creates `.agents/` when missing, replaces stale files in those managed skills, and preserves every unrelated skill directory. The project-structure skill requires both the coding-rules and hexagonal-architecture skills before structural work can continue.
@@ -1,6 +1,12 @@
1
1
  export declare const agentsManagedFiles: readonly [{
2
2
  readonly relativePath: "AGENTS.md";
3
3
  readonly render: typeof renderAgentsFile;
4
+ }, {
5
+ readonly relativePath: "CLAUDE.md";
6
+ readonly symlinkTarget: "AGENTS.md";
7
+ }, {
8
+ readonly relativePath: "GEMINI.md";
9
+ readonly symlinkTarget: "AGENTS.md";
4
10
  }];
5
11
  /*** Render mandatory repository instructions from the target package identity. */
6
12
  declare function renderAgentsFile(targetDirectory: string): Promise<string>;
@@ -5,6 +5,14 @@ export const agentsManagedFiles = [
5
5
  relativePath: 'AGENTS.md',
6
6
  render: renderAgentsFile,
7
7
  },
8
+ {
9
+ relativePath: 'CLAUDE.md',
10
+ symlinkTarget: 'AGENTS.md',
11
+ },
12
+ {
13
+ relativePath: 'GEMINI.md',
14
+ symlinkTarget: 'AGENTS.md',
15
+ },
8
16
  ];
9
17
  /*** Render mandatory repository instructions from the target package identity. */
10
18
  async function renderAgentsFile(targetDirectory) {
@@ -13,6 +21,7 @@ async function renderAgentsFile(targetDirectory) {
13
21
  const description = readNonEmptyString(manifest.description) ?? 'No package description is declared.';
14
22
  const sections = [
15
23
  renderCurrentArchitectureInstructions(),
24
+ renderStandaloneRepositoryInstructions(),
16
25
  renderRequiredRepositoryInstructions(),
17
26
  renderDocumentationInstructions(),
18
27
  renderPullRequestInstructions(),
@@ -45,6 +54,26 @@ published public APIs and declared dependencies, never sibling source files.
45
54
  Current-runtime error handling and canonical database or infrastructure migrations remain valid
46
55
  when they support states that the current architecture can intentionally produce.
47
56
 
57
+ `;
58
+ }
59
+ /*** Render the mandatory standalone repository and package contract. */
60
+ function renderStandaloneRepositoryInstructions() {
61
+ return `## Standalone contract
62
+
63
+ Every repository managed by \`@ankhorage/devtools\` is standalone. It must be independently
64
+ installable, buildable, testable, and usable from its own checkout using only declared dependencies
65
+ and explicit configuration. It must not require sibling repositories, sibling source imports,
66
+ \`workspace:\`, \`file:\`, or \`link:\` dependencies to unpublished packages, hidden
67
+ organization-local state, or assumptions about a specific consuming application, infrastructure,
68
+ hosting provider, web server, container runtime, or deployment topology.
69
+
70
+ Published packages are additionally consumer-agnostic and reusable outside Ankhorage.
71
+ Environment- or provider-specific behavior belongs behind explicit configuration and adapters,
72
+ never in the package core.
73
+
74
+ If an existing repository violates this contract, treat that as architecture debt to remove, not
75
+ as an exception to preserve.
76
+
48
77
  `;
49
78
  }
50
79
  /*** Render contextual repository skill-selection requirements. */
@@ -5,6 +5,7 @@ export interface ManagedFileDefinition {
5
5
  readonly sourceUrl?: URL;
6
6
  readonly contents?: string;
7
7
  readonly render?: ManagedFileRenderer;
8
+ readonly symlinkTarget?: string;
8
9
  readonly mode?: ManagedFileMode;
9
10
  readonly isApplicable?: (targetDirectory: string) => Promise<boolean> | boolean;
10
11
  }
@@ -18,8 +19,11 @@ export interface ManagedFileSyncResult {
18
19
  readonly relativePath: string;
19
20
  readonly action: ManagedFileSyncAction;
20
21
  }
22
+ /*** Resolve and validate the repository directory targeted by a managed-files operation. */
21
23
  export declare function resolveManagedTargetDirectory(cwd: string, requestedPath: string | undefined): Promise<string>;
24
+ /*** Inspect managed files and symbolic links without mutating repository state. */
22
25
  export declare function inspectManagedFiles(targetDirectory: string, definitions: readonly ManagedFileDefinition[]): Promise<readonly ManagedFileStatus[]>;
26
+ /*** Synchronize managed files and symbolic links to their canonical definitions. */
23
27
  export declare function syncManagedFiles(targetDirectory: string, definitions: readonly ManagedFileDefinition[], options: {
24
28
  readonly dryRun: boolean;
25
29
  }): Promise<readonly ManagedFileSyncResult[]>;
@@ -1,5 +1,6 @@
1
- import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
1
+ import { lstat, mkdir, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
+ /*** Resolve and validate the repository directory targeted by a managed-files operation. */
3
4
  export async function resolveManagedTargetDirectory(cwd, requestedPath) {
4
5
  const targetDirectory = resolve(cwd, requestedPath ?? '.');
5
6
  let targetStats;
@@ -14,46 +15,62 @@ export async function resolveManagedTargetDirectory(cwd, requestedPath) {
14
15
  }
15
16
  return targetDirectory;
16
17
  }
18
+ /*** Inspect managed files and symbolic links without mutating repository state. */
17
19
  export async function inspectManagedFiles(targetDirectory, definitions) {
18
- const statuses = await Promise.all(definitions.map(async (definition) => {
19
- const targetPath = resolve(targetDirectory, definition.relativePath);
20
- const isApplicable = await (definition.isApplicable?.(targetDirectory) ?? true);
21
- try {
22
- const targetContents = await readFile(targetPath, 'utf8');
23
- if (!isApplicable) {
24
- return { relativePath: definition.relativePath, state: 'obsolete' };
25
- }
26
- if ((definition.mode ?? 'replace') === 'create-only') {
27
- return { relativePath: definition.relativePath, state: 'current' };
28
- }
29
- const canonicalContents = await readCanonicalContents(definition, targetDirectory);
30
- return {
31
- relativePath: definition.relativePath,
32
- state: targetContents === canonicalContents ? 'current' : 'outdated',
33
- };
34
- }
35
- catch (error) {
36
- if (isMissingFileError(error)) {
37
- return isApplicable
38
- ? { relativePath: definition.relativePath, state: 'missing' }
39
- : undefined;
40
- }
41
- throw new Error(`Failed to inspect managed file: ${targetPath}`, { cause: error });
42
- }
43
- }));
20
+ const statuses = await Promise.all(definitions.map((definition) => inspectManagedFileAsync(targetDirectory, definition)));
44
21
  return statuses.filter((status) => status !== undefined);
45
22
  }
23
+ /*** Synchronize managed files and symbolic links to their canonical definitions. */
46
24
  export async function syncManagedFiles(targetDirectory, definitions, options) {
47
25
  const statuses = await inspectManagedFiles(targetDirectory, definitions);
48
26
  const definitionsByPath = new Map(definitions.map((definition) => [definition.relativePath, definition]));
49
27
  const results = [];
50
28
  for (const status of statuses) {
51
- const result = await syncManagedFile(targetDirectory, status, definitionsByPath, options);
52
- results.push(result);
29
+ results.push(await syncManagedFileAsync(targetDirectory, status, definitionsByPath, options));
53
30
  }
54
31
  return results;
55
32
  }
56
- async function syncManagedFile(targetDirectory, status, definitionsByPath, options) {
33
+ /*** Inspect one managed artifact against its file or symbolic-link definition. */
34
+ async function inspectManagedFileAsync(targetDirectory, definition) {
35
+ assertSingleContentSource(definition);
36
+ const targetPath = resolve(targetDirectory, definition.relativePath);
37
+ const isApplicable = await (definition.isApplicable?.(targetDirectory) ?? true);
38
+ try {
39
+ const targetStats = await lstat(targetPath);
40
+ if (!isApplicable) {
41
+ return { relativePath: definition.relativePath, state: 'obsolete' };
42
+ }
43
+ if ((definition.mode ?? 'replace') === 'create-only') {
44
+ return { relativePath: definition.relativePath, state: 'current' };
45
+ }
46
+ const current = definition.symlinkTarget === undefined
47
+ ? await isCurrentFileAsync(targetPath, targetStats.isSymbolicLink(), definition, targetDirectory)
48
+ : await isCurrentSymlinkAsync(targetPath, targetStats.isSymbolicLink(), definition.symlinkTarget);
49
+ return {
50
+ relativePath: definition.relativePath,
51
+ state: current ? 'current' : 'outdated',
52
+ };
53
+ }
54
+ catch (error) {
55
+ if (isMissingFileError(error)) {
56
+ return isApplicable ? { relativePath: definition.relativePath, state: 'missing' } : undefined;
57
+ }
58
+ throw new Error(`Failed to inspect managed file: ${targetPath}`, { cause: error });
59
+ }
60
+ }
61
+ /*** Compare one regular managed file with its canonical rendered contents. */
62
+ async function isCurrentFileAsync(targetPath, isSymbolicLink, definition, targetDirectory) {
63
+ if (isSymbolicLink)
64
+ return false;
65
+ return ((await readFile(targetPath, 'utf8')) ===
66
+ (await readCanonicalContents(definition, targetDirectory)));
67
+ }
68
+ /*** Compare one managed symbolic link with its canonical relative target. */
69
+ async function isCurrentSymlinkAsync(targetPath, isSymbolicLink, symlinkTarget) {
70
+ return isSymbolicLink && (await readlink(targetPath)) === symlinkTarget;
71
+ }
72
+ /*** Apply one managed artifact status to the target repository. */
73
+ async function syncManagedFileAsync(targetDirectory, status, definitionsByPath, options) {
57
74
  if (status.state === 'current') {
58
75
  return { relativePath: status.relativePath, action: 'unchanged' };
59
76
  }
@@ -63,7 +80,7 @@ async function syncManagedFile(targetDirectory, status, definitionsByPath, optio
63
80
  }
64
81
  if (status.state === 'obsolete') {
65
82
  if (!options.dryRun) {
66
- await rm(resolve(targetDirectory, definition.relativePath));
83
+ await rm(resolve(targetDirectory, definition.relativePath), { force: true, recursive: true });
67
84
  }
68
85
  return {
69
86
  relativePath: status.relativePath,
@@ -78,12 +95,41 @@ async function syncManagedFile(targetDirectory, status, definitionsByPath, optio
78
95
  }
79
96
  const targetPath = resolve(targetDirectory, definition.relativePath);
80
97
  await mkdir(dirname(targetPath), { recursive: true });
81
- await writeFile(targetPath, await readCanonicalContents(definition, targetDirectory), 'utf8');
98
+ const canonicalContents = definition.symlinkTarget === undefined
99
+ ? await readCanonicalContents(definition, targetDirectory)
100
+ : undefined;
101
+ await prepareManagedTargetAsync(targetPath, definition);
102
+ await writeManagedArtifactAsync(targetPath, definition, canonicalContents);
82
103
  return {
83
104
  relativePath: status.relativePath,
84
105
  action: status.state === 'missing' ? 'created' : 'updated',
85
106
  };
86
107
  }
108
+ /*** Remove an existing artifact only when writing through it would violate the target type. */
109
+ async function prepareManagedTargetAsync(targetPath, definition) {
110
+ try {
111
+ const targetStats = await lstat(targetPath);
112
+ if (definition.symlinkTarget !== undefined || targetStats.isSymbolicLink()) {
113
+ await rm(targetPath, { force: true, recursive: true });
114
+ }
115
+ }
116
+ catch (error) {
117
+ if (!isMissingFileError(error))
118
+ throw error;
119
+ }
120
+ }
121
+ /*** Write either a canonical regular file or a canonical symbolic link. */
122
+ async function writeManagedArtifactAsync(targetPath, definition, canonicalContents) {
123
+ if (definition.symlinkTarget !== undefined) {
124
+ await symlink(definition.symlinkTarget, targetPath);
125
+ return;
126
+ }
127
+ if (canonicalContents === undefined) {
128
+ throw new Error(`Missing canonical file contents for ${definition.relativePath}.`);
129
+ }
130
+ await writeFile(targetPath, canonicalContents, 'utf8');
131
+ }
132
+ /*** Resolve canonical contents for one regular managed file definition. */
87
133
  async function readCanonicalContents(definition, targetDirectory) {
88
134
  assertSingleContentSource(definition);
89
135
  if (definition.sourceUrl !== undefined) {
@@ -95,17 +141,25 @@ async function readCanonicalContents(definition, targetDirectory) {
95
141
  if (definition.render !== undefined) {
96
142
  return await definition.render(targetDirectory);
97
143
  }
98
- throw new Error(`Managed file has no content source: ${definition.relativePath}`);
144
+ throw new Error(`Managed file does not contain regular-file content: ${definition.relativePath}`);
99
145
  }
146
+ /*** Require every managed artifact to define exactly one canonical source. */
100
147
  function assertSingleContentSource(definition) {
101
- const sourceCount = [definition.sourceUrl, definition.contents, definition.render].filter((value) => value !== undefined).length;
148
+ const sourceCount = [
149
+ definition.sourceUrl,
150
+ definition.contents,
151
+ definition.render,
152
+ definition.symlinkTarget,
153
+ ].filter((value) => value !== undefined).length;
102
154
  if (sourceCount !== 1) {
103
155
  throw new Error(`Managed file must define exactly one content source: ${definition.relativePath}`);
104
156
  }
105
157
  }
158
+ /*** Check whether an unknown failure means the target path does not exist. */
106
159
  function isMissingFileError(error) {
107
160
  return isNodeError(error) && error.code === 'ENOENT';
108
161
  }
162
+ /*** Check whether an unknown failure carries a Node.js error code. */
109
163
  function isNodeError(error) {
110
164
  return error instanceof Error && 'code' in error;
111
165
  }
@@ -83,6 +83,19 @@ jobs:
83
83
  echo "No test script found; skipping."
84
84
  fi
85
85
 
86
+ - name: Run standalone contract
87
+ run: |
88
+ if node -e "const p=require('./package.json'); const required=p.private!==true&&p.publishConfig?.access==='public'; process.exit(required||p.scripts?.['test:standalone']?0:1)"; then
89
+ if node -e "const p=require('./package.json'); process.exit(p.scripts?.['test:standalone']?0:1)"; then
90
+ bun run test:standalone
91
+ else
92
+ echo "::error title=Standalone contract test required::Public packages must define test:standalone."
93
+ exit 1
94
+ fi
95
+ else
96
+ echo "No standalone owner test required; skipping."
97
+ fi
98
+
86
99
  - name: Run typecheck
87
100
  run: |
88
101
  if node -e "const p=require('./package.json'); process.exit(p.scripts?.typecheck ? 0 : 1)"; then
@@ -76,6 +76,18 @@ jobs:
76
76
  echo "No build script found; skipping."
77
77
  fi
78
78
 
79
+ - name: Validate standalone release contract
80
+ run: |
81
+ BUN_INSTALL_CACHE_DIR="${RUNNER_TEMP}/doctor-cache" bunx @ankhorage/doctor@__ANKH_DOCTOR_VERSION__ validate .
82
+ if node -e "const p=require('./package.json'); const required=p.private!==true&&p.publishConfig?.access==='public'; process.exit(required||p.scripts?.['test:standalone']?0:1)"; then
83
+ if node -e "const p=require('./package.json'); process.exit(p.scripts?.['test:standalone']?0:1)"; then
84
+ bun run test:standalone
85
+ else
86
+ echo "::error title=Standalone contract test required::Public packages must define test:standalone."
87
+ exit 1
88
+ fi
89
+ fi
90
+
79
91
  - name: Version packages directly on main
80
92
  id: release
81
93
  if: hashFiles('.changeset/config.json') != ''
@@ -148,6 +160,31 @@ jobs:
148
160
  echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
149
161
  fi
150
162
 
163
+ - name: Verify standalone packed install
164
+ if: steps.release.outputs.versioned == 'true'
165
+ run: |
166
+ set -euo pipefail
167
+ bun install --frozen-lockfile --ignore-scripts
168
+ if node -e "const p=require('./package.json'); process.exit(p.scripts?.build?0:1)"; then
169
+ bun run build
170
+ fi
171
+ if node -e "const p=require('./package.json'); process.exit(p.scripts?.['test:standalone']?0:1)"; then
172
+ bun run test:standalone
173
+ fi
174
+ pack_dir="$(mktemp -d "${RUNNER_TEMP}/standalone-pack.XXXXXX")"
175
+ install_dir="$(mktemp -d "${RUNNER_TEMP}/standalone-install.XXXXXX")"
176
+ npm pack --ignore-scripts --pack-destination "$pack_dir" >/dev/null
177
+ artifact="$(find "$pack_dir" -maxdepth 1 -type f -name '*.tgz' -print -quit)"
178
+ if [ -z "$artifact" ]; then
179
+ echo "::error title=Standalone pack failed::npm pack did not produce a package artifact."
180
+ exit 1
181
+ fi
182
+ printf '{"name":"standalone-consumer","private":true,"version":"0.0.0"}\n' > "$install_dir/package.json"
183
+ (
184
+ cd "$install_dir"
185
+ bun add "$artifact" --ignore-scripts
186
+ )
187
+
151
188
  - name: Publish unpublished packages
152
189
  if: hashFiles('.changeset/config.json') != ''
153
190
  run: |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/devtools",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Shared tooling, repository automation, runtime policies, and agent standards for Ankhorage TypeScript projects",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ankhorage/devtools#readme",
@@ -121,7 +121,8 @@
121
121
  "changeset": "bun src/cli/bin/changeset.ts",
122
122
  "changeset:status": "bun src/cli/bin/changeset.ts status --since=origin/main",
123
123
  "version-packages": "bun src/cli/bin/changeset.ts version",
124
- "check-types": "bun x tsc --noEmit -p tsconfig.test.json"
124
+ "check-types": "bun x tsc --noEmit -p tsconfig.test.json",
125
+ "test:standalone": "bun test src/tools/agents/index.test.ts src/tools/shared/managedFiles.test.ts"
125
126
  },
126
127
  "dependencies": {
127
128
  "@ankhorage/apm": "^0.8.10",