@ankhorage/devtools 1.17.2 → 1.18.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.
Files changed (37) hide show
  1. package/README.md +4 -1
  2. package/dist/cli/bin/structure.d.ts +2 -0
  3. package/dist/cli/bin/structure.js +15 -0
  4. package/dist/cli/commands/structure/build.d.ts +5 -0
  5. package/dist/cli/commands/structure/build.js +5 -0
  6. package/dist/cli/commands/structure/check.d.ts +5 -0
  7. package/dist/cli/commands/structure/check.js +5 -0
  8. package/dist/cli/commands.d.ts +6 -2
  9. package/dist/cli/commands.js +14 -0
  10. package/dist/cli/index.d.ts +2 -2
  11. package/dist/cli/index.js +2 -0
  12. package/dist/cli/runProviderCommand.js +5 -0
  13. package/dist/features/structure-descriptor-generation/adapters/inbound/runStructureGenerationCommandAsync.d.ts +10 -0
  14. package/dist/features/structure-descriptor-generation/adapters/inbound/runStructureGenerationCommandAsync.js +22 -0
  15. package/dist/features/structure-descriptor-generation/adapters/outbound/createStructureGenerationWorkspacePort.d.ts +3 -0
  16. package/dist/features/structure-descriptor-generation/adapters/outbound/createStructureGenerationWorkspacePort.js +92 -0
  17. package/dist/features/structure-descriptor-generation/adapters/outbound/generateStructureArtifactFromTypescriptAsync.d.ts +3 -0
  18. package/dist/features/structure-descriptor-generation/adapters/outbound/generateStructureArtifactFromTypescriptAsync.js +142 -0
  19. package/dist/features/structure-descriptor-generation/adapters/outbound/resolveStructureReleaseCommandAsync.d.ts +2 -0
  20. package/dist/features/structure-descriptor-generation/adapters/outbound/resolveStructureReleaseCommandAsync.js +21 -0
  21. package/dist/features/structure-descriptor-generation/application/use-cases/synchronizeStructureArtifactAsync.d.ts +3 -0
  22. package/dist/features/structure-descriptor-generation/application/use-cases/synchronizeStructureArtifactAsync.js +20 -0
  23. package/dist/features/structure-descriptor-generation/composition/synchronizeStructureArtifactForDirectoryAsync.d.ts +3 -0
  24. package/dist/features/structure-descriptor-generation/composition/synchronizeStructureArtifactForDirectoryAsync.js +6 -0
  25. package/dist/features/structure-descriptor-generation/utils/resolveSemanticStructureWrapper.d.ts +9 -0
  26. package/dist/features/structure-descriptor-generation/utils/resolveSemanticStructureWrapper.js +150 -0
  27. package/dist/features/structure-descriptor-generation/utils/resolveStructureSymbolPackage.d.ts +4 -0
  28. package/dist/features/structure-descriptor-generation/utils/resolveStructureSymbolPackage.js +33 -0
  29. package/dist/features/structure-descriptor-generation/utils/resolveStructureType.d.ts +5 -0
  30. package/dist/features/structure-descriptor-generation/utils/resolveStructureType.js +210 -0
  31. package/dist/tools/workflows/files/release.yml +3 -0
  32. package/dist/tools/workflows/index.js +3 -0
  33. package/dist/tools/workflows/renderWorkflowAsync.d.ts +1 -0
  34. package/dist/tools/workflows/renderWorkflowAsync.js +1 -0
  35. package/dist/types/structure-generation.d.ts +42 -0
  36. package/dist/types/structure-generation.js +1 -0
  37. package/package.json +11 -4
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.17.2](././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.18.1](././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
 
@@ -452,6 +452,8 @@ Run and synchronize the shared development toolchain through the Ankh CLI.
452
452
  Repository synchronization is available through
453
453
  `ankh devtools sync` and `ankh devtools status`, with focused `agents`, `skills`, `eslint`,
454
454
  `prettier`, `knip`, `package`, `workflows`, and `vscode` sync/status subcommands.
455
+ Owner packages can opt into deterministic structural metadata with
456
+ `ankh devtools structure build` and verify committed evidence with `ankh devtools structure check`.
455
457
 
456
458
  Sync commands accept an optional target directory and `--dry-run`. Aggregate sync is
457
459
  deterministic and idempotent: canonical managed files and skill trees are created or updated,
@@ -477,6 +479,7 @@ bunx @ankhorage/devtools ankhorage-changeset
477
479
  bunx @ankhorage/devtools ankhorage-eslint
478
480
  bunx @ankhorage/devtools ankhorage-knip
479
481
  bunx @ankhorage/devtools ankhorage-prettier
482
+ bunx @ankhorage/devtools ankhorage-structure
480
483
  ```
481
484
 
482
485
  ## Generated documentation
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { runStructureGenerationCommandAsync } from '../../features/structure-descriptor-generation/adapters/inbound/runStructureGenerationCommandAsync.js';
3
+ const [operation, ...argv] = process.argv.slice(2);
4
+ if (operation !== 'build' && operation !== 'check') {
5
+ console.error('Usage: ankhorage-structure <build|check> [directory]');
6
+ process.exitCode = 1;
7
+ }
8
+ else {
9
+ const result = await runStructureGenerationCommandAsync(operation, argv, {
10
+ cwd: process.cwd(),
11
+ writeStdout: (text) => process.stdout.write(text),
12
+ writeStderr: (text) => process.stderr.write(text),
13
+ });
14
+ process.exitCode = result.exitCode;
15
+ }
@@ -0,0 +1,5 @@
1
+ import type { DevtoolsProviderCommandContext } from '../../runProviderCommand.js';
2
+ /*** Adapt the public structure build command to deterministic owner artifact generation. */
3
+ export declare function build(argv: readonly string[], context: DevtoolsProviderCommandContext): Promise<{
4
+ readonly exitCode: number;
5
+ }>;
@@ -0,0 +1,5 @@
1
+ import { runStructureGenerationCommandAsync } from '../../../features/structure-descriptor-generation/adapters/inbound/runStructureGenerationCommandAsync.js';
2
+ /*** Adapt the public structure build command to deterministic owner artifact generation. */
3
+ export async function build(argv, context) {
4
+ return runStructureGenerationCommandAsync('build', argv, context);
5
+ }
@@ -0,0 +1,5 @@
1
+ import type { DevtoolsProviderCommandContext } from '../../runProviderCommand.js';
2
+ /*** Adapt the public structure check command to deterministic owner artifact verification. */
3
+ export declare function check(argv: readonly string[], context: DevtoolsProviderCommandContext): Promise<{
4
+ readonly exitCode: number;
5
+ }>;
@@ -0,0 +1,5 @@
1
+ import { runStructureGenerationCommandAsync } from '../../../features/structure-descriptor-generation/adapters/inbound/runStructureGenerationCommandAsync.js';
2
+ /*** Adapt the public structure check command to deterministic owner artifact verification. */
3
+ export async function check(argv, context) {
4
+ return runStructureGenerationCommandAsync('check', argv, context);
5
+ }
@@ -1,7 +1,7 @@
1
1
  export type DevtoolsToolName = 'changeset' | 'format' | 'knip' | 'lint';
2
2
  type DevtoolsManagedScope = 'agents' | 'all' | 'eslint' | 'knip' | 'package' | 'prettier' | 'skills' | 'vscode' | 'workflows';
3
3
  type DevtoolsManagedOperation = 'status' | 'sync';
4
- type DevtoolsCapability = 'devtools.apm.sync' | 'devtools.apm.validate' | 'devtools.changeset' | 'devtools.format' | 'devtools.knip' | 'devtools.lint' | 'devtools.status' | 'devtools.sync' | 'devtools.agents.status' | 'devtools.agents.sync' | 'devtools.eslint.status' | 'devtools.eslint.sync' | 'devtools.knip.status' | 'devtools.knip.sync' | 'devtools.package.status' | 'devtools.package.sync' | 'devtools.prettier.status' | 'devtools.prettier.sync' | 'devtools.skills.status' | 'devtools.skills.sync' | 'devtools.vscode.status' | 'devtools.vscode.sync' | 'devtools.workflows.status' | 'devtools.workflows.sync';
4
+ type DevtoolsCapability = 'devtools.apm.sync' | 'devtools.apm.validate' | 'devtools.changeset' | 'devtools.format' | 'devtools.knip' | 'devtools.lint' | 'devtools.status' | 'devtools.sync' | 'devtools.agents.status' | 'devtools.agents.sync' | 'devtools.eslint.status' | 'devtools.eslint.sync' | 'devtools.knip.status' | 'devtools.knip.sync' | 'devtools.package.status' | 'devtools.package.sync' | 'devtools.prettier.status' | 'devtools.prettier.sync' | 'devtools.skills.status' | 'devtools.skills.sync' | 'devtools.structure.build' | 'devtools.structure.check' | 'devtools.vscode.status' | 'devtools.vscode.sync' | 'devtools.workflows.status' | 'devtools.workflows.sync';
5
5
  interface DevtoolsCommandBase {
6
6
  readonly path: readonly [string, ...string[]];
7
7
  readonly capability: DevtoolsCapability;
@@ -22,7 +22,11 @@ interface DevtoolsApmReleaseCommandDefinition extends DevtoolsCommandBase {
22
22
  readonly kind: 'apm-release';
23
23
  readonly operation: 'sync' | 'validate';
24
24
  }
25
- export type DevtoolsCommandDefinition = DevtoolsExternalCommandDefinition | DevtoolsRepositoryCommandDefinition | DevtoolsApmReleaseCommandDefinition;
25
+ interface DevtoolsStructureCommandDefinition extends DevtoolsCommandBase {
26
+ readonly kind: 'structure';
27
+ readonly operation: 'build' | 'check';
28
+ }
29
+ export type DevtoolsCommandDefinition = DevtoolsExternalCommandDefinition | DevtoolsRepositoryCommandDefinition | DevtoolsApmReleaseCommandDefinition | DevtoolsStructureCommandDefinition;
26
30
  export declare function getDevtoolsCommands(): readonly DevtoolsCommandDefinition[];
27
31
  export declare function findDevtoolsCommandByPath(path: readonly string[]): DevtoolsCommandDefinition | null;
28
32
  export declare function getDevtoolsToolCommand(toolName: DevtoolsToolName): DevtoolsExternalCommandDefinition;
@@ -13,6 +13,20 @@ const DEVTOOLS_COMMANDS = [
13
13
  capability: 'devtools.apm.validate',
14
14
  summary: 'Validate the exact packed APM release artifact.',
15
15
  },
16
+ {
17
+ kind: 'structure',
18
+ operation: 'build',
19
+ path: ['structure', 'build'],
20
+ capability: 'devtools.structure.build',
21
+ summary: 'Generate deterministic structural descriptor evidence.',
22
+ },
23
+ {
24
+ kind: 'structure',
25
+ operation: 'check',
26
+ path: ['structure', 'check'],
27
+ capability: 'devtools.structure.check',
28
+ summary: 'Verify structural descriptor evidence is current.',
29
+ },
16
30
  externalCommand('changeset', 'devtools.changeset', 'Run the shared Changesets toolchain.', '@changesets/cli', 'changeset'),
17
31
  externalCommand('lint', 'devtools.lint', 'Run the shared ESLint toolchain.', 'eslint', 'eslint'),
18
32
  externalCommand('format', 'devtools.format', 'Run the shared Prettier toolchain.', 'prettier', 'prettier'),
@@ -2,10 +2,10 @@ declare const provider: {
2
2
  id: string;
3
3
  category: string;
4
4
  version: string;
5
- capabilities: ("devtools.apm.sync" | "devtools.apm.validate" | "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync")[];
5
+ capabilities: ("devtools.apm.sync" | "devtools.apm.validate" | "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.structure.build" | "devtools.structure.check" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync")[];
6
6
  commands: {
7
7
  path: string[];
8
- capability: "devtools.apm.sync" | "devtools.apm.validate" | "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync";
8
+ capability: "devtools.apm.sync" | "devtools.apm.validate" | "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.structure.build" | "devtools.structure.check" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync";
9
9
  summary: string;
10
10
  }[];
11
11
  handlers: {
package/dist/cli/index.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * Repository synchronization is available through
7
7
  * `ankh devtools sync` and `ankh devtools status`, with focused `agents`, `skills`, `eslint`,
8
8
  * `prettier`, `knip`, `package`, `workflows`, and `vscode` sync/status subcommands.
9
+ * Owner packages can opt into deterministic structural metadata with
10
+ * `ankh devtools structure build` and verify committed evidence with `ankh devtools structure check`.
9
11
  *
10
12
  * Sync commands accept an optional target directory and `--dry-run`. Aggregate sync is
11
13
  * deterministic and idempotent: canonical managed files and skill trees are created or updated,
@@ -1,11 +1,16 @@
1
1
  import { sync } from './commands/apm/sync.js';
2
2
  import { validate } from './commands/apm/validate.js';
3
+ import { build } from './commands/structure/build.js';
4
+ import { check } from './commands/structure/check.js';
3
5
  import { runExternalTool } from './runExternalTool.js';
4
6
  import { runRepositoryCommand, } from './runRepositoryCommand.js';
5
7
  export async function runProviderCommand(command, argv, context) {
6
8
  if (command.kind === 'apm-release') {
7
9
  return command.operation === 'sync' ? sync(argv, context) : validate(argv, context);
8
10
  }
11
+ if (command.kind === 'structure') {
12
+ return command.operation === 'build' ? build(argv, context) : check(argv, context);
13
+ }
9
14
  if (command.kind === 'external') {
10
15
  return await runExternalTool(command, argv, {
11
16
  cwd: context.cwd,
@@ -0,0 +1,10 @@
1
+ /*** Parse structure build/check CLI input and emit one machine-readable result. */
2
+ export declare function runStructureGenerationCommandAsync(operation: 'build' | 'check', argv: readonly string[], context: StructureGenerationCommandContext): Promise<{
3
+ readonly exitCode: number;
4
+ }>;
5
+ interface StructureGenerationCommandContext {
6
+ readonly cwd: string;
7
+ readonly writeStdout: (text: string) => void;
8
+ readonly writeStderr: (text: string) => void;
9
+ }
10
+ export {};
@@ -0,0 +1,22 @@
1
+ import { resolve } from 'node:path';
2
+ import { synchronizeStructureArtifactForDirectoryAsync } from '../../composition/synchronizeStructureArtifactForDirectoryAsync.js';
3
+ /*** Parse structure build/check CLI input and emit one machine-readable result. */
4
+ export async function runStructureGenerationCommandAsync(operation, argv, context) {
5
+ try {
6
+ const target = parseTarget(argv, context.cwd);
7
+ const result = await synchronizeStructureArtifactForDirectoryAsync(operation, target);
8
+ context.writeStdout(`${JSON.stringify(result)}\n`);
9
+ return { exitCode: operation === 'check' && result.applicable && !result.current ? 1 : 0 };
10
+ }
11
+ catch (error) {
12
+ context.writeStderr(`${error instanceof Error ? error.message : 'Structure generation failed.'}\n`);
13
+ return { exitCode: 1 };
14
+ }
15
+ }
16
+ /*** Resolve the optional target directory and reject unsupported flags/extra operands. */
17
+ function parseTarget(argv, cwd) {
18
+ if (argv.length > 1 || argv.some((value) => value.startsWith('-'))) {
19
+ throw new Error('Expected at most one structure-generation target directory.');
20
+ }
21
+ return resolve(cwd, argv.at(0) ?? '.');
22
+ }
@@ -0,0 +1,3 @@
1
+ import type { StructureGenerationWorkspacePort } from '../../../../types/structure-generation.js';
2
+ /*** Create the bounded filesystem/compiler adapter used by structure build and check commands. */
3
+ export declare function createStructureGenerationWorkspacePort(targetDirectory: string): StructureGenerationWorkspacePort;
@@ -0,0 +1,92 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { isRecord } from '@ankhorage/utility/object';
4
+ import { generateStructureArtifactFromTypescriptAsync } from './generateStructureArtifactFromTypescriptAsync.js';
5
+ /*** Create the bounded filesystem/compiler adapter used by structure build and check commands. */
6
+ export function createStructureGenerationWorkspacePort(targetDirectory) {
7
+ return {
8
+ readPackageAsync: async () => readStructurePackageAsync(targetDirectory),
9
+ generateArtifactAsync: (packageConfig) => Promise.resolve(generateStructureArtifactFromTypescriptAsync(targetDirectory, packageConfig)),
10
+ readOutputAsync: async (relativePath) => readOptionalFileAsync(targetDirectory, relativePath),
11
+ writeOutputAsync: async (relativePath, source) => writeGeneratedFileAsync(targetDirectory, relativePath, source),
12
+ };
13
+ }
14
+ /*** Read and validate the package-owned structure generation declaration. */
15
+ async function readStructurePackageAsync(targetDirectory) {
16
+ const packagePath = resolve(targetDirectory, 'package.json');
17
+ const raw = JSON.parse(await readFile(packagePath, 'utf8'));
18
+ if (!isRecord(raw))
19
+ throw new Error('package.json must contain one JSON object.');
20
+ const { ankh, name, version } = raw;
21
+ if (typeof name !== 'string' || name.trim() === '') {
22
+ throw new Error('package.json needs a package name.');
23
+ }
24
+ if (typeof version !== 'string' || version.trim() === '') {
25
+ throw new Error('package.json needs a package version.');
26
+ }
27
+ if (!isRecord(ankh) || ankh.structure === undefined)
28
+ return null;
29
+ return { name, version, config: parseStructureConfig(ankh.structure, targetDirectory) };
30
+ }
31
+ /*** Parse explicit structure roots and one generated output path without accepting path escapes. */
32
+ function parseStructureConfig(value, targetDirectory) {
33
+ if (!isRecord(value))
34
+ throw new Error('ankh.structure must define output and roots.');
35
+ const { output: configuredOutput, roots: configuredRoots } = value;
36
+ if (typeof configuredOutput !== 'string' || !isRecord(configuredRoots)) {
37
+ throw new Error('ankh.structure must define output and roots.');
38
+ }
39
+ const output = validateRelativePath(configuredOutput, targetDirectory, 'ankh.structure.output');
40
+ const roots = Object.fromEntries(Object.entries(configuredRoots)
41
+ .sort(([left], [right]) => left.localeCompare(right))
42
+ .map(([name, root]) => [name, parseRoot(name, root, targetDirectory)]));
43
+ if (Object.keys(roots).length === 0)
44
+ throw new Error('ankh.structure.roots must not be empty.');
45
+ return { output, roots };
46
+ }
47
+ /*** Parse one explicit root source/export declaration. */
48
+ function parseRoot(name, value, targetDirectory) {
49
+ if (name.trim() === '' ||
50
+ !isRecord(value) ||
51
+ typeof value.source !== 'string' ||
52
+ typeof value.export !== 'string' ||
53
+ value.export.trim() === '') {
54
+ throw new Error(`Invalid structural root "${name}".`);
55
+ }
56
+ return {
57
+ source: validateRelativePath(value.source, targetDirectory, `structure root ${name}`),
58
+ export: value.export,
59
+ };
60
+ }
61
+ /*** Require repository-relative paths that resolve inside the selected package root. */
62
+ function validateRelativePath(value, targetDirectory, label) {
63
+ if (value.trim() === '' || isAbsolute(value))
64
+ throw new Error(`${label} must be relative.`);
65
+ const absolute = resolve(targetDirectory, value);
66
+ const resolved = relative(targetDirectory, absolute);
67
+ if (resolved === '..' || resolved.startsWith('../') || resolved.startsWith('..\\')) {
68
+ throw new Error(`${label} must stay inside the package root.`);
69
+ }
70
+ return value.replaceAll('\\', '/');
71
+ }
72
+ /*** Read an optional generated artifact without treating absence as a filesystem error. */
73
+ async function readOptionalFileAsync(targetDirectory, relativePath) {
74
+ try {
75
+ return await readFile(resolve(targetDirectory, relativePath), 'utf8');
76
+ }
77
+ catch (error) {
78
+ if (isNodeError(error) && error.code === 'ENOENT')
79
+ return null;
80
+ throw error;
81
+ }
82
+ }
83
+ /*** Write one generated artifact after ensuring its owning directory exists. */
84
+ async function writeGeneratedFileAsync(targetDirectory, relativePath, source) {
85
+ const output = resolve(targetDirectory, relativePath);
86
+ await mkdir(dirname(output), { recursive: true });
87
+ await writeFile(output, source, 'utf8');
88
+ }
89
+ /*** Narrow Node filesystem failures without unsafe error casts. */
90
+ function isNodeError(error) {
91
+ return error instanceof Error && 'code' in error;
92
+ }
@@ -0,0 +1,3 @@
1
+ import type { StructureGenerationArtifact, StructureGenerationPackage } from '../../../../types/structure-generation.js';
2
+ /*** Generate one deterministic structural descriptor artifact from explicit public type roots. */
3
+ export declare function generateStructureArtifactFromTypescriptAsync(targetDirectory: string, packageConfig: StructureGenerationPackage): StructureGenerationArtifact;
@@ -0,0 +1,142 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { resolve } from 'node:path';
3
+ import { isStructureDescriptorDocument } from '@ankhorage/contracts/structure';
4
+ import ts from 'typescript';
5
+ import { resolveSemanticStructureWrapperNode } from '../../utils/resolveSemanticStructureWrapper.js';
6
+ import { resolveStructureType } from '../../utils/resolveStructureType.js';
7
+ /*** Generate one deterministic structural descriptor artifact from explicit public type roots. */
8
+ export function generateStructureArtifactFromTypescriptAsync(targetDirectory, packageConfig) {
9
+ const { program, checker } = createCompiler(targetDirectory, packageConfig);
10
+ assertProgramDiagnostics(program, targetDirectory);
11
+ const context = {
12
+ checker,
13
+ program,
14
+ targetDirectory,
15
+ packageName: packageConfig.name,
16
+ definitions: new Map(),
17
+ definitionSymbols: new Map(),
18
+ resolving: new Set(),
19
+ };
20
+ const roots = resolveRoots(packageConfig, context);
21
+ const document = {
22
+ protocolVersion: 1,
23
+ packageName: packageConfig.name,
24
+ packageVersion: packageConfig.version,
25
+ roots,
26
+ descriptors: Object.fromEntries([...context.definitions.entries()].sort(([left], [right]) => left.localeCompare(right))),
27
+ };
28
+ if (!isStructureDescriptorDocument(document)) {
29
+ throw new Error('Generated structural descriptor document failed canonical Contracts validation.');
30
+ }
31
+ const serialized = JSON.stringify(document, null, 2);
32
+ const compilerVersion = ts.version;
33
+ const fingerprintSource = JSON.stringify({ compilerVersion, document });
34
+ const fingerprint = `sha256:${createHash('sha256').update(fingerprintSource).digest('hex')}`;
35
+ return {
36
+ document,
37
+ fingerprint,
38
+ source: renderArtifactSource(serialized, fingerprint, compilerVersion),
39
+ };
40
+ }
41
+ /*** Build a TypeScript program containing the target project plus every explicit structure root source. */
42
+ function createCompiler(targetDirectory, packageConfig) {
43
+ const configPath = ts.findConfigFile(targetDirectory, (fileName) => ts.sys.fileExists(fileName), 'tsconfig.json');
44
+ if (!configPath)
45
+ throw new Error('Structure generation requires a project tsconfig.json.');
46
+ const loaded = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName));
47
+ if (loaded.error)
48
+ throw new Error(formatDiagnostic(loaded.error, targetDirectory));
49
+ const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, targetDirectory);
50
+ const rootSources = Object.values(packageConfig.config.roots).map((root) => resolve(targetDirectory, root.source));
51
+ const rootNames = [...new Set([...parsed.fileNames, ...rootSources])].sort();
52
+ const program = ts.createProgram({ rootNames, options: parsed.options });
53
+ return { program, checker: program.getTypeChecker() };
54
+ }
55
+ /*** Fail closed when target compilation contains an error diagnostic. */
56
+ function assertProgramDiagnostics(program, targetDirectory) {
57
+ const errors = ts
58
+ .getPreEmitDiagnostics(program)
59
+ .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
60
+ if (errors.length === 0)
61
+ return;
62
+ throw new Error(errors
63
+ .slice(0, 10)
64
+ .map((diagnostic) => formatDiagnostic(diagnostic, targetDirectory))
65
+ .join('\n'));
66
+ }
67
+ /*** Resolve every opted-in export to a stable local descriptor definition. */
68
+ function resolveRoots(packageConfig, context) {
69
+ return Object.fromEntries(Object.entries(packageConfig.config.roots)
70
+ .sort(([left], [right]) => left.localeCompare(right))
71
+ .map(([rootName, root]) => [rootName, resolveRoot(rootName, root, context)]));
72
+ }
73
+ /*** Resolve one configured public export and materialize its root definition. */
74
+ function resolveRoot(rootName, root, context) {
75
+ const sourcePath = resolve(context.targetDirectory, root.source);
76
+ const sourceFile = context.program.getSourceFile(sourcePath);
77
+ if (!sourceFile) {
78
+ throw new Error(`Structure root "${rootName}" source is not part of the TypeScript program.`);
79
+ }
80
+ const moduleSymbol = context.checker.getSymbolAtLocation(sourceFile);
81
+ if (!moduleSymbol) {
82
+ throw new Error(`Structure root "${rootName}" source has no module symbol.`);
83
+ }
84
+ const exported = context.checker
85
+ .getExportsOfModule(moduleSymbol)
86
+ .find((symbol) => symbol.getName() === root.export);
87
+ if (!exported) {
88
+ throw new Error(`Structure root "${rootName}" cannot find exported type "${root.export}".`);
89
+ }
90
+ const symbol = (exported.flags & ts.SymbolFlags.Alias) !== 0
91
+ ? context.checker.getAliasedSymbol(exported)
92
+ : exported;
93
+ const id = symbol.getName();
94
+ materializeRootDefinition(id, symbol, context);
95
+ return id;
96
+ }
97
+ /*** Materialize a configured root inline while nested named types remain references. */
98
+ function materializeRootDefinition(id, symbol, context) {
99
+ const previous = context.definitionSymbols.get(id);
100
+ if (previous && previous !== symbol) {
101
+ throw new Error(`Duplicate structural descriptor id "${id}".`);
102
+ }
103
+ if (context.definitions.has(id))
104
+ return;
105
+ const type = context.checker.getDeclaredTypeOfSymbol(symbol);
106
+ const aliasDeclaration = symbol.declarations?.find(ts.isTypeAliasDeclaration);
107
+ const declaredDescriptor = aliasDeclaration
108
+ ? resolveSemanticStructureWrapperNode(aliasDeclaration.type, context, (value) => resolveStructureType(value, context))
109
+ : null;
110
+ context.definitionSymbols.set(id, symbol);
111
+ context.resolving.add(id);
112
+ context.definitions.set(id, {
113
+ id,
114
+ descriptor: declaredDescriptor ?? resolveStructureType(type, context, symbol),
115
+ });
116
+ context.resolving.delete(id);
117
+ }
118
+ /*** Render a generated source module containing the canonical document and its content fingerprint. */
119
+ function renderArtifactSource(serialized, fingerprint, compilerVersion) {
120
+ return [
121
+ '/* This file is generated by @ankhorage/devtools. Do not edit manually. */',
122
+ "import type { StructureDescriptorDocument } from '@ankhorage/contracts/structure';",
123
+ '',
124
+ `export const STRUCTURE_DESCRIPTOR_COMPILER_VERSION = '${compilerVersion}';`,
125
+ `export const STRUCTURE_DESCRIPTOR_FINGERPRINT = '${fingerprint}';`,
126
+ '',
127
+ 'export const STRUCTURE_DESCRIPTOR = ' +
128
+ serialized +
129
+ ' as const satisfies StructureDescriptorDocument;',
130
+ '',
131
+ ].join('\n');
132
+ }
133
+ /*** Render one compiler diagnostic without leaking absolute source paths. */
134
+ function formatDiagnostic(diagnostic, targetDirectory) {
135
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
136
+ if (!diagnostic.file)
137
+ return message;
138
+ const relative = diagnostic.file.fileName.startsWith(targetDirectory)
139
+ ? diagnostic.file.fileName.slice(targetDirectory.length).replace(/^[/\\]+/u, '')
140
+ : diagnostic.file.fileName.split(/[/\\]/u).at(-1);
141
+ return relative ? `${relative}: ${message}` : message;
142
+ }
@@ -0,0 +1,2 @@
1
+ /*** Use Devtools' built structure CLI for self-release and the installed binary in consumers. */
2
+ export declare function resolveStructureReleaseCommandAsync(targetDirectory: string): Promise<string>;
@@ -0,0 +1,21 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ /*** Use Devtools' built structure CLI for self-release and the installed binary in consumers. */
4
+ export async function resolveStructureReleaseCommandAsync(targetDirectory) {
5
+ try {
6
+ const packageJson = JSON.parse(await readFile(resolve(targetDirectory, 'package.json'), 'utf8'));
7
+ return isPackageNamed(packageJson, '@ankhorage/devtools') ? SELF_COMMAND : CONSUMER_COMMAND;
8
+ }
9
+ catch (error) {
10
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
11
+ return CONSUMER_COMMAND;
12
+ }
13
+ throw error;
14
+ }
15
+ }
16
+ /*** Narrow a package manifest by its canonical package name. */
17
+ function isPackageNamed(value, expectedName) {
18
+ return (typeof value === 'object' && value !== null && 'name' in value && value.name === expectedName);
19
+ }
20
+ const SELF_COMMAND = 'node ./dist/cli/bin/structure.js';
21
+ const CONSUMER_COMMAND = './node_modules/.bin/ankhorage-structure';
@@ -0,0 +1,3 @@
1
+ import type { StructureGenerationResult, StructureGenerationWorkspacePort } from '../../../../types/structure-generation.js';
2
+ /*** Build or check the deterministic structural descriptor artifact for one opted-in owner package. */
3
+ export declare function synchronizeStructureArtifactAsync(operation: 'build' | 'check', port: StructureGenerationWorkspacePort): Promise<StructureGenerationResult>;
@@ -0,0 +1,20 @@
1
+ /*** Build or check the deterministic structural descriptor artifact for one opted-in owner package. */
2
+ export async function synchronizeStructureArtifactAsync(operation, port) {
3
+ const packageConfig = await port.readPackageAsync();
4
+ if (!packageConfig) {
5
+ return { applicable: false, changed: false, current: true };
6
+ }
7
+ const artifact = await port.generateArtifactAsync(packageConfig);
8
+ const current = await port.readOutputAsync(packageConfig.config.output);
9
+ const matches = current === artifact.source;
10
+ if (operation === 'build' && !matches) {
11
+ await port.writeOutputAsync(packageConfig.config.output, artifact.source);
12
+ }
13
+ return {
14
+ applicable: true,
15
+ changed: operation === 'build' && !matches,
16
+ current: matches,
17
+ outputPath: packageConfig.config.output,
18
+ fingerprint: artifact.fingerprint,
19
+ };
20
+ }
@@ -0,0 +1,3 @@
1
+ import type { StructureGenerationResult } from '../../../types/structure-generation.js';
2
+ /*** Compose structure artifact generation with the Node workspace adapter for one package root. */
3
+ export declare function synchronizeStructureArtifactForDirectoryAsync(operation: 'build' | 'check', targetDirectory: string): Promise<StructureGenerationResult>;
@@ -0,0 +1,6 @@
1
+ import { createStructureGenerationWorkspacePort } from '../adapters/outbound/createStructureGenerationWorkspacePort.js';
2
+ import { synchronizeStructureArtifactAsync } from '../application/use-cases/synchronizeStructureArtifactAsync.js';
3
+ /*** Compose structure artifact generation with the Node workspace adapter for one package root. */
4
+ export async function synchronizeStructureArtifactForDirectoryAsync(operation, targetDirectory) {
5
+ return synchronizeStructureArtifactAsync(operation, createStructureGenerationWorkspacePort(targetDirectory));
6
+ }
@@ -0,0 +1,9 @@
1
+ import type { StructureDescriptor } from '@ankhorage/contracts/structure';
2
+ import ts from 'typescript';
3
+ import type { StructureCompilerContext } from '../../../types/structure-generation.js';
4
+ type ResolveType = (type: ts.Type) => StructureDescriptor;
5
+ /*** Resolve canonical Contracts collection wrappers before generic TypeScript object expansion. */
6
+ export declare function resolveSemanticStructureWrapper(type: ts.Type, context: StructureCompilerContext, resolveType: ResolveType): StructureDescriptor | null;
7
+ /*** Resolve canonical collection semantics from a declared TypeScript type node before normalization. */
8
+ export declare function resolveSemanticStructureWrapperNode(node: ts.TypeNode, context: StructureCompilerContext, resolveType: ResolveType): StructureDescriptor | null;
9
+ export {};
@@ -0,0 +1,150 @@
1
+ import ts from 'typescript';
2
+ import { resolveStructureSymbolPackage } from './resolveStructureSymbolPackage.js';
3
+ /*** Resolve canonical Contracts collection wrappers before generic TypeScript object expansion. */
4
+ export function resolveSemanticStructureWrapper(type, context, resolveType) {
5
+ const match = resolveWrapperMatch(type, context, new Set());
6
+ return match ? buildWrapperDescriptor(match.name, match.arguments, context, resolveType) : null;
7
+ }
8
+ /*** Resolve canonical collection semantics from a declared TypeScript type node before normalization. */
9
+ export function resolveSemanticStructureWrapperNode(node, context, resolveType) {
10
+ const match = resolveWrapperNodeMatch(node, context, new Set());
11
+ return match ? buildWrapperDescriptor(match.name, match.arguments, context, resolveType) : null;
12
+ }
13
+ /*** Follow declared type-reference aliases until a canonical Contracts wrapper symbol is reached. */
14
+ function resolveWrapperNodeMatch(node, context, visited) {
15
+ if (!ts.isTypeReferenceNode(node))
16
+ return null;
17
+ const rawSymbol = context.checker.getSymbolAtLocation(node.typeName);
18
+ if (!rawSymbol)
19
+ return null;
20
+ const canonicalName = resolveCanonicalWrapperName(rawSymbol, context);
21
+ if (canonicalName) {
22
+ return {
23
+ name: canonicalName,
24
+ arguments: node.typeArguments?.map((argument) => context.checker.getTypeFromTypeNode(argument)) ?? [],
25
+ };
26
+ }
27
+ const symbol = resolveAliasedSymbol(rawSymbol, context.checker);
28
+ if (visited.has(symbol))
29
+ return null;
30
+ const declaration = symbol.declarations?.find(ts.isTypeAliasDeclaration);
31
+ if (!declaration)
32
+ return null;
33
+ const nextVisited = new Set(visited);
34
+ nextVisited.add(symbol);
35
+ return resolveWrapperNodeMatch(declaration.type, context, nextVisited);
36
+ }
37
+ /*** Follow source alias declarations until the exact canonical Contracts wrapper is reached. */
38
+ function resolveWrapperMatch(type, context, visited) {
39
+ const direct = resolveDirectWrapper(type, context);
40
+ if (direct)
41
+ return direct;
42
+ const { aliasSymbol } = type;
43
+ if (!aliasSymbol || visited.has(aliasSymbol))
44
+ return null;
45
+ const nextVisited = new Set(visited);
46
+ nextVisited.add(aliasSymbol);
47
+ const declaration = aliasSymbol.declarations?.find(ts.isTypeAliasDeclaration);
48
+ if (!declaration)
49
+ return null;
50
+ const targetType = context.checker.getTypeFromTypeNode(declaration.type);
51
+ const nested = targetType === type ? null : resolveWrapperMatch(targetType, context, nextVisited);
52
+ if (nested)
53
+ return nested;
54
+ return ts.isTypeReferenceNode(declaration.type)
55
+ ? resolveWrapperFromReferenceNode(declaration.type, context)
56
+ : null;
57
+ }
58
+ /*** Resolve one source-level type reference against the canonical owner API identity. */
59
+ function resolveWrapperFromReferenceNode(reference, context) {
60
+ const rawSymbol = context.checker.getSymbolAtLocation(reference.typeName);
61
+ if (!rawSymbol)
62
+ return null;
63
+ const name = resolveCanonicalWrapperName(rawSymbol, context);
64
+ if (!name)
65
+ return null;
66
+ return {
67
+ name,
68
+ arguments: reference.typeArguments?.map((argument) => context.checker.getTypeFromTypeNode(argument)) ??
69
+ [],
70
+ };
71
+ }
72
+ /*** Resolve an exact canonical wrapper through package ownership or its public structure import. */
73
+ function resolveCanonicalWrapperName(rawSymbol, context) {
74
+ const importedName = resolveStructureImportName(rawSymbol);
75
+ if (importedName && WRAPPER_NAMES.has(importedName))
76
+ return importedName;
77
+ const symbol = resolveAliasedSymbol(rawSymbol, context.checker);
78
+ if (resolveStructureSymbolPackage(symbol, context) !== '@ankhorage/contracts')
79
+ return null;
80
+ const name = symbol.getName();
81
+ return WRAPPER_NAMES.has(name) ? name : null;
82
+ }
83
+ /*** Recognize a named type imported from the exact Contracts structure owner subpath. */
84
+ function resolveStructureImportName(symbol) {
85
+ for (const declaration of symbol.declarations ?? []) {
86
+ if (!ts.isImportSpecifier(declaration))
87
+ continue;
88
+ const importDeclaration = declaration.parent.parent.parent;
89
+ if (!ts.isImportDeclaration(importDeclaration) ||
90
+ !ts.isStringLiteral(importDeclaration.moduleSpecifier) ||
91
+ importDeclaration.moduleSpecifier.text !== '@ankhorage/contracts/structure') {
92
+ continue;
93
+ }
94
+ return (declaration.propertyName ?? declaration.name).text;
95
+ }
96
+ return null;
97
+ }
98
+ /*** Resolve TypeScript import/re-export alias chains to their final declaration symbol. */
99
+ function resolveAliasedSymbol(symbol, checker, visited = new Set()) {
100
+ if ((symbol.flags & ts.SymbolFlags.Alias) === 0 || visited.has(symbol))
101
+ return symbol;
102
+ const next = checker.getAliasedSymbol(symbol);
103
+ if (next === symbol || next.getName() === 'unknown')
104
+ return symbol;
105
+ const nextVisited = new Set(visited);
106
+ nextVisited.add(symbol);
107
+ return resolveAliasedSymbol(next, checker, nextVisited);
108
+ }
109
+ /*** Match a direct instantiation of one canonical collection wrapper. */
110
+ function resolveDirectWrapper(type, context) {
111
+ const rawSymbol = type.aliasSymbol;
112
+ if (!rawSymbol)
113
+ return null;
114
+ const name = resolveCanonicalWrapperName(rawSymbol, context);
115
+ return name ? { name, arguments: type.aliasTypeArguments ?? [] } : null;
116
+ }
117
+ /*** Convert one canonical wrapper instantiation to its portable descriptor kind. */
118
+ function buildWrapperDescriptor(name, arguments_, context, resolveType) {
119
+ if (name === 'SerializableSet') {
120
+ return {
121
+ kind: 'set',
122
+ member: resolveType(arguments_.at(0) ?? context.checker.getStringType()),
123
+ };
124
+ }
125
+ const key = arguments_.at(0);
126
+ const value = arguments_.at(1);
127
+ if (!key || !value)
128
+ throw new Error(`${name} requires key and value type arguments.`);
129
+ if (name === 'ValueMap') {
130
+ return {
131
+ kind: 'value-map',
132
+ key: resolveType(key),
133
+ value: resolveType(value),
134
+ };
135
+ }
136
+ const identity = arguments_.at(2);
137
+ return {
138
+ kind: 'entity-registry',
139
+ key: resolveType(key),
140
+ value: resolveType(value),
141
+ ...resolveIdentityField(identity),
142
+ };
143
+ }
144
+ /*** Resolve an EntityRegistry identity-field marker from a string literal type argument. */
145
+ function resolveIdentityField(type) {
146
+ if (!type || (type.flags & ts.TypeFlags.Never) !== 0)
147
+ return {};
148
+ return type.isStringLiteral() ? { identityField: type.value } : {};
149
+ }
150
+ const WRAPPER_NAMES = new Set(['EntityRegistry', 'SerializableSet', 'ValueMap']);
@@ -0,0 +1,4 @@
1
+ import type ts from 'typescript';
2
+ import type { StructureCompilerContext } from '../../../types/structure-generation.js';
3
+ /*** Resolve the owning npm package for a TypeScript symbol without exposing source paths in artifacts. */
4
+ export declare function resolveStructureSymbolPackage(symbol: ts.Symbol, context: StructureCompilerContext): string | null;
@@ -0,0 +1,33 @@
1
+ import { relative, sep } from 'node:path';
2
+ /*** Resolve the owning npm package for a TypeScript symbol without exposing source paths in artifacts. */
3
+ export function resolveStructureSymbolPackage(symbol, context) {
4
+ const declaration = symbol.declarations?.at(0);
5
+ if (!declaration)
6
+ return null;
7
+ const { fileName } = declaration.getSourceFile();
8
+ const nodeModulePackage = resolveNodeModulePackage(fileName);
9
+ if (nodeModulePackage)
10
+ return nodeModulePackage;
11
+ const relativePath = relative(context.targetDirectory, fileName);
12
+ if (relativePath !== '..' &&
13
+ !relativePath.startsWith(`..${sep}`) &&
14
+ !relativePath.startsWith('..')) {
15
+ return context.packageName;
16
+ }
17
+ return null;
18
+ }
19
+ /*** Resolve npm package ownership before classifying source paths as local project files. */
20
+ function resolveNodeModulePackage(fileName) {
21
+ const normalized = fileName.replaceAll('\\', '/');
22
+ const marker = '/node_modules/';
23
+ const markerIndex = normalized.lastIndexOf(marker);
24
+ if (markerIndex < 0)
25
+ return null;
26
+ const packagePath = normalized.slice(markerIndex + marker.length);
27
+ const [first, second] = packagePath.split('/');
28
+ if (!first)
29
+ return null;
30
+ if (!first.startsWith('@'))
31
+ return first;
32
+ return second ? `${first}/${second}` : null;
33
+ }
@@ -0,0 +1,5 @@
1
+ import type { StructureDescriptor } from '@ankhorage/contracts/structure';
2
+ import ts from 'typescript';
3
+ import type { StructureCompilerContext } from '../../../types/structure-generation.js';
4
+ /*** Resolve one TypeScript type into the canonical structural descriptor graph. */
5
+ export declare function resolveStructureType(type: ts.Type, context: StructureCompilerContext, inlineSymbol?: ts.Symbol): StructureDescriptor;
@@ -0,0 +1,210 @@
1
+ import ts from 'typescript';
2
+ import { resolveSemanticStructureWrapper, resolveSemanticStructureWrapperNode, } from './resolveSemanticStructureWrapper.js';
3
+ import { resolveStructureSymbolPackage } from './resolveStructureSymbolPackage.js';
4
+ /*** Resolve one TypeScript type into the canonical structural descriptor graph. */
5
+ export function resolveStructureType(type, context, inlineSymbol) {
6
+ const wrapper = resolveSemanticStructureWrapper(type, context, (value) => resolveStructureType(value, context));
7
+ if (wrapper)
8
+ return wrapper;
9
+ const symbol = resolveNamedSymbol(type);
10
+ if (symbol && symbol !== inlineSymbol)
11
+ return resolveNamedType(symbol, type, context);
12
+ return resolveTypeBody(type, context);
13
+ }
14
+ /*** Resolve a named local type by reference or preserve an external owner reference. */
15
+ function resolveNamedType(symbol, type, context) {
16
+ const owner = resolveStructureSymbolPackage(symbol, context);
17
+ const id = stableSymbolName(symbol);
18
+ if (owner && owner !== context.packageName)
19
+ return { kind: 'ref', packageName: owner, id };
20
+ ensureStructureDefinition(symbol, type, context);
21
+ return { kind: 'ref', id };
22
+ }
23
+ /*** Materialize one local named descriptor exactly once while supporting recursive references. */
24
+ function ensureStructureDefinition(symbol, type, context) {
25
+ const id = stableSymbolName(symbol);
26
+ const previous = context.definitionSymbols.get(id);
27
+ if (previous && previous !== symbol) {
28
+ throw new Error(`Duplicate structural descriptor id "${id}" in ${context.packageName}.`);
29
+ }
30
+ if (context.definitions.has(id) || context.resolving.has(id))
31
+ return;
32
+ context.definitionSymbols.set(id, symbol);
33
+ context.resolving.add(id);
34
+ const descriptor = resolveStructureType(type, context, symbol);
35
+ context.definitions.set(id, { id, descriptor });
36
+ context.resolving.delete(id);
37
+ }
38
+ /*** Resolve primitives, literals, arrays, unions, and fixed object shapes. */
39
+ function resolveTypeBody(type, context) {
40
+ const primitive = resolvePrimitive(type);
41
+ if (primitive)
42
+ return primitive;
43
+ const literal = resolveLiteral(type, context.checker);
44
+ if (literal !== undefined)
45
+ return { kind: 'enum', values: [literal] };
46
+ if (type.isUnion())
47
+ return resolveUnion(type, context);
48
+ const arrayItem = resolveArrayItem(type, context.checker);
49
+ if (arrayItem) {
50
+ return { kind: 'ordered-list', item: resolveStructureType(arrayItem, context) };
51
+ }
52
+ if ((type.flags & ts.TypeFlags.Object) !== 0)
53
+ return resolveObject(type, context);
54
+ throw unsupportedType(type, context, 'unsupported TypeScript type');
55
+ }
56
+ /*** Resolve JSON-safe scalar primitives. */
57
+ function resolvePrimitive(type) {
58
+ if ((type.flags & ts.TypeFlags.String) !== 0)
59
+ return { kind: 'scalar', type: 'string' };
60
+ if ((type.flags & ts.TypeFlags.Number) !== 0)
61
+ return { kind: 'scalar', type: 'number' };
62
+ if ((type.flags & ts.TypeFlags.Boolean) !== 0)
63
+ return { kind: 'scalar', type: 'boolean' };
64
+ if ((type.flags & ts.TypeFlags.Null) !== 0)
65
+ return { kind: 'scalar', type: 'null' };
66
+ return null;
67
+ }
68
+ /*** Resolve one scalar literal while leaving non-literal types untouched. */
69
+ function resolveLiteral(type, checker) {
70
+ if (type.isStringLiteral())
71
+ return type.value;
72
+ if (type.isNumberLiteral())
73
+ return type.value;
74
+ if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0) {
75
+ return checker.typeToString(type) === 'true';
76
+ }
77
+ if ((type.flags & ts.TypeFlags.Null) !== 0)
78
+ return null;
79
+ return undefined;
80
+ }
81
+ /*** Resolve finite literal unions or a deterministic general union descriptor. */
82
+ function resolveUnion(type, context) {
83
+ const effective = type.types.filter((entry) => (entry.flags & ts.TypeFlags.Undefined) === 0);
84
+ if (effective.length === 0)
85
+ throw unsupportedType(type, context, 'undefined-only union');
86
+ if (effective.length === 1)
87
+ return resolveStructureType(effective[0], context);
88
+ const literals = effective.map((entry) => resolveLiteral(entry, context.checker));
89
+ if (literals.every(isDefinedLiteral)) {
90
+ return { kind: 'enum', values: sortLiterals(literals) };
91
+ }
92
+ const variants = effective.map((entry) => resolveStructureType(entry, context));
93
+ variants.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
94
+ const discriminator = resolveUnionDiscriminator(effective, context.checker);
95
+ return { kind: 'union', variants, ...(discriminator ? { discriminator } : {}) };
96
+ }
97
+ /*** Narrow an optional scalar literal after union projection. */
98
+ function isDefinedLiteral(value) {
99
+ return value !== undefined;
100
+ }
101
+ /*** Resolve a homogeneous Array/ReadonlyArray element type while rejecting tuples. */
102
+ function resolveArrayItem(type, checker) {
103
+ if (checker.isTupleType(type)) {
104
+ throw new Error('Tuple types are not supported by structural descriptor generation.');
105
+ }
106
+ if (checker.isArrayType(type)) {
107
+ return checker.getTypeArguments(type).at(0) ?? null;
108
+ }
109
+ if ((type.flags & ts.TypeFlags.Object) === 0)
110
+ return null;
111
+ const symbolName = type.getSymbol()?.getName();
112
+ if (symbolName !== 'ReadonlyArray')
113
+ return null;
114
+ return checker.getTypeArguments(type).at(0) ?? null;
115
+ }
116
+ /*** Resolve a fixed object shape and reject unmarked records or executable objects. */
117
+ function resolveObject(type, context) {
118
+ if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
119
+ throw unsupportedType(type, context, 'callable/class-like object');
120
+ }
121
+ if (context.checker.getIndexTypeOfType(type, ts.IndexKind.String)) {
122
+ throw unsupportedType(type, context, 'unmarked string-indexed record');
123
+ }
124
+ const fields = Object.fromEntries(type
125
+ .getProperties()
126
+ .map((property) => resolveObjectField(property, context))
127
+ .sort(([left], [right]) => left.localeCompare(right)));
128
+ return { kind: 'object', fields };
129
+ }
130
+ /*** Resolve one object property including optionality while rejecting methods. */
131
+ function resolveObjectField(property, context) {
132
+ const declaration = property.valueDeclaration ?? property.declarations?.at(0);
133
+ if (!declaration)
134
+ throw new Error(`Property "${property.getName()}" has no source declaration.`);
135
+ if (ts.isMethodDeclaration(declaration) || ts.isMethodSignature(declaration)) {
136
+ throw new Error(`Method "${property.getName()}" is not a serializable structural field.`);
137
+ }
138
+ const propertyType = context.checker.getTypeOfSymbolAtLocation(property, declaration);
139
+ try {
140
+ const declaredType = hasDeclaredTypeNode(declaration)
141
+ ? resolveSemanticStructureWrapperNode(declaration.type, context, (value) => resolveStructureType(value, context))
142
+ : null;
143
+ return [
144
+ property.getName(),
145
+ {
146
+ value: declaredType ?? resolveStructureType(propertyType, context),
147
+ ...((property.flags & ts.SymbolFlags.Optional) !== 0 ? { optional: true } : {}),
148
+ },
149
+ ];
150
+ }
151
+ catch (error) {
152
+ throw new Error(`Cannot generate field "${property.getName()}": ${error instanceof Error ? error.message : String(error)}`, { cause: error });
153
+ }
154
+ }
155
+ /*** Narrow property declarations that carry a source-level type node. */
156
+ function hasDeclaredTypeNode(declaration) {
157
+ return ((ts.isPropertyDeclaration(declaration) || ts.isPropertySignature(declaration)) &&
158
+ declaration.type !== undefined);
159
+ }
160
+ /*** Resolve a stable discriminator shared by every object-like union variant. */
161
+ function resolveUnionDiscriminator(variants, checker) {
162
+ const [first] = variants;
163
+ const candidates = first
164
+ .getProperties()
165
+ .map((property) => property.getName())
166
+ .sort();
167
+ for (const name of candidates) {
168
+ const values = variants.map((variant) => resolveDiscriminatorValue(variant, name, checker));
169
+ if (values.every((value) => value !== null) && new Set(values).size === values.length) {
170
+ return name;
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ /*** Resolve one string/number literal discriminator value from an object variant. */
176
+ function resolveDiscriminatorValue(type, name, checker) {
177
+ const property = type.getProperty(name);
178
+ if (!property)
179
+ return null;
180
+ const declaration = property.valueDeclaration ?? property.declarations?.at(0);
181
+ if (!declaration)
182
+ return null;
183
+ const value = checker.getTypeOfSymbolAtLocation(property, declaration);
184
+ if (value.isStringLiteral() || value.isNumberLiteral())
185
+ return value.value;
186
+ return null;
187
+ }
188
+ /*** Return the meaningful alias/interface symbol for reference identity. */
189
+ function resolveNamedSymbol(type) {
190
+ const symbol = type.aliasSymbol ?? type.getSymbol();
191
+ if (!symbol)
192
+ return null;
193
+ const name = symbol.getName();
194
+ return name.startsWith('__') || name === 'Array' || name === 'ReadonlyArray' ? null : symbol;
195
+ }
196
+ /*** Require one stable source-level symbol name independent of declaration file paths. */
197
+ function stableSymbolName(symbol) {
198
+ const name = symbol.getName();
199
+ if (name.startsWith('__'))
200
+ throw new Error('Structural descriptor symbols need stable names.');
201
+ return name;
202
+ }
203
+ /*** Sort finite scalar values deterministically without changing scalar identity. */
204
+ function sortLiterals(values) {
205
+ return [...values].sort((left, right) => `${typeof left}:${String(left)}`.localeCompare(`${typeof right}:${String(right)}`));
206
+ }
207
+ /*** Create one actionable unsupported-type error without exposing source paths. */
208
+ function unsupportedType(type, context, reason) {
209
+ return new Error(`Cannot generate structure for ${context.checker.typeToString(type)}: ${reason}.`);
210
+ }
@@ -114,6 +114,9 @@ jobs:
114
114
  after_version="$(node -p "require('./package.json').version")"
115
115
 
116
116
  if [ "$before_version" != "$after_version" ]; then
117
+ if node -e "const p=require('./package.json'); process.exit(p.ankh?.structure === undefined ? 1 : 0)"; then
118
+ __ANKH_STRUCTURE_RELEASE_COMMAND__ build .
119
+ fi
117
120
  if node -e "const p=require('./package.json'); process.exit(p.scripts?.docs ? 0 : 1)"; then
118
121
  bun run docs
119
122
  else
@@ -1,6 +1,7 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { resolveApmReleaseCommandAsync } from '../../features/apm-release-validation/adapters/outbound/resolveApmReleaseCommandAsync.js';
4
+ import { resolveStructureReleaseCommandAsync } from '../../features/structure-descriptor-generation/adapters/outbound/resolveStructureReleaseCommandAsync.js';
4
5
  import { bunRuntimePolicy, nodeRuntimePolicy } from '../../policy/bunRuntimePolicy.js';
5
6
  import { readCurrentDoctorVersion } from './readCurrentDoctorVersion.js';
6
7
  import { renderRenovateWorkflowAsync } from './renderRenovateWorkflowAsync.js';
@@ -35,6 +36,7 @@ function createWorkflowDefinition(relativePath, sourcePath) {
35
36
  relativePath,
36
37
  render: async (targetDirectory) => await renderWorkflowAsync(sourceUrl, {
37
38
  apmReleaseCommand: await resolveApmReleaseCommandAsync(targetDirectory),
39
+ structureReleaseCommand: await resolveStructureReleaseCommandAsync(targetDirectory),
38
40
  bunVersion: bunRuntimePolicy.version,
39
41
  doctorVersion: readCurrentDoctorVersion(),
40
42
  nodeVersion: nodeRuntimePolicy.setupVersion,
@@ -48,6 +50,7 @@ function createRenovateWorkflowDefinition() {
48
50
  relativePath: '.github/workflows/renovate.yml',
49
51
  render: async (targetDirectory) => await renderRenovateWorkflowAsync(sourceUrl, targetDirectory, {
50
52
  apmReleaseCommand: await resolveApmReleaseCommandAsync(targetDirectory),
53
+ structureReleaseCommand: await resolveStructureReleaseCommandAsync(targetDirectory),
51
54
  bunVersion: bunRuntimePolicy.version,
52
55
  doctorVersion: readCurrentDoctorVersion(),
53
56
  nodeVersion: nodeRuntimePolicy.setupVersion,
@@ -3,6 +3,7 @@ export interface WorkflowPolicy {
3
3
  readonly doctorVersion: string;
4
4
  readonly nodeVersion: string;
5
5
  readonly apmReleaseCommand?: string;
6
+ readonly structureReleaseCommand?: string;
6
7
  }
7
8
  /*** Renders one workflow template with the canonical runtime, Doctor, and Changesets policy. */
8
9
  export declare function renderWorkflowAsync(sourceUrl: URL, policy: WorkflowPolicy): Promise<string>;
@@ -10,6 +10,7 @@ export async function renderWorkflowAsync(sourceUrl, policy) {
10
10
  .replaceAll(CHANGESETS_VERSION_COMMAND_TOKEN, changesetsPolicy.workflowCommands.version)
11
11
  .replaceAll(DOCTOR_VERSION_TOKEN, policy.doctorVersion)
12
12
  .replaceAll('__ANKH_APM_RELEASE_COMMAND__', policy.apmReleaseCommand ?? './node_modules/.bin/ankhorage-apm-release')
13
+ .replaceAll('__ANKH_STRUCTURE_RELEASE_COMMAND__', policy.structureReleaseCommand ?? './node_modules/.bin/ankhorage-structure')
13
14
  .replaceAll(NODE_VERSION_TOKEN, policy.nodeVersion);
14
15
  }
15
16
  const BUN_VERSION_TOKEN = '__ANKH_BUN_VERSION__';
@@ -0,0 +1,42 @@
1
+ import type { StructureDescriptorDefinition, StructureDescriptorDocument } from '@ankhorage/contracts/structure';
2
+ import type ts from 'typescript';
3
+ export interface StructureGenerationRoot {
4
+ readonly source: string;
5
+ readonly export: string;
6
+ }
7
+ export interface StructureGenerationConfig {
8
+ readonly output: string;
9
+ readonly roots: Readonly<Record<string, StructureGenerationRoot>>;
10
+ }
11
+ export interface StructureGenerationPackage {
12
+ readonly name: string;
13
+ readonly version: string;
14
+ readonly config: StructureGenerationConfig;
15
+ }
16
+ export interface StructureGenerationArtifact {
17
+ readonly document: StructureDescriptorDocument;
18
+ readonly fingerprint: string;
19
+ readonly source: string;
20
+ }
21
+ export interface StructureGenerationResult {
22
+ readonly applicable: boolean;
23
+ readonly changed: boolean;
24
+ readonly current: boolean;
25
+ readonly outputPath?: string;
26
+ readonly fingerprint?: string;
27
+ }
28
+ export interface StructureGenerationWorkspacePort {
29
+ readonly readPackageAsync: () => Promise<StructureGenerationPackage | null>;
30
+ readonly generateArtifactAsync: (packageConfig: StructureGenerationPackage) => Promise<StructureGenerationArtifact>;
31
+ readonly readOutputAsync: (relativePath: string) => Promise<string | null>;
32
+ readonly writeOutputAsync: (relativePath: string, source: string) => Promise<void>;
33
+ }
34
+ export interface StructureCompilerContext {
35
+ readonly checker: ts.TypeChecker;
36
+ readonly program: ts.Program;
37
+ readonly targetDirectory: string;
38
+ readonly packageName: string;
39
+ readonly definitions: Map<string, StructureDescriptorDefinition>;
40
+ readonly definitionSymbols: Map<string, ts.Symbol>;
41
+ readonly resolving: Set<string>;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/devtools",
3
- "version": "1.17.2",
3
+ "version": "1.18.1",
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",
@@ -21,6 +21,8 @@
21
21
  "capabilities": [
22
22
  "devtools.apm.sync",
23
23
  "devtools.apm.validate",
24
+ "devtools.structure.build",
25
+ "devtools.structure.check",
24
26
  "devtools.changeset",
25
27
  "devtools.lint",
26
28
  "devtools.format",
@@ -64,7 +66,8 @@
64
66
  "ankhorage-eslint": "./dist/cli/bin/eslint.js",
65
67
  "ankhorage-knip": "./dist/cli/bin/knip.js",
66
68
  "ankhorage-prettier": "./dist/cli/bin/prettier.js",
67
- "ankhorage-apm-release": "./dist/cli/bin/apm-release.js"
69
+ "ankhorage-apm-release": "./dist/cli/bin/apm-release.js",
70
+ "ankhorage-structure": "./dist/cli/bin/structure.js"
68
71
  },
69
72
  "exports": {
70
73
  "./cli": {
@@ -140,7 +143,8 @@
140
143
  "eslint-plugin-unused-imports": "^4.4.1",
141
144
  "knip": "^6.34.0",
142
145
  "prettier": "^3.9.6",
143
- "typescript-eslint": "^8.69.0"
146
+ "typescript-eslint": "^8.69.0",
147
+ "@ankhorage/contracts": "^22.1.1"
144
148
  },
145
149
  "devDependencies": {
146
150
  "@ankhorage/paradox": "^0.1.26",
@@ -152,5 +156,8 @@
152
156
  "sharp": "^0.35.4",
153
157
  "typescript": "~6.0.3"
154
158
  },
155
- "packageManager": "bun@1.4.2"
159
+ "packageManager": "bun@1.4.2",
160
+ "peerDependencies": {
161
+ "typescript": ">=5.9.3 <7"
162
+ }
156
163
  }